pinecall-protocol 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/.gitignore +3 -0
  2. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/PKG-INFO +1 -1
  3. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/_version.py +1 -1
  4. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/events.py +6 -2
  5. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/fixtures/call-log-golden.json +1 -1
  6. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/rest.py +373 -2
  7. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/.python-version +0 -0
  8. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/LICENSE +0 -0
  9. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/README.md +0 -0
  10. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/__init__.py +0 -0
  11. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/_base.py +0 -0
  12. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/codec.py +0 -0
  13. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/commands.py +0 -0
  14. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/defs.py +0 -0
  15. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/envelope.py +0 -0
  16. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/fixtures/__init__.py +0 -0
  17. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/fixtures/call-log-golden.state.json +0 -0
  18. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/metrics.py +0 -0
  19. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/py.typed +0 -0
  20. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/registry.py +0 -0
  21. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/room.py +0 -0
  22. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/state.py +0 -0
  23. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pinecall_protocol/verbs.py +0 -0
  24. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/pyproject.toml +0 -0
  25. {pinecall_protocol-0.1.0 → pinecall_protocol-0.3.0}/uv.lock +0 -0
@@ -12,3 +12,6 @@ docs/decision.md
12
12
  Gemfile.lock
13
13
  pkg/
14
14
  .bundle/
15
+
16
+ # `gem build` writes the package beside the gemspec; the registry keeps the copy that matters.
17
+ *.gem
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pinecall-protocol
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: The Pinecall wire: pydantic models generated from the protocol schema
5
5
  Author-email: Bernardo Castro <me@bernardocastro.dev>
6
6
  License-Expression: Apache-2.0
@@ -1,4 +1,4 @@
1
1
  """The one place the version lives; hatch reads it from here at build time."""
2
2
 
3
3
  # 0.0.0 means "unreleased". The number is the maintainer's call and is never bumped from code.
4
- __version__ = "0.1.0"
4
+ __version__ = "0.3.0"
@@ -60,9 +60,12 @@ class AgentStateChanged(WireModel):
60
60
  state: AgentState
61
61
 
62
62
 
63
- # Interim while final is false; the whole reply becomes turn.agent. Interim entries are ephemeral.
63
+ # One delta of the reply the agent is giving, never the reply so far: in a voice call one word, as
64
+ # the voice plays it, with the seconds it was aligned to; in a written call one model token. The
65
+ # reply so far is every delta of the same speech_id since the last turn.agent, joined; turn.agent
66
+ # carries the whole reply and closes it. Interim entries are ephemeral.
64
67
  class AgentTranscript(WireModel):
65
- """Words from the agent as they are played, synced to the audio."""
68
+ """One delta of the reply the agent is giving, never the reply so far."""
66
69
 
67
70
  speech_id: str
68
71
  text: str
@@ -81,6 +84,7 @@ class CallDialing(WireModel):
81
84
  run: str | None = None
82
85
  caller: Contact | None
83
86
  external_id: str | None = None
87
+ asked_by: str | None = None
84
88
 
85
89
 
86
90
  # Nothing about the conversation follows; call.summary still does.
@@ -122,6 +122,6 @@
122
122
  {"seq": 122, "ts": 1786537552.4, "call": "CA_8f4a2c", "agent": "clinica-norte", "type": "call.ended", "ephemeral": false, "data": {"reason": "caller_hung_up", "ended_by": "caller", "ended_at": 1786537552.4, "duration_s": 51.988}},
123
123
  {"seq": 123, "ts": 1786537552.81, "call": "CA_8f4a2c", "agent": "clinica-norte", "type": "memory.ops", "ephemeral": false, "data": {"ops": [{"op": "remember", "contact": "ct_7d1e", "facts": [{"id": "mem_03", "text": "Cita de revisión con la Dra. Vidal el 2026-08-13 a las 09:30 (BK-5521)", "source": "CA_8f4a2c"}], "took_ms": 410}]}},
124
124
  {"seq": 124, "ts": 1786537553.46, "call": "CA_8f4a2c", "agent": "clinica-norte", "type": "call.summary", "ephemeral": false, "data": {"reason": "caller_hung_up", "outcome": "booked BK-5521: revisión Dra. Vidal, jueves 13 a las 09:30", "duration_s": 51.988, "turns": 11, "usage": [{"type": "llm_usage", "provider": "anthropic", "model": "claude-haiku-4-5", "input_tokens": 14963, "input_cached_tokens": 13145, "input_cache_creation_tokens": 562, "input_audio_tokens": 0, "input_cached_audio_tokens": 0, "input_text_tokens": 0, "input_cached_text_tokens": 0, "input_image_tokens": 0, "input_cached_image_tokens": 0, "output_tokens": 281, "output_audio_tokens": 0, "output_text_tokens": 0, "output_reasoning_tokens": 0, "session_duration": 0.0}, {"type": "tts_usage", "provider": "elevenlabs", "model": "eleven_flash_v2_5", "input_tokens": 0, "output_tokens": 0, "characters_count": 397, "audio_duration": 25.6}, {"type": "stt_usage", "provider": "soniox", "model": "stt-rt-v5", "input_tokens": 0, "output_tokens": 0, "audio_duration": 12.1}, {"type": "interruption_usage", "provider": "livekit", "model": "adaptive", "total_requests": 12}, {"type": "eot_usage", "provider": "livekit", "model": "turn-detector-v1", "total_requests": 5}], "cost": {"eur": 0.022937, "rate": {"currency": "EUR", "usd_to_eur": 0.92, "as_of": "2026-08-12"}, "rows": [{"provider": "anthropic", "model": "claude-haiku-4-5", "unit": "input_tokens", "quantity": 1256, "unit_price_usd": 1e-06, "eur": 0.001156}, {"provider": "anthropic", "model": "claude-haiku-4-5", "unit": "cached_input_tokens", "quantity": 13145, "unit_price_usd": 1e-07, "eur": 0.001209}, {"provider": "anthropic", "model": "claude-haiku-4-5", "unit": "cache_creation_tokens", "quantity": 562, "unit_price_usd": 1.25e-06, "eur": 0.000646}, {"provider": "anthropic", "model": "claude-haiku-4-5", "unit": "output_tokens", "quantity": 281, "unit_price_usd": 5e-06, "eur": 0.001293}, {"provider": "elevenlabs", "model": "eleven_flash_v2_5", "unit": "characters", "quantity": 397, "unit_price_usd": 5e-05, "eur": 0.018262}, {"provider": "soniox", "model": "stt-rt-v5", "unit": "audio_seconds", "quantity": 12.1, "unit_price_usd": 3.33e-05, "eur": 0.000371}], "unpriced": [{"provider": "livekit", "model": "adaptive"}, {"provider": "livekit", "model": "turn-detector-v1"}]}, "recording": "recordings/2026/08/12/CA_8f4a2c.ogg"}},
125
- {"seq": 125, "ts": 1786537553.102, "call": "CA_8f4a2c", "agent": "clinica-norte", "type": "call.score", "ephemeral": false, "data": {"passed": false, "judges": [{"name": "consent", "verdict": "broken", "criteria": "Every irreversible tool call in this conversation ran after a confirm.granted for the same tool call and the same audience.", "reason": "book_slot ran at seq 79, before its confirm.granted at seq 93", "evidence": {"seqs": [79, 93], "said": "Sí, confirmo."}}], "panel": ["consent", "grounded"], "judge_calls": 0}},
125
+ {"seq": 125, "ts": 1786537553.102, "call": "CA_8f4a2c", "agent": "clinica-norte", "type": "call.score", "ephemeral": false, "data": {"passed": false, "judges": [{"name": "consent", "verdict": "broken", "criteria": "Every irreversible tool call in this conversation ran after a confirm.granted for the same tool call and the same audience.", "reason": "book_slot ran at seq 79, before its confirm.granted at seq 93", "evidence": {"seqs": [79, 93], "said": "Sí, confirmo."}}], "panel": ["consent", "grounded", "promises"], "judge_calls": 0}},
126
126
  {"seq": 125, "ts": 1786537553.461, "call": "CA_8f4a2c", "agent": "clinica-norte", "type": "log.caught_up", "ephemeral": true, "data": {"seq": 125}}
127
127
  ]
@@ -1,6 +1,6 @@
1
1
  """Generated from schema/rest.json: the envelopes the read doors answer in."""
2
2
 
3
- from typing import Any
3
+ from typing import Any, Literal
4
4
 
5
5
  from pydantic import Field
6
6
 
@@ -35,6 +35,23 @@ class LogPage(WireModel):
35
35
  next: int | None
36
36
 
37
37
 
38
+ # One call's call.score as a list draws it: how many judges held of how many answered, and why the
39
+ # first one that broke did.
40
+ class SessionScore(WireModel):
41
+ """One call's call.score as a list draws it."""
42
+
43
+ held: int
44
+ judged: int
45
+ passed: bool
46
+ reason: str | None
47
+
48
+
49
+ # escalated: a person took part — a transfer, a supervisor taking the line, saying something, or
50
+ # ending the call. low_score: a judge answered broken. promise: the promises judge found the agent
51
+ # committing the business to something no tool call records.
52
+ type SessionFlag = Literal["escalated", "low_score", "promise"]
53
+
54
+
38
55
  class SessionLine(WireModel):
39
56
  """One call as a list draws it: which call, how far the log got, and the state's own fields."""
40
57
 
@@ -53,12 +70,324 @@ class SessionLine(WireModel):
53
70
  end_reason: EndReason | None
54
71
  outcome: str | None
55
72
  cost: Cost | None
73
+ score: SessionScore | None = None
74
+ flags: list[SessionFlag] | None = None
56
75
 
57
76
 
77
+ # GET /v1/agents/{slug}/sessions and GET /v1/sessions: the calls that match, newest first, a page at
78
+ # a time.
58
79
  class SessionList(WireModel):
59
- """GET /v1/agents/{slug}/sessions: which calls that agent handled, newest first."""
80
+ """GET /v1/agents/{slug}/sessions and GET /v1/sessions."""
60
81
 
61
82
  calls: list[SessionLine]
83
+ total: int | None = None
84
+ next: str | None = None
85
+
86
+
87
+ class InsightsConversations(WireModel):
88
+ """How many calls started on the day, and on the day before it."""
89
+
90
+ today: int
91
+ yesterday: int
92
+
93
+
94
+ class InsightsChannels(WireModel):
95
+ """The day's calls by the door they came in by."""
96
+
97
+ phone: int
98
+ web: int
99
+ whatsapp: int
100
+
101
+
102
+ class InsightsAgent(WireModel):
103
+ """One agent's day."""
104
+
105
+ slug: str
106
+ today: int
107
+ score: float | None
108
+
109
+
110
+ class InsightsBudget(WireModel):
111
+ """What the org may spend in a month and what it has spent so far, both worlds together."""
112
+
113
+ limit_eur: float | None
114
+ spent_eur_month: float
115
+
116
+
117
+ # GET /v1/insights: one day of the key's world and corner at a glance, counted off the call index
118
+ # and never off a log.
119
+ class Insights(WireModel):
120
+ """GET /v1/insights."""
121
+
122
+ day: str
123
+ timezone: str
124
+ conversations: InsightsConversations
125
+ resolved_rate: float | None
126
+ median_e2e_s: float | None
127
+ spend_eur: float
128
+ channels: InsightsChannels
129
+ sessions_total: int
130
+ live: int
131
+ agents: list[InsightsAgent]
132
+ budget: InsightsBudget
133
+
134
+
135
+ # GET and PUT /v1/org/judging: whether the org's calls are judged at hang-up, and what judging one
136
+ # may spend on a model.
137
+ class Judging(WireModel):
138
+ """GET and PUT /v1/org/judging."""
139
+
140
+ on: bool
141
+ ceiling_eur: float | None
142
+
143
+
144
+ class JudgingWanted(WireModel):
145
+ """PUT /v1/org/judging, the body."""
146
+
147
+ on: bool
148
+
149
+
150
+ # in: the contact wrote it. out: the agent, or a person as the agent, did. call: a spoken call,
151
+ # drawn as one pill.
152
+ type ThreadKind = Literal["in", "out", "call"]
153
+
154
+
155
+ class ThreadLast(WireModel):
156
+ """The newest thing on a contact's thread."""
157
+
158
+ text: str | None
159
+ at: float
160
+ kind: ThreadKind
161
+
162
+
163
+ class ThreadLine(WireModel):
164
+ """One contact of an agent's inbox: every call of theirs, folded into one line."""
165
+
166
+ contact: str
167
+ name: str | None
168
+ channel_last: Channel
169
+ last: ThreadLast
170
+ unread: int
171
+ calls: int
172
+
173
+
174
+ class ThreadList(WireModel):
175
+ """GET /v1/agents/{slug}/threads: the agent's contacts, the newest thread first."""
176
+
177
+ threads: list[ThreadLine]
178
+ next: str | None
179
+
180
+
181
+ class ThreadMessage(WireModel):
182
+ """One message of a thread, or one spoken call drawn as a pill."""
183
+
184
+ kind: ThreadKind
185
+ text: str | None
186
+ at: float
187
+ call: str
188
+ channel: Channel
189
+ duration_s: float | None = None
190
+ answered: bool | None = None
191
+
192
+
193
+ class Thread(WireModel):
194
+ """GET /v1/agents/{slug}/threads/{contact}: every call of one contact, merged, oldest first."""
195
+
196
+ contact: str
197
+ name: str | None
198
+ messages: list[ThreadMessage]
199
+
200
+
201
+ class ThreadSay(WireModel):
202
+ """POST /v1/agents/{slug}/threads/{contact}/messages, the body."""
203
+
204
+ text: str
205
+
206
+
207
+ # The turn.agent it lands as is on that call's log.
208
+ class ThreadSaid(WireModel):
209
+ """POST /v1/agents/{slug}/threads/{contact}/messages, the answer: the call it was said on."""
210
+
211
+ contact: str
212
+ call: str
213
+
214
+
215
+ class AgentFact(WireModel):
216
+ """One current fact memory holds, across the contacts an agent's calls taught."""
217
+
218
+ id: str
219
+ contact: str
220
+ text: str
221
+ category: str | None
222
+ written_at: float
223
+
224
+
225
+ class AgentMemory(WireModel):
226
+ """GET /v1/agents/{slug}/memory: the current facts the agent's calls taught, newest first."""
227
+
228
+ facts: list[AgentFact]
229
+ next: str | None
230
+
231
+
232
+ # GET and PUT /v1/agents/{slug}/widget: how the widget presents this agent, kept per org, world and
233
+ # agent. PUT takes the whole set.
234
+ class WidgetSettings(WireModel):
235
+ """GET and PUT /v1/agents/{slug}/widget."""
236
+
237
+ title: str | None
238
+ tagline: str | None
239
+ greeting: str | None
240
+ accent: str | None
241
+ autostart: bool
242
+
243
+
244
+ # GET and PUT /v1/org/sso: the OpenID Connect provider this org's people sign in at, and never the
245
+ # client secret it was wired with.
246
+ class OrgSso(WireModel):
247
+ """GET and PUT /v1/org/sso."""
248
+
249
+ configured: bool
250
+ issuer: str | None
251
+ client_id: str | None
252
+ domains: list[str]
253
+ role: str | None
254
+ required: bool
255
+ redirect_uri: str
256
+
257
+
258
+ # The configuration is replaced whole, the client secret included: it is write-only, so a change of
259
+ # anything else carries it again.
260
+ class OrgSsoWanted(WireModel):
261
+ """PUT /v1/org/sso, the body."""
262
+
263
+ issuer: str
264
+ client_id: str
265
+ client_secret: str
266
+ domains: list[str]
267
+ role: str | None = None
268
+ required: bool | None = None
269
+
270
+
271
+ # GET and PUT /v1/org/mail: the SMTP account this org's letters go out through, how the last one
272
+ # went, and never the password it was wired with. An org that wired none sends through the box's own
273
+ # mail, or through nothing.
274
+ class OrgMail(WireModel):
275
+ """GET and PUT /v1/org/mail."""
276
+
277
+ configured: bool
278
+ host: str | None
279
+ port: int | None
280
+ security: str | None
281
+ username: str | None
282
+ from_: str | None = Field(alias="from")
283
+ verified_at: str | None
284
+ last_error: str | None
285
+
286
+
287
+ # The account is replaced whole, the password included: it is write-only, so a change of anything
288
+ # else carries it again — and the standing is reset with it, since what a server said about the old
289
+ # password is not news about a new one.
290
+ class OrgMailWanted(WireModel):
291
+ """PUT /v1/org/mail, the body."""
292
+
293
+ host: str
294
+ port: int
295
+ security: Literal["starttls", "tls", "none"] | None = None
296
+ username: str | None = None
297
+ password: str | None = None
298
+ from_: str = Field(alias="from")
299
+
300
+
301
+ # GET and PUT /v1/ops/mail: the SMTP account the BOX posts its letters through — what the operator
302
+ # stored, or the environment's two variables — how the last one went, and never the password. An
303
+ # org's own account (OrgMail) still wins over both.
304
+ class BoxMail(WireModel):
305
+ """GET and PUT /v1/ops/mail."""
306
+
307
+ configured: bool
308
+ source: str | None
309
+ host: str | None
310
+ port: int | None
311
+ security: str | None
312
+ username: str | None
313
+ from_: str | None = Field(alias="from")
314
+ verified_at: str | None
315
+ last_error: str | None
316
+
317
+
318
+ # GET and PUT /v1/ops/brand, and `brand` on GET /.well-known/pinecall: what this box's letters and
319
+ # its sign-in page are called and painted with. Pinecall, its accent and no logo until the operator
320
+ # says.
321
+ class BoxBrand(WireModel):
322
+ """GET and PUT /v1/ops/brand, and `brand` on GET /.well-known/pinecall."""
323
+
324
+ name: str
325
+ logo_url: str | None
326
+ accent: str
327
+
328
+
329
+ # A field left out keeps what it had; an empty string goes back to the default — which for the logo
330
+ # is none at all, the only way to clear one.
331
+ class BoxBrandWanted(WireModel):
332
+ """PUT /v1/ops/brand, the body."""
333
+
334
+ name: str | None = None
335
+ logo_url: str | None = None
336
+ accent: str | None = None
337
+
338
+
339
+ # One box-wide identity provider as the operator reads it: whether it is usable, the client this
340
+ # gateway is at it, and the URI to register there. Never the secret.
341
+ class BoxProvider(WireModel):
342
+ """One box-wide identity provider as the operator reads it."""
343
+
344
+ configured: bool
345
+ client_id: str | None
346
+ redirect_uri: str
347
+
348
+
349
+ # GET /v1/ops/signin: every provider this box can offer every org's people, wired or not, one key
350
+ # each. Only `google` today; a second is a key here and a row on the box.
351
+ class BoxSignIn(WireModel):
352
+ """GET /v1/ops/signin."""
353
+
354
+ google: BoxProvider
355
+
356
+
357
+ # PUT /v1/ops/signin/google, the body: the OAuth client the operator made at Google for this
358
+ # gateway. Replaced whole, the secret write-only; the issuer is Google's own and is not a field.
359
+ class BoxProviderWanted(WireModel):
360
+ """PUT /v1/ops/signin/google, the body."""
361
+
362
+ client_id: str
363
+ client_secret: str
364
+
365
+
366
+ # POST /v1/org/mail/test: one letter, waited for — the only door of this runtime that waits for a
367
+ # mail server, because it is the one a person is watching.
368
+ class MailSent(WireModel):
369
+ """POST /v1/org/mail/test."""
370
+
371
+ sent: bool
372
+ error: str | None
373
+
374
+
375
+ # One org a sign-in page may send somebody to, named — and nothing about whether anybody answers to
376
+ # the address that asked.
377
+ class SsoOrg(WireModel):
378
+ """SsoOrg, as protocol/schema declares it."""
379
+
380
+ org: str
381
+ slug: str
382
+ name: str
383
+
384
+
385
+ # POST /v1/login/sso/discover: the orgs whose domains match an address's and that wired a provider.
386
+ # Empty is the answer for a domain nobody wired, and for a box that keeps no provider at all.
387
+ class SsoDiscovery(WireModel):
388
+ """POST /v1/login/sso/discover."""
389
+
390
+ orgs: list[SsoOrg]
62
391
 
63
392
 
64
393
  # One corner of a world holding an agent: the member whose it is, named so a person can read it.
@@ -342,3 +671,45 @@ class Remembered(WireModel):
342
671
 
343
672
  ops: int
344
673
  took_ms: float
674
+
675
+
676
+ # POST /v1/agents/{slug}/dial, the answer: the call a dial became, accepted before the far end has
677
+ # heard anything ring.
678
+ class Dialled(WireModel):
679
+ """POST /v1/agents/{slug}/dial, the answer."""
680
+
681
+ call: str
682
+ agent: str
683
+ to: str
684
+ from_: str = Field(alias="from")
685
+ env: Env
686
+
687
+
688
+ class DialGuards(WireModel):
689
+ """GET /v1/carrier/outbound, the guards: what this org may dial and how often."""
690
+
691
+ dial_anywhere: bool
692
+ per_minute: int
693
+ per_day: int
694
+ max_duration_s: int
695
+
696
+
697
+ # GET /v1/carrier/outbound, the answer: whether this org can place a call yet, and what is missing.
698
+ class CarrierOutbound(WireModel):
699
+ """GET /v1/carrier/outbound, the answer."""
700
+
701
+ ready: bool
702
+ kind: str | None
703
+ from_numbers: list[str]
704
+ steps_missing: list[str]
705
+ guards: DialGuards
706
+
707
+
708
+ class OutboundProvisioned(WireModel):
709
+ """POST /v1/carrier/outbound, the answer: the plan, and whether it was carried out."""
710
+
711
+ steps: list[str]
712
+ dry_run: bool
713
+ ready: bool
714
+ trunk: str | None = None
715
+ address: str | None = None