@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/lead.md ADDED
@@ -0,0 +1,333 @@
1
+ # `@xema/omni-protocol`: Team leads
2
+
3
+ The lead's own surface: the team member list, the lead's commands, lead assist, listening to a call. 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
+ ## Team leads
6
+
7
+ The team feature is the lead's own surface, beside the agent's and never mixed with it. The
8
+ login's `lead` flag turns it on in the agent application; the lead retrieves their members and
9
+ sees, for each, their assignments as they stand and the full history of each, and acts on them
10
+ with the authority the flag carries. Nothing the lead does here is a task on their desk. A lead
11
+ who also takes calls is an agent like any other on the agent side, and a lead who does not want
12
+ the agent role for now places themself on a break of a working kind and keeps the team feature on.
13
+
14
+ The team reaches the lead whole once, on `team-updated` after the lead switches the feature on
15
+ and on every snapshot while it is on, and from then on one member at a time: when a member is
16
+ offered a call, answers, holds, mutes or is taken over, the provider publishes that member whole
17
+ on `team-member-updated`, so the lead's screen moves when the member's does and a team of two
18
+ hundred is never resent because one of them held. The agent application asks for nothing and
19
+ computes nothing. See **`team-updated`** in `guide.md` and its companions.
20
+
21
+ | Field | Contract |
22
+ | --- | --- |
23
+ | `members` | Every member of this lead's team, whatever their state, each with their open tasks. `[]` says the lead has a team with nobody in it; omitting the team member list says something else entirely — see **The login is the permission** below. |
24
+ | `policies` | The team's policy per capability as it stands — the setting, who set it, and `lockedBy` where a level above the team made it theirs to keep. Present where the provider offers policy control; absent where it does not. See **Who decides what an agent may do** in `guide.md`. |
25
+
26
+ | `TeamMember` field | Contract |
27
+ | --- | --- |
28
+ | `id` | Required `UserId`. A task carries no names and neither does a team member list: Omni resolves what to display with `getUserDetails()`. |
29
+ | `availability` | Required. What the member is doing now. |
30
+ | `since` | Optional. When the current `availability` began — not when they signed in, and not when the team member list was read. |
31
+ | `break` | Present only while the member has an outstanding break request. See **A member waiting for a break**. |
32
+ | `tasks` | The member's open tasks as `MemberTask`s: the same task the member's desk holds, less the workspace. The assignment, title, task type, phase, party, room, audio and completion terms -- `completionMode`, `wrapAllowance`, `wrapEndsInSeconds` -- are what the lead reads, and the task's record -- `history`, steps and totals -- travels whole, exactly as the member's desk holds it, so the lead's screen and the member's show the same record. The workspace -- `capabilities`, `capabilitySource`, `browsers` and their URLs -- is the member's desk's alone and never travels (`team.member.task.workspace`). The party's number and email are shown to leads or locked as the provider decides for leads, apart from what it decides for the member -- see **What the queue locks is locked on the whole task**. `[]` when the member holds none; omitted only where the provider cannot see them. |
33
+ | `listening` | Every lead on this member's call — listening, coaching or joined — published to every lead alike: each entry names the lead by `leadId`, which of the member's calls by `assignmentId`, the `mode` they are heard in, and since when. The entry naming the signed-in lead is what their own audio opens on. Absent while nobody is on the call, never `[]` (`team.member.listening.*`); a lead is on one member's call at a time (`team.listening.single`), and a lead who is heard is also in the task's `onCall` (`team.member.listening.heard`). |
34
+ | `request` | Present while the member is asking a lead to join one of their calls: which assignment, the note they wrote, since when. The call named is one their `tasks` carry (`team.member.request.assignment`). Absent when they are not asking. See **Lead assist**. |
35
+ | `shift` | The member's own history for the day, about the person rather than any one call: `signedInAt`, `signedOutAt` once it has happened, today's totals as the provider counts them — `talkSeconds`, `holdSeconds`, `breakSeconds`, `tasksHandled`, each present only when the provider knows it — and the day's sign-in, sign-out and break `events`, oldest first. The same `Shift` the member's own snapshot carries, the same numbers from the same count. Omitted where the provider cannot say. |
36
+
37
+ Each availability value means one thing:
38
+
39
+ | Value | Meaning |
40
+ | --- | --- |
41
+ | `ready` | Signed in, able to take work, none assigned. |
42
+ | `on-task` | Working on at least one task. It says nothing about how many, and nothing about whether more will fit. |
43
+ | `on-break` | Stopped and not taking work, whether they asked or somebody stopped them. The reason lives on their own `BreakState`, not here. |
44
+ | `elsewhere` | Signed in here, and the agent application holds this agent's capacity for another provider (`count: 0`, agent application-stopped): not receiving this provider's work, and not on a break. See **Capacity**. |
45
+ | `signed-out` | Known to this team but not signed in to this provider. |
46
+
47
+ **Publish the whole team once, then each member whole.** Team presence typically reaches an
48
+ adapter over a best-effort channel with no ordering and no delivery guarantee, so the adapter
49
+ reconciles against its own authoritative read before it publishes anything. What it publishes is
50
+ the whole team on `team-updated` -- after the switch, and on every snapshot -- and after that one
51
+ member at a time on `team-member-updated`, carrying the member whole: their availability, their
52
+ tasks, their listening, their shift, their request, as they now stand. Applying the same member
53
+ twice changes nothing, so a redelivery is harmless, and a member the team never carried is added
54
+ by it, as a colleague signing in is. `team-member-removed` takes one off. Never a field of a
55
+ member, never a task of theirs on its own: a hold on a member's desk is that member republished
56
+ whole on the lead's, and nothing else on the lead's screen moves.
57
+
58
+ **Whatever the member's desk hears of a task, every lead hears of the member.** The trigger is
59
+ not a list of changes but the publication itself: each `task-offered`, `task-updated`,
60
+ `task-audio-started`, `task-audio-ended` and `task-ended` the provider sends to the member is
61
+ matched by a `team-member-updated` to every lead with the feature on, carrying the member whole
62
+ with the task as it now stands, or without it once it has ended. A held leg closing with its
63
+ `seconds`, a room restated, a phase moving, the wrap left ticking on a restatement: each reaches
64
+ the lead's copy in the same round it reaches the member's, and the two copies of the record never
65
+ part for longer than the delay between them.
66
+
67
+ **The agent's day is on the wire.** The lead sees `shift` totals as the provider counts them, and
68
+ the agent sees the same: `Snapshot.shift` and `shift-updated` carry the agent's own day in the same
69
+ shape, from the same count, so "calls today: 12" is one number on both screens. The desk computes
70
+ nothing of the day; `tasksHandled` moves when a task ends and `talkSeconds` when a leg closes, and
71
+ the active call's running time is the screen's own until then, as **Every screen counts from the
72
+ provider's clock** sets out.
73
+
74
+ **Omit `since` rather than inventing one.** Omni renders it as a duration, so a timestamp
75
+ synthesised from the adapter's own clock at seed time reads as "on task for 0 seconds" for
76
+ everybody — worse than showing nothing, because it looks like data. Send it only when the provider
77
+ knows when the state actually began. It times the current `availability`, so it moves every time
78
+ that value does. And it is counted from the provider's clock, never the screen's, so a provider
79
+ that publishes it declares `timeCheck` -- see **Every screen counts from the provider's clock** in `guide.md`.
80
+
81
+ **The login is the permission.** A team member list goes to a login that declares
82
+ `capabilities.lead`, once the team feature is on, and to nobody else. Omni never decides who
83
+ leads a team: the provider said so at sign-in, and the team member list agrees with it — owed,
84
+ `members: []` included, once a lead has switched the feature on; absent for everybody else, which
85
+ is the correct rendering for an agent who leads nobody (`team.required`, `team.unentitled`). What
86
+ the lead may do with it comes with the flag: there is no per-action permission on the login or on
87
+ the list.
88
+
89
+ **The lead switches the feature on and off, and the provider is told.** A lead may work as an
90
+ agent alone, as agent and lead, or as lead alone, and moves between them on the fly. The
91
+ agent application says which with `{ type: "lead-features", enabled }`: while it is on, the
92
+ provider sends team events scoped to the lead's team and the lead may act; while it is off, the
93
+ lead is an ordinary agent, nothing of the team is owed or expected (`team.unexpected`,
94
+ `event.team.features`), and no lead act is possible, since the lead learns their members'
95
+ assignments only from the list.
96
+
97
+ **The provider assumes nothing.** The switch is not remembered across connections: the agent
98
+ application sends `lead-features` on every connect, after it has stated capacity, and until it has
99
+ the provider treats the feature as off. The connect snapshot therefore carries no team, whatever
100
+ the login declares; the whole team arrives on `team-updated` once the switch is on, and every
101
+ snapshot after that carries it. A conformance run switches the feature on for a lead and holds the
102
+ provider to that first `team-updated` (`team.required`); a team on the connect snapshot is the
103
+ provider assuming (`team.unexpected`).
104
+
105
+ **The team member list never carries the agent it is published to.** A lead does not report to
106
+ themself: their own break request and their own ask for a lead go up to whoever leads them and
107
+ appear on *that* person's team member list, as a member with a `request`, while the requester sees
108
+ only their own `BreakState` and their task's `leadAssist` move. An adapter whose platform lists the lead
109
+ among their own members filters the signed-in identity out before publishing. **Being a lead is a
110
+ role the provider knows, never inferred from who is listed:** it is declared at sign-in, a lead
111
+ with nobody in their team publishes `members: []`, an agent with no such role publishes nothing, and no
112
+ member count can tell those two apart.
113
+
114
+ ### Lead commands
115
+
116
+ One method, `executeTeam`, taking a discriminated command exactly as `execute` takes a
117
+ `TaskCommand`. Every act on a member's call names the member, `memberId`, and may name the
118
+ assignment, `assignmentId`, where the lead has it; the provider resolves the member's current
119
+ assignment from the member alone.
120
+
121
+ | Command | Effect |
122
+ | --- | --- |
123
+ | `{ type: "lead-features", enabled }` | The team feature on or off. See **The lead switches the feature on and off** above. |
124
+ | `{ type: "decide-break-request", memberId, decision, reason? }` | Settles one pending request. `decision` is `granted` or `denied`. A grant moves the member to `granted`; a denial ends the request and moves it directly to `not-requested`. |
125
+ | `{ type: "set-break-policy", policy }` | `approval-required`, `automatically-approved`, or `requests-suspended`. |
126
+ | `{ type: "force-break", memberId, reasonId?, reason?, expectedDurationMs? }` | Puts a member on a break they did not ask for. `reasonId` names a published `BreakReason.id` and is required whenever the provider publishes `reasons`; the member's forced break carries it as `activeReasonId`, so its kind is known. Optional `expectedDurationMs` is advisory; the resulting forced break publishes what is left of it as `expectedEndsInSeconds`, restated on every publication. |
127
+ | `{ type: "end-forced-break", memberId }` | Asks the provider to lift a forced-break restriction a lead asked for, by clearing `BreakState.forced`; one the platform imposed is its own to lift (`team.command.endForcedBreak.provider`, `omni.break-forced-by-provider`). The committed break continues; only the agent resumes work. |
128
+ | `{ type: "join", memberId, assignmentId? }` | Answers the member's request: the provider bridges the lead's channel into the call. See **Lead assist**. |
129
+ | `{ type: "decline", memberId, assignmentId?, reason? }` | Refuses the member's request. |
130
+ | `{ type: "listen", memberId, assignmentId? }` | The lead's channel joins the member's call unasked, in silence. See **Listening to a call**. |
131
+ | `{ type: "coach", memberId, assignmentId? }` | The lead is heard by the member alone. Needs the lead on that member's call. |
132
+ | `{ type: "join-call", memberId, assignmentId? }` | The lead is heard by everyone on the call. Needs the lead on that member's call. |
133
+ | `{ type: "leave", memberId, assignmentId? }` | The lead's channel leaves the member's call, however it got there. The member's call goes on. |
134
+ | `{ type: "take-over-call", memberId, assignmentId? }` | The provider moves the call to the lead as its new agent, from a joined call or a listened one alike. See **Lead assist**. |
135
+ | `{ type: "set-policy", capability, setting }` | Sets the team's policy for one capability: `on`, `off`, or `person`. See **Who decides what an agent may do** in `guide.md`. |
136
+
137
+ `memberId` is this provider's own identifier for the member, as published on its team member list. It is
138
+ never an identifier from another provider, and Omni does not translate between them; names come
139
+ from `getUserDetails()`. `validateTeamCommand` holds each command to the team member list as it
140
+ stands -- see **Runtime break prerequisite checks** in `guide/breaks.md`.
141
+
142
+ `requests-suspended` means requests are **rejected outright** rather than left pending — nobody is coming to
143
+ approve them. A provider that suspends break requests must also publish `canRequestBreak: false` to the team's
144
+ agents so they see it before asking. A `force-break` must likewise reach that member as a `forced` break
145
+ on their own `BreakState`, or they are stopped from working with no way to see why.
146
+
147
+ What happens when no lead is online — auto-approving, for instance — is the provider's decision and is
148
+ never expressed here.
149
+
150
+ ### Lead assist
151
+
152
+ An agent on a call may ask a lead to join it -- a dispute that needs approval, a customer who
153
+ asks for a manager, a moment the agent wants a second pair of ears. A call centre calls this
154
+ assistance or escalation, and the name says who assists: the capability is `leadAssist` on the
155
+ task; the lead's side is the team member list, which is already the lead's view of the team. The
156
+ flow, in order:
157
+
158
+ ```ts
159
+ // 1. The agent asks, with a small note. Their task carries `leadAssist` from here on.
160
+ execute({ assignmentId: "alloc-42", command: { type: "lead-assist", action: "request", note: "Refund dispute, needs approval" } })
161
+ // task.leadAssist = { stage: "requested", note: "Refund dispute, needs approval", since }
162
+
163
+ // 2. Every lead with the team feature on sees the request on the member.
164
+ // team-member-updated: member: { id: "A-1", ..., request: { assignmentId: "alloc-42", note, since } }
165
+
166
+ // 3. A lead joins, or declines.
167
+ executeTeam({ command: { type: "join", memberId: "A-1", assignmentId: "alloc-42" } })
168
+ executeTeam({ command: { type: "decline", memberId: "A-1", reason: "In a call" } })
169
+ ```
170
+
171
+ **On `join` the provider bridges the lead's channel into the call.** The member's task moves to
172
+ `leadAssist: { stage: "joined", leadId }` and its `onCall` gains the lead as an `agent`, since a
173
+ lead who is heard is in the room; the member on the team member list carries
174
+ `listening: [{ leadId, assignmentId, mode: "join-call", since }]`, and the lead's audio opens on
175
+ it as **Listening to a call** describes. Nothing is a task on the lead's desk: a join is the lead's act on the team surface, not
176
+ work assigned to them.
177
+
178
+ **A lead is on one member's call at a time.** A `join` from a lead already on a call -- their own
179
+ or a member's -- is answered `failed`; the request stands for another lead, or until it is
180
+ withdrawn or declined. A join is work, so Omni offers Join only to a lead whose voice channel is
181
+ free, and a provider answers a `join` from one whose channel is not `failed`.
182
+
183
+ **On `decline`, or a request the agent withdraws with `{ type: "lead-assist", action: "cancel" }`, the
184
+ provider clears `leadAssist` from the agent's task** and drops the request from every team member list. Nothing
185
+ else changes; the agent is still on the call.
186
+
187
+ **On `leave`, the lead's channel drops and the call goes on**: the member's task loses its
188
+ `leadAssist`, the member loses this lead's entry in `listening`, and the lead is free. Nothing ended for the member.
189
+
190
+ **On `take-over-call`, the provider moves the call from the member to the lead**, and the lead is
191
+ its new agent. There are two paths to it -- the agent asked and the lead joined, or the lead was
192
+ listening unasked -- and one act at the end of either. What follows is the same on both:
193
+
194
+ | | The member's task | The lead's desk |
195
+ | --- | --- | --- |
196
+ | Take-over | `task-audio-ended`, then `completing`: the member's interaction is over and their wrap runs, exactly as after `end-call`. The audio ends first, as before every voice ending (`stream.taskEnded.audioOpen`). The member loses this lead's `listening` entry on the team member list no later than the offer, and the lead's listening audio closes. | An ordinary assignment arrives: `task-offered` with `acceptance: "automatic"`, carrying the call's history and `takenOver: { memberId, since }`, within the lead's capacity. |
197
+ | The member completes | `task-ended` with `{ type: "taken-over", leadId }`, at the member's own completion. The lead is named by user id, since a lead is not a directory item. | The lead works the call as any agent would, and ends it as any call ends. |
198
+
199
+ **The taken-over call is offered whatever break the lead is on, and counted like any other.** A
200
+ lead working as lead alone is on a break of a working kind, and the provider assigns the call
201
+ regardless: it is the one task a break in effect may hold (`break.on-break.tasks` allows it, and
202
+ nothing else). It is a call landing on the lead's desk, so it counts against their capacity as
203
+ every call does, with no exemption: a lead at capacity cannot take over, and the provider answers
204
+ `failed`. The other conditions are that the lead is not already on another call and their voice
205
+ channel is free. A lead with the team feature off cannot take over, since they have no member's
206
+ assignment to name.
207
+
208
+ ```ts
209
+ const leadAssistCapable = {
210
+ channel: "voice",
211
+ capabilities: { hold: true, leadAssist: true, outcomes: true },
212
+ phase: "in-progress",
213
+ leadAssist: { stage: "joined", leadId: "L-9", note: "Refund dispute, needs approval", since: "2026-08-21T09:04:00Z" },
214
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "leadAssist">;
215
+
216
+ const takenOver = {
217
+ channel: "voice",
218
+ capabilities: { hold: true, endCall: true, outcomes: true },
219
+ phase: "in-progress",
220
+ takenOver: { memberId: "A-1", since: "2026-08-21T09:06:00Z" },
221
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "takenOver">;
222
+ ```
223
+
224
+ Lead and member alike are `UserId`s of this provider, so an adapter publishing them implements
225
+ `getUserDetails()`; names never travel on a task or a team member list.
226
+
227
+ ### Listening to a call
228
+
229
+ A lead may listen to a member's call without being asked -- to coach, to check quality, to step in
230
+ when it goes wrong. It is a team act naming the member, and no task on the lead's desk carries it:
231
+ the lead's view of the call is the member's task on the team member list, with its room and its
232
+ history. The three modes determine who hears the lead:
233
+
234
+ | Mode | Who hears the lead |
235
+ | --- | --- |
236
+ | `listen` | Nobody. The lead hears both sides in silence. |
237
+ | `coach` | The agent alone. The customer hears nothing. |
238
+ | `join-call` | Everyone on the call. |
239
+
240
+ All three come with the `lead` flag; a centre reserves none of them per lead.
241
+
242
+ **There is no team audio.** Audio is the agent's or the lead's, and the lead's audio while
243
+ listening follows the provider's published state exactly as a task's does: the entry naming this
244
+ lead in `listening` on the member is the provider's word that the lead's channel is in the call.
245
+ When it appears, the application opens `openAudio({ assignmentId })` on the lead's connection for
246
+ the call it names, and plays the `CallAudio` returned; when it goes, the application closes it.
247
+ Another lead's entry opens nothing: it is shown, and that is all. Nothing else says
248
+ when the lead's audio is up, and a reload reopens it from the snapshot's team member list as a task
249
+ carried with `audio: "started"` is reopened.
250
+
251
+ The flow, end to end:
252
+
253
+ ```ts
254
+ // 1. The lead, with the feature on, sees a member on-task with their tasks, and picks a call.
255
+ // 2. The lead's channel joins it in silence. Conditions: the lead's voice channel is free --
256
+ // not on a call, not listening elsewhere. Capacity is not involved: nothing is assigned.
257
+ executeTeam({ command: { type: "listen", memberId: "A-1", assignmentId: "alloc-42" } })
258
+ // 3. The provider publishes the member whole on team-member-updated, to every lead; the member carries
259
+ // listening: [{ leadId: "L-9", assignmentId: "alloc-42", mode: "listen", since }]
260
+ // -- L-9's application opens openAudio({ assignmentId: "alloc-42" }) on its own connection; every
261
+ // other lead's shows L-9 on the call and opens nothing.
262
+ // 4. The lead changes how they are heard; the member is published again with the entry's new mode. The audio stays open.
263
+ // join-call is heard by everyone, so the member's task is also republished with L-9 on onCall as an agent.
264
+ executeTeam({ command: { type: "coach", memberId: "A-1" } })
265
+ executeTeam({ command: { type: "join-call", memberId: "A-1" } })
266
+ // 5. The lead leaves: the provider publishes the member without L-9's entry, and the task without L-9 in the room; the application closes the audio.
267
+ executeTeam({ command: { type: "leave", memberId: "A-1" } })
268
+ // 6. The member's call ends: the same as leave, from the provider's side.
269
+ // 7. The lead takes the call: the member loses this lead's listening entry no later than the offer, the application
270
+ // closes the listening audio, and the lead's own call arrives as an ordinary assignment marked
271
+ // takenOver, counted against capacity, with its audio following task-audio-started as any call's does.
272
+ // At capacity the take-over is refused and the lead stays listening.
273
+ executeTeam({ command: { type: "take-over-call", memberId: "A-1" } })
274
+ // 8. A join on the member's request is the same flow entered at step 3 with mode: "join-call",
275
+ // and the member's task shows leadAssist: joined.
276
+ // 9. A reload of the lead's application: the switch is sent again, the team-updated that follows
277
+ // still carries this lead's entry in listening on the member, so the audio is reopened.
278
+ ```
279
+
280
+ **`listening` on the member is the state**, one entry per lead on the call, restated on every
281
+ change of mode, naming the lead (`team.member.listening.leadId`, once per member,
282
+ `team.member.listening.unique`) and the call, so the application knows which audio is its own to
283
+ open (`team.member.listening.assignmentId`, and one the member's tasks carry,
284
+ `team.member.listening.assignment`). Every lead sees every entry: a silent lead is hidden from
285
+ the agent, never from the other leads and managers watching the same member on other screens.
286
+ A lead is on one member's call at a time (`team.listening.single`).
287
+
288
+ **A lead listens while holding no call of their own.** Omni offers Listen to a lead whose voice
289
+ channel is free, and a provider answers a `listen` from one whose channel is not `failed`. That
290
+ includes a lead on a break: a lead working as lead alone is on a break of a working kind, and
291
+ listens through it.
292
+
293
+ **A silent lead reaches nobody but the leads.** In `listen` and `coach` the member's task carries
294
+ no trace of the lead: not on `onCall`, not in the record. Whether coaching is announced to the
295
+ agent is the platform's business and travels on the audio, not on this wire. In `join-call`, and
296
+ on a `join` answering a request, the lead is heard by everyone and is in the room: the task's
297
+ `onCall` carries them as an `agent` by user id, on the member's desk and on every lead's copy of
298
+ the task alike, and `leave` takes the entry off as it takes the channel off
299
+ (`team.member.listening.heard`).
300
+
301
+ ### A member waiting for a break
302
+
303
+ A member who has asked for a break **keeps working** until Omni commits it, so asking is not an
304
+ availability of its own — it rides alongside one on `break`. That a request exists says nothing
305
+ about whether anybody has to act on it, and the difference is a lead's entire action list:
306
+
307
+ | `break` | Means |
308
+ | --- | --- |
309
+ | `awaiting-approval` | Somebody has to decide. This is the lead's queue. |
310
+ | `granted` | Decided yes, but Omni has not told the provider to stop yet. Work continues and nobody needs to decide. |
311
+ | `starting-after-task` | Already granted; it begins when their current task ends. Nobody needs to act. |
312
+
313
+ Those three are the only values that appear here. `not-requested` is absence — omit `break`
314
+ instead. `on-break` is `availability: "on-break"`, and a denial transitions to `not-requested`,
315
+ so neither survives to be reported. It is otherwise the same `BreakStatus` the member's own
316
+ break state uses, rather than a parallel vocabulary for the lead's view, so the two cannot drift
317
+ apart.
318
+
319
+ Omni offers Approve and Deny only while a member is `awaiting-approval`, shows `granted` as agreed
320
+ but not started, and shows `starting-after-task` as settled.
321
+
322
+ **Live status, not a record.** The provider derives it from what is true now — not stored, not
323
+ historical, carrying no decision made earlier. Like the team member list it belongs to, it is published
324
+ whole and replaced whole, and a provider that cannot say omits it.
325
+
326
+ **An agent is not waiting on one person.** Authority is held by several, everyone who holds it
327
+ sees the request on their own console, and **any one of them settles it**. Omni offers the
328
+ decision to every login that declares `lead` and has the team feature on — which is how the provider already
329
+ says who may decide — and does not try to work out whose turn it is.
330
+
331
+ A request needing *more than one* approval is not something this contract describes. There is
332
+ no partial state to report and no progress to display: a request is either still owed a
333
+ decision or it is not.
package/guide/phone.md ADDED
@@ -0,0 +1,274 @@
1
+ # `@xema/omni-protocol`: The phone
2
+
3
+ How the agent hears the call: softphone and desk phone, the station and its headset, opening the audio, the microphone and the record. 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
+ ## Real-time audio
6
+
7
+ Every voice provider has audio: `channel: "voice"` says audio exists. Where the agent hears it
8
+ depends on the `phone` the agent application selected at authentication and connect from the manifest's
9
+ `phones`. On a softphone, audio lands in Omni; on a desk phone, it lands on the handset and Omni
10
+ opens no audio. See **How the agent hears the call**. Audio transitions are voice-only;
11
+ chat and email publish none (`event.audio.channel`, `stream.taskAudio.channel`).
12
+
13
+ The provider owns signalling and the platform's endpoint configuration. The agent application selects the
14
+ declared phone mode; it does not enumerate or reconfigure the platform's devices. The selected
15
+ mode stays with the login rather than being inferred from a snapshot or a device failure.
16
+
17
+ Nor does the audio ever stand in for the task: a task's presence and
18
+ phase follow the provider's reports about the work, and the audio — attaching, moving through a
19
+ hold, a conference, a lead joining or a take-over, and ending — is transient beside it. See **A task is
20
+ never its audio** under **Task assignment lifecycle**.
21
+
22
+ ### The agent application reports, the adapter decides
23
+
24
+ The adapter runs inside Omni and sees nothing of the station for itself: the devices, the
25
+ permissions, the audio element and the network are the agent application's, and only the agent application can tell. So
26
+ the agent application reports, and the adapter asks. **An adapter consults `ConnectContext.host` before it
27
+ declares the agent ready to its platform, and again whenever the report changes**, and it decides
28
+ what any of it means and what to relay upstream — go not-ready, refuse calls, carry on because the
29
+ platform's audio lands elsewhere. Omni facilitates and does not take responsibility: it captures
30
+ the microphone once as the voice connection opens, so the permission prompt lands while the agent
31
+ is signing in rather than over a contact, prompts, retries on the agent's request, tells the agent
32
+ what failed, and reports. It never decides for the adapter what a missing microphone means.
33
+
34
+ **The agent application also guarantees, and a promise the provider cannot see is not one it can rely on.**
35
+ Two of this contract's obligations fall on the agent application rather than the provider — honouring a task
36
+ browser's `urlVisibility`, and taking a `consent` offer only on the person's own press — and more
37
+ than one desk speaks this contract: a browser and a native application differ in what they can
38
+ reach, and a provider never branches on which it is talking to, only on what it has declared —
39
+ here, and in `host.mute` (see **The station is the agent application's**). So `ConnectContext.host.guarantees` says which promises the
40
+ connected agent application makes, declared once per connection, presence being the guarantee exactly as it is
41
+ the permission everywhere else:
42
+
43
+ | Guarantee | Contract |
44
+ | --- | --- |
45
+ | `browserUrlVisibility` | Every task browser's `urlVisibility` is honoured in this agent application's chrome, tab by tab. A provider that would send a caller's number in a URL checks this first and tokenises where the promise is absent. |
46
+ | `personConsent` | A `consent` offer is accepted only by the person's own explicit act, never on their behalf. A provider whose work may only be taken by a human checks this first and does not offer it where the promise is absent. |
47
+
48
+ A guarantee the agent application does not make is an absent key, never `false` — `validateHostGuarantees`
49
+ refuses a false one, as it refuses a name this contract does not list.
50
+
51
+ | Field | Contract |
52
+ | --- | --- |
53
+ | `online` | Whether the agent application has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the agent application does — so `false` is a reason not to go ready and `true` is not a reason to. |
54
+ | `audio` | Present on a voice connection, absent where there is no audio. |
55
+ | `audio.input` | `available` with `localAudio` — the microphone as captured, the same stream `openAudio` receives — and `flowing`, false while no audio moves through it, when `mutedBy` says who stopped it: `host`, the agent application's own Mute on an agent application that mutes the station, or `station`, a slider on the headset or the operating system, which the agent application observed and did not do. An adapter treats `host` as the agent's act on a call and `station` as a condition of the station. `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which an agent application that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. An agent application decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
56
+ | `audio.output` | `available`, with `flowing` where the agent application can know whether audio reaches the speaker — a browser mostly cannot, and omits it; a native agent application reads the endpoint — false while it is silenced, when `mutedBy` says who did it, as on input. Or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
57
+
58
+ Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
59
+ a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
60
+ not reported, so an adapter may act on what it reads without debouncing it again. Nothing in the
61
+ report is a task fact or a capacity fact; it is what the station can do, and the adapter's platform
62
+ is the one that knows whether an agent without a microphone, or without a speaker, can work.
63
+
64
+ The agent application's own obligations here — asking at connect, never publishing `not-asked` when it does,
65
+ publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
66
+ other side: it validates the shape of whatever agent application a test hands the adapter, requires `audio` on
67
+ a softphone login and none elsewhere -- not on a desk phone, not off voice
68
+ (`context.host.audio.required` / `.unexpected`), holds `host.mute` to the same line (`host.mute.required`
69
+ / `.unexpected`, `host.mute` for a third word) -- and refuses a voice adapter that never asked the
70
+ agent application anything (`connection.host.consulted`), and one that subscribed to nothing will never hear of a
71
+ change (`connection.host.subscribed`): the obligation has two halves, and each is held.
72
+
73
+ ### Capacity around setup
74
+
75
+ Connecting is not the same as being able to take a call. A provider that treats a live connection
76
+ as reachability opens a window where it believes the agent is available and the agent is not yet
77
+ set up — the adapter's own registration incomplete, its credentials not yet renewed.
78
+
79
+ Nothing closes that window, because nothing opens it: **Omni states no capacity until the
80
+ connection is established**, and **Work is pulled, never pushed** makes an assignment with none
81
+ stated a violation. Capacity does not wait on the microphone: whether an agent without agent application audio
82
+ can take calls is the platform's question, answered by the adapter from the agent application's report, not by
83
+ Omni withholding capacity for every platform alike.
84
+
85
+ Capacity follows **automatically** once the connection is established; the agent does not press
86
+ anything to become available.
87
+
88
+ | Situation | What Omni sends |
89
+ | --- | --- |
90
+ | Connecting | Nothing. No capacity has been stated, so nothing may be assigned. |
91
+ | Connected and idle | `setCapacity({ count: n })`, with the agent application's report already available to consult. |
92
+ | A task starts or ends | Nothing. The provider counts its own against the ceiling. |
93
+ | The agent's provisioned capacity changes | `setCapacity({ count: n })` |
94
+ | Agent asks for a break | `requestBreak`. Capacity is unchanged and work continues. |
95
+ | Omni commits a break | `commitBreak`. The break stops assignment, not the ceiling. |
96
+ | Agent returns from break | `endBreak` |
97
+
98
+ **A break is not a capacity of zero, and neither is the reverse.** Capacity says how much of this
99
+ agent this provider may carry; a break says they are not working at all. A provider told
100
+ `count: 0` shows an agent whose capacity is elsewhere, `elsewhere`; a provider told a break shows
101
+ `on-break`. Collapsing the two would leave a provider unable to tell an agent working on another
102
+ provider from an agent who has gone to lunch, and only one of those needs a reason, a decision and
103
+ a return.
104
+
105
+ ### How the agent hears the call
106
+
107
+ A voice platform can put an agent on a **softphone**, where the call's audio lands in the agent application
108
+ and Omni owns the microphone and the playback, or on a **desk phone**, a handset on the platform's
109
+ switch that the platform rings, where the agent application owns no audio and only shows the call. Which of
110
+ these a platform can do is a fact about the platform, and which one a login is on is a choice the
111
+ agent application makes for that agent -- so the manifest declares and the agent application chooses.
112
+
113
+ ```ts
114
+ // Manifest: what the platform can do
115
+ phones: ["softphone", "deskPhone"]
116
+ // The agent application, at authentication and again at connect: which one this login is on
117
+ { phone: "softphone" }
118
+ ```
119
+
120
+ A voice manifest lists its `phones` and any other channel lists none (`manifest.phones.required`,
121
+ `manifest.phones.channel`). The agent application's `phone` is required on a voice login, absent on any other,
122
+ and one the manifest listed (`context.phone.required`, `.unexpected`, `.unsupported`). Everything
123
+ about audio then follows the phone rather than the channel: on a softphone the agent application reports its
124
+ audio, the adapter implements `openAudio`, and `task-audio-started` is the word to open it; on a
125
+ desk phone the agent application reports no audio, opens nothing, and `task-audio-started` still marks the
126
+ moment the call is live so the desk shows it, with the sound on the handset. A platform that lists
127
+ `deskPhone` alone never implements `openAudio`; one that lists `softphone` always does.
128
+
129
+ **The mode comes from the agent application; the status comes from the provider.** The agent application knows which kind
130
+ of station the agent signed in at, because the person chose it, and nothing else can know that.
131
+ The provider knows whether that station can carry a call right now -- whether a handset is
132
+ registered -- which an agent application in a browser cannot see. So the agent application's `phone` selects the branch, and
133
+ the provider's own knowledge of the device decides readiness within it. A provider never overrides
134
+ the agent application's declaration from its device record: treating a desk-phone login as a softphone because
135
+ the handset is momentarily unregistered, and demanding a microphone of it, is the reading this
136
+ sentence exists to refuse. What the provider does when the handset is not registered is what it
137
+ does for any station that cannot take a call -- hold the agent not-ready and say why.
138
+
139
+ **On a desk-phone login the agent application never calls `openAudio`.** There is no stream to hand over and
140
+ no audio to attach; an agent application that calls it anyway is in error, and an adapter that receives the call
141
+ answers `unavailable` with a non-retryable failure, since waiting changes nothing about a station
142
+ that is a telephone. The harness requires no `openAudio` of such an adapter and never calls it.
143
+
144
+ **Which handset a desk-phone login rings is the platform's configuration for that agent**, and
145
+ this wire never asks the agent for it: an agent application declares `phone` and nothing more. What a platform
146
+ asks on surfaces of its own is its own decision.
147
+
148
+ **A declared phone the platform does not permit for this agent is a login that cannot be
149
+ established.** The manifest says what the platform can do; the agent's record, kept by an
150
+ administrator, says what this agent is configured for, and the two can disagree with the agent application's
151
+ declaration -- a softphone declared for an agent whose record says desk phone. Honouring that
152
+ declaration on a switch that allows one registration per endpoint would evict the handset: the
153
+ agent's phone stops ringing and their calls land in a tab, and nobody is told. So the provider
154
+ refuses the login at authentication, with `omni.phone-not-permitted` and a message the agent application shows
155
+ -- "this agent is configured for a desk phone" -- and refusing is correct, not an override of the
156
+ agent application. It is a refusal, never a negotiation afterwards and never a quiet substitution, and it is
157
+ never retryable: trying again does not reconfigure the agent, and an agent application validating the answer
158
+ (`validateAuthenticationResult`) refuses a phone refusal marked otherwise
159
+ (`authentication.failure.phone.retryable`). And **a
160
+ provider never makes a declared phone true by changing the platform's configuration**: the record
161
+ is the administrator's, and a login is not a request to reconfigure an agent.
162
+
163
+ ### Opening the audio
164
+
165
+ `openAudio` hands Omni the remote audio for one task. Every adapter whose manifest lists
166
+ `softphone` implements it, because on a softphone the call's audio lands in Omni:
167
+
168
+ ```ts
169
+ openAudio({ assignmentId, localAudio }): Promise<OpenAudioResult>
170
+ // { status: "opened", session } | { status: "unavailable", failure }
171
+ ```
172
+
173
+ The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor SDK, plain
174
+ WebRTC — and **none of that appears in this contract**. Registration, signalling, credential
175
+ renewal and reconnect are the adapter's, exactly as its authentication and transport already
176
+ are. Omni owns what belongs to the agent application: the microphone, the output element, mute, and when a
177
+ session ends — owning the microphone meaning capturing it, prompting, retrying and reporting how
178
+ it stands, never deciding for the adapter what a missing one means (see **The agent application reports, the
179
+ adapter decides**).
180
+
181
+ | Member | Contract |
182
+ | --- | --- |
183
+ | `remoteAudio` | `MediaStream` Omni plays. |
184
+ | `setMuted(muted)` | Mutes the agent's microphone on this session. |
185
+ | `close()` | Releases the session. Omni calls it when the task ends. |
186
+
187
+ `localAudio` is the agent's microphone as Omni captured it, the same stream the agent application's report
188
+ carries as `audio.input.localAudio`, and absent while that input is `unavailable`. A provider that
189
+ bridges audio without an application-side input may ignore it; one that needs it and finds it absent
190
+ answers `unavailable` with a failure Omni shows the agent.
191
+
192
+ **When to ask is the provider's word, not Omni's guess.** On a softphone login, Omni opens audio on `task-audio-started`,
193
+ and on a task arriving with `audio: "started"` on a snapshot; it closes on `task-audio-ended` and
194
+ when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
195
+ track that ends — moves the task or its audio.
196
+
197
+ **A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
198
+ leg for a whole shift may return the same session for every task and release the underlying
199
+ path only when the connection closes. A platform placing a call per contact returns a new one
200
+ each time. Omni asks when it needs audio and closes when it is done; how that maps to the
201
+ platform is the adapter's business.
202
+
203
+ ### The station is the agent application's
204
+
205
+ The microphone, the speaker, the headset and its buttons are the station's, and the station is the
206
+ agent application's. So mute is not a capability a provider declares, not a control a queue allows or a team
207
+ locks, not a preference the person keeps with the provider, and not a command: nothing about the
208
+ station crosses to the provider except two things -- the station's condition, in the agent application report,
209
+ and the record of when the agent could not be heard, through `recordStep`. An agent application offers Mute on
210
+ every voice task with audio open, under its own local policy, in `in-progress` and `paused`
211
+ alone, and performs it through `CallAudio.setMuted()`. A task carrying
212
+ `capabilities.mute` is refused (`task.capability.unknown`), and so is a `mute` command
213
+ (`command.type`), a `mute` preference (`preference.id`) and a `mute` policy (`team.policy.key`).
214
+
215
+ **Two mutes, and the agent application states which its Mute performs.** A *stream* mute stops the audio the
216
+ agent application sends: the microphone keeps capturing, the party hears silence, nothing else on the machine
217
+ changes, and every softphone agent application can do it. A *station* mute silences the microphone itself at
218
+ the operating system: every application on the machine goes quiet, the system shows it, and a
219
+ headset that follows the system follows it. Only a native agent application can do it. `Host.mute` says which
220
+ this agent application does -- `stream` or `station` -- stated on a softphone login, where the agent application holds the
221
+ microphone, and absent on a desk phone and off voice (`host.mute.required` / `.unexpected`). A
222
+ browser says `stream`. A native agent application says whichever it does. Nothing on the wire says what kind of
223
+ application the agent application is; a provider reads only what the agent application declared.
224
+
225
+ **A silenced device says who silenced it.** `audio.input.flowing: false` and
226
+ `audio.output.flowing: false` carry `mutedBy`: `host` when the agent application's own Mute did it -- which is
227
+ only ever true of an agent application whose mute is `station`, since a stream mute leaves the microphone
228
+ flowing -- and `station` when a slider on the headset or the operating system did, which the agent application
229
+ observed and did not do. Without the word, a station-muting agent application's own Mute would read as a fault
230
+ and an adapter would take the agent out of ready in the middle of a call. A browser reads a station
231
+ mute through the capture track's muted state, read-only, and cannot lift it; a native agent application reads
232
+ the endpoint and can clear an operating-system mute. A hardware slider is nobody's to clear.
233
+
234
+ **The record carries the same word.** A `muted` interaction leg is a period the agent could not be
235
+ heard, and the record exists so that period is not a hole. The agent application reports every such period
236
+ through `recordStep` -- its own Mute, and a station mute it observed during a call -- with
237
+ `mutedBy` saying whose the silence was, and the provider writes the word into the entry
238
+ (`task.history.mutedBy`). The report names no agent because the agent application has exactly one, and
239
+ the provider knows who that is: it attributes the leg to the login's agent in `by`, a fact it
240
+ holds and not an inference, for a station mute as much as for the agent application's own. An unattributed
241
+ `muted` entry is refused (`task.history.muted.by`); a shared handset's unattributed hold
242
+ is a different claim, and stands. A supervisor reading the record then sees "the agent muted for
243
+ forty seconds" and "the agent's headset was muted for forty seconds" as the different things they
244
+ are. See **The agent application records what it performs** in `guide/agent.md`.
245
+
246
+ **Mute has a lifecycle, and none of it is inferred.** A call that starts while the station is
247
+ already muted begins a `muted` leg the moment its audio starts, `mutedBy: "station"`. Audio that
248
+ ends while any leg is open ends the leg at that instant, as every agent application-performed leg ends. And the
249
+ agent application's own Mute starts off on every call, which is every `task-audio-started` -- a connect-back
250
+ opens a second call on the same task with no offer -- so an agent is never muted by the call before.
251
+
252
+ **Every press on a headset is the agent's own press**, performed the agent application's way, and the lights
253
+ follow the agent application's state. The `personConsent` guarantee is honoured by a hook-switch press exactly
254
+ as by a click. What a call-control headset sends, and what becomes of it:
255
+
256
+ | Input | Where it comes from | What the agent application does | What crosses to the provider |
257
+ | --- | --- | --- | --- |
258
+ | Mute button | The headset, on the HID Telephony usage page: a Phone Mute report on the press, a Mute LED the agent application writes back | A press of the agent application's Mute, performed the agent application's way; the LED follows the agent application's state, so button, light and control are one state with one owner | The `muted` leg, `mutedBy: "host"` |
259
+ | Hook switch | The headset, HID Telephony | A press of the agent application's Answer or End call, only where the task is in a phase that has one; the off-hook and ring lights follow the task | The existing `answer` and `end-call` commands |
260
+ | Flash, redial, speed dial | Older headsets and desk-phone style devices | Flash is Hold and Resume where the task offers `hold`; redial and speed dial map to nothing on a desk that never redials, and are ignored | Nothing new |
261
+ | Volume up and down | The headset's own amplifier, or the operating system through the consumer-control keys | Nothing: the device and the system handle it | Nothing, except that a speaker at nought is `audio.output.flowing: false` where the agent application can know it |
262
+ | Microphone gain and level | The operating system, the headset's own boost | A level meter, so the agent can see they are heard | Nothing: a level is a flicker, not a state |
263
+ | Hardware or operating-system mute | A slider on the headset, the system's input mute | Observes it, publishes `flowing: false, mutedBy: "station"`, records the leg during a call, and tells the agent which it is where it knows -- headset or system -- and what to do; an agent application whose mute is `station` clears a system mute itself, a browser can only say so | The report and the leg |
264
+ | Device changed, unplugged, permission revoked | The operating system | Re-capture, prompt, report | Already `audio.input` and `audio.output` with `lost`, `denied`, `no-device` |
265
+
266
+ **What the agent application shows.** Beside Mute, a standing line while the station is muted, saying which and
267
+ what to do, with a button that clears a system mute only on an agent application whose mute is `station`. The
268
+ Mute control does not pretend: while the station is muted it shows the agent cannot be heard and
269
+ that pressing it will not change that, and a press still toggles the agent application's own mute, so the two
270
+ states stay separate and the agent is not unmuted by surprise when the slider moves back. Before a
271
+ call, the same line in the ready state, so an agent does not learn of it from a silent customer;
272
+ whether work is held back is the adapter's decision from the report, as it is for every station
273
+ fact. Where a provider's own platform holds a mute of its own -- a bridge that silences a leg --
274
+ that is a fact about the switch, reported as the provider sees fit, and never the agent application's Mute.