@xema/omni-protocol 0.1.106 → 0.1.107

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.
package/guide/voice.md ADDED
@@ -0,0 +1,602 @@
1
+ # `@xema/omni-protocol`: Voice
2
+
3
+ What is true of a call and of nothing else: preview, connecting back, ending a call, conference, every dial and its outcome, recording. Part of the contract in `guide.md`, which holds the terms, the shapes and the rules every file here relies on; a reference in bold names the file it points into where that is not this one.
4
+
5
+ ## Preview: the agent presses Call
6
+
7
+ An outbound campaign that lets the agent see who they are about to call is a **preview**: the
8
+ record arrives as a task and sits in `preview` with the party on it before a call goes
9
+ out. The agent prepares; manual or declared automatic initiation may then begin dialing. It
10
+ remains preview while ringing until an evidenced answer or non-answer outcome. The agent may press Call. That is the whole phase, and it exists on voice alone --
11
+ chat and email have no call to place, and their reading is ordinary work in `in-progress`.
12
+
13
+ ```ts
14
+ const previewed = {
15
+ channel: "voice",
16
+ capabilities: {},
17
+ phase: "preview",
18
+ party: { name: "Maya Rao", number: "+919876543210" },
19
+ previewEndsInSeconds: 120,
20
+ atDeadline: "provider-dials",
21
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "party" | "previewEndsInSeconds" | "atDeadline">;
22
+
23
+ // The agent presses Call. It is a dial like any other.
24
+ const pressed: TaskCommand<"voice"> = { type: "dial", dialId: "dial-7f2" };
25
+ ```
26
+
27
+ **Call is a dial.** It carries the agent application's `dialId`, is answered `dialling`, and ends in exactly one
28
+ `dial-outcome`; the phase is its gate and there is no capability, since a record put in front of an
29
+ agent is there to be called. Its audio starts on `dialling`, as every agent application-placed dial's does --
30
+ ring-back is audio the agent hears -- so `task-audio-started` arrives on the `preview` task with
31
+ its party ringing. On `answered` the task is `in-progress`; on any other outcome the audio ends,
32
+ `task-audio-ended` starts the wrap clock as on every voice task, and the task goes to
33
+ `completing`, so the agent records the
34
+ no-answer as the outcome it is -- in a campaign that is the commonest outcome there is, and it
35
+ is work, not a cancellation. A `preview` task therefore needs a manifest that says how a dial ends
36
+ (`task.preview.dialOutcomes.required`).
37
+
38
+ **Preparation and trigger ownership are per task.** Unlimited preparation omits both
39
+ `previewEndsInSeconds` and `atDeadline`: only an agent pressing Call starts dialing. Fixed
40
+ preparation carries the seconds the provider says are left, counted down on the desk from the
41
+ publication that carried them and restated on every later one, and exactly one trigger owner:
42
+
43
+ | Declaration | At preparation end |
44
+ | --- | --- |
45
+ | `atDeadline: "provider-dials"` | The provider initiates dialing. The agent application never sends a timer-triggered Call. |
46
+ | `atDeadline: "host-dials"` | The agent application sends the ordinary `dial` command with a fresh agent application `dialId`. The provider does not independently auto-dial this task. |
47
+ | `atDeadline: "waits"` | Neither side auto-dials. The task remains in preview until the agent presses Call. |
48
+
49
+ The agent may press Call early in either fixed-preparation dialing mode, or at any time in
50
+ `waits` mode. With `waits`, the countdown is a preparation target, not an expiry: when it reaches
51
+ zero the UI says "Waiting for agent" and keeps Call available. There is no automatic task-ended,
52
+ completion, dialing or phase change. No repeating timer action occurs on later snapshots. This differs
53
+ from unlimited preparation only by displaying a preparation target. The former `expires` deadline
54
+ value is not supported; elapsed preparation must not withdraw the task. For `provider-dials` and `host-dials`, the deadline ends
55
+ preparation and triggers initiation; it does not promise ringing, playable audio or customer
56
+ answer at that exact instant. Scheduling/dispatch delay must remain visible, and a failed or
57
+ prevented initiation must be reported rather than leaving a silently expired countdown.
58
+ A source eligibility threshold that may never trigger an attempt is not this promise.
59
+
60
+ The two deadline fields travel together and only in preview. The countdown is the seconds the
61
+ publication carried, counted from its receipt: the agent application infers no deadline where none
62
+ was stated and reads no clock against another. Neither elapsed time nor submission changes the
63
+ task phase: subsequent authoritative events state ringing, answer, audio and completion.
64
+
65
+ An agent application must serialize manual clicks and its timer under the exact provider/login/task/assignment
66
+ scope, allowing at most one unresolved submission. Recheck the current phase, deadline owner,
67
+ assignment and whether an attempt already exists before dispatch. A new snapshot or policy update
68
+ cancels an obsolete timer; login loss, disconnect, task end and assignment replacement fence its
69
+ callback. Reconnect past a deadline first reconciles current task and attempt state; it must not
70
+ blindly submit again. An uncertain prior submission remains unresolved, without automatic retry.
71
+ The provider atomically arbitrates any residual manual/timer race and does not accept a second
72
+ command as ownership of an already-running independent attempt.
73
+
74
+ Agent application-triggered automatic dialing is an ordinary agent application dial: its result is `dialling` with the same
75
+ agent application `dialId`, followed by exactly one correlated terminal `dial-outcome`. A provider-triggered
76
+ dial has no invented agent application ID or agent application dial outcome. It reports source-evidenced party ringing,
77
+ actual audio and customer answer separately. In preview with `atDeadline: "provider-dials"`, a ringing
78
+ party without an agent application ID may carry actual pre-answer audio; the deadline alone permits no audio.
79
+ Audio is still introduced/ended by the corresponding events and must not imply customer answer.
80
+ Submission acceptance is never an answered outcome or permission to publish `in-progress` early.
81
+ Unsupported source correlation/outcome/audio evidence remains an integration blocker.
82
+
83
+ **Drop both fields when the phase moves.** A provider that builds the `in-progress` task by
84
+ spreading the `preview` one carries `previewEndsInSeconds` and `atDeadline` with it, and the agent application refuses
85
+ the update (`task.preview.deadline.unexpected`): the desk keeps the task as it last stood, and the
86
+ provider is told through `refused` exactly which rule, so the state that looked right on its side
87
+ is named on its side. The task past preview has no deadline to wait for, so it carries neither
88
+ field.
89
+
90
+ **Provider execution and agent application controls are distinct.** Answer, hold/resume, conference,
91
+ lead assist and interaction completion are executed by the provider
92
+ through the adapter. The task's applicable capability, phase, completion mode and command-specific
93
+ prerequisites decide which controls are offered. Caller disconnect also executes at the provider. Microphone mute is agent application-owned, never a provider
94
+ TaskCommand or task capability. The separately declared agent application recording facility keeps its existing
95
+ recording contract; this distinction does not remove it or move provider call operations to the agent application.
96
+
97
+ A caller journey may revisit the same agent while an earlier interaction still wraps. Each is its
98
+ own assignment, and two open at once carry two assignment ids. Restore them unchanged on
99
+ reconnect. Commands, audio, endings and history reports name the exact assignment; never retarget
100
+ an old command to the newest interaction of a journey. `validateTaskCommandRequest` checks the
101
+ request's assignment against the supplied published task and then its command prerequisites. The
102
+ caller must select that task within the correct provider/login and recheck at the provider; the
103
+ validator cannot prove source freshness or authorization.
104
+ `task-ended` retires only that interaction's resources. Another interaction, caller channel or IVR/queue
105
+ portion must not be cleared because this task ended. Snapshot counts include all still-owned wrap
106
+ and active interactions; task completion is not caller hangup.
107
+
108
+ **Voice describes the agent's interaction within the wider call.** The task defines that agent's
109
+ workspace, tools, permissions, audio participation and completion work. The caller's channel may
110
+ continue through IVR, queues, other agents, holds or conferences while this interaction ends or wraps.
111
+ Completing this task ends this interaction responsibility; it is not evidence that the caller's channel
112
+ or journey ended. Task audio describes this agent's attachment, not the lifetime of every party's
113
+ connection. Commands retain their explicit targets and effects; task completion must not silently
114
+ become a caller-disconnect operation.
115
+
116
+ **A task is never its audio.** A voice task represents the agent’s interaction: the call is offered when it is
117
+ routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
118
+ the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
119
+ conference leg joining or leaving, a lead joining, a take-over, a connect-back — the audio follows
120
+ separately, arriving on `task-audio-started`, attaching through `openAudio` and ending with
121
+ `task-audio-ended`. Omni does not ring,
122
+ bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
123
+ are the adapter's and the platform's, transient, and decide neither when a task exists nor what
124
+ phase it is in.
125
+
126
+ The line runs between the provider's word and Omni's own senses. `task-audio-ended` is the
127
+ provider's report that primary interaction ended — a fact about the work, which is why the completion
128
+ allowance starts on it and the Connect back control appears on it — and Omni follows that report as it
129
+ follows any other. What Omni never does is derive a task's state from its own audio session: a
130
+ stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
131
+ endpoint re-registering change nothing about the task until the provider says so. Structurally:
132
+ `task-audio-started` and `task-audio-ended` alternate on a task whose work has begun, audio ends
133
+ only where it arrived, what follows the audio ending is `completing` or `task-ended`, and every
134
+ task is introduced once — `exerciseAdapter` holds the stream to that from the connect snapshot on,
135
+ and `assertAudioFollowsTheTask` holds any sequence.
136
+
137
+ ## Connecting back during completion
138
+
139
+ A task that declares `connectBack` lets the agent connect back to the party while the task is
140
+ `completing` -- to finish what the call left unfinished, on the same task rather than a new one,
141
+ whoever placed the call in the first place. Most such calls came in, so this is not a redial: the
142
+ agent rings a party who rang them. Omni issues `{ type: "connect-back", dialId }`; it is issuable
143
+ only in `completing`, and only where the capability is declared. The provider knows who the party
144
+ is; the command carries no destination, and it is a dial like any other, so it carries the agent application's
145
+ `dialId`, is answered `dialling`, and ends in one `dial-outcome`.
146
+
147
+ On `dialling` the provider is placing the call and the task returns to `in-progress`: the agent is
148
+ working again, and the wrap allowance is **discarded, not paused**. From there the call is
149
+ reported as any call is -- `paused`, `in-progress`, and when its audio ends, `task-audio-ended`
150
+ again, which starts a fresh allowance from that instant. A party who does not answer is a dial
151
+ whose outcome says so and a call whose audio ended: the task returns to `completing` through the
152
+ same event and the clock starts again from there. At no point is an agent dialling against a
153
+ deadline.
154
+
155
+ **The room shows the party being dialled.** From the moment any agent application dial places the party -- a
156
+ dialpad call, a preview Call, a connect-back -- the `party` entry carries `stage: "ringing"` and
157
+ the agent application's `dialId`, exactly as a conferenced entry does from its dial; `joined` on
158
+ the publication that follows the answered `dial-outcome` and never before it
159
+ (`stream.taskUpdated.stage`); and no stage from the next publication on, so `joined` is transient
160
+ and a later copy still carrying it is stale (`stream.taskUpdated.stage.lingering`). An agent application shows a
161
+ call being placed, not a party it asserts is on a call that is still ringing. A callback the platform places itself on the same task -- the first
162
+ call over, the platform dialling the party again without a command -- shows the same thing with no
163
+ agent application `dialId`, as any platform-placed dial does. A party with no stage is on the call, and a dial
164
+ with no stage is half a claim (`task.onCall.party.dial`). The stage is also what lets the stream
165
+ tell a task coming back from one going backwards (`stream.taskUpdated.phase`).
166
+
167
+ **The control exists only while there is a window to use it in.** Under `agent-command` the task
168
+ stays `completing` until the agent completes it, so the window is open for as long as they need.
169
+ Under `provider-automatic` the window is the allowance -- and with `wrapAllowance: 0` there
170
+ is none: the provider completes the task at provider end, and Omni does not offer Connect back,
171
+ whatever the task declares. A capability names a control that can be used; on a task with no `completing`
172
+ window it cannot, and declaring it there changes nothing.
173
+
174
+ ```ts
175
+ const connectBackCapable = {
176
+ channel: "voice",
177
+ capabilities: { hold: true, connectBack: true, outcomes: true },
178
+ phase: "completing",
179
+ completionMode: "provider-automatic",
180
+ wrapAllowance: 30,
181
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "completionMode" | "wrapAllowance">;
182
+ ```
183
+
184
+ With ten seconds of the thirty left, the agent presses Connect back: `execute({ command: { type:
185
+ "connect-back", dialId } })` returns `dialling`, the task is `in-progress`, and the thirty seconds
186
+ are gone.
187
+ The second call ends: `task-audio-ended`, the task is `completing`, and a new thirty seconds runs
188
+ from that instant.
189
+
190
+ ```ts
191
+ const immediateProviderCompletion = {
192
+ completionMode: "provider-automatic",
193
+ wrapAllowance: 0,
194
+ } satisfies Pick<Task, "completionMode" | "wrapAllowance">;
195
+ ```
196
+
197
+ ## Voice capabilities
198
+
199
+ | Capability | Omni UI | Contract |
200
+ | --- | --- | --- |
201
+ | `decline` | Pending-task button: Decline | The provider can decline a pending voice offer. Omni shows it only when local policy also permits declining. |
202
+ | `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
203
+ | `endCall` | Primary button: End call | The provider ends the agent's channel and every channel the agent added; the caller continues on the provider's path. The task goes to its wrap. See **Ending a call, and removing one person from it**. |
204
+ | `terminateCall` | Primary button: Terminate call | The provider ends the whole call: every channel on it, the agent's, the caller's, any colleague's and anyone else's. The task goes to its wrap. See **Ending a call, and removing one person from it**. |
205
+ | `connectBack` | Completing-task button: Connect back | Omni may have the provider connect the agent back to the task's party while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance completes the task at provider end. See **Connecting back during completion**. |
206
+ | `schedule` | Secondary menu item: Schedule | Omni may put a follow-up for this party on the calendar, from the call or from its wrap: a time, and a note. See **Scheduling a follow-up** in `guide/agent.md`. |
207
+ | `leadAssist` | Secondary menu item: Lead assist | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.leadAssist`. See **Lead assist** in `guide/lead.md`. |
208
+ | `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and remove one person from it -- a conferenced entry, one still ringing included, which calls the dial off, or the party, leaving the agent with the colleague. See **Ending a call, and removing one person from it**. |
209
+ | `recording` | Overflow menu item: Recording | Per-task provider and agent application policies expose only their permitted recording actions; each has independent state and routing. |
210
+ | `outcomes` | Primary button: Complete | Omni may request task completion with a provider outcome and notes. |
211
+
212
+ ## `conference`
213
+
214
+ ```ts
215
+ capabilities: {
216
+ conference: {
217
+ destinations: [
218
+ { id: "tier2", label: "Tier 2 support" },
219
+ { id: "main-menu", label: "Back to the main menu" },
220
+ ],
221
+ },
222
+ }
223
+ ```
224
+
225
+ | Field | Contract |
226
+ | --- | --- |
227
+ | `destinations` | The items the control offers, at least one, with unique `id` values. Each is a button or a menu item on the desk. |
228
+ | `id` | What Omni sends as `destinationId` on a `conference` command, and what the provider executes. |
229
+ | `label` | What the agent reads. |
230
+
231
+ **The protocol does not say what an item does.** A queue, an IVR menu, an outside line, a
232
+ skill group -- that is the queue's configuration and the provider's business. The agent sees a
233
+ list of labels, picks one, and Omni sends its `id`; the provider executes whatever the item is. No
234
+ kind travels, nothing is typed, and a control with an empty directory has nothing to offer and is
235
+ refused (`task.destinations.offer`). A control declared as bare `true` is refused for the same
236
+ reason (`task.destinations.shape`): once nothing is typed, the directory is the control.
237
+
238
+ **The provider chooses the supported destinations.** A published item may route to an IVR,
239
+ queue, named agent or another configured destination. No destination kind is inferred from its
240
+ label. The agent application offers only the directory attached to this task's `conference` capability,
241
+ and sends the exact destination ID; it does not invent an agent picker or arbitrary dial target.
242
+ The provider rechecks eligibility and executes the routing. Returning to an IVR or queue does not
243
+ end the caller journey; a later assignment to the same agent is a new interaction.
244
+
245
+ ## Ending a call, and removing one person from it
246
+
247
+ A call has everyone on `Task.onCall`, and three commands take people off it, all performed by the
248
+ provider. Two of them end the agent's part; they differ in what happens to the caller.
249
+
250
+ ```ts
251
+ { type: "end-call" } // the agent's channel and the channels the agent added end; the caller continues
252
+ { type: "terminate-call" } // every channel on the call ends
253
+ { type: "conference", action: "remove", party: true } // the customer leaves; the agent stays with the colleague
254
+ { type: "conference", action: "remove", destinationId: "tier2" } // the conferenced person leaves; ringing, this calls the dial off
255
+ ```
256
+
257
+ **`end-call` ends my part.** The agent's channel and every channel the agent added -- a
258
+ conferenced colleague, a lead who joined -- end, and the caller continues on the path the
259
+ provider decides: another IVR, a queue, a survey, or the end of the call. Gated by `endCall`.
260
+
261
+ **`terminate-call` ends the whole call.** Every channel on it ends at the provider: the agent's,
262
+ the caller's, any colleague the agent added, and anyone else on the call. Nobody is left on the
263
+ line. Gated by `terminateCall`, a separate permission, since ending a customer's call is a
264
+ different thing from putting my own side down.
265
+
266
+ Both are commands to the provider through `execute`, and nothing happens on the desk until the
267
+ provider reports it: the agent's audio ends on `task-audio-ended`, the task moves to `completing`,
268
+ any wrap allowance runs, and the agent completes. Neither is `task-ended`, and `complete` is
269
+ neither of them. Both capabilities are the queue's to grant and a level's to lock, and both may
270
+ change while the task is open: the provider republishes the task at the moment a permission
271
+ changes, and a command that arrives after its capability was withdrawn is refused, as **Task
272
+ capabilities** sets out. The provider knows which channels are the agent's and which were added;
273
+ the agent application sends the command and no channel list, and the provider rechecks ownership
274
+ when it acts.
275
+
276
+ `conference` `remove` takes one person off, named as the room names them, and the call goes on
277
+ for the rest; it is gated by `conference`, since it only means something with a third person on
278
+ the line. Removing the party leaves the agent with the colleague, a warm hand-over in reverse. A
279
+ remove that would leave the agent alone is not a remove but an `end-call`, and a provider answers
280
+ it `failed`.
281
+
282
+ **There is no transfer.** The agent hands the call to nobody, cold or warm: help arrives by
283
+ **Lead assist** in `guide/lead.md`, where a lead joins the call or takes it over, and a call that changes hands does
284
+ so on the platform's own routing -- an IVR return, a queue, a lead's take-over -- with the record
285
+ saying so in a `transferred` step and the `transfers` total. Nothing the agent commands moves the
286
+ caller to somebody else.
287
+
288
+ ## Idle actions
289
+
290
+ ### `dial(request)`
291
+
292
+ Starts one outbound call from the idle dialpad. It is present only when the voice provider
293
+ declares `dial`.
294
+
295
+ - `dialId` is the agent application's identity for this dial, minted before the request leaves.
296
+ - `destination` is the original number selected or entered by the agent.
297
+ - The provider holds `destination` to its declared `destinations`: under `contacts-only`, a
298
+ number that is not one of its contacts answers `failed` with `omni.destination-not-permitted`.
299
+ - `dialling` says the request was accepted and the call is being placed, and restates the
300
+ `dialId`. It says nothing about whether anyone will answer.
301
+ - `failed` contains a `ProtocolFailure` and confirms no call was placed.
302
+
303
+ The resulting call is offered through the normal `task-offered` event, and how the dial ended
304
+ arrives on `dial-outcome` under the same `dialId`. A `dialling` result does not manufacture a task
305
+ inside Omni.
306
+
307
+ ## Every dial has an outcome
308
+
309
+ Four commands place a call: `dial()` from the idle dialpad, the `dial` command from preview, a
310
+ `conference` `add`, and `connect-back`. Each is accepted or refused at once, and each then ends later
311
+ and apart from its answer -- the destination picks up, is busy, or never does -- and a dial placed
312
+ late in a call routinely outlives the call. Nothing in between is reported: the wire says
313
+ `dialling`, then says how it ended, once.
314
+
315
+ **The agent application mints the identity.** Every command that dials carries a `dialId` the agent application made, unique
316
+ across the installation, and it travels four ways so no leg is a lookup:
317
+
318
+ ```ts
319
+ declare const connection: Connection<"voice">;
320
+ declare const assignmentId: AssignmentId;
321
+
322
+ // 1. Out, minted by the agent application.
323
+ const result = await connection.execute({ assignmentId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
324
+
325
+ // 2. Back on the result, restated, so the agent application compares and confirms.
326
+ expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
327
+
328
+ // 3. On the outcome, however late, against the task it belonged to.
329
+ const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", assignmentId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
330
+
331
+ // 4. In the record, so a dial made before a take-over is placeable by whoever holds the task now.
332
+ const step: TaskHistoryStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };
333
+ ```
334
+
335
+ The identity is a correlation handle between agent application and provider and **is never shown to the
336
+ agent**. An agent application holding several tasks places an outcome by the `dialId` it minted; one it did not
337
+ mint -- a dial made by the previous agent before a take-over -- it finds on the task's `onCall` or in
338
+ its `history`, which is why the steps that dial carry it. The `dialId` is not a retry key:
339
+ Omni never repeats a dial, and a command still carries no key for that purpose.
340
+
341
+ **`dialling` is the answer to a dial, and `applied` is not.** A command that dials answers
342
+ `{ status: "dialling", dialId }`, restating the agent application's identity (`result.dialId`,
343
+ `result.dialId.mismatch`); `applied` from such a command, or `dialling` from one that dialled
344
+ nothing, is refused (`result.status`). `validateResult(result, method, path, dialId)` takes the
345
+ `dialId` the agent application sent for that reason.
346
+
347
+ **The outcome is stated, once, either way.** A `dial-outcome` names the `dialId`, the task where
348
+ there was one, the directory item it went to where there was one, and how the dial ended -- from a closed
349
+ set: `answered`, `busy`, `no-answer`, `unreachable`, `rejected`, `cancelled`, `unexplained`.
350
+ `answered` is stated rather than read off somebody appearing on the call, because an agent application that infers
351
+ success cannot tell "reached" from "still ringing". `cancelled` is the dialler calling the dial off
352
+ before anyone answered -- the agent hanging up while a conferenced colleague still rings -- which is not a
353
+ `no-answer` the destination never gave. `unexplained` is the switch dropping the call with no cause among
354
+ those named here: a statement about the switch, sent instead of the nearest cause it did not give.
355
+ `reason` is the switch's own words, optional, and shown to the agent attributed to the switch rather
356
+ than to Omni. The two are different things and may travel together: a switch can say "could not
357
+ create dialog" in words and still name no cause a code would carry, so `unexplained` with a
358
+ `reason` is a dial the switch described but did not classify, and a desk renders the words without
359
+ inventing the class.
360
+
361
+ **`assignmentId` is the task the dial was placed on**, resolved from the dial's own identity and never from
362
+ whatever task the agent is looking at when the outcome arrives. That task may already have ended,
363
+ and the harness places the outcome all the same (`TaskStream`, `stream.dialOutcome.unknown`,
364
+ `stream.dialOutcome.duplicate`); an agent who moved on still learns nobody was reached, against the
365
+ call it belonged to and never against the next one.
366
+
367
+ The stream knows a dial from three places: `TaskStream.dialled(dialId)`, which an agent application calls when it
368
+ places one; an entry on a task's `onCall`; and a step in its `history`. Since a dialled entry
369
+ is listed from placement, `dialled()` is load-bearing only for a dial that never has an entry -- an
370
+ idle dialpad call, which has no task yet -- or whose entry has already left the room; an
371
+ outcome for a dial nobody placed and no task mentions is what `stream.dialOutcome.unknown` refuses.
372
+
373
+ **`answered` says what happened to the dial; `onCall` says who is in the room.** They are two
374
+ claims, and both are true at once when a leg answers and never joins. A desk shows `answered` as the
375
+ dial's conclusion and says "on the call" only when the person appears on `Task.onCall`; the obvious
376
+ reading of `answered` -- that they are on the line -- is the one it does not make.
377
+
378
+ **A provider says which outcomes it can tell apart.** No switch distinguishes all five, and a
379
+ provider that cannot tell busy from unreachable must not pick one. The manifest declares
380
+ `dialOutcomes`, and a `dial-outcome` carries only a declared member
381
+ (`event.dialOutcome.undeclared`). The list includes `answered`, or the provider could never state a
382
+ success (`manifest.dialOutcomes.answered`), and at least one other member, or it could never state
383
+ a failure (`manifest.dialOutcomes.failure`). `unexplained` is declarable only beside a cause the
384
+ provider does report -- `busy`, `no-answer`, `unreachable`, `rejected` or `cancelled`
385
+ (`manifest.dialOutcomes.unexplained.alone`): a provider that reports causes may honestly say the
386
+ switch gave none, while one that reports none would be sending the generic failure this set exists
387
+ to refuse. A provider that dials at all declares it: an idle
388
+ dialpad requires it (`manifest.dialOutcomes.required`), and a task that declares `connectBack`
389
+ or `conference` under a manifest without it is refused
390
+ (`task.capability.dialOutcomes.required`). Off voice nothing dials, and the field is a compile
391
+ error there.
392
+
393
+ **Who is on the call is stated on the task.** `Task.onCall` lists everyone the task's audio joins
394
+ right now, replaced whole with the task like every other field, and present when the provider
395
+ knows the room:
396
+
397
+ ```ts
398
+ const onCall: OnCall[] = [
399
+ { role: "party", since: "2026-08-21T09:12:00Z" },
400
+ { role: "agent", userId: "A-12", since: "2026-08-21T09:12:04Z" },
401
+ { role: "conferenced", destinationId: "tier2", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
402
+ { role: "conferenced", destinationId: "main-menu", dialId: "dial-3c9", stage: "ringing", since: "2026-08-21T09:16:02Z" },
403
+ ];
404
+ ```
405
+
406
+ `party` is the customer and `agent` a person by user id; neither was dialled from anywhere, so
407
+ neither carries a destination. A `conferenced` colleague was, so the entry carries the
408
+ `destinationId` of the directory item and, where an agent application's dial brought them, its `dialId`; a person
409
+ the platform added itself carries none. `held: true` is presence as claim. A snapshot establishes state, so an agent application that attaches
410
+ after a take-over reads the room from here rather than inferring it from a sequence of outcomes it
411
+ never saw.
412
+
413
+ **`onCall` describes this agent's current interaction.** When the caller disconnects but the agent
414
+ and added channels remain connected, the remaining room is still valid. When this interaction's
415
+ audio ends or the task enters `completing`, clear its `onCall` view; absence is used only by a
416
+ provider that never publishes the room. This does not assert that the caller, bridge or other
417
+ agents' channels ended. A published room receives a final empty view for this interaction, and the
418
+ task may remain open for wrap. Completion is a separate task action, not the caller's disconnect.
419
+
420
+ **A field that describes the present is cleared by the transition that ends it.** `onCall`,
421
+ `previewEndsInSeconds` and `atDeadline` are three instances of one shape, and there will be more: each
422
+ describes a state that is true now, and each is carried past the moment it stops being true by the
423
+ most natural implementation there is, building the next task by spreading the last one. The
424
+ provider's own state looks right, the claim on the wire is stale, and only the agent application sees it. An
425
+ adapter that builds each transition from what is true after it, rather than from what was true
426
+ before, needs none of these rules named. Two agents were
427
+ once shown on calls for the better part of an hour while callers queued, because the last message
428
+ describing the room live was never followed by one saying it had emptied, and every consumer that
429
+ derived anything from it was wrong in a way that looked like its own bug.
430
+
431
+ **A dialled entry is listed from the moment the dial is placed, not from the answer.** The entry is
432
+ what `conference` `remove` names, and a destination the agent cannot call off while it rings is a
433
+ customer kept waiting with no way back -- which is the very case `cancelled` exists for. The
434
+ `conferenced` entry appears with its `dialId` when the dial is placed, and a `conference` `remove`
435
+ naming a still-ringing `destinationId` calls that dial off, with `cancelled` as its outcome.
436
+
437
+ **The entry says where it stands.** A dialled entry carries `stage`: `ringing` until its dial is
438
+ answered, `joined` after. The `dial-outcome` is the transition and the stage is the state, the same
439
+ pairing as `task-audio-started` and `audio`: an `answered` outcome and the task restated with the
440
+ entry `joined` say one thing twice, and any other outcome removes the entry. The outcome comes
441
+ first: a `task-updated` that itself moves an entry from `ringing` to `joined` before an `answered`
442
+ outcome for its dial is refused (`stream.taskUpdated.stage`), as an update that moves `audio` is.
443
+ The rule keys on the entry's `dialId`, so an entry the platform added itself, with none, is one
444
+ whose move the stream cannot hold to an outcome: a clean stream proves nothing about ordering there. It is stated rather
445
+ than left to whoever placed the dial because that knowledge lives in one process: after a reload,
446
+ on a second session, or on a supervisor's screen the snapshot is the only source, and a room that
447
+ cannot tell ringing from joined says it contains someone who may never arrive. Nobody ringing is
448
+ held (`task.onCall.held.ringing`). A colleague who never answers is a dial that ended: the
449
+ provider restates the room without the entry, and the agent is back with the customer.
450
+
451
+ The desk shows a dial as placed on `dialling`, never as reached; shows the person in the room when
452
+ they appear on `onCall`; and shows the outcome, with the switch's reason, against the call it
453
+ belonged to -- with no retry, since a wrong number is not worth redialling and the decision is the
454
+ agent's.
455
+
456
+ ## Independent task recording
457
+
458
+ Recording is voice-only. A task can arrive already recording, including while pending, and offer
459
+ no recording controls. The provider publishes only its own current state on
460
+ the provider field of the task’s `recording` state. The agent application publishes its own full scoped view in `HostReport.recordings`.
461
+ A provider task update cannot replace agent application state. Neither a capability nor an accepted command
462
+ establishes recording. Missing state, lost recorder observation, transport loss and expired
463
+ observation mean unknown; they never mean stopped. Paused means a recording remains open without
464
+ capturing. Task hold, task pause and recording pause are independent.
465
+
466
+ The per-task policy is `Task.capabilities.recording`, with independently optional provider and agent application
467
+ action sets. The outer capability may be locked. Absence grants no permission, and a state may
468
+ exist without any permission. A provider may offer only stop for a recording started automatically,
469
+ or withdraw a control on a later task update. There is no global recording mode and no automatic
470
+ start from a capability declaration. Agent application support is declared on `ConnectContext.host.recording`,
471
+ not the provider-owned manifest. The task names one of the storage places the agent application provisioned, by `storageId`;
472
+ an unknown storage place or an unsupported action is refused visibly, never redirected to provider
473
+ recording or a default upload location. Initial agent application support requires voice softphone audio and
474
+ capture of both local and remote audio; an agent application unable to capture either must refuse start/resume.
475
+ This package declares that contract; it supplies no recorder, audio mixing, storage or upload.
476
+
477
+ | Action | Required current recording state | Confirmed outcome |
478
+ | --- | --- | --- |
479
+ | start | inactive | active with a new recording identity |
480
+ | pause | active | paused, preserving recording identity and captured audio |
481
+ | resume | paused | active with the same recording identity |
482
+ | stop | active or paused | inactive; finish and retain captured audio |
483
+ | cancel | active or paused | inactive; abandon and discard captured audio |
484
+
485
+ Stop finishes the recording and retains captured audio. Cancel abandons the recording and discards
486
+ its captured audio; the task policy offers it with `cancel: true`. There is no configurable cancel
487
+ effect. A recorder that cannot confirm completion must not offer Cancel; it can offer Stop instead.
488
+ Cancel never means cancelling an in-flight start request or deleting arbitrary past recordings.
489
+ Stop can be applied only after finalization/retention succeeds; Cancel only after both cessation
490
+ and completion of this recording's audio succeed under the recorder's storage contract.
491
+ Partial success -- capture stopped but the storage outcome unknown, a start the recorder cannot
492
+ vouch for either way -- is a settled `failed`, under the code `omni.recording-unsettled`: the
493
+ command did not do what it promised, and the state the provider publishes with it is whatever
494
+ capture state is actually known. It is never `applied`, and never an unsettled promise the agent
495
+ application would be left to resync, since the provider knows exactly what it could not settle.
496
+ There is no request identity on a recording command: the outcome is the state, and what the
497
+ agent sees is the state. Retention is not a promise of sample-perfect audio.
498
+
499
+ Provider commands go exclusively to `Connection.execute`; agent application commands go exclusively to
500
+ `HostRecording.execute`. Both include the assignment and observation identities; all
501
+ non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
502
+ task and recorder owner. They are never inferred from filenames or current agent identity. A
503
+ recording ID survives pause/resume and reassignment only where the same recorder confirms continuity;
504
+ commands always name the current assignment. A later start gets a different ID. Reconnect does not
505
+ create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
506
+ There is no automatic restart, hand-over to another recorder, or stop on task hold/disconnect.
507
+ Task removal does not prove recording stopped: outstanding agent application recorders remain tracked by the
508
+ agent application until its executor reconciles/finishes them, with visible unresolved cleanup failures.
509
+
510
+ Only start/resume require an in-progress or paused task with started audio. Pause/stop/cancel may
511
+ also finish an independently observed recorder while the task is completing. A pending task may
512
+ show active recording, but agent controls wait until interaction begins. The two recording paths may
513
+ both be active. A command to either path has no implied effect on the other.
514
+
515
+ Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
516
+ observation instant and an exclusive expiry. These times describe current evidence, never historical
517
+ capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
518
+ Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
519
+ Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
520
+ consumers using the explicit recording-expiry clock-estimation exception must invalidate that
521
+ estimate and use monotonic aging so clock rollback cannot
522
+ revive expired evidence. The executor compares observation identity and recording identity against
523
+ its latest state atomically before I/O. An intervening observation or assignment change refuses the
524
+ stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
525
+ identity or observation time publish unknown and offer no issuable controls.
526
+
527
+ Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
528
+ Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
529
+ task and explicit observer-domain time. For agent application commands also supply the current agent application declaration,
530
+ full agent application report and softphone context. The same checks must run at the executing boundary;
531
+ client validation alone is not authorization. The executor serializes operations on each recorder
532
+ and compares `observationId` and `recordingId` against its latest evidence before acting: a
533
+ command against superseded evidence is refused, and nothing is retried on the executor's own
534
+ account.
535
+
536
+ Keep command progress in agent application UI separately from authoritative state. Applied means the requested
537
+ recording transition and any retain/discard effect actually completed; publish the confirming task or agent application report before
538
+ resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
539
+ show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
540
+ updates remain full current task/agent application views, not replayed provider events. These current-state fields
541
+ create no interaction-history entries and infer no actors, durations or historical capture boundaries.
542
+
543
+ Migration is explicit: replace the old true recording capability with per-path action policies,
544
+ replace untargeted commands with scoped commands, and retain protocol version 1. During pre-release
545
+ development, hosts and adapters must align their recording contracts; negotiation does not detect
546
+ differences between package revisions that share that protocol number. No legacy-shape fallback is provided.
547
+ This change does not publish a package or enable recording in any existing agent application/provider by itself.
548
+
549
+ `validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
550
+ semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
551
+ retention/completion or source truth from a status flag; those remain executor obligations.
552
+
553
+ The storage place named on the task is bound when start actually creates a recording. Existing recordings
554
+ keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
555
+ The executor rejects a mismatched storage place rather than moving or discarding another binding.
556
+ Action permission does not authorize unattended invocation: agent application controls require the agent's explicit
557
+ act, and provider authorization remains enforced at its authenticated command boundary.
558
+
559
+
560
+ Recording dispatch must receive the same known task-validation context as the published task.
561
+ Pass organisation levels and other known restrictions through the optional fourth argument of
562
+ `validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`. This keeps a valid
563
+ custom organisation lock from being rejected against the default ladder, and prevents known
564
+ capability restrictions from disappearing at dispatch. The standalone `validateRecordingCommandState`
565
+ requires an affirmative permission; false, malformed permission declarations and unknown actions grant nothing.
566
+
567
+
568
+ ### Agent application recording announcements to the caller
569
+
570
+ `HostRecording.announcesToCaller`, when true, is a guarantee that the agent application delivers audible recording
571
+ status messages to the remote party over the call's outgoing audio. It belongs inside the optional
572
+ agent application recording capability: without that capability there is no such guarantee. Omission makes no
573
+ promise; false is invalid. It does not belong in the provider manifest, the task's recording policy
574
+ or the general `HostGuarantees` object. A provider can inspect this agent application guarantee when deciding
575
+ whether to permit agent application recording on a task.
576
+
577
+ The guarantee applies only to agent application-owned recordings. Providers implement announcements for their
578
+ own recordings at their end; provider state updates must not cause the agent application to announce those
579
+ recordings. Both recorders can operate independently on the same call.
580
+
581
+ On a confirmed agent application start, the remote party hears that recording started. On a confirmed stop or
582
+ cancel, they hear that recording stopped. If pause/resume is supported, say paused/resumed so a
583
+ pause is not presented as a finished recording. Announcements follow actual capture transitions,
584
+ not button clicks, accepted requests or task-policy changes. When attaching to an already-active
585
+ agent application recording, announce that recording is active, without inventing its original start time.
586
+ Repeated observations of the same state do not repeat announcements. Unknown or expired evidence
587
+ must never produce a stopped announcement.
588
+
589
+ A local sound, UI indicator, screen-reader message to the agent, or sound leaking through the
590
+ microphone does not fulfil this guarantee. The agent application must deliver the message directly in the audio
591
+ sent to the remote party, even when the agent microphone is muted. It may also notify the agent.
592
+ Only declare the guarantee when the agent application can provide that outgoing audio path. An announcement
593
+ that cannot be delivered, including after the remote party has disconnected, is a visible failure;
594
+ never claim delivery or replay the notice into a different call.
595
+
596
+ For a command under this guarantee, applied requires both the recording action and its announcement
597
+ to succeed. If capture changes but announcement delivery fails, publish the actual recording state,
598
+ report the announcement failure visibly and answer `failed` under `omni.recording-unsettled`. Do not claim no
599
+ effect, automatically repeat the recording action or silently switch to an agent-only notice.
600
+
601
+ `validateHostRecording` checks the declaration, including its true-or-absent guarantee. Actual audio
602
+ delivery remains the agent application implementation's responsibility and must be exercised in its own tests.