@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/README.md +2 -1
- package/guide/agent.md +1090 -0
- package/guide/breaks.md +540 -0
- package/guide/chat.md +12 -0
- package/guide/email.md +11 -0
- package/guide/lead.md +333 -0
- package/guide/phone.md +274 -0
- package/guide/voice.md +602 -0
- package/guide.md +41 -2865
- package/package.json +2 -1
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.
|