@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/agent.md ADDED
@@ -0,0 +1,1090 @@
1
+ # `@xema/omni-protocol`: The agent's work
2
+
3
+ What an agent does on any channel: the assignment from offer to wrap, the task and its record, the controls a task offers, the commands the desk sends. 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
+ ## Task assignment lifecycle
6
+
7
+ The task-assignment lifecycle has five ordered stages:
8
+
9
+ **1. The agent signs in.** Nothing on the wire says ready or not-ready: the agent is not ready
10
+ until the agent application has stated a capacity, and ready once it has. A provider assigns nothing before a
11
+ capacity is stated, and needs no other word for it.
12
+
13
+ **2. The agent becomes ready.** The agent application states the capacity when its local policy says: at once,
14
+ or when the agent presses Ready. A successful connection, a healthy provider, or the absence of a
15
+ break does not imply readiness. After that, an agent stops taking work on this provider only by a
16
+ break under `capabilities.breaks`, or by the agent application restating `count: 0` -- which is how
17
+ a forced break on another provider reaches this one; a login that declares no breaks has no
18
+ not-ready of its own, and `count: 0` is the agent application's division of capacity, not the
19
+ agent's choice.
20
+
21
+ **3. Omni states their concurrent capacity.** Only now does Omni ask providers for work, saying how
22
+ much the agent can take at once. It is a standing declaration rather than a poll: Omni restates the
23
+ capacity every time it changes, in either direction, and does not ask again while that value holds.
24
+ Silence keeps the last one in force, so a provider that waits to be asked a second time will never
25
+ deliver again once it has gone quiet. Hold the capacity and deliver when work arrives.
26
+
27
+ **4. A provider offers a task.** The provider emits `task-offered` within the stated capacity, and
28
+ the task is `pending`: an offer introduces work nobody has accepted, so it is the only phase an
29
+ offer introduces (`event.taskOffered.phase`). `confirmed`, `preview` and the rest are reached from
30
+ it, or carried on a snapshot.
31
+
32
+ **5. Omni decides how the task is accepted.** When `autoAcceptTasks` is `false`, every task requires
33
+ agent acceptance. When it is `true`, the pending task's `acceptance` states the provider's
34
+ intent.
35
+
36
+ ### Acceptance modes
37
+
38
+ During login, Omni sends the agent's `autoAcceptTasks` value to the provider. When it is `true`, the
39
+ provider states `acceptance` on each pending task — on the task rather than the offer, so a
40
+ reconnect snapshot says it too and an offer the agent application never received is not accepted on the
41
+ person's behalf for want of a word:
42
+
43
+ | Directive | Contract |
44
+ | --- | --- |
45
+ | `no-preference` | The provider leaves acceptance to Omni; with `autoAcceptTasks: true`, Omni accepts automatically. |
46
+ | `consent` | The provider requires the person's explicit consent: Omni presents **Accept** and waits, whatever its own policy would have done. An agent application that declares `guarantees.personConsent` promises exactly this; a provider checks it before offering work only a person may take. |
47
+ | `automatic` | Omni accepts immediately without agent interaction. |
48
+
49
+ When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task the
50
+ queue routes requires agent acceptance. The two are never confused on the wire: `consent` is always the
51
+ provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own
52
+ no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
53
+ press is Omni's doing, not the provider's.
54
+
55
+ **The value is stated, never assumed.** `autoAcceptTasks` is required of every connection: a
56
+ agent application says which policy the deployment provisioned, and a validator that is not told checks
57
+ neither rule rather than guess the permissive one (`task.acceptance.required` and
58
+ `.unexpected` fire only against a stated value). `acceptance` is the provider's own control and
59
+ outranks the policy: `consent` puts the decision back in the agent's hands for any task where it
60
+ belongs, whatever the agent application was configured with.
61
+
62
+ An automatically accepted task still arrives through `task-offered`.
63
+
64
+ **Work the agent originated is accepted by the command that created it.** A task born of the
65
+ agent's own act — a dialpad call or a connect-back, recognisable by the agent application's `dialId` on
66
+ `onCall`; a call a lead took over, carrying `takenOver` — arrives
67
+ through `task-offered` with `acceptance: "automatic"` whatever `autoAcceptTasks` says, and the
68
+ validator holds it there under either local policy and under none (`task.acceptance.originated`):
69
+ the desk shows no **Accept** for a call the agent placed. The local policy governs work the queue
70
+ routes to the agent, and nothing else.
71
+
72
+ ### Pending
73
+
74
+ A task in the `pending` phase has been **offered to the agent and not yet accepted**. Omni applies
75
+ `autoAcceptTasks` and the task's `acceptance` to decide whether acceptance is
76
+ automatic or requires the agent. A provider that requires automatic acceptance still emits
77
+ `task-offered`; it does not introduce new work as `in-progress`.
78
+
79
+ ```ts
80
+ declare const task: Task;
81
+
82
+ const assignment = {
83
+ type: "task-offered",
84
+ task: { ...task, phase: "pending", acceptance: "consent", expiresInSeconds: 30 },
85
+ } satisfies Extract<ProviderEvent, { type: "task-offered" }>;
86
+ ```
87
+
88
+ The rule the phase exists to express: **nothing is acquired on the agent's behalf while a task
89
+ is pending.** An agent application that carries audio must not open the microphone until the task is
90
+ accepted. Omni does not open the task's browsers either — a task that rings out costs nothing.
91
+
92
+ When consent is required, Omni offers the agent an **Accept** control. The call is the
93
+ medium the task arrives on, not a separate decision.
94
+
95
+ **Once a task is accepted, the call that comes with it is answered.** Omni has no discretion
96
+ there and the provider is not consulted twice: one decision about the work, and the medium
97
+ follows it. Where the audio lands was settled at sign-in — in Omni on a softphone, on the handset
98
+ on a desk phone — and is not in question per call. See **How the agent hears the call** in `guide/phone.md`.
99
+
100
+ Automatic acceptance still begins with `task-offered`.
101
+
102
+ `expiresInSeconds` is how long the offer has left, in whole seconds from the publication that
103
+ carries it. It rides on the pending task, as `acceptance` does, so a reconnect snapshot restates
104
+ it from what is actually left; it belongs to `pending` alone and is gone once the task is accepted
105
+ (`task.pending.expiresInSeconds`, `.unexpected`). Where present, Omni counts down from receipt and
106
+ stops offering **Accept** once it runs out; the provider ends the lapsed offer with `task-ended`
107
+ and an `expired` outcome naming `pending`, since nobody cancelled it. **Omit it unless the provider can observe it.** A provider that reports only elapsed
108
+ ring time after the fact cannot say how long an offer has left, and a computed value would have
109
+ Omni withdraw **Accept** from a task still pending.
110
+
111
+ **A deadline is seconds left, never an instant.** The agent application and the provider keep
112
+ different clocks, and a countdown that compares one against the other is a countdown that runs
113
+ fast or slow by their skew. Seconds from the publication that carries them are the provider's own
114
+ arithmetic, counted down on the desk from the moment they arrive, and the transport delay is the
115
+ only error. Restated on every publication that carries them, so a snapshot after a reconnect
116
+ starts the countdown afresh from what is actually left (`task.pending.expiresInSeconds`,
117
+ `task.preview.previewEndsInSeconds`: a whole number, zero or more).
118
+
119
+ A preview's deadline is not on the offer. It travels on the task, as `previewEndsInSeconds` with
120
+ `atDeadline`, so a snapshot carries it too -- see **Preview: the agent presses Call** in `guide/voice.md`.
121
+
122
+ A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome, `by:
123
+ "provider"`. **`cancelled` says who called the work off**, as `completed` says who completed it:
124
+ `agent` for a decline, `provider` for a withdrawal or a re-route, `party` for a caller who abandoned
125
+ the ring. One word for the three left a supervisor's record unable to tell them apart, so `by` is
126
+ required and closed (`event.taskEnded.outcome.cancelled.by`). An offer nobody acted on while
127
+ `expiresInSeconds` ran is not cancelled by anyone: it lapses, and ends `expired` naming `pending`.
128
+
129
+ ### Tasks already in progress
130
+
131
+ The provider does not introduce new work as already `in-progress`.
132
+
133
+ A task appears already `in-progress` only in a snapshot taken after a reconnect or a resync,
134
+ reporting work that began earlier in this login and never stopped. A fresh login has none to
135
+ report: nothing has been assigned yet, and work the agent was working elsewhere is not carried
136
+ into a new session.
137
+
138
+ ## Tasks
139
+
140
+ ### `Task`
141
+
142
+ `Task` is the provider-owned description of one task presented to Omni.
143
+
144
+ `task-offered` introduces a new task and does not imply acceptance.
145
+
146
+ `Task<C>` is channel-discriminated. For example, `Task<"email">` accepts
147
+ `browsers` and `outcomes`, but rejects voice-only controls such as `hold` and `endCall` at compile
148
+ time. Runtime conformance checks also require the task channel to match its provider manifest.
149
+
150
+ | Field | Contract |
151
+ | --- | --- |
152
+ | `assignmentId` | Required `AssignmentId`: the assignment this task is the record of, and its one identity. Issued by the provider, unique within the provider, never reused. Every event, command and report that names a task names it by this, so a late dial outcome or a late history report for one customer never lands on the next. The stream refuses an assignment introduced twice (`stream.taskOffered.duplicate`) and an audio or ending event naming one that has ended or that nobody has seen (`stream.assignment.ended`, `.unknown`); a `dial-outcome` may name an ended assignment, since a dial placed late routinely outlives its call, and the agent application routes it there. See **A task's life on the wire**. |
153
+ | `title` | Agent-facing task title. |
154
+ | `channel` | Channel used for this task. It must equal the source provider's manifest channel. |
155
+ | `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
156
+ | `capabilities` | Controls and workspace features available for this specific task. |
157
+ | `capabilitySource` | Required. Who chose the capabilities: `queue` when somebody configured these terms, `nobody` when nothing handed the work over -- an agent's own outbound -- and `not-yet-read` when a queue was named and its terms could not be read, in which case `capabilities` is what the provider will honour, not what the platform permits. An agent application shows `not-yet-read` where the agent works. See **Task capabilities**. |
158
+ | `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
159
+ | `party` | The person or entity on the other end of this task, as a `Contact`: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is *with*; `contacts` is the directory. |
160
+ | `phase` | Current canonical task phase: `pending`, `confirmed`, `preview`, `in-progress`, `paused`, or `completing`. `preview` is voice only. |
161
+ | `audio` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-audio-started`** in `guide.md`. Audio names a task whose work has begun, or whose party the agent application is dialling: on a `pending`, `confirmed` or `preview` task with nobody ringing it is refused (`task.audio.beforeWork`), on a snapshot as on the event, since an agent application opens the microphone on it; with the party ringing by an agent application dial or provider-triggered preview dial, actual ring-back audio may precede answer. |
162
+ | `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
163
+ | `expiresInSeconds` | In `pending` only: how long the offer has left, in whole seconds from this publication, restated on every publication that carries it. Absent, the offer stands until the provider says otherwise. See **Pending**. |
164
+ | `previewEndsInSeconds` | Voice only, in `preview`: how long the preparation has left, in whole seconds from this publication, restated on every publication that carries it. Absent, the agent has as long as they need without a preparation countdown. Always with `atDeadline`. See **Preview: the agent presses Call** in `guide/voice.md`. |
165
+ | `atDeadline` | Voice only, in `preview`, with `previewEndsInSeconds`: what the system does when it runs out -- `provider-dials` makes the provider initiate dialing, `host-dials` makes the agent application issue Call, `waits` keeps the task in preview awaiting the agent. |
166
+ | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
167
+ | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one, and takes the agent's `complete` as finishing early. A required outcome is the agent's to give, so it needs `agent-command` (`task.outcomes.required.mode`). |
168
+ | `wrapAllowance` | Fixed time allowed to complete the task after primary interaction ends. For real-time audio, it begins after `task-audio-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down; stated, it is the expected wrap, shown and never acted on. `0` under `provider-automatic` is no wrap at all, so such a task publishes no `outcomes` (`task.outcomes.wrapAllowance`). |
169
+ | `wrapEndsInSeconds` | In `completing`, wherever `wrapAllowance` is stated: how much of the wrap is left, in whole seconds from this publication, restated on every publication that carries it (`task.completing.wrapEndsInSeconds`, `.required`, `.unexpected`). A reloaded desk and a lead's screen count down the same number. See **Completion timing**. |
170
+ | `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
171
+ | `history` | The call record: `steps` — the ordered interaction history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `interactionSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **Interaction history**. |
172
+ | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer -- carrying a `stage` while being dialled again on the same task, a connect-back with the agent application's `dialId` or a platform's callback without, ringing from the moment the dial is placed and joined on its answered outcome --, `agent` a person by user id, `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where an agent application placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. `label` names a destination -- a person, a queue -- not a phrase; the agent application supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome** in `guide/voice.md`. |
173
+ | `leadAssist` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Lead assist** in `guide/lead.md`. |
174
+ | `takenOver` | Voice only, on a task that reached this agent by a lead's take-over: which member the call was taken from, and when. It is an ordinary assignment with the call's history, offered whatever break the lead is on and counted against their capacity like any other call. See **Lead assist** in `guide/lead.md`. |
175
+
176
+ `TaskAttribute` entries carry typed detail alongside the task:
177
+
178
+ ```ts
179
+ const attributes: TaskAttribute[] = [
180
+ {
181
+ key: "related-contact",
182
+ label: "Related contact",
183
+ type: "contact",
184
+ party: { name: "Asha Rao", number: "+919876543210" },
185
+ },
186
+ {
187
+ key: "answered",
188
+ label: "Answered",
189
+ type: "timestamp",
190
+ at: "2026-08-27T09:30:00.000Z",
191
+ },
192
+ ];
193
+ ```
194
+
195
+ `key` is the stable machine identifier and must be unique within the task's `attributes` array.
196
+ `label` is the optional agent-facing name.
197
+
198
+ The canonical task transitions are:
199
+
200
+ | From | Decision or event | To |
201
+ | --- | --- | --- |
202
+ | No task | Provider assigns a task | `pending` |
203
+ | `pending` | Task is accepted | `confirmed` |
204
+ | `confirmed` | The customer's record is put in front of the agent before any call goes out (voice) | `preview` |
205
+ | `confirmed` | Work begins | `in-progress` |
206
+ | `preview` | Agent presses Call (`dial`) and the customer answers, or the deadline `provider-dials` and they answer | `in-progress` |
207
+ | `preview` | The call goes out and nobody answers | `completing` |
208
+ | `preview` | The preparation target passes with `atDeadline: "waits"` | Remains `preview`, waiting for the agent to press Call |
209
+ | `pending` | Provider withdraws the assignment, the agent declines, or the party abandons the ring | Removed by `task-ended` with `cancelled` outcome, `by` saying which |
210
+ | `pending` | The offer's `expiresInSeconds` runs out | Removed by `task-ended` with `expired` outcome naming `pending` |
211
+ | No task | Snapshot reports work already underway | `in-progress` |
212
+ | `in-progress` | Provider or agent pauses the task | `paused` |
213
+ | `paused` | Provider or agent resumes the task | `in-progress` |
214
+ | `in-progress` or `paused` | Contact interaction ends and follow-up work remains | `completing` |
215
+ | `completing` | Agent connects back to the party (`connect-back`) | `in-progress` |
216
+ | Any phase | Provider emits `task-ended` | Removed |
217
+
218
+ #### A task's life on the wire
219
+
220
+ A task has one name, the assignment it is the record of, and the provider issues it. Platforms
221
+ reuse their own handles: a closed one is retired minutes later, a requeue takes seconds, and on
222
+ one platform a call was offered twice, thirteen seconds apart, under one handle, through a close
223
+ and a re-offer. Everything that names a task after the fact -- a dial outcome, an audio event, an
224
+ ending, a history report, a command -- would land on whichever assignment is open when it arrives.
225
+ So the assignment id is unique within the provider and never reused: where the platform's handle
226
+ never comes back, the adapter passes it through; where it does, the adapter mints the assignment id
227
+ and keeps the mapping, and the desk never learns which. A late event finds the assignment it
228
+ belongs to or is refused, and a task-scoped browser session (`PROVIDER_NAME__ASSIGNMENT_ID__TAB_NAME`)
229
+ lives one assignment, never the next customer's cookies. A dial the agent application placed was
230
+ already safe, since its outcome is placed by the agent application's own `dialId`; where the
231
+ assignment earns its place is everything the agent application does not mint --
232
+ `task-audio-started`, `task-audio-ended`, `task-ended`, a `recordStep` naming a task -- any of which
233
+ would otherwise find the next customer under a reused handle and act on them. A minted id is part
234
+ of the task the adapter keeps, not a field held in memory beside it: it lives in the login's
235
+ `store` with the task, or is derived from a platform handle that itself never reuses, so a rebuilt
236
+ adapter comes back with the same assignment on the same task, or every late event it was minted to
237
+ catch mismatches and the second adapter's snapshot no longer carries the task (`drive.reload.snapshot`).
238
+
239
+ Assignment, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
240
+ task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
241
+ subsequent transitions to `preview` or `in-progress`; Omni does not infer them from the acceptance
242
+ command.
243
+
244
+ #### Completion timing
245
+
246
+ `completionMode` determines how completion is triggered. With `agent-command`, the provider keeps
247
+ the task open until Omni sends the channel's `complete` command. With `provider-automatic`, the
248
+ provider may complete the task without receiving that command.
249
+
250
+ **The agent may finish early under either mode.** `complete` is issuable wherever there is a wrap
251
+ to cut short: under `agent-command` it is the end, and under `provider-automatic` it says the agent
252
+ is done before the allowance ran out, and the provider is free to end the task at once. Only a task
253
+ with no wrap at all -- `provider-automatic` with `wrapAllowance: 0` -- has nothing to complete, and
254
+ the command is refused there (`command.complete.wrapAllowance`). An ending the agent asked for says
255
+ so, `completed` with `by: "agent"`, whichever mode the task was under.
256
+
257
+ **A required outcome is the agent's to give.** A task whose `outcomes` says `required: true` waits
258
+ for a code the agent chooses, so it completes on the agent's command: `provider-automatic` cannot
259
+ wait for it, and the pair is refused (`task.outcomes.required.mode`). Optional outcomes may ride on
260
+ a task the provider completes itself; the agent gives one if in time. And a code is given in wrap,
261
+ so a `provider-automatic` task with `wrapAllowance: 0` publishes no `outcomes` at all
262
+ (`task.outcomes.wrapAllowance`).
263
+
264
+ **The allowance means one thing per mode.** Under `provider-automatic` it is what the provider
265
+ acts on: the task ends when it runs out. Under `agent-command` it is the expected wrap, stated so
266
+ the agent can see it: the desk counts it down and, past zero, shows how far over they are, and
267
+ nothing acts on it -- the task waits for `complete` however long that takes, with its outcomes
268
+ collected whenever the agent gives them. A provider that would end the task at a time is
269
+ `provider-automatic`.
270
+
271
+ **What is left travels with the task.** The allowance is the length; `wrapEndsInSeconds` is how
272
+ much of it remains, stated on the `completing` task and restated on every publication that
273
+ carries it, as `previewEndsInSeconds` is on a preview. A desk that reloads mid-wrap finds the
274
+ number on the snapshot; a lead's screen finds it on the member; neither starts a clock of its own,
275
+ and both count down from receipt. Under `provider-automatic` the provider ends the task when it
276
+ reaches zero; under `agent-command` it is the expected wrap left, `0` once overrun, and nothing
277
+ acts on it. Off voice, where `completing` is the provider's word that interaction ended, the field
278
+ arrives with that word, so the clock starts nowhere else.
279
+
280
+ `wrapAllowance` is fixed, and when it starts depends on whether the channel carries real-time
281
+ audio:
282
+
283
+ | Channel | Wrap allowance starts at |
284
+ | --- | --- |
285
+ | Voice and any channel with real-time audio | The `task-audio-ended` event |
286
+ | Chat | The `task-updated` that moves the task to `completing`: the provider's word that the conversation is closed on its side; what is left arrives on that publication as `wrapEndsInSeconds` and is counted down from receipt |
287
+ | Email | The `task-updated` that moves the task to `completing`: the provider's word that the platform has accepted the outgoing message; what is left arrives on that publication as `wrapEndsInSeconds` and is counted down from receipt |
288
+ | Other non-audio channels | The `task-updated` that moves the task to `completing` |
289
+
290
+ **Off voice, `completing` is the provider's word that interaction ended.** There is no audio event to
291
+ carry it, so the phase does; an agent application starts the wrap clock at that publication and nowhere else. It
292
+ is optional: a conversation with nothing to wrap moves from `in-progress` to `task-ended` and
293
+ `completing` is never published. Under `provider-automatic` with a non-zero `wrapAllowance` it is
294
+ required, because the clock has to start somewhere: a chat or email task the provider completes
295
+ from `in-progress` with an allowance to run gave the agent none of it, and the stream refuses the
296
+ ending (`stream.taskEnded.unwrapped`); an ending the agent's own `complete` brought forward says
297
+ `by: "agent"` and is not that. Under `agent-command` the agent's `complete` is the end, from
298
+ `in-progress` or from `completing` alike.
299
+
300
+ ```ts
301
+ const emailCompletion = {
302
+ completionMode: "agent-command",
303
+ wrapAllowance: 120,
304
+ } satisfies Pick<Task<"email">, "completionMode" | "wrapAllowance">;
305
+ ```
306
+
307
+ In this example, the agent has two minutes after sending the email to add notes, select a
308
+ outcome, and complete the task.
309
+
310
+ `0` means no wrap under `provider-automatic`: the provider completes the task at the end of the
311
+ interaction, and `complete` has nothing to cut short. Under `agent-command` it is an expected wrap
312
+ of nothing -- the desk shows the overrun from the start -- and the task still waits for `complete`.
313
+
314
+ There is no value meaning "unlimited", because a number that is not a duration would be read as
315
+ one. A provider that imposes no deadline says so by **omitting** `wrapAllowance`, which
316
+ only `agent-command` permits: the provider will not complete the task itself, so there is nothing
317
+ for a deadline to trigger, and Omni counts nothing down. **Omitted and empty are different
318
+ claims** applies -- omitted says there is no deadline to see, where `0` says the deadline is now.
319
+ Under `provider-automatic` the field is required, because the provider is going to act on it.
320
+
321
+ ```ts
322
+ const untimedWrap = {
323
+ completionMode: "agent-command",
324
+ } satisfies Pick<Task<"voice">, "completionMode" | "wrapAllowance">;
325
+ ```
326
+
327
+ Here the customer has hung up, `task-audio-ended` has been sent on time, the task is `completing`,
328
+ and the agent takes as long as the work needs. Moving the task to `completing` late to avoid a
329
+ deadline is not an alternative on an audio channel: the clock starts at a real event, and delaying
330
+ that event would falsify the phase and everything timed from it.
331
+
332
+ ### History
333
+
334
+ The record is the call's, not the task's: most of what it holds happened before this agent, and
335
+ `queued` and `offered` are not interactions at all, so the field is `history` and its types are
336
+ `TaskHistory`, `TaskHistoryStep`, `HistoryStep`, `HistoryReport` and `HistoryReportResult`.
337
+ *Interaction* keeps the one meaning **Terms** in `guide.md` gives it, the agent's time on the call, which is why
338
+ the total of earlier agents' time is `interactionSeconds` and the phase rules are
339
+ `command.phase.interaction`. Hosts and providers adopt these names together; no legacy aliases
340
+ are provided. The manifest's `settleMs` bounds final task completion, not entry into
341
+ the `completing` wrap-up phase, and bounds an applied `schedule` the same way. The `recordStep` method and `complete` command keep their names.
342
+
343
+ Migration from the earlier spellings:
344
+
345
+ | Former API | Current API |
346
+ | --- | --- |
347
+ | Task.handlingHistory, Task.interactionHistory | `Task.history` |
348
+ | TaskHandlingHistory, TaskInteractionHistory | `TaskHistory` |
349
+ | TaskHandlingStep, HandlingStep, TaskInteractionStep, InteractionStep | `TaskHistoryStep`, `HistoryStep` |
350
+ | HandlingReport, HandlingReportResult, validateHandlingReport, and their Interaction spellings | `HistoryReport`, `HistoryReportResult`, `validateHistoryReport` |
351
+ | HANDLING_STEPS_WITH_A_PERSON, HANDLING_STEPS_THAT_DIAL, and their INTERACTION spellings | `HISTORY_STEPS_WITH_A_PERSON`, `HISTORY_STEPS_THAT_DIAL` |
352
+ | handlingStepExpectsAPerson, handlingStepDials, and their interaction spellings | `historyStepExpectsAPerson`, `historyStepDials` |
353
+ | handleSeconds | `interactionSeconds` |
354
+ | Task.allocationId, AllocationId, allocationExpiresAt | `Task.assignmentId`, `AssignmentId`, `expiresInSeconds` |
355
+ | Task.id, TaskId, and `taskId` on every event, command, report and lead request | gone: a task is named by `assignmentId` alone, and `assignmentKey(providerId, assignmentId)` scopes it |
356
+ | taskKey, omni.task-not-found, PROVIDER_NAME__TASK_ID__TAB_NAME (ProviderName.TaskId.TabName) | `assignmentKey`, `omni.assignment-not-found`, `PROVIDER_NAME__ASSIGNMENT_ID__TAB_NAME` (`ProviderName.AssignmentId.TabName`) |
357
+ | Manifest.disposalSettleMs, Manifest.completionSettleMs | `Manifest.settleMs` (`manifest.settleMs`, `manifest.settleMs.renamed`): the bound after `applied` for `complete` and `schedule` alike |
358
+ | the call command, atDeadline calls and host-calls | `dial`, `"provider-dials"`, `"host-dials"` |
359
+ | media: task-media-started/-ended, team-media-*, Task.media, TaskMediaState, openMedia, OpenMediaRequest/Result, VoiceMediaSession and its session field | audio: `task-audio-started`, `task-audio-ended`, `audio` on the task, `TaskAudioState`, `openAudio`, `OpenAudioRequest`, `OpenAudioResult`, `CallAudio` on the result's `audio`; there is no team audio, the lead's follows `listening` on the member |
360
+ | capabilitySource values ungoverned and undetermined | `nobody`, `not-yet-read` (`capabilitySource.notYetRead`) |
361
+ | Snapshot.scheduledActivities, and scheduledActivities on calendar-updated | `calendar` in both |
362
+ | availability reserved | `elsewhere` |
363
+ | recording destinationId, HostRecording.destinationIds | `storageId`, `storageIds` (`recording.storage`, `recording.host.storage`) |
364
+ | coldTransfer, warmTransfer, the transfer command and its four actions, the consulted role, the transferred outcome | gone: help arrives by **Lead assist** in `guide/lead.md`; the `transferred` history step and the `transfers` total stay, since the platform's own routing still changes hands |
365
+ | assignmentExpiresAt on task-offered, previewEndsAt on the task | `expiresInSeconds`, `previewEndsInSeconds`: seconds left from the publication, never an instant (`event.taskOffered.expiresInSeconds`, `task.preview.previewEndsInSeconds`) |
366
+ | TeamMembers.requests, LeadRequest | `TeamMember.request` (`MemberRequest`): the ask rides on the member (`team.member.request.*`) |
367
+ | team-updated on every change | `team-updated` whole once after the switch and on snapshots; `team-member-updated`, `team-member-removed`, `team-policies-updated` after (`stream.team.baseline`, `stream.teamMember.unknown`) |
368
+ | RecordingCommand.requestId | gone: no request identity; a partial effect is a settled `failed` under `omni.recording-unsettled` |
369
+ | command.complete.mode | `command.complete.wrapAllowance`: `complete` under either mode, refused only on `provider-automatic` with no wrap |
370
+ | expiresInSeconds on task-offered | `Task.expiresInSeconds`, in `pending` (`task.pending.expiresInSeconds`, `.unexpected`; `event.taskOffered.expiresInSeconds.unexpected`) |
371
+ | the expired outcome naming preview | gone: nothing expires a preview; withdrawn, it is `cancelled` by the provider |
372
+ | schedule on voice alone | `schedule` on every channel, under a manifest that declares `calendar` (`task.capability.calendar.required`), bounded by `settleMs` (`drive.schedule.unsettled`) |
373
+ | a forced break requesting breaks on the other providers | `setCapacity({ count: 0 })` on every other usable provider (`assertForcedBreakStopsTheRest`) |
374
+ | end-forced-break on a break the platform imposed | refused: `team.command.endForcedBreak.provider`, `omni.break-forced-by-provider` |
375
+ | ForcedBreak.expectedDurationMs | `expectedEndsInSeconds`: seconds left, restated (`break.forced.expectedEndsInSeconds`, `.expectedDurationMs.renamed`); the command keeps `expectedDurationMs` |
376
+ | MemberShift; the day on the lead's list alone | `Shift`, on `Snapshot.shift` and `shift-updated` for the agent, on `TeamMember.shift` for the lead |
377
+ | TeamMember.listening as this lead's one entry | `MemberListening[]`, every lead's, each with `leadId` (`team.member.listening.leadId`, `.unique`, `.empty`, `.entry`, `.heard`) |
378
+ | a member's history trimmed on the lead's list | the record whole, as the member's desk holds it |
379
+ | durations counted from the screen's clock | the provider's: `timeCheck` required of a provider publishing a running instant (`manifest.timeCheck.required`), `Snapshot.providerTime` with it (`snapshot.providerTime`, `.unexpected`) |
380
+ | a wrap the desk timed from an event's receipt | `Task.wrapEndsInSeconds` in `completing`, restated (`task.completing.wrapEndsInSeconds`, `.required`, `.unexpected`) |
381
+ | MemberTask.capabilities, capabilitySource, browsers | gone: the workspace never travels to a lead (`team.member.task.workspace`); the record and the completion terms do |
382
+ | a member republished on a hold, a mute, a take-over | on every publication of the member's task to the member |
383
+ | two clients on one login | the later wins; the first is ended with `recovery: "displaced"` |
384
+
385
+ Update producers, consumers, saved task snapshots, and validation-rule assertions together.
386
+ History and report rule names use `history` and `historyReport`; assignment rules use
387
+ `assignment` (`task.assignmentId`, `stream.taskOffered.duplicate`, `stream.assignment.ended`);
388
+ phase and conformance rules use `interaction` and final-task rules use `completion`. The former
389
+ fields are refused even when the new field is also supplied (`task.history.renamed`,
390
+ `task.assignmentId.renamed` for a task's `id` or `allocationId`, `event.assignmentId.renamed`,
391
+ `command.request.assignmentId.renamed` and `historyReport.assignmentId.renamed` for a `taskId`).
392
+
393
+
394
+ `Task.history` is the call record: the steps that brought the task to the agent, oldest
395
+ first, and what they add up to before this agent:
396
+
397
+ ```ts
398
+ history: {
399
+ steps: [
400
+ { step: "queued", at: "2026-08-21T00:59:00Z", seconds: 41 },
401
+ { step: "answered", at: "2026-08-21T00:59:41Z", seconds: 312, by: "a-17" },
402
+ { step: "held", at: "2026-08-21T01:02:10Z", seconds: 35, by: "a-17" },
403
+ { step: "transferred", at: "2026-08-21T01:04:53Z", by: "a-17" },
404
+ { step: "answered", at: "2026-08-21T01:05:02Z", by: "a-23" },
405
+ ],
406
+ interactionSeconds: 312, // handled by others before this agent
407
+ holdSeconds: 35, // holds others put the caller on
408
+ queueSeconds: 41, // waiting before anyone answered
409
+ transfers: 1, // hands the call has changed
410
+ }
411
+ ```
412
+
413
+ **This is not an archive.** It is live data about a task that is still open: it travels with the task
414
+ to whoever holds it next and ends when the task does. Nothing stores it, nothing queries it, and
415
+ there is no archive behind it. A provider that keeps a record of *completed* contacts is describing
416
+ something else, which will arrive under its own name and must not be folded in here.
417
+
418
+ It rides in the snapshot and is replaced whole like everything else there.
419
+
420
+ Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
421
+ `unanswered`, and each is defined on `TaskHistoryStep`.
422
+
423
+ **The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
424
+ when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
425
+ entry after the first, never a revision of it. A leg that has ended states its duration, and the
426
+ task says whether a leg can still be running: a hold runs only while the task is `paused`, a mute
427
+ only while its audio is up, so a `held` entry without `seconds` on a task that is not paused, or a
428
+ `muted` one on a call that is over, is a leg nobody closed and reads exactly like a leg running now
429
+ (`task.history.held.open`, `.muted.open`). Whoever performs the leg closes it — the
430
+ provider whose platform parks the caller, the agent application whose microphone it is — by restating the entry
431
+ with its duration, and **an entry that cannot be closed is not written**: open for ever is a
432
+ plausible nought one level down from a total. **A leg still open when the audio ends is closed by
433
+ the provider**, at that instant, in the same publication that says the audio ended: the provider
434
+ is the one that knows the instant, and the agent application can only learn of it afterwards. Agents end calls
435
+ muted, so the agent application's leg is routinely open at audio end; the provider closes it with the duration
436
+ from the leg's `at` to the audio's end, and the agent application's own closing report for that leg, which
437
+ follows what it hears, is answered `recorded` and changes nothing, the entry standing as the
438
+ provider closed it. One publisher of the closed leg, and no order between them to get right. There is no resumed step; a resume is the end of a
439
+ hold, at the entry's `at` plus its `seconds`, and nothing is lost by not naming it twice. The same goes for every step: two mutes are two
440
+ `muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
441
+ entries, one per join. Order is enforced: an entry earlier than the one before it is refused
442
+ (`task.history.order`). **Intervals between steps are the agent application's to subtract**, never a
443
+ total the provider adds: *time to offer*, from the call's arrival to the agent's screen lighting
444
+ up, is the `offered` entry's `at` minus the last `queued` entry's, and the ring is outside it.
445
+ `queueSeconds` keeps the industry's meaning -- the whole wait until somebody answered -- and is not
446
+ that interval. **The second is the record's grain.** A duration is a positive whole number of
447
+ seconds, and nought is refused because it claims no time; a leg that happened is stated at the
448
+ grain: rounded to the nearest second, and a leg that rounds to nought is stated as `1`, the error
449
+ being under the contract's resolution, where dropping the entry would say the leg never happened.
450
+
451
+ **A record once read is not unread.** Every restatement of a task's record -- on `task-updated`,
452
+ on a resync snapshot -- carries every entry the agent application has already read, by step and instant, and
453
+ may add to them; it never has fewer, and never drops the record while the task is open. A fact
454
+ stated to the agent application and then withdrawn without an event is a record contradicting itself, exactly
455
+ as a task that lost the terms it had read would be, and the stream refuses it the same way
456
+ (`stream.taskUpdated.history`, `stream.snapshot.history`). A provider whose
457
+ platform cannot hold a leg the agent application reported keeps it in the login's `store` for the life of the
458
+ task, so a reload of the agent application restates the same record; the entry is keyed by `step` and `at`, so
459
+ a running hold restated with its final `seconds` is the same entry.
460
+
461
+ **The provider owns the timestamps in its record.** Agent application timestamps are advisory. The provider
462
+ may retain an agent application instant it accepts or assign its own observation/receipt instant; it need not
463
+ copy the agent application clock. A provider declaring `timestampAuthority: "provider"` uses its own timestamps
464
+ for final records. Receipt time is an observation boundary, not a claim about when the physical
465
+ agent application action happened. Records are ordered by their published provider-selected instants, with no
466
+ rewriting of an already-published entry when a later agent application report arrives.
467
+
468
+ The incoming agent application `step` and `at`, scoped to the task and its current life, identify one reported
469
+ leg for correlation only. The provider retains a binding from that key to its chosen history
470
+ `at`. Every successful `recordStep` returns `{ status: "recorded", at }` with that same canonical
471
+ history instant, even for subsequent duration/end reports. The agent application keeps sending the original
472
+ report key, never the returned history timestamp. A changed agent application timestamp is not a correction
473
+ to the same report: it names a different leg. Bindings survive reconnect/reload for the retained
474
+ task lifetime. The agent application renders provider-published history unchanged. It never compares the
475
+ provider instant back to its own clock, substitutes its own timestamp, or adjusts later messages
476
+ to match an earlier agent application estimate. The acknowledgment identifies the provider record for
477
+ correlation/conformance only; it is not an application-side timestamp reconciliation instruction. Conflicting reuse is refused visibly, not paired by arrival time or nearest instant.
478
+ If two distinct entries would collide under the history's `(step, at)` key, do not invent a time
479
+ or combine them: report the representation conflict. No ambiguous event is silently accepted.
480
+
481
+ **Interaction time is anchored, not restarted.** It runs from the
482
+ `answered` step's `at` — from the task's first `in-progress` where the provider reports no
483
+ history — until the task's audio ends, and a hold neither pauses nor resets it: the hold's own
484
+ duration is the `held` entry's `seconds`, and a desk that restarts its counter on resume is
485
+ counting the wrong thing.
486
+
487
+ `muted` is there because the mute is the agent application's and never the provider's — see **The station is
488
+ the agent application's** — so without a step the provider, which alone keeps the task's record, would have no
489
+ account of a period the agent could not be heard. Each `muted` entry carries `mutedBy`, as the agent application
490
+ reported it: `host` for the agent's own Mute, `station` for a headset or system mute the agent application
491
+ observed. No other step has it (`task.history.mutedBy`, `.mutedBy.unexpected`).
492
+
493
+ **A step that dialled says which dial and where.** `conferenced` and `unanswered` each end a dial,
494
+ and `transferred` a hand-over on the platform's own routing, so the entry carries the `destinationId`
495
+ it went to and, where an agent application placed the dial, the `dialId` the agent application minted; no other step dialled, and either field on one is refused
496
+ (`task.history.dialId.unexpected`). This is how a dial made before a take-over is placeable
497
+ by whoever holds the task now: a `dial-outcome` naming a `dialId` the agent application never minted is looked
498
+ up in the record, not discarded.
499
+
500
+ Four rules a provider has to keep:
501
+
502
+ - **Report `seconds`; never expect Omni to derive it.** Omni does not subtract one timestamp from
503
+ the next. An entry can be written while its leg is still running, so the arithmetic has no second
504
+ operand, and a provider holding the authoritative number should not have it recomputed from
505
+ instants that may be rounded or clock-skewed. And once stated, `seconds` is what every screen
506
+ shows for that leg: a count a screen kept while the leg ran is replaced by it, never kept beside it.
507
+ - **Omit `seconds` while it is unknown. Never send `0`.** A leg still talking is not a zero-second
508
+ conversation, and on live data that is the ordinary case rather than an edge. A zero is rejected.
509
+ - **`by` is a bare `UserId`, and not necessarily an agent.** A lead or a manager takes part
510
+ during an interaction too — a call taken over, a call conferenced in — so the field names whoever it was,
511
+ from the same directory `ForcedBreak.by` names a lead from. It comes from this provider's own directory, the same
512
+ namespace as `AuthenticationState.identity.id` and the team member list, so entries pair
513
+ within a provider and never across one.
514
+ - **A task carries no names.** Omni resolves what to display with `getUserDetails()`. Two people
515
+ called Arun on one site is ordinary, and anything pairing entries on a display name pairs them
516
+ wrongly; carrying the name here would also copy it into every task and leave it to go stale.
517
+
518
+ **An absent `by` means different things on different steps, and both are legitimate.** On
519
+ `queued` nobody takes part, so there is nothing to name. On every other step somebody did — see
520
+ `HISTORY_STEPS_WITH_A_PERSON` and `historyStepExpectsAPerson()` — so an absent `by` there says
521
+ *this was handled and the provider cannot say by whom*. The one step that is never unattributed is
522
+ `muted`: the agent application has exactly one agent and the provider knows who, so `by` is required there
523
+ (`task.history.muted.by`).
524
+
525
+ That case is ordinary rather than theoretical: a leg answered on a shared phone, a manager's
526
+ handset, or a device the provider cannot resolve to a person. **Report the step without `by`
527
+ rather than dropping it.** A list missing a real handler looks complete and is wrong, which is
528
+ worse than one saying plainly it could not attribute a leg — and far better than publishing
529
+ nothing because a single leg could not be named.
530
+
531
+ An agent application must render the two differently. Showing an unattributed `answered` the same way as
532
+ `queued` tells the agent nobody was involved, which is not what was said. Omni renders it as
533
+ *"not recorded"* in the place the name would go.
534
+
535
+ Omit `history` entirely when the provider cannot observe the steps. Empty `steps` is a
536
+ different claim — it says the task has had none.
537
+
538
+ **What the record adds up to is stated, not summed.** A call that has changed hands arrives
539
+ carrying what others already used of it, and the agent reads that before they say hello: the
540
+ totals are the provider's own — never a desk summing instants that may be rounded or skewed, for
541
+ the same reason `seconds` is reported and not derived — and each is present when the provider
542
+ knows it and absent when it does not, never a plausible nought. A fresh call from the queue has
543
+ `steps` with no prior `answered` and no totals to state, and a desk shows nothing rather than
544
+ zeros. The record is restated with the task on every `task-updated` and snapshot, so an entry
545
+ that arrives late corrects the sums. These four are what every platform reports; more will be
546
+ added here as a need is shown, not invented ahead of one.
547
+
548
+ #### The agent application records what it performs
549
+
550
+ `muted` is the one leg the agent application performs rather than the provider, and a record kept by the
551
+ provider would have a hole exactly there. So the agent application reports it, through `recordStep`, and the provider
552
+ writes it into its record as it writes every other leg:
553
+
554
+ ```ts
555
+ declare const connection: Connection<"voice">;
556
+ declare const assignmentId: AssignmentId;
557
+ declare const at: IsoTimestamp;
558
+
559
+ void connection.recordStep?.({ assignmentId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
560
+ void connection.recordStep?.({ assignmentId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the agent application chooses to say
561
+ void connection.recordStep?.({ assignmentId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
562
+ ```
563
+
564
+ Every report about one leg repeats its original `step` and agent application `at` as a correlation key;
565
+ the published history uses the provider-selected `at` returned by `recordStep`. The agent application reports
566
+ the action it performed and its measured duration, not an authoritative provider timestamp. It may say how long so far — `seconds` is
567
+ elapsed while the leg runs and final once it has ended — and **the end is stated, never
568
+ inferred**: `ended: true` marks the last report, and it carries the final duration. **What a
569
+ provider never asked for never crosses.** The running report is sent only to a provider whose
570
+ manifest declares `runningStepReports`; every other provider receives exactly two reports per
571
+ leg, when it began and when it ended, and `validateHistoryReport(report, path, manifest)` refuses
572
+ a running one it was never asked for (`historyReport.running.unexpected`). What a provider that
573
+ did ask for them forwards upstream, and how often, is its own business. The step appears in
574
+ `history` when the *provider* publishes it: Omni never writes the record itself.
575
+ `recordStep` is required of every softphone login's connection, since every call on a softphone
576
+ can be muted by the agent application, and answers `recorded` with the canonical history `at`. A `muted` report says whose the silence was,
577
+ `mutedBy: "host"` or `"station"`, and no other report has the word (`historyReport.mutedBy`,
578
+ `.mutedBy.unexpected`). On a desk phone the microphone is the phone's:
579
+ the agent application mutes nothing and records nothing.
580
+
581
+ **The record is a record.** The microphone is the agent application's, and nothing in the
582
+ history moves it: a `muted` entry the provider publishes, or closes with a final `seconds`, is the
583
+ provider's account of a leg the agent application reported, and the mute itself ends only when the
584
+ agent, or the station, ends it. The agent application keeps its own mute state, reports each leg
585
+ as it begins and ends, and takes the provider's published entry as the closed account of that leg:
586
+ it does not reopen the leg, replace the provider's timestamp, or overwrite the provider's final
587
+ duration with a later report. A later agent mute is a new leg, never a reopening of the ended one.
588
+
589
+ Correlate the provider-published `muted` entry using the provider-selected `at` acknowledged
590
+ for the current report key; a final `seconds` closes that leg under the history contract.
591
+ This is identity matching, not reconciliation between clocks. An old closed entry, an unrelated
592
+ task, or a stale connection cannot end a newer local mute. If publication precedes the acknowledgment,
593
+ retain the current provider view and apply the matching closure once correlation is available;
594
+ never guess a match from arrival order or nearest timestamp. Failure to release an agent application-controlled
595
+ mute is reported visibly; local device reports still describe the actual device state.
596
+
597
+ At a provider-confirmed task/audio end, the agent application also stops the associated local mute and reports
598
+ its observed ending where the report is still accepted. An agent application closing report repeats its original
599
+ key and may include its measured duration, but cannot reverse an already confirmed provider end.
600
+ The provider acknowledges a known closed leg without rewriting its final record; after the task
601
+ has been completed, it may refuse the report as assignment-not-found. The agent application reads that response rather
602
+ than retrying or recreating the task. An observed agent application end before a provider closure is still
603
+ reported normally; the provider decides the final timestamp and publishes the record.
604
+
605
+ ### Browser capability
606
+
607
+ `browsers` is available to voice, chat, and email tasks. When declared, Omni renders the task's
608
+ `TaskBrowser` entries as named browsers in the task workspace. A task that supplies one or more
609
+ browser definitions must declare:
610
+
611
+ ```ts
612
+ capabilities: { browsers: true }
613
+ ```
614
+
615
+ Tasks without browser definitions omit the capability and provide an empty `browsers` array.
616
+
617
+ **A browser is one tab, and each workspace keeps its own.** `Browser` is what a tab is — an id, a
618
+ name, a URL. The task workspace shows the task's `TaskBrowser` entries, one tab each, fixed at the
619
+ task's definition: their count and their details, `urlVisibility` included, arrive with the task,
620
+ and nothing adds a tab to a task later. The personal workspace is the agent's: as many
621
+ `PersonalBrowser` tabs as they open, never on the wire, and no provider says anything about what
622
+ they may see there.
623
+
624
+ #### `TaskBrowser` and isolation
625
+
626
+ Each `TaskBrowser` defines one named browser in the task workspace.
627
+
628
+ | Field | Contract |
629
+ | --- | --- |
630
+ | `id` | Stable internal selection and update identity within the task. |
631
+ | `name` | Agent-facing tab label, unique within the task, and an input to schemes containing `TAB_NAME`. |
632
+ | `purpose` | Human-readable explanation of the browser's role. |
633
+ | `url` | Initial URL. Must use `http:` or `https:`; see below. Later navigation comes from Chromium. |
634
+ | `sharedSession` | Required. `false` creates a task-specific browser session. |
635
+ | `isolationScheme` | **Required when `sharedSession` is `true`**, and rejected when it is `false`. There is no default: see below. |
636
+ | `urlVisibility` | What the agent sees of this tab's URL in Omni's chrome: `hidden`, `domain`, or `full`. Omitted, the URL shows as any browser's does; a provider says `hidden` where the URL carries what the agent may not read — a caller's number, a CRM token. Per browser, on the provider's word; an agent application that declares `guarantees.browserUrlVisibility` honours it tab by tab, and a provider checks that guarantee before it sends such a URL at all. |
637
+
638
+ ##### Choosing an isolation scheme
639
+
640
+ Every scheme is supported and the provider picks the one its deployment needs. There is no
641
+ default, and a `sharedSession: true` browser that declares none is invalid — the type will not compile
642
+ it and `validateSnapshot` reports `task.browser.isolationScheme.required`.
643
+
644
+ That is deliberate. Sharing a signed-in session decides **who else may see those credentials**,
645
+ and it is not a decision to inherit from whichever value happened to be the default. `TAB_NAME`
646
+ keys on the tab label alone, so two providers that each publish a browser named "CRM" share one
647
+ signed-in session — legitimate where a deployment wants exactly that, and a silent credential
648
+ leak where it does not. It remains available; it has to be asked for.
649
+
650
+ `browserSessionKey` fails closed: given a reusing browser with no scheme it returns `undefined`
651
+ and the browser is isolated. The safe reading of an invalid declaration is "do not share", never
652
+ "share with everyone named the same".
653
+
654
+ ##### Permitted URL schemes
655
+
656
+ `TaskBrowser.url` is provider-supplied and is loaded inside Omni's managed browser, so it is
657
+ restricted to the schemes in `ALLOWED_BROWSER_URL_SCHEMES` — currently `http:` and `https:`.
658
+ `file:`, `chrome:`, `javascript:`, and every other scheme are rejected. Omni substitutes a blank
659
+ page rather than following a disallowed URL, and `isAllowedBrowserUrl()` is the shared predicate.
660
+
661
+ ##### Reuse and isolation
662
+
663
+ With `sharedSession: true`, definitions producing the same isolation key share one **storage profile**:
664
+ cookies, local storage, session storage, permissions, and cached credentials. Different keys are
665
+ isolated from one another.
666
+
667
+ Sharing a profile is not sharing a window. Two browsers in the same task keep their own tab, their
668
+ own visible label, and their own navigation state and history even when their keys match — a
669
+ scheme that omits `TAB_NAME`, such as `PROVIDER_NAME__TASK_TYPE_NAME`, deliberately places every
670
+ named browser of that task type in one signed-in profile without merging them into one page.
671
+
672
+ `browserSessionKey()` derives the key. Every part is escaped before the `.` separator is applied,
673
+ because `encodeURIComponent` leaves `.` untouched and a raw join would let one value forge
674
+ another key: provider `Acme.Voice` with task type `Support` would otherwise produce the same key
675
+ as provider `Acme` with task type `Voice.Support`, silently placing two providers in one cookie
676
+ jar. Hosts that must flatten the key further — for a native window label or a partition name with
677
+ a restricted charset — must keep the mapping injective, for example by appending a fingerprint of
678
+ the exact key, since lowercasing or replacing punctuation reintroduces exactly this collision.
679
+
680
+ ```ts
681
+ browsers: [
682
+ {
683
+ id: "crm",
684
+ name: "CRM",
685
+ purpose: "Contact record",
686
+ url: "https://crm.example.com/contact/42",
687
+ sharedSession: true,
688
+ isolationScheme: BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME,
689
+ }
690
+ ]
691
+ ```
692
+
693
+ The supported `BrowserIsolationScheme` values, declared under **Shapes** in `guide.md`, key as follows:
694
+
695
+ | Enum member | Example session key |
696
+ | --- | --- |
697
+ | `PROVIDER_NAME__ASSIGNMENT_ID__TAB_NAME` | `mailflow.EMAIL-829102%2Ea1.CRM` -- the task's assignment id, so one assignment, never the next customer's cookies |
698
+ | `TAB_NAME` | `CRM` |
699
+ | `PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME` | `mailflow.Support.CRM` |
700
+ | `PROVIDER_NAME__TAB_NAME` | `mailflow.CRM` |
701
+ | `PROVIDER_NAME__TASK_TYPE_NAME` | `mailflow.Support` |
702
+ | `TASK_TYPE_NAME__TAB_NAME` | `Support.CRM` |
703
+
704
+ `TASK_TYPE_NAME` refers to the mandatory `Task.taskType`. The isolation scheme never changes
705
+ the browser tab label. Serialized values are stable protocol values and must not be renamed or
706
+ reused.
707
+
708
+ **`PROVIDER_NAME` is `manifest.id`, never `manifest.displayName`.** Only the id is required unique
709
+ across an installation; two providers may legitimately share a display label, and keying a cookie
710
+ jar on one would put them in the same signed-in session. The id is also stable across launches,
711
+ where a display name may be re-worded — and a changed key silently signs the agent out of every
712
+ browser that used it.
713
+
714
+ ### Task capabilities
715
+
716
+ Task capabilities belong to each `Task`. If `hold` is omitted on one task, Omni
717
+ must not show or issue hold for that task even if another task from the same provider supports it.
718
+
719
+ **A capability is a property of the task, and of nothing the task names.** A provider derives it
720
+ from no other source -- not the queue the task came from, not the login, not its own configuration
721
+ -- because each of those answers a question about this task from somewhere that does not know
722
+ about this task, and a client with two sources and a rule for choosing between them is the shape
723
+ that produces two consumers disagreeing about one fact. The task is the one source, and the
724
+ provider puts on the task what the platform permits for it -- and says, in `capabilitySource`, who
725
+ chose those terms. `queue` says somebody configured them. `nobody` says nothing handed the work
726
+ over: an agent's own outbound call has no queue behind it, and the provider states what it permits
727
+ for such a call. Neither changes where an agent application reads the capabilities from, which is the task; the
728
+ source is one more published fact about them, and the one that lets an agent application tell a fact from a fault
729
+ (see below).
730
+
731
+ **Completing the task is not a capability, and no capability set withholds it.** `complete` is
732
+ governed by `completionMode` alone: under `agent-command` it is always available, whatever the set
733
+ says, and the `outcomes` capability decides only whether a code travels with it. Likewise
734
+ Answer on a pending task and Call on a preview are the phase's controls, not the set's. The
735
+ capability set governs what the agent may do *with* the task; completing it is never on the
736
+ list.
737
+
738
+ **A permission that changes while the task is open is republished on the task at the moment it
739
+ changes.** An agent on a billing dispute may refund up to their own limit and no further. The
740
+ customer wants more, the agent asks for a lead, and the moment the lead joins the call the task is
741
+ republished with the Refund control the agent could not have a minute ago; when the lead leaves, it
742
+ is republished without it. Nothing about the agent changed -- who is on the call did -- and the
743
+ task said so both times, at the moment it became true. A call moves queues the same way: a caller
744
+ identified as a priority customer is transferred to the priority queue, and the task arrives under
745
+ that queue's terms -- a longer wrap allowance, a discount control the general queue never offered --
746
+ restated on the task at the hand-over, not inferred from the queue it left. A capability stated
747
+ once at offer or answer and never corrected is a fact with a shelf life and no expiry, and an agent application
748
+ draws a control the provider will now refuse -- or withholds one the agent now has. So the provider republishes
749
+ the task, with its capabilities as they now stand, and an agent application treats the last statement as current
750
+ rather than re-deriving anything. This is the same shape as a room left full after the call ends
751
+ -- a field describing the present, carried past the moment it stopped being true -- carried past
752
+ this time not by a spread but by nobody sending the correction. A provider whose platform can
753
+ change a permission mid-task and does not republish is in breach, however conformant its offer was.
754
+ A capability set can shrink as well as grow, and an agent application that has only ever seen it grow meets a
755
+ control that was there a moment ago and is gone; a command that arrives after its capability was
756
+ withdrawn is refused with a reason, never acted on. The asymmetry decides which direction a
757
+ provider gets right first: a control gained late is a nicety, a control withdrawn and not
758
+ republished is a button that fails when pressed. `assertTaskCapabilityWithdrawal` is the test for that
759
+ direction: the task as offered and as republished, and one command that was issuable under the
760
+ first and is refused under the last for want of the capability withdrawn -- and nothing else.
761
+
762
+ **An empty capability set is a statement, not a shrug.** `capabilities: {}` under `queue` or
763
+ `nobody` says the platform permits nothing capability-gated on this task, and an agent application draws
764
+ nothing beyond what the phase and `completionMode` require. A provider that has not yet learned
765
+ what the platform permits -- a queue's configuration that has not reached it -- knows nothing of
766
+ the kind, and must not publish the task as if it did, neither as `{}` nor as every control it has.
767
+ It publishes the task under `capabilitySource: "not-yet-read"`, with the capabilities it will
768
+ honour until it knows, and an agent application shows that where the agent works, beside the controls it draws
769
+ from them: "no queue governs this call" is a fact, "the configuration has not arrived" is a fault,
770
+ and the set alone cannot tell them apart, so the provider says which. What a provider honours
771
+ under not-yet-read terms is its own call -- a floor that would rather an agent briefly hold a
772
+ control the platform might not have granted than lose hold or hang-up mid-call over a slow
773
+ configuration read publishes those -- and the protocol chooses no default set for it. The fault is
774
+ also reported as a `diagnostic` naming the task, one per occurrence, so an operator counts it. When
775
+ the terms arrive, the task is republished under `queue` with the set as it now stands: the same
776
+ republish as any other permission that changed while the task was open. The move goes one way.
777
+ Terms once read stay read: a re-read that fails mid-task is not a new fact about the task, so the
778
+ last statement stands and the failure is a `diagnostic`, and a task that was published under
779
+ `queue` or `nobody` never returns to `not-yet-read`, on an update (`stream.taskUpdated.capabilitySource`)
780
+ or on a resync snapshot (`stream.snapshot.capabilitySource`); a record once read never loses an
781
+ entry the same two ways (`stream.taskUpdated.history`, `stream.snapshot.history`);
782
+ a snapshot carrying a task still at work does not forget the audio the stream held up, since audio
783
+ ends on `task-audio-ended` and the call moves on (`stream.snapshot.audio`); a voice task ends after
784
+ its audio ends, never around it, whatever the outcome (`stream.taskEnded.audioOpen`); and a task does not go
785
+ backwards, on an update or on a resync (`stream.taskUpdated.phase`, `stream.snapshot.phase`): the
786
+ stream sees publications, not
787
+ transitions, and a task may pass through a phase between two, so `pending` to `in-progress` stands
788
+ with `confirmed` between them, and what is refused is a phase unreachable from the last one read by
789
+ the transition table -- back to `pending`, back to `confirmed` or `preview` once work began, out of
790
+ `completing` except by the party being dialled again, a connect-back or a platform's callback,
791
+ which the update itself shows: the party ringing, or joined by a dial whose answered outcome the
792
+ stream saw in this life. A completing task republished as `in-progress` from a stale copy carries
793
+ no such stage, and that is the ending the agent never saw. Audio arrives only on a task at work: `task-audio-started` on a `completing` task
794
+ is refused as it is on a pending one (`stream.taskAudioStarted.beforeWork`), since a connect-back
795
+ returns the task to `in-progress` before any audio. And a task completes after its audio ends,
796
+ never around it: an update moving a task to `completing` while the stream holds its audio as
797
+ started is refused (`stream.taskUpdated.audioOpen`), whoever caused the ending, and a task stating
798
+ `completing` with `audio: "started"` contradicts itself on any snapshot or update
799
+ (`task.audio.completing`). The consequence the rule exists for is concrete: the customer's audio
800
+ keeps playing through the agent's wrap-up.
801
+
802
+ What the agent is told differs by source, and only one source tells them anything. Under `queue`
803
+ and `nobody` the agent sees controls and nothing about where they came from: both are facts,
804
+ and an agent working a call has no use for the name of the rule behind its buttons. Under
805
+ `not-yet-read` the agent is told, beside the controls, that these are what the provider will honour
806
+ until the queue's terms arrive, and that the controls may change when they do -- a statement about
807
+ the buttons in front of them now, not about the provider, because that is what changes when the
808
+ republish lands. It stays for as long as the set is provisional, beside the controls it qualifies;
809
+ a message that shows and clears has said nothing about the buttons still on the screen. The two
810
+ words are close in English and far apart on the desk: `nobody` is silence, `not-yet-read` is a
811
+ standing notice.
812
+
813
+ One consequence for whoever builds the agent application: because a conformance run fails on `not-yet-read`, a
814
+ conformant adapter never shows an agent application `not-yet-read` under test, and a clean `exerciseAdapter`
815
+ result says nothing about how the agent application renders it. That rendering is tested against a fixture --
816
+ a task published under `not-yet-read` shows the notice, the same task under `queue` shows none --
817
+ and never against an adapter, even in principle. `exerciseAdapter` treats a
818
+ task published under `not-yet-read` as a violation (`capabilitySource.notYetRead`), as it treats
819
+ a diagnostic: a conformance run against a platform that cannot say what it permits fails loudly
820
+ rather than passing with a note.
821
+
822
+ ```ts
823
+ const taskCapabilities = {
824
+ channel: "voice",
825
+ capabilities: {
826
+ hold: true,
827
+ outcomes: true,
828
+ },
829
+ browsers: [],
830
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "browsers">;
831
+ ```
832
+
833
+ A capability says the control may be offered; the task's phase says whether there is anything to use
834
+ it on. Every control below that acts on the call or the conversation -- everything but `decline`,
835
+ `connectBack`, `schedule` and `outcomes` -- is offered only while the task is `in-progress` or `paused`.
836
+ See **Which commands need a capability**.
837
+
838
+ ### Publishing codes and destinations
839
+
840
+ Two capabilities accept an object when the provider wants Omni to render real choices. For
841
+ `outcomes`, `true` remains valid and means "offer the control with nothing published"; the one
842
+ directory control, `conference`, carries its directory or is refused, since once nothing is typed
843
+ the directory is the control.
844
+
845
+ #### `outcomes`
846
+
847
+ An outcome describes what happened; completion finishes the task. Outcome codes are selected
848
+ from the task's own published list. They are distinct from `DialOutcome`, which reports the
849
+ result of a dial attempt.
850
+
851
+ Migration requires providers and hosts to adopt the renamed types and fields together:
852
+
853
+ | Former API | Current API |
854
+ | --- | --- |
855
+ | DispositionCode | `OutcomeCode` |
856
+ | DispositionRules | `OutcomeRules` |
857
+ | DispositionPayload | `OutcomePayload` |
858
+ | Task.capabilities.dispositions | `Task.capabilities.outcomes` |
859
+ | complete.disposition | `complete.outcome` |
860
+
861
+ Validation and conformance diagnostics use `task.outcomes`, `task.outcome`, and
862
+ `command.complete.outcome` in place of the former names. Update retained task snapshots and
863
+ command producers as well as imports. Old capability and command fields are rejected, including
864
+ payloads containing both names; there are no compatibility aliases. Code IDs, notes, required
865
+ selection rules, and completion behavior are unchanged.
866
+
867
+ ```ts
868
+ capabilities: {
869
+ outcomes: {
870
+ required: true,
871
+ notes: "optional",
872
+ codes: [
873
+ { id: "resolved", label: "Resolved" },
874
+ { id: "callback", label: "Callback needed", group: "Follow-up" },
875
+ ],
876
+ },
877
+ }
878
+ ```
879
+
880
+ | Field | Contract |
881
+ | --- | --- |
882
+ | `required` | When `true`, Omni must collect a code before issuing `complete`. A required policy must publish at least one code. |
883
+ | `notes` | `required`, `optional`, or `none`; controls the free-text field beside the code. |
884
+ | `codes` | Codes Omni offers. `id` values are non-empty and unique; Omni sends the chosen `id` as `TaskCommand.complete.outcome`. |
885
+
886
+ With `outcomes: true` Omni shows a Complete control and sends `complete` with no code, because
887
+ the provider published none.
888
+
889
+ #### Scheduling a follow-up
890
+
891
+ An agent on a call promises to call back on Thursday, or in wrap writes up that the customer wants
892
+ a callback once the refund lands. The follow-up goes on the calendar, and the platform owns the
893
+ calendar: `schedule` asks the provider to put it there. A chat or an email agent promises a
894
+ callback as often, so the control is on every channel.
895
+
896
+ ```ts
897
+ // From the call or the conversation, or from its wrap: the time, and a note where the agent wrote one.
898
+ { type: "schedule", at: "2026-08-28T10:00:00Z", note: "Call back about the refund" }
899
+ ```
900
+
901
+ `schedule` is gated by the `schedule` capability and issuable in `in-progress`, `paused` or
902
+ `completing` (`command.phase.interaction`): a follow-up is promised on the call and written up in
903
+ wrap alike, and the task names the party it is for. It lands on the calendar, so a task may offer
904
+ it only under a manifest that declares the `calendar` idle capability
905
+ (`task.capability.calendar.required`), as a control that dials needs `dialOutcomes`; a locked
906
+ `schedule` is still a declared one. The command carries a time and a note and nothing else
907
+ (`command.schedule.at`, `command.schedule.note`, `command.field`): what the platform makes of a
908
+ follow-up -- a campaign record, a reminder, a scheduled dial -- is its own, and the agent sees it
909
+ on the calendar.
910
+
911
+ **`applied` says the follow-up is on the calendar**, and the `calendar-updated` that shows it
912
+ follows within the manifest's `settleMs`, as a `task-ended` follows an applied `complete`. Past
913
+ the bound the agent application calls `snapshot()`: a calendar carrying an activity at that instant
914
+ clears the wait; one without it shows the follow-up as unsettled -- "Scheduled... the provider has
915
+ not confirmed" -- naming the command. The drive schedules one follow-up where the task offers it,
916
+ at a time it takes from the provider's own publication, and holds the provider to the same bound
917
+ (`drive.schedule.unsettled`).
918
+
919
+ ### Custom capabilities
920
+
921
+ Every task may publish additional provider-specific controls in `capabilities.custom`:
922
+
923
+ ```ts
924
+ capabilities: {
925
+ hold: true,
926
+ custom: [
927
+ { id: "request-supervisor", ui: { control: "button", label: "Request supervisor", placement: "secondary" } },
928
+ { id: "mark-vip", ui: { control: "toggle", label: "Mark as VIP", placement: "overflow" } },
929
+ ],
930
+ }
931
+ ```
932
+
933
+ Custom capability IDs must be non-empty and unique within the task. `ui.control` is `button`, `toggle`,
934
+ or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. `ui.render` says where the
935
+ control's work appears: `inline`, in the workspace beside the task, or `page`, as a page of its own
936
+ — a tab in the same work area as the task's browsers, beside them, and alone on a task that has
937
+ none; stated on every control, never defaulted (`task.custom.ui.render`). `prompt.fields` are what the agent supplies before the action runs — a
938
+ destination number, a reference — as `CredentialField`s Omni renders as a form; the values travel
939
+ on the custom command under the fields' names, as strings, and Omni sends the command only once
940
+ every `required` field has a value. The provider validates what arrives as it validates any
941
+ command; a form is a declaration, not a contract for what the agent typed. Omni renders the
942
+ control and invokes it with the shared custom task command:
943
+
944
+ ```ts
945
+ {
946
+ type: "custom",
947
+ name: "request-supervisor",
948
+ }
949
+ ```
950
+
951
+ `name` is the `id` of the custom capability the agent used. There is no `assignmentId` here: like every
952
+ other command it travels on the `TaskCommandRequest` around it. A `toggle` carries the state it
953
+ wants — `{ type: "custom", name: "mark-vip", on: true }` — never a flip, for the reason under
954
+ **Task commands**.
955
+
956
+ Custom capabilities must not redefine the meaning of a standard channel capability.
957
+
958
+ ## Task commands
959
+
960
+ Command names follow the channel's operational vocabulary, and each channel's command is a union
961
+ discriminated by `type` — the same discriminant `executeTeam` and `custom` already use. The
962
+ unions are declared under **Shapes** in `guide.md`.
963
+
964
+ `assignmentId` is not repeated on the command. It travels on the `TaskCommandRequest` around it.
965
+
966
+ **A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot converge
967
+ with a stale view: a flip against a state the provider has already changed turns something on and
968
+ then off again. A custom `toggle` control therefore carries its own boolean. `hold` and `resume`,
969
+ `pause` and `resume` need no flag, being pairs rather than toggles.
970
+
971
+ **`complete` sends an outcome only where one was published.** `outcome` is a
972
+ `OutcomeCode.id` from the task's own `outcomes` capability, and `notes` obeys that
973
+ capability's `notes` setting. A task publishing no codes still receives `complete`, with neither.
974
+
975
+ ### Where a command executes
976
+
977
+ Every command reaches the provider through `execute`, with no branch at the call site, and every
978
+ command asks the provider to **perform** something: `hold`, `conference`, `schedule`, `end-call`,
979
+ Provider-targeted `recording` commands and the rest act on the platform's own call leg, its bridge, or its record of the
980
+ task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
981
+
982
+ **The provider performs every action; the agent application only offers it.** A control drawn on the agent application is
983
+ an affordance, never the enforcement: the agent application asks, and the provider does or declines. A provider
984
+ answers `failed` for a command whose capability it did not publish, and that is the provider
985
+ honouring its own declaration, not the agent application deciding policy -- an agent application that refuses a command on
986
+ its own reading of a queue's flag has decided something that was never its to decide. What a
987
+ command means on the platform is the provider's to work out: `end-call` disconnects the caller channel, and
988
+ which legs on which bridge that touches is a fact about the switch, never a choice the agent application makes.
989
+ The one thing the agent application performs physically is the microphone, and that is not a command at all.
990
+
991
+ ### Which commands need a capability
992
+
993
+ **Presence is the permission** gates the controls a provider chooses to offer. Four commands are
994
+ not among them, because every task has them; each is authorized by a different field the provider
995
+ declared:
996
+
997
+ | Command | What makes it available |
998
+ | --- | --- |
999
+ | `answer`, `accept` | Nothing. A task that was offered can be accepted, or offering it meant nothing. |
1000
+ | `end-call` | The `endCall` capability: ends my part, the caller continues. |
1001
+ | `terminate-call` | The `terminateCall` capability: ends the whole call, every channel on it. |
1002
+ | `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
1003
+ | `decline` | The `decline` capability on any channel, **and** Omni local policy permitting it. One word for refusing an offer, whatever the channel. |
1004
+ | `dial` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
1005
+ | `complete` | A wrap to cut short: any task but a `provider-automatic` one with `wrapAllowance: 0`, under either completion mode (`command.complete.wrapAllowance`). The `outcomes` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.outcome.unknown`), a code at all where it requires one (`.outcome.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no outcomes (`.outcome.unexpected`). |
1006
+ | `connect-back` | The `connectBack` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
1007
+ | `schedule` | The `schedule` capability, on any channel, in `in-progress`, `paused` or `completing`: a follow-up is promised on the call and written up in wrap alike. The manifest declares `calendar` for it to land on. |
1008
+ | `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with `stage: "requested"`. |
1009
+ | `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
1010
+ | `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying a non-empty string for each `required` prompt field and strings for any optional fields supplied (`command.custom.prompt`). A toggle carries its target `on` boolean (`command.custom.on`). |
1011
+ | Everything else | Its own named capability. |
1012
+
1013
+ **A control on the contact belongs to the interaction phases**, `in-progress` and `paused`:
1014
+ `hold`, `resume` and `pause`, `end-call` and `terminate-call`, every `conference` action, and
1015
+ every `lead-assist` action; `schedule` alone reaches into `completing`. These controls act within this agent's current interaction.
1016
+ In `completing`, that interaction has ended and its wrap work remains. The caller may still be in
1017
+ an IVR, queue or another agent's interaction, and other channels may remain connected. Completion
1018
+ of this interaction does not establish that the caller or bridge ended. The capability stays
1019
+ declared while the task remains open; the phase prevents this task from controlling an interaction
1020
+ it no longer owns. Omni shows these controls only in the two interaction phases, and
1021
+ `validateTaskCommand` refuses them outside those phases (`command.phase.interaction`).
1022
+ The commands with a phase of their own -- `answer`, `accept` and `decline` in `pending`,
1023
+ `dial` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
1024
+
1025
+ `validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
1026
+ capability it needs, the phase it belongs to, and the state that has to stand. The task it wants
1027
+ is the one the provider published, not an agent application's own mapping of it: an agent application that keeps only its
1028
+ mapped shape has nothing honest to pass, and then checks shape alone, which is still worth doing
1029
+ -- it names a command the wire never had -- but is not the table. Keeping the published task
1030
+ beside the mapped one is what the full check costs an agent application; an adapter has it for free. The
1031
+ validator holds a command only to a task that stands: a task handed in that is not one the wire
1032
+ published is named (`command.task`) rather than checked against.
1033
+
1034
+ Declining a pending offer ends it without accepting or completing it. The provider confirms the
1035
+ end with `task-ended` and a `cancelled` outcome, `by: "agent"`.
1036
+
1037
+ ### `execute(request)`
1038
+
1039
+ Applies a `TaskCommandRequest` to one provider-local task.
1040
+
1041
+ - Omni serializes commands per task and sends the next only after the previous settled or was
1042
+ given up as unknown; it stops sending when `task-ended` arrives.
1043
+ - `applied` confirms the side effect completed. `failed` confirms it did **not**, with a typed
1044
+ `ProtocolFailure`; a provider that will not and one that cannot report the same shape, and `code`
1045
+ says which.
1046
+ - A command sent while `transport-status` is not `active` answers `failed` with `omni.unavailable`.
1047
+ Neither Omni nor the adapter queues it.
1048
+ - **A command that asks for a state answers `applied` when that state holds, whoever brought it
1049
+ about; a command that acts answers `failed` when it cannot act.** Declining a lead request
1050
+ already gone is `applied`; joining one already gone is `failed`, since nobody joined. A lead
1051
+ deciding a member's break that another lead has already decided is `applied` when the decisions
1052
+ agree and `failed`, saying so in `message`, when they differ. `commitBreak()` on a break already
1053
+ in effect is `committed` for the same reason.
1054
+ - **A result is untrusted for the same reason a snapshot is.** It comes from an adapter that may be
1055
+ compiled against another version, and Omni shows the agent what it says. Omni validates it at
1056
+ the boundary with `validateResult(result, "execute")` — a status the method does not answer, a
1057
+ `failed` without its failure, a success carrying one, or an `omni.` code this contract lacks is
1058
+ refused, and the command is treated as unsettled.
1059
+ - **A settled result is a fact; an unsettled promise is not.** Transport uncertainty may reject the
1060
+ promise with no result at all, and that means *unknown*, not *failed*, and a snapshot follows —
1061
+ see **An unsettled result is unknown**. `failed` must never be returned for something the
1062
+ provider is unsure of, because Omni will show the agent it did not happen.
1063
+
1064
+ ### `ProtocolFailure`
1065
+
1066
+ | Field | Contract |
1067
+ | --- | --- |
1068
+ | `code` | Required stable machine-readable value. See the reserved codes below. |
1069
+ | `message` | Required, and safe for logs or agent display. |
1070
+ | `retryable` | Whether repeating the action can succeed at all. |
1071
+ | `retryAfterMs` | Optional minimum suggested delay before a retry. A suggestion, not a guarantee. |
1072
+
1073
+ The `omni.` prefix is **reserved**. Adapters must not invent codes under it; every other value is
1074
+ provider-private and Omni treats it as opaque. Using a reserved code where it applies lets Omni
1075
+ react rather than only display the message:
1076
+
1077
+ | Code | Meaning |
1078
+ | --- | --- |
1079
+ | `omni.not-authenticated` | The provider session is no longer usable. The adapter has published `expired` at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal. |
1080
+ | `omni.capability-not-enabled` | The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose `capabilities` no longer carry it. |
1081
+ | `omni.assignment-not-found` | The assignment named is not one the provider holds, typically after the task already ended. |
1082
+ | `omni.phone-not-permitted` | The agent application declared a `phone` the platform does not permit for this agent -- a softphone for an agent configured for a desk phone, or the reverse. The login is refused at authentication, and the provider never reconfigures the agent to make the declaration true. See **How the agent hears the call** in `guide/phone.md`. |
1083
+ | `omni.destination-not-permitted` | The dialled number, or the `destinationId` named, is not one the provider offers this agent. |
1084
+ | `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
1085
+ | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
1086
+ | `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
1087
+ | `omni.recording-unsettled` | A recording command the provider could not settle either way: a settled `failed`, never a promise left unresolved. See **Independent task recording** in `guide/voice.md`. |
1088
+ | `omni.break-forced-by-provider` | A lead asked to lift a break the platform imposed; the platform lifts its own. See **Forced breaks** in `guide/breaks.md`. |
1089
+
1090
+ They are published as `OMNI_FAILURE_CODES`.