@xema/omni-protocol 0.1.0
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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/design.d.ts +49 -0
- package/dist/design.js +27 -0
- package/dist/index.d.ts +894 -0
- package/dist/index.js +185 -0
- package/dist/testing.d.ts +62 -0
- package/dist/testing.js +294 -0
- package/dist/validation.d.ts +21 -0
- package/dist/validation.js +940 -0
- package/guide.md +2935 -0
- package/package.json +52 -0
package/guide.md
ADDED
|
@@ -0,0 +1,2935 @@
|
|
|
1
|
+
# `@xema/omni-protocol` API contract
|
|
2
|
+
|
|
3
|
+
This document is the normative contract between Omni Agent and a provider adapter. It describes
|
|
4
|
+
observable behavior in addition to TypeScript shapes. When an example and a type declaration
|
|
5
|
+
appear to disagree, the exported TypeScript declaration is authoritative.
|
|
6
|
+
|
|
7
|
+
All protocol definitions and code examples are written as valid TypeScript. Samples should name
|
|
8
|
+
their contract type or use `satisfies` so the relationship between the example and the contract is
|
|
9
|
+
visible and compiler-checkable. Use another language only when the artifact itself is not
|
|
10
|
+
TypeScript.
|
|
11
|
+
|
|
12
|
+
## Terms
|
|
13
|
+
|
|
14
|
+
**Define a vocabulary; do not merely list it** applies to this document's own prose. These words
|
|
15
|
+
are used precisely throughout and mean nothing looser here.
|
|
16
|
+
|
|
17
|
+
| Word | What it means |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| **Omni** | The desktop application an agent works in. It composes several providers into one agent-facing experience and owns everything outside a provider's own system. |
|
|
20
|
+
| **Host** | Omni, named that way where the contrast with a provider is the point — *the host carries the audio*, *host-side input*. |
|
|
21
|
+
| **Provider** | One independently connected external system: a voice platform, a chat platform, a mail platform. |
|
|
22
|
+
| **Adapter** | The package implementing this contract for one provider. One adapter is one provider, so the words are often interchangeable; *provider* names the system, *adapter* the code speaking for it. |
|
|
23
|
+
| **Agent** | The person signed in and taking work. Not to be confused with a transfer destination whose `kind` is `agent`, which is a routing target. |
|
|
24
|
+
| **Lead** | An agent the provider also publishes a `TeamRoster` to. Nothing else makes somebody a lead: **its presence is the permission**. |
|
|
25
|
+
| **Provisioning** | Omni-side policy about this agent, configured outside the protocol and never sent to a provider. It gates whether an offer may be rejected, whether the agent goes ready on login, and whether tasks are auto-accepted. Where a capability and provisioning disagree, the stricter wins. |
|
|
26
|
+
| **Task** | One unit of assigned work — a call, a chat, a mail. |
|
|
27
|
+
| **Channel** | The kind of work a provider carries: `voice`, `chat`, or `email`. Fixed per provider by its manifest. |
|
|
28
|
+
| **Task type** | The provider's own name for a category of work — a queue, a mailbox folder, a chat source. Free-form, and finer-grained than a channel. |
|
|
29
|
+
| **Capability** | A provider's declaration that a control exists for a task or a session. It says *offer this*, and nothing about who carries it out — that is fixed per command, see **Where a command executes**. |
|
|
30
|
+
| **Login** | One authenticated sign-in to one provider, identified by `sessionId`. A transport reconnect keeps it; signing in again replaces it, and nothing tied to the old `sessionId` survives. |
|
|
31
|
+
| **Transport** | The adapter's connection to its platform: a WebSocket or SignalR connection, required to be persistent and ordered. Which one and how it reconnects are the adapter's business; losing it does not end a login. |
|
|
32
|
+
| **Connection** | The `Connection` object Omni holds for one login: the methods it can call and the events it receives. |
|
|
33
|
+
| **Concurrent capacity** | How many tasks this provider may have allocated to the agent at once — an absolute ceiling, stated as `AgentCapacity.count` and standing until Omni restates it. The provider counts its own outstanding tasks against it. |
|
|
34
|
+
| **Snapshot** | The provider's complete state at one moment. It replaces what Omni holds; it is never a patch. |
|
|
35
|
+
| **Event** | One completed transaction reported after a snapshot established the baseline. |
|
|
36
|
+
| **Break** | A reported, supervised state in which the agent is not working — one with a reason, a decision behind it and a return. It covers what a platform may call *not-ready*, including equipment trouble. An agent who is merely at capacity is not on a break. |
|
|
37
|
+
| **Workspace** | What Omni shows the agent. The **task workspace** holds the selected task, its controls and its browsers; the **idle workspace** holds what a provider contributes when no task is selected — dialpad, contacts, calendar, roster. |
|
|
38
|
+
|
|
39
|
+
Four words describe *what state a thing is in*, and they are not interchangeable. `status` is the
|
|
40
|
+
one used twice, for two unrelated things — which is why a bare "status" in conversation is always
|
|
41
|
+
worth pinning down:
|
|
42
|
+
|
|
43
|
+
| Word | Belongs to | Values |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `phase` | A task | `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, `completing` |
|
|
46
|
+
| `status` | A connection | `connecting`, `active`, `error` |
|
|
47
|
+
| `status` | An authentication session | `signed-out`, `authenticating`, `authenticated`, `refreshing`, `expired` |
|
|
48
|
+
| `approval` | A break request | `not-requested`, `awaiting-decision`, `granted`, `starting-after-task`, `in-effect` |
|
|
49
|
+
| `availability` | A roster member | `ready`, `on-task`, `on-break`, `signed-out` |
|
|
50
|
+
|
|
51
|
+
## Versioning
|
|
52
|
+
|
|
53
|
+
### `OMNI_PROTOCOL_VERSION`
|
|
54
|
+
|
|
55
|
+
The exact protocol version implemented by this package. The current value is `1`.
|
|
56
|
+
|
|
57
|
+
### `Manifest.supportedProtocolVersions`
|
|
58
|
+
|
|
59
|
+
An adapter declares **every** version it can speak, not just the one it was compiled against:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
supportedProtocolVersions: [1] // v1 only
|
|
63
|
+
supportedProtocolVersions: [1, 2] // can serve either host
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A single pinned version would make migration impossible: recompiling against a newer package
|
|
67
|
+
would silently move an adapter to the new version with no window in which both sides
|
|
68
|
+
interoperate. Declaring a set lets an adapter support the old and new host at once, so the two
|
|
69
|
+
can be deployed independently.
|
|
70
|
+
|
|
71
|
+
### `negotiateProtocolVersion(adapterVersions, hostVersions?)`
|
|
72
|
+
|
|
73
|
+
Returns the highest version both sides support, or `undefined` when they share none. Omni must
|
|
74
|
+
refuse to connect on `undefined`; silently attempting partial compatibility is not permitted.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
negotiateProtocolVersion([1, 2], [2, 3]); // 2
|
|
78
|
+
negotiateProtocolVersion([1], [2]); // undefined — refuse to connect
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Omni negotiates the version before it creates an authentication session. The selected
|
|
82
|
+
`protocolVersion` is included in both `AuthenticationContext` and `ConnectContext` and remains
|
|
83
|
+
fixed for that login, including transport reconnects. An adapter that advertises several versions
|
|
84
|
+
must use this value to select the corresponding contract behavior.
|
|
85
|
+
|
|
86
|
+
## Semantic types
|
|
87
|
+
|
|
88
|
+
This section is the registry for protocol-wide semantic aliases. Add an alias here when two values
|
|
89
|
+
share a primitive wire representation but have different domain meaning or validation rules. Use
|
|
90
|
+
the semantic name in every contract field rather than repeating the primitive type.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
type IsoTimestamp = string;
|
|
94
|
+
type UserId = string;
|
|
95
|
+
type TaskId = string;
|
|
96
|
+
type DurationSeconds = number;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Semantic type | Wire type | Meaning and constraints |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `IsoTimestamp` | `string` | An RFC-3339 timestamp with `Z` or an explicit numeric offset. Timezone-less values are invalid. It must pass the shared runtime validator. A JavaScript `Date` never crosses the protocol boundary. |
|
|
102
|
+
| `UserId` | `string` | A non-empty, opaque, stable identifier for a person, **issued by the provider** and drawn from the same directory as `AuthenticationState.identity.id`. It names agents and managers alike; the role is established by where the value appears, not by its type. Compare it exactly and only within one provider; do not parse it or infer meaning from its format. |
|
|
103
|
+
| `TaskId` | `string` | A non-empty, opaque task identifier unique within one provider. Omni scopes it with the provider ID. |
|
|
104
|
+
| `DurationSeconds` | `number` | A non-negative integer duration measured in seconds. |
|
|
105
|
+
|
|
106
|
+
### There is no Omni-wide user identity
|
|
107
|
+
|
|
108
|
+
Every person named in this protocol is named by the provider that reported them. Omni holds no
|
|
109
|
+
identifier of its own for an agent or a manager, and none crosses this boundary — not the
|
|
110
|
+
operating-system account, not a directory identity, not a licence.
|
|
111
|
+
|
|
112
|
+
So a `UserId` means nothing outside the provider that issued it. Provider A's
|
|
113
|
+
`handlingHistory[].by` and provider B's roster `memberId` are unrelated strings that will
|
|
114
|
+
eventually collide, and one person on several providers has several identities that nothing here
|
|
115
|
+
pairs. Scope every user identifier with its provider ID before storing or comparing it, exactly as
|
|
116
|
+
`taskKey()` already does for tasks — see `userKey()` under **Utilities**.
|
|
117
|
+
|
|
118
|
+
## Shapes
|
|
119
|
+
|
|
120
|
+
Every data shape and published constant this contract names, declared once. The sections that
|
|
121
|
+
follow explain what each field means and when to send it; this is where a reader checks a name an
|
|
122
|
+
example uses.
|
|
123
|
+
|
|
124
|
+
Three method surfaces are not here — `Adapter`, `Connection` and `AuthenticationSession`. They are
|
|
125
|
+
defined by what they do rather than what they hold, and each has its own table: **Declaring an
|
|
126
|
+
adapter**, **Live connection**, and **Authenticating with a provider**.
|
|
127
|
+
|
|
128
|
+
### Channel and identity
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
type Channel = "voice" | "chat" | "email";
|
|
132
|
+
|
|
133
|
+
type User = {
|
|
134
|
+
id: UserId;
|
|
135
|
+
displayName: string;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type Attribute = { key: string; value: string };
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`Attribute` is the same key/value detail on a `Contact` and a `ScheduledActivity`. A task's
|
|
142
|
+
`attributes` are a different, typed shape — see `TaskAttribute`.
|
|
143
|
+
|
|
144
|
+
### Manifest
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
type AuthenticationMethod = "browser-sso" | "credentials";
|
|
148
|
+
|
|
149
|
+
type BrowserAccessPolicy = {
|
|
150
|
+
mode: "allow-all" | "block-all";
|
|
151
|
+
allowList?: string[];
|
|
152
|
+
blockList?: string[];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type PersonalBrowserCapability = {
|
|
156
|
+
access: BrowserAccessPolicy;
|
|
157
|
+
accessPolicyScope?: "initial-url" | "all-navigation";
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type DialDestinationPolicy = "contacts-only" | "any-number";
|
|
161
|
+
|
|
162
|
+
type DialCapability = { destinationPolicy: DialDestinationPolicy };
|
|
163
|
+
|
|
164
|
+
type IdleCapabilities<C extends Channel = Channel> = {
|
|
165
|
+
personalBrowser?: PersonalBrowserCapability;
|
|
166
|
+
calendar?: true;
|
|
167
|
+
contacts?: true;
|
|
168
|
+
} & (C extends "voice" ? { dial?: DialCapability } : { dial?: never });
|
|
169
|
+
|
|
170
|
+
type Manifest<C extends Channel = Channel> = {
|
|
171
|
+
id: string;
|
|
172
|
+
displayName: string;
|
|
173
|
+
channel: C;
|
|
174
|
+
supportedProtocolVersions: number[];
|
|
175
|
+
authenticationMethods: AuthenticationMethod[];
|
|
176
|
+
idleCapabilities?: IdleCapabilities<C>;
|
|
177
|
+
phaseLabels?: TaskPhaseLabels;
|
|
178
|
+
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
179
|
+
};
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Closed sets are string-literal unions, never `enum`.** An `enum` is the one TypeScript construct
|
|
183
|
+
that is not type-only — it emits runtime code, which no other declaration here does — and it does
|
|
184
|
+
not narrow inside a union as cleanly. Where a wire value is also the name you would want to type,
|
|
185
|
+
as `"contacts-only"` is, the union alone is enough.
|
|
186
|
+
|
|
187
|
+
A named constant is added only where the wire value is *not* something to type at a call site.
|
|
188
|
+
`BROWSER_ISOLATION_SCHEMES` is the one case: its values are structured strings, easy to mistype and
|
|
189
|
+
unreadable as an argument, so the symbolic name earns its keep — and the constant-plus-derived-union
|
|
190
|
+
pattern is the same one `TASK_COMMAND_NAMES` and `TaskCommandName` already use.
|
|
191
|
+
|
|
192
|
+
These serialized strings are stable protocol values and must not be renamed or reused.
|
|
193
|
+
|
|
194
|
+
### Presentation
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
type TaskPhaseLabels = Readonly<Partial<Record<TaskPhase, string>>>;
|
|
198
|
+
|
|
199
|
+
type TaskTypePresentation = {
|
|
200
|
+
singular: string;
|
|
201
|
+
plural: string;
|
|
202
|
+
referenceLabel?: string;
|
|
203
|
+
};
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Authentication and connection
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
type SecretStore = {
|
|
210
|
+
get(key: string): Promise<string | undefined>;
|
|
211
|
+
set(key: string, value: string): Promise<void>;
|
|
212
|
+
delete(key: string): Promise<void>;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
type AuthenticationContext = {
|
|
216
|
+
protocolVersion: number;
|
|
217
|
+
sessionId: string;
|
|
218
|
+
secrets: SecretStore;
|
|
219
|
+
signal?: AbortSignal;
|
|
220
|
+
log?: (entry: unknown) => void;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type AuthenticationState =
|
|
224
|
+
| { status: "signed-out" }
|
|
225
|
+
| { status: "authenticating" }
|
|
226
|
+
| { status: "authenticated"; identity: User; expiresAt?: IsoTimestamp }
|
|
227
|
+
| { status: "refreshing"; identity: User }
|
|
228
|
+
| { status: "expired"; identity?: User; failure?: AuthenticationFailure };
|
|
229
|
+
|
|
230
|
+
type AuthenticationFailure = {
|
|
231
|
+
code: string;
|
|
232
|
+
message: string;
|
|
233
|
+
retryable: boolean;
|
|
234
|
+
retryAfterMs?: number;
|
|
235
|
+
field?: string;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
type ConnectContext = {
|
|
239
|
+
protocolVersion: number;
|
|
240
|
+
sessionId: string;
|
|
241
|
+
autoAcceptTasks?: boolean;
|
|
242
|
+
signal?: AbortSignal;
|
|
243
|
+
log?: (entry: unknown) => void;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
type ConnectionStatus = "connecting" | "active" | "error";
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Provider state
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
type SessionCapabilities = {
|
|
253
|
+
breaks?: true;
|
|
254
|
+
teamBreakControl?: true;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
type Snapshot = {
|
|
258
|
+
status: ConnectionStatus;
|
|
259
|
+
sessionId: string;
|
|
260
|
+
sessionCapabilities: SessionCapabilities;
|
|
261
|
+
break: BreakState;
|
|
262
|
+
tasks: Task[];
|
|
263
|
+
contacts?: Contact[];
|
|
264
|
+
scheduledActivities?: ScheduledActivity[];
|
|
265
|
+
team?: TeamRoster;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
type AgentCapacity = {
|
|
269
|
+
count: number; // absolute ceiling, at least 1
|
|
270
|
+
};
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Idle contributions
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
type Contact = {
|
|
277
|
+
name?: string;
|
|
278
|
+
number?: string;
|
|
279
|
+
email?: string;
|
|
280
|
+
attributes?: Attribute[];
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
type ScheduledActivity = {
|
|
284
|
+
id: string;
|
|
285
|
+
title: string;
|
|
286
|
+
startsAt: IsoTimestamp;
|
|
287
|
+
endsAt?: IsoTimestamp;
|
|
288
|
+
contact?: Contact;
|
|
289
|
+
attributes?: Attribute[];
|
|
290
|
+
};
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Task capabilities
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
type DispositionCode = { id: string; label: string; group?: string };
|
|
297
|
+
|
|
298
|
+
type DispositionPolicy = {
|
|
299
|
+
required?: boolean;
|
|
300
|
+
notes?: "required" | "optional" | "hidden";
|
|
301
|
+
codes?: DispositionCode[];
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
type Destination = {
|
|
305
|
+
id: string;
|
|
306
|
+
label: string;
|
|
307
|
+
address: string;
|
|
308
|
+
kind: "queue" | "agent" | "external";
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
type DestinationDirectory = {
|
|
312
|
+
destinations?: Destination[];
|
|
313
|
+
allowManualEntry: boolean;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
type CustomCapability = {
|
|
317
|
+
id: string;
|
|
318
|
+
ui: {
|
|
319
|
+
kind: "button" | "toggle" | "menu-item";
|
|
320
|
+
label: string;
|
|
321
|
+
placement: "primary" | "secondary" | "overflow";
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
type SharedTaskCapabilities = {
|
|
326
|
+
browsers?: true;
|
|
327
|
+
dispositions?: true | DispositionPolicy;
|
|
328
|
+
custom?: CustomCapability[];
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
type TaskCapabilities<C extends Channel = Channel> =
|
|
332
|
+
C extends "voice"
|
|
333
|
+
? SharedTaskCapabilities & {
|
|
334
|
+
decline?: true;
|
|
335
|
+
mute?: true;
|
|
336
|
+
hold?: true;
|
|
337
|
+
agentDisconnect?: true;
|
|
338
|
+
blindTransfer?: true | DestinationDirectory;
|
|
339
|
+
conference?: true | DestinationDirectory;
|
|
340
|
+
recording?: true;
|
|
341
|
+
}
|
|
342
|
+
: C extends "chat"
|
|
343
|
+
? SharedTaskCapabilities & { reject?: true; hold?: true }
|
|
344
|
+
: SharedTaskCapabilities & { reject?: true };
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
|
|
348
|
+
|
|
349
|
+
### Task workspace
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const BROWSER_ISOLATION_SCHEMES = {
|
|
353
|
+
PROVIDER_NAME__TASK_ID__TAB_NAME: "ProviderName.TaskId.TabName",
|
|
354
|
+
TAB_NAME: "TabName",
|
|
355
|
+
PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME: "ProviderName.TaskTypeName.TabName",
|
|
356
|
+
PROVIDER_NAME__TAB_NAME: "ProviderName.TabName",
|
|
357
|
+
PROVIDER_NAME__TASK_TYPE_NAME: "ProviderName.TaskTypeName",
|
|
358
|
+
TASK_TYPE_NAME__TAB_NAME: "TaskTypeName.TabName",
|
|
359
|
+
} as const;
|
|
360
|
+
|
|
361
|
+
type BrowserIsolationScheme =
|
|
362
|
+
(typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];
|
|
363
|
+
|
|
364
|
+
type TaskBrowser = {
|
|
365
|
+
id: string;
|
|
366
|
+
name: string;
|
|
367
|
+
purpose: string;
|
|
368
|
+
url: string;
|
|
369
|
+
} & (
|
|
370
|
+
| { reuse: false; isolationScheme?: never }
|
|
371
|
+
| { reuse: true; isolationScheme: BrowserIsolationScheme }
|
|
372
|
+
);
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
That union is what makes a reusing browser with no scheme fail to compile rather than inherit a
|
|
376
|
+
default — see **Choosing a reuse scheme**.
|
|
377
|
+
|
|
378
|
+
### Task
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
type TaskPhase =
|
|
382
|
+
| "pending"
|
|
383
|
+
| "confirmed"
|
|
384
|
+
| "preparing"
|
|
385
|
+
| "in-progress"
|
|
386
|
+
| "paused"
|
|
387
|
+
| "completing";
|
|
388
|
+
|
|
389
|
+
type CompletionMode = "agent-command" | "provider-automatic";
|
|
390
|
+
|
|
391
|
+
type TaskAttributeBase = {
|
|
392
|
+
key: string;
|
|
393
|
+
label?: string;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
type TaskAttribute = TaskAttributeBase & (
|
|
397
|
+
| { type: "text"; value: string }
|
|
398
|
+
| { type: "contact"; contact: Contact }
|
|
399
|
+
| { type: "timestamp"; at: IsoTimestamp }
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
type HandlingStep =
|
|
403
|
+
| "queued"
|
|
404
|
+
| "offered"
|
|
405
|
+
| "answered"
|
|
406
|
+
| "held"
|
|
407
|
+
| "muted"
|
|
408
|
+
| "transferred"
|
|
409
|
+
| "conferenced"
|
|
410
|
+
| "unanswered";
|
|
411
|
+
|
|
412
|
+
type TaskHandlingStep = {
|
|
413
|
+
step: HandlingStep;
|
|
414
|
+
at: IsoTimestamp;
|
|
415
|
+
seconds?: DurationSeconds;
|
|
416
|
+
by?: UserId;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
type Task<C extends Channel = Channel> = {
|
|
420
|
+
id: TaskId;
|
|
421
|
+
title: string;
|
|
422
|
+
channel: C;
|
|
423
|
+
taskType: string;
|
|
424
|
+
capabilities: TaskCapabilities<C>;
|
|
425
|
+
browsers: TaskBrowser[];
|
|
426
|
+
contact?: Contact;
|
|
427
|
+
phase: TaskPhase;
|
|
428
|
+
reference?: string;
|
|
429
|
+
completionMode: CompletionMode;
|
|
430
|
+
completionAllowance: DurationSeconds;
|
|
431
|
+
attributes?: TaskAttribute[];
|
|
432
|
+
handlingHistory?: TaskHandlingStep[];
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
type AcceptanceMode =
|
|
436
|
+
| "no-preference"
|
|
437
|
+
| "require-agent-acceptance"
|
|
438
|
+
| "require-automatic-acceptance";
|
|
439
|
+
|
|
440
|
+
type TaskOutcome =
|
|
441
|
+
| { type: "completed"; by: "agent" | "provider" }
|
|
442
|
+
| { type: "transferred"; destination?: string }
|
|
443
|
+
| { type: "cancelled"; reason?: string }
|
|
444
|
+
| { type: "expired"; phase: "pending" | "confirmed" | "preparing" }
|
|
445
|
+
| { type: "failed"; failure: ProtocolFailure };
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Task commands
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
const TASK_COMMAND_NAMES = {
|
|
452
|
+
voice: [
|
|
453
|
+
"answer",
|
|
454
|
+
"decline",
|
|
455
|
+
"start-call",
|
|
456
|
+
"mute",
|
|
457
|
+
"hold",
|
|
458
|
+
"resume",
|
|
459
|
+
"disconnect",
|
|
460
|
+
"transfer",
|
|
461
|
+
"conference",
|
|
462
|
+
"recording",
|
|
463
|
+
"complete",
|
|
464
|
+
],
|
|
465
|
+
chat: ["accept", "reject", "pause", "resume", "complete"],
|
|
466
|
+
email: ["accept", "reject", "complete"],
|
|
467
|
+
} as const;
|
|
468
|
+
|
|
469
|
+
type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> =
|
|
470
|
+
(typeof TASK_COMMAND_NAMES)[C][number];
|
|
471
|
+
|
|
472
|
+
type DispositionPayload = { disposition?: string; notes?: string };
|
|
473
|
+
|
|
474
|
+
type VoiceTaskCommand =
|
|
475
|
+
| { type: "answer" }
|
|
476
|
+
| { type: "decline" }
|
|
477
|
+
| { type: "start-call" }
|
|
478
|
+
| { type: "mute"; muted: boolean }
|
|
479
|
+
| { type: "hold" }
|
|
480
|
+
| { type: "resume" }
|
|
481
|
+
| { type: "disconnect" }
|
|
482
|
+
| { type: "transfer"; destination: string }
|
|
483
|
+
| { type: "conference"; participant: string; action: "add" | "remove" }
|
|
484
|
+
| { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
|
|
485
|
+
| ({ type: "complete" } & DispositionPayload);
|
|
486
|
+
|
|
487
|
+
type ChatTaskCommand =
|
|
488
|
+
| { type: "accept" }
|
|
489
|
+
| { type: "reject" }
|
|
490
|
+
| { type: "pause" }
|
|
491
|
+
| { type: "resume" }
|
|
492
|
+
| ({ type: "complete" } & DispositionPayload);
|
|
493
|
+
|
|
494
|
+
type EmailTaskCommand =
|
|
495
|
+
| { type: "accept" }
|
|
496
|
+
| { type: "reject" }
|
|
497
|
+
| ({ type: "complete" } & DispositionPayload);
|
|
498
|
+
|
|
499
|
+
type CustomTaskCommand = { type: "custom"; name: string; [key: string]: unknown };
|
|
500
|
+
|
|
501
|
+
type TaskCommand<C extends Channel = Channel> =
|
|
502
|
+
| (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand)
|
|
503
|
+
| CustomTaskCommand;
|
|
504
|
+
|
|
505
|
+
type TaskCommandRequest<C extends Channel = Channel> = {
|
|
506
|
+
commandId: string;
|
|
507
|
+
taskId: TaskId;
|
|
508
|
+
command: TaskCommand<C>;
|
|
509
|
+
};
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Breaks
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
type BreakApproval =
|
|
516
|
+
| "not-requested"
|
|
517
|
+
| "awaiting-decision"
|
|
518
|
+
| "granted"
|
|
519
|
+
| "starting-after-task"
|
|
520
|
+
| "in-effect";
|
|
521
|
+
|
|
522
|
+
type BreakReason = {
|
|
523
|
+
id: string;
|
|
524
|
+
label: string;
|
|
525
|
+
group?: string;
|
|
526
|
+
kind?: BreakKind;
|
|
527
|
+
alwaysAvailable?: true;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
type BreakRequest = {
|
|
531
|
+
requestId: string;
|
|
532
|
+
reason?: string;
|
|
533
|
+
reasonId?: string;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
type ImposedBreak =
|
|
537
|
+
| { by: UserId; endsAutomatically: true; endsAt: IsoTimestamp }
|
|
538
|
+
| { by: UserId; endsAutomatically: false; endsAt?: never };
|
|
539
|
+
|
|
540
|
+
type BreakState = {
|
|
541
|
+
approval: BreakApproval;
|
|
542
|
+
requestId?: string;
|
|
543
|
+
accepting: boolean;
|
|
544
|
+
refusedReason?: string;
|
|
545
|
+
decisionReason?: string;
|
|
546
|
+
retryAfterMs?: number;
|
|
547
|
+
reasons?: BreakReason[];
|
|
548
|
+
activeReasonId?: string;
|
|
549
|
+
imposed?: ImposedBreak;
|
|
550
|
+
};
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### Team
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
type TeamMemberAvailability = "ready" | "on-task" | "on-break" | "signed-out";
|
|
557
|
+
|
|
558
|
+
type TeamMember = {
|
|
559
|
+
id: UserId;
|
|
560
|
+
availability: TeamMemberAvailability;
|
|
561
|
+
since?: IsoTimestamp;
|
|
562
|
+
break?: BreakApproval;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
type TeamRoster = {
|
|
566
|
+
members: TeamMember[];
|
|
567
|
+
breakControl?: true;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
type TeamBreakCommand =
|
|
571
|
+
| { type: "decide"; memberId: UserId; decision: "granted" | "denied"; reason?: string }
|
|
572
|
+
| { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
|
|
573
|
+
| { type: "place"; memberId: UserId; reason?: string }
|
|
574
|
+
| { type: "release"; memberId: UserId };
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Media
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
type VoiceMediaSession = {
|
|
581
|
+
remoteAudio: MediaStream;
|
|
582
|
+
setMuted(muted: boolean): void;
|
|
583
|
+
close(): void;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
type OpenMediaResult =
|
|
587
|
+
| { status: "opened"; session: VoiceMediaSession }
|
|
588
|
+
| { status: "unavailable"; failure: ProtocolFailure };
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Events
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
type SummaryMetric = { id: string; label: string; value: string };
|
|
595
|
+
|
|
596
|
+
type ProviderSummary = {
|
|
597
|
+
title: string;
|
|
598
|
+
subtitle?: string;
|
|
599
|
+
waitingCount: number;
|
|
600
|
+
updatedAt: IsoTimestamp;
|
|
601
|
+
metrics?: SummaryMetric[];
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
type ProviderEvent =
|
|
605
|
+
| { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
|
|
606
|
+
| { type: "provider-status"; status: ConnectionStatus; message?: string }
|
|
607
|
+
| { type: "break-state"; break: BreakState }
|
|
608
|
+
| {
|
|
609
|
+
type: "task-offered";
|
|
610
|
+
task: Task;
|
|
611
|
+
acceptanceMode?: AcceptanceMode;
|
|
612
|
+
allocationExpiresAt?: IsoTimestamp;
|
|
613
|
+
preparationEndsAt?: IsoTimestamp;
|
|
614
|
+
}
|
|
615
|
+
| { type: "task-updated"; task: Task }
|
|
616
|
+
| { type: "task-media-ended"; taskId: TaskId }
|
|
617
|
+
| { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
|
|
618
|
+
| { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
|
|
619
|
+
| { type: "provider-summary"; summary: ProviderSummary }
|
|
620
|
+
| { type: "team-updated"; team: TeamRoster }
|
|
621
|
+
| { type: "contacts-updated"; contacts: Contact[] }
|
|
622
|
+
| { type: "calendar-updated"; scheduledActivities: ScheduledActivity[] };
|
|
623
|
+
|
|
624
|
+
type ProviderEventEnvelope = {
|
|
625
|
+
id: string;
|
|
626
|
+
sessionId: string;
|
|
627
|
+
occurredAt: IsoTimestamp;
|
|
628
|
+
event: ProviderEvent;
|
|
629
|
+
};
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
`ProviderEvent` and its envelope keep a `Provider` prefix where nothing else does, for a mechanical
|
|
633
|
+
reason rather than a naming one: `Event` is a DOM global, and a bare one would shadow it for every
|
|
634
|
+
adapter compiled against the browser lib.
|
|
635
|
+
|
|
636
|
+
### Published constants
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;
|
|
640
|
+
|
|
641
|
+
const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
|
|
642
|
+
|
|
643
|
+
const IDLE_CAPABILITY_UI = {
|
|
644
|
+
dial: "Dialpad",
|
|
645
|
+
personalBrowser: "Browser",
|
|
646
|
+
calendar: "Calendar",
|
|
647
|
+
contacts: "Contacts",
|
|
648
|
+
} as const;
|
|
649
|
+
|
|
650
|
+
const BREAK_KINDS = [
|
|
651
|
+
"short-break",
|
|
652
|
+
"meal",
|
|
653
|
+
"rest",
|
|
654
|
+
"training",
|
|
655
|
+
"coaching",
|
|
656
|
+
"meeting",
|
|
657
|
+
"administrative",
|
|
658
|
+
"technical",
|
|
659
|
+
"personal",
|
|
660
|
+
"other",
|
|
661
|
+
] as const;
|
|
662
|
+
|
|
663
|
+
type BreakKind = (typeof BREAK_KINDS)[number];
|
|
664
|
+
|
|
665
|
+
const HANDLING_STEPS_WITH_A_PERSON = [
|
|
666
|
+
"offered",
|
|
667
|
+
"answered",
|
|
668
|
+
"held",
|
|
669
|
+
"muted",
|
|
670
|
+
"transferred",
|
|
671
|
+
"conferenced",
|
|
672
|
+
"unanswered",
|
|
673
|
+
] as const;
|
|
674
|
+
|
|
675
|
+
const OMNI_FAILURE_CODES = [
|
|
676
|
+
"omni.not-authenticated",
|
|
677
|
+
"omni.capability-not-enabled",
|
|
678
|
+
"omni.task-not-found",
|
|
679
|
+
"omni.destination-not-permitted",
|
|
680
|
+
"omni.rate-limited",
|
|
681
|
+
"omni.unavailable",
|
|
682
|
+
"omni.break-already-committed",
|
|
683
|
+
] as const;
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`HANDLING_STEPS_WITH_A_PERSON` is every `HandlingStep` except `queued`, which is the one nobody
|
|
687
|
+
takes part in. `handlingStepExpectsAPerson()` tests membership.
|
|
688
|
+
|
|
689
|
+
### Failure and validation
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
type ProtocolFailure = {
|
|
693
|
+
code: string;
|
|
694
|
+
message: string;
|
|
695
|
+
retryable: boolean;
|
|
696
|
+
retryAfterMs?: number;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
interface ProtocolViolation {
|
|
700
|
+
rule: string;
|
|
701
|
+
path: string;
|
|
702
|
+
message: string;
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
## Presentation labels
|
|
707
|
+
|
|
708
|
+
Adapters may supply static display labels for canonical protocol values. Labels affect presentation
|
|
709
|
+
only and cannot vary by connection, task, snapshot, or event.
|
|
710
|
+
|
|
711
|
+
### Renaming a phase
|
|
712
|
+
|
|
713
|
+
`manifest.phaseLabels` renames the canonical `TaskPhase` values for the agent. It never adds a
|
|
714
|
+
phase or removes one.
|
|
715
|
+
|
|
716
|
+
`TaskPhaseLabels` is `Partial` so that renaming one phase keeps the default wording for the rest —
|
|
717
|
+
an adapter changing "On Call" does not restate the other five.
|
|
718
|
+
|
|
719
|
+
**A task is labelled by the provider that owns it**, from that provider's manifest merged over its
|
|
720
|
+
channel defaults. Two adapters may word the same phase differently and each is right about its own
|
|
721
|
+
tasks: a call reads as the voice platform names it while a chat beside it reads as its own does.
|
|
722
|
+
How Omni words a view spanning several providers is its own presentation problem, not a provider's.
|
|
723
|
+
|
|
724
|
+
### `DEFAULT_TASK_PHASE_LABELS`
|
|
725
|
+
|
|
726
|
+
What Omni shows when an adapter overrides nothing.
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
const DEFAULT_TASK_PHASE_LABELS = {
|
|
730
|
+
voice: {
|
|
731
|
+
pending: "Offered",
|
|
732
|
+
confirmed: "Accepted",
|
|
733
|
+
preparing: "Preview",
|
|
734
|
+
"in-progress": "On Call",
|
|
735
|
+
paused: "On Hold",
|
|
736
|
+
completing: "After Call Work",
|
|
737
|
+
},
|
|
738
|
+
chat: {
|
|
739
|
+
pending: "Incoming Chat",
|
|
740
|
+
confirmed: "Accepted",
|
|
741
|
+
preparing: "Preparing",
|
|
742
|
+
"in-progress": "In Chat",
|
|
743
|
+
paused: "Paused",
|
|
744
|
+
completing: "Wrap-up",
|
|
745
|
+
},
|
|
746
|
+
email: {
|
|
747
|
+
pending: "Assigned",
|
|
748
|
+
confirmed: "Accepted",
|
|
749
|
+
preparing: "Reviewing",
|
|
750
|
+
"in-progress": "Working",
|
|
751
|
+
paused: "Paused",
|
|
752
|
+
completing: "Completing",
|
|
753
|
+
},
|
|
754
|
+
} as const satisfies Readonly<
|
|
755
|
+
Record<"voice" | "chat" | "email", Readonly<Record<TaskPhase, string>>>
|
|
756
|
+
>;
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
### Naming a kind of work
|
|
760
|
+
|
|
761
|
+
`TaskTypePresentation` says what one kind of work is called. Unlike `phaseLabels`, an entry
|
|
762
|
+
**replaces the channel default outright** rather than merging.
|
|
763
|
+
`singular` and `plural` are required together because pluralisation is not mechanical, and a
|
|
764
|
+
half-supplied entry would leave Omni pairing one provider's noun with another's plural.
|
|
765
|
+
|
|
766
|
+
### `DEFAULT_TASK_TYPE_PRESENTATION`
|
|
767
|
+
|
|
768
|
+
The per-channel fallback, used for any `taskType` the adapter does not name.
|
|
769
|
+
|
|
770
|
+
```ts
|
|
771
|
+
const DEFAULT_TASK_TYPE_PRESENTATION = {
|
|
772
|
+
voice: {
|
|
773
|
+
singular: "Call",
|
|
774
|
+
plural: "Calls",
|
|
775
|
+
referenceLabel: "Call ID",
|
|
776
|
+
},
|
|
777
|
+
chat: {
|
|
778
|
+
singular: "Chat",
|
|
779
|
+
plural: "Chats",
|
|
780
|
+
referenceLabel: "Chat ID",
|
|
781
|
+
},
|
|
782
|
+
email: {
|
|
783
|
+
singular: "Email",
|
|
784
|
+
plural: "Emails",
|
|
785
|
+
referenceLabel: "Email ID",
|
|
786
|
+
},
|
|
787
|
+
} as const satisfies Readonly<
|
|
788
|
+
Record<"voice" | "chat" | "email", TaskTypePresentation>
|
|
789
|
+
>;
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### Naming task types
|
|
793
|
+
|
|
794
|
+
`channel` is too coarse for agent-facing names. A WhatsApp call and a PSTN call are both `voice`,
|
|
795
|
+
but the provider may name them differently through static manifest metadata:
|
|
796
|
+
|
|
797
|
+
```ts
|
|
798
|
+
const taskTypePresentation = {
|
|
799
|
+
WhatsApp: {
|
|
800
|
+
singular: "Conversation",
|
|
801
|
+
plural: "Conversations",
|
|
802
|
+
referenceLabel: "Conversation ID",
|
|
803
|
+
},
|
|
804
|
+
} satisfies Record<string, TaskTypePresentation>;
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
The map key is the exact `Task.taskType`. When no entry exists, Omni uses the provider
|
|
808
|
+
channel's entry in `DEFAULT_TASK_TYPE_PRESENTATION`. A mixed-channel list falls back to the words
|
|
809
|
+
"Task" and "Tasks" when its items do not share one label.
|
|
810
|
+
|
|
811
|
+
`referenceLabel` labels `Task.reference`; it does not label the protocol `id`. Omni shows a
|
|
812
|
+
reference only when both values are present.
|
|
813
|
+
|
|
814
|
+
**So supply it whenever the task type has references.** Because an entry replaces the channel
|
|
815
|
+
default outright, naming a task type and omitting `referenceLabel` removes the reference from the
|
|
816
|
+
agent's view — the case or call number simply stops appearing, with nothing to indicate it was
|
|
817
|
+
dropped. An adapter that wanted only a better noun loses a field it never meant to touch.
|
|
818
|
+
|
|
819
|
+
## Protocol contract rules
|
|
820
|
+
|
|
821
|
+
Nine rules govern protocol data and behavior.
|
|
822
|
+
|
|
823
|
+
### 1. The protocol is authoritative
|
|
824
|
+
|
|
825
|
+
Adapters conform to the protocol. The protocol does not conform to adapters.
|
|
826
|
+
|
|
827
|
+
### 2. Never report a value you cannot observe
|
|
828
|
+
|
|
829
|
+
Report only values the provider can observe. Omit an unknown optional value; if a required value is
|
|
830
|
+
unknown, do not publish the structure that requires it. Never substitute a default, placeholder,
|
|
831
|
+
or inference.
|
|
832
|
+
|
|
833
|
+
Omni cannot distinguish an asserted zero from an unknown, and will present it to the agent as
|
|
834
|
+
fact.
|
|
835
|
+
|
|
836
|
+
### 3. Omitted and empty are different claims
|
|
837
|
+
|
|
838
|
+
Omitting a field says *I cannot see this*. An empty value says *I looked, and there is nothing*.
|
|
839
|
+
|
|
840
|
+
Send whichever is true. A host renders them differently and cannot recover the distinction once
|
|
841
|
+
it is lost.
|
|
842
|
+
|
|
843
|
+
The same distinction applies to nested fields. Omit a nested field only when its value is unknown;
|
|
844
|
+
use an explicit empty value when the provider knows it contains nothing.
|
|
845
|
+
|
|
846
|
+
### 4. Presence is the permission
|
|
847
|
+
|
|
848
|
+
A capability authorizes a control the provider **chooses** to offer. Omni may offer or issue one
|
|
849
|
+
only where the corresponding capability is declared; an absent capability means unavailable, and
|
|
850
|
+
there is no separate permission flag.
|
|
851
|
+
|
|
852
|
+
The commands every task has are authorized by other fields the provider declared — a task that was
|
|
853
|
+
offered can be accepted, one in `preparing` can be started, one whose `completionMode` is
|
|
854
|
+
`agent-command` can be completed. Nothing is issuable that the provider did not publish; only
|
|
855
|
+
which field says so varies. See **Which commands need a capability**.
|
|
856
|
+
|
|
857
|
+
### 5. Snapshots establish state; events report transactions
|
|
858
|
+
|
|
859
|
+
Snapshots establish and replace provider state when an agent signs in, reconnects, or resynchronises.
|
|
860
|
+
|
|
861
|
+
Events report completed transactions after that baseline. Nothing is missed while the connection
|
|
862
|
+
holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event
|
|
863
|
+
is applied.
|
|
864
|
+
|
|
865
|
+
### 6. Commands are idempotent
|
|
866
|
+
|
|
867
|
+
Handle every command as though it may arrive twice — a retry, a reconnect, an agent pressing
|
|
868
|
+
twice.
|
|
869
|
+
|
|
870
|
+
Every retryable call carries a stable key — `commandId` on `execute` and `dial`, `requestId` on the
|
|
871
|
+
break methods. Processing the same key more than once must not repeat its side effects, and a
|
|
872
|
+
retry is answered with that method's `already-` form: `already-applied`, `already-dialled`,
|
|
873
|
+
`already-committed`, and so on. Each answers in its own words; see **Capacity and break actions**.
|
|
874
|
+
|
|
875
|
+
`setCapacity` is the one exception and needs no key. A capacity supersedes rather than
|
|
876
|
+
accumulates, so re-sending the current one is not a repeat of anything.
|
|
877
|
+
|
|
878
|
+
### 7. Work is pulled, never pushed
|
|
879
|
+
|
|
880
|
+
Allocate only within the concurrent capacity Omni has stated for this agent.
|
|
881
|
+
|
|
882
|
+
An allocation beyond that capacity, or with none currently stated, is invalid and Omni rejects it
|
|
883
|
+
as a protocol violation. Work the agent was already handling when the connection came back is
|
|
884
|
+
reported in the snapshot; it is not an allocation.
|
|
885
|
+
|
|
886
|
+
### 8. State is authoritative at the provider
|
|
887
|
+
|
|
888
|
+
Each provider is authoritative for the state it owns. Omni composes that state with Omni-owned
|
|
889
|
+
policy and user actions. When provider-owned state diverges, Omni obtains a fresh snapshot and
|
|
890
|
+
replaces its local provider view; it does not overwrite the provider.
|
|
891
|
+
|
|
892
|
+
### 9. Define a vocabulary; do not merely list it
|
|
893
|
+
|
|
894
|
+
Every member of a closed set — break kinds, handling steps, destination kinds — must have a
|
|
895
|
+
normative definition; matching names alone do not establish shared meaning.
|
|
896
|
+
|
|
897
|
+
## Provider adapter requirements
|
|
898
|
+
|
|
899
|
+
### 1. One adapter is one provider
|
|
900
|
+
|
|
901
|
+
An adapter represents one independently connected system: one voice platform, one chat platform,
|
|
902
|
+
one mail platform. It owns its own authentication, transport, reconnection and internal state.
|
|
903
|
+
|
|
904
|
+
Omni composes providers into one agent. No adapter needs to know another exists.
|
|
905
|
+
|
|
906
|
+
### 2. The transport is a persistent ordered connection
|
|
907
|
+
|
|
908
|
+
An adapter reaches its platform over a WebSocket or a SignalR connection. Which of the two, which
|
|
909
|
+
library, which framing and how it reconnects are the adapter's business and reach nothing in this
|
|
910
|
+
contract — but that it is **one long-lived, ordered, bidirectional connection** is not optional,
|
|
911
|
+
because the rest of this document rests on it.
|
|
912
|
+
|
|
913
|
+
Two properties are what everything else assumes. Events arrive in the order the provider observed
|
|
914
|
+
them, and loss shows up as the connection dropping rather than as a message quietly going missing.
|
|
915
|
+
That is why there is no sequence number to reconcile and no event log to replay: while the
|
|
916
|
+
connection is up nothing has been lost, and when it comes back the adapter sends a snapshot.
|
|
917
|
+
|
|
918
|
+
Request/response polling does not have those properties and is not a transport for this contract.
|
|
919
|
+
|
|
920
|
+
## Package entry points
|
|
921
|
+
|
|
922
|
+
| Import | Purpose |
|
|
923
|
+
| --- | --- |
|
|
924
|
+
| `@xema/omni-protocol` | Provider adapter contract and shared domain types |
|
|
925
|
+
| `@xema/omni-protocol/testing` | Adapter conformance helpers |
|
|
926
|
+
| `@xema/omni-protocol/validation` | Runtime validators Omni and adapters both use to reject malformed data |
|
|
927
|
+
| `@xema/omni-protocol/design` | Host design-language integration. Specified separately; no part of it is a provider surface. |
|
|
928
|
+
|
|
929
|
+
## Declaring an adapter
|
|
930
|
+
|
|
931
|
+
### `defineAdapter(adapter)`
|
|
932
|
+
|
|
933
|
+
Compile-time helper that preserves the adapter's inferred concrete type while checking that it
|
|
934
|
+
implements `Adapter`. It performs no connection and has no runtime side effects.
|
|
935
|
+
|
|
936
|
+
```ts
|
|
937
|
+
import { defineAdapter, OMNI_PROTOCOL_VERSION } from "@xema/omni-protocol";
|
|
938
|
+
|
|
939
|
+
export default defineAdapter({
|
|
940
|
+
manifest: {
|
|
941
|
+
id: "acme-voice",
|
|
942
|
+
displayName: "Acme Voice",
|
|
943
|
+
channel: "voice",
|
|
944
|
+
supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
|
|
945
|
+
authenticationMethods: ["browser-sso"],
|
|
946
|
+
idleCapabilities: {
|
|
947
|
+
dial: { destinationPolicy: "any-number" },
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
createAuthenticationSession: context => createAcmeAuthentication(context),
|
|
951
|
+
connect: context => createConnection(context),
|
|
952
|
+
});
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
### `Adapter.manifest`
|
|
956
|
+
|
|
957
|
+
Static metadata that Omni can inspect before connecting.
|
|
958
|
+
`Manifest<C>` is discriminated by `channel`, so a voice manifest may declare voice idle
|
|
959
|
+
capabilities while `Manifest<"chat">` and `Manifest<"email">` reject `dial` at
|
|
960
|
+
compile time.
|
|
961
|
+
|
|
962
|
+
| Field | Contract |
|
|
963
|
+
| --- | --- |
|
|
964
|
+
| `id` | Required, stable and installation-wide unique. It must not change between launches. Omni refuses to load an adapter whose `id` a loaded adapter already claims — see below. |
|
|
965
|
+
| `displayName` | Human-readable provider label. |
|
|
966
|
+
| `channel` | Protocol-v1 value `voice`, `chat`, or `email`. New channels require a later protocol version. |
|
|
967
|
+
| `supportedProtocolVersions` | Required non-empty list of versions this adapter can speak. Must share at least one with Omni. |
|
|
968
|
+
| `authenticationMethods` | Required non-empty list of supported login methods. |
|
|
969
|
+
| `idleCapabilities` | Declares actions Omni may offer while the agent has no active task, such as voice dialing. Task controls do not belong here. |
|
|
970
|
+
| `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
|
|
971
|
+
| `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
|
|
972
|
+
|
|
973
|
+
### Authentication methods
|
|
974
|
+
|
|
975
|
+
A provider declares one or both protocol-v1 login methods:
|
|
976
|
+
|
|
977
|
+
| Value | Contract |
|
|
978
|
+
| --- | --- |
|
|
979
|
+
| `browser-sso` | OAuth or OpenID Connect through the system browser or an Omni-managed browser. |
|
|
980
|
+
| `credentials` | Provider-specific username and password collected through an Omni-hosted form. |
|
|
981
|
+
|
|
982
|
+
```ts
|
|
983
|
+
authenticationMethods: [
|
|
984
|
+
"browser-sso",
|
|
985
|
+
"credentials",
|
|
986
|
+
]
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
The list must not be empty or contain duplicates. It declares available login methods only;
|
|
990
|
+
credentials and tokens never belong in the manifest.
|
|
991
|
+
|
|
992
|
+
`manifest.id` partitions the `SecretStore`, so an id is first-claimed: an adapter whose id another
|
|
993
|
+
loaded adapter already holds fails to load and is reported. It is never renamed to make room —
|
|
994
|
+
that would move its secrets to a partition it has never used.
|
|
995
|
+
|
|
996
|
+
### Idle capabilities
|
|
997
|
+
|
|
998
|
+
Idle capabilities are contributed by a provider but appear in Omni's idle workspace. They describe
|
|
999
|
+
work the agent may initiate when no task is active; they never grant controls over an assigned
|
|
1000
|
+
task.
|
|
1001
|
+
|
|
1002
|
+
| Capability | Channel | Omni UI | Contract |
|
|
1003
|
+
| --- | --- | --- | --- |
|
|
1004
|
+
| `dial` | Voice | Dialpad | Contributes an idle-dashboard dialpad and declares whether destinations are restricted to known contacts. |
|
|
1005
|
+
| `personalBrowser` | Voice, chat, email | Browser | Contributes Omni's managed personal browser and its allowed URL patterns. |
|
|
1006
|
+
| `calendar` | Voice, chat, email | Calendar | Contributes an idle-dashboard calendar of callbacks and other scheduled activities from this provider. |
|
|
1007
|
+
| `contacts` | Voice, chat, email | Contacts | Contributes an idle-dashboard contact list populated by this provider. |
|
|
1008
|
+
|
|
1009
|
+
They are published through `IDLE_CAPABILITIES`, with UI metadata in
|
|
1010
|
+
`IDLE_CAPABILITY_UI`. Omni combines capabilities contributed by its active providers and removes a
|
|
1011
|
+
provider's contribution when that provider becomes inactive.
|
|
1012
|
+
|
|
1013
|
+
**There is no `enabled` flag. Presence is the permission**, here as everywhere: a capability the
|
|
1014
|
+
provider names is offered, and one it omits is not. A boolean on top of an optional field would be
|
|
1015
|
+
a second way to say what absence already says, and **Omitted and empty are different claims** only
|
|
1016
|
+
holds while each says something different.
|
|
1017
|
+
|
|
1018
|
+
#### Dialpad (`dial`)
|
|
1019
|
+
|
|
1020
|
+
`dial` is available to voice providers. It contributes a dialpad to the idle dashboard and requires
|
|
1021
|
+
a destination policy:
|
|
1022
|
+
|
|
1023
|
+
```ts
|
|
1024
|
+
idleCapabilities: {
|
|
1025
|
+
dial: { destinationPolicy: "any-number" }
|
|
1026
|
+
}
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
`destinationPolicy` is required and accepts one `DialDestinationPolicy`:
|
|
1030
|
+
|
|
1031
|
+
| Value | Contract |
|
|
1032
|
+
| --- | --- |
|
|
1033
|
+
| `contacts-only` | The destination must be selected from **this provider's own** contributed contacts, and only entries carrying a `number` can be selected. Manual entry cannot bypass the restriction. |
|
|
1034
|
+
| `any-number` | The agent may enter any destination or select one from the contact list. |
|
|
1035
|
+
|
|
1036
|
+
**A contact restriction is scoped to the provider that declared it.** Omni's idle contact list is
|
|
1037
|
+
aggregated from every provider, but a `contacts-only` dialpad offers only the entries this provider
|
|
1038
|
+
contributed. Any other reading lets a second provider put destinations into a directory the first
|
|
1039
|
+
restricted precisely so it could control what was dialled.
|
|
1040
|
+
|
|
1041
|
+
A declared dial capability requires `Connection.dial()`. Omni sends the original
|
|
1042
|
+
destination and whether it came from a `contact` or `manual` entry. With `contacts-only`, Omni must
|
|
1043
|
+
never send `source: "manual"`.
|
|
1044
|
+
|
|
1045
|
+
#### Personal Browser (`personalBrowser`)
|
|
1046
|
+
|
|
1047
|
+
`personalBrowser` is available to voice, chat, and email providers. It contributes Omni's managed
|
|
1048
|
+
personal browser to the idle dashboard. Each enabling provider supplies a complete URL access
|
|
1049
|
+
policy:
|
|
1050
|
+
|
|
1051
|
+
```ts
|
|
1052
|
+
idleCapabilities: {
|
|
1053
|
+
personalBrowser: {
|
|
1054
|
+
access: {
|
|
1055
|
+
mode: "block-all",
|
|
1056
|
+
allowList: [
|
|
1057
|
+
"https://help.example.com/*",
|
|
1058
|
+
"https://*.microsoft.com/*"
|
|
1059
|
+
],
|
|
1060
|
+
blockList: ["https://help.example.com/private/*"]
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
| Field | Contract |
|
|
1067
|
+
| --- | --- |
|
|
1068
|
+
| `access.mode` | `allow-all` permits unmatched URLs; `block-all` denies unmatched URLs. |
|
|
1069
|
+
| `access.allowList` | URL-pattern exceptions permitted when the mode is `block-all`. |
|
|
1070
|
+
| `access.blockList` | Explicit denials. A match takes precedence over the same policy's allow list and mode. |
|
|
1071
|
+
| `accessPolicyScope` | `all-navigation` by default: every redirect and navigation is checked. `initial-url` checks the starting URL alone. |
|
|
1072
|
+
|
|
1073
|
+
Patterns use the standard `URLPattern` syntax. Omni owns browser navigation, and the browser is
|
|
1074
|
+
hidden when no active provider contributes one.
|
|
1075
|
+
|
|
1076
|
+
`accessPolicyScope` defaults to `all-navigation`: every redirect and subsequent navigation is
|
|
1077
|
+
validated against the current combined policy, not only the starting URL. A provider may set
|
|
1078
|
+
`initial-url` to check the first hop alone, but that has to be asked for. A `block-all` policy
|
|
1079
|
+
enforced only on the initial URL stops nothing — one redirect leaves it — so the permissive
|
|
1080
|
+
reading is not something to inherit from a default.
|
|
1081
|
+
|
|
1082
|
+
##### Combining policies
|
|
1083
|
+
|
|
1084
|
+
The personal browser is one browser shared by every provider that contributes to it, so their
|
|
1085
|
+
policies have to be combined. Omni does it in the order that fails closed:
|
|
1086
|
+
|
|
1087
|
+
1. **An explicit `blockList` match denies the URL, whoever wrote it.** A block is a deliberate
|
|
1088
|
+
statement about one address, and it is honoured across the whole combined policy — not only
|
|
1089
|
+
within the policy that declared it.
|
|
1090
|
+
2. Otherwise a URL is available when **any** contributing provider allows it, by its `allowList`
|
|
1091
|
+
or by an `allow-all` mode.
|
|
1092
|
+
3. Omni's own local policy denies on top of both.
|
|
1093
|
+
|
|
1094
|
+
Allow-lists are contributions and blocks are vetoes. Without step 1 a single provider declaring
|
|
1095
|
+
`mode: "allow-all"` would silently undo every block every other provider had written, and nothing
|
|
1096
|
+
in the agent's view would show that it had happened.
|
|
1097
|
+
|
|
1098
|
+
#### Calendar (`calendar`)
|
|
1099
|
+
|
|
1100
|
+
`calendar` is available to voice, chat, and email providers. It contributes a calendar to the idle
|
|
1101
|
+
dashboard for callbacks and other scheduled activities associated with that provider. Calendar is
|
|
1102
|
+
read-only in protocol v1: Omni can display activities but cannot create, reschedule, cancel, or
|
|
1103
|
+
complete them.
|
|
1104
|
+
|
|
1105
|
+
```ts
|
|
1106
|
+
idleCapabilities: { calendar: true }
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
Omit `calendar` to make no calendar contribution. Omni combines calendar
|
|
1110
|
+
contributions from active providers into one agent-facing calendar while retaining the source
|
|
1111
|
+
provider identity for each activity.
|
|
1112
|
+
|
|
1113
|
+
When declared, the provider publishes its authoritative list through
|
|
1114
|
+
`Snapshot.scheduledActivities` and replaces it with a `calendar-updated` event when it
|
|
1115
|
+
changes.
|
|
1116
|
+
|
|
1117
|
+
| Field | Contract |
|
|
1118
|
+
| --- | --- |
|
|
1119
|
+
| `id` | Required stable provider-local activity identity. It is what a replacement list is reconciled against. |
|
|
1120
|
+
| `title` | Required agent-facing activity title. |
|
|
1121
|
+
| `startsAt` | Required RFC-3339 start time with an explicit timezone. |
|
|
1122
|
+
| `endsAt` | Optional RFC-3339 end time with an explicit timezone. |
|
|
1123
|
+
| `contact` | Optional related `Contact`. |
|
|
1124
|
+
| `attributes` | Optional ordered `Attribute` entries. Keys must be non-empty. |
|
|
1125
|
+
|
|
1126
|
+
**There is no `type` field**, for the reason there is none on `Contact`: an open category is
|
|
1127
|
+
matched by nobody and defined by nobody, and calendars merge across providers exactly as contacts
|
|
1128
|
+
do, so one provider's `Callback` and another's `Call back` fragment a list that looks organised.
|
|
1129
|
+
Send the category as an attribute and Omni displays it.
|
|
1130
|
+
|
|
1131
|
+
Nothing is lost by that here, because there is nothing for a category to drive. A read-only
|
|
1132
|
+
calendar has no action to vary by kind, and what an activity *is* already shows in what it carries:
|
|
1133
|
+
a callback has a `contact`, a training does not.
|
|
1134
|
+
|
|
1135
|
+
#### Contacts (`contacts`)
|
|
1136
|
+
|
|
1137
|
+
`contacts` is available to voice, chat, and email providers. It contributes contacts to the idle
|
|
1138
|
+
dashboard and can supply destinations to a contact-restricted dialpad.
|
|
1139
|
+
|
|
1140
|
+
```ts
|
|
1141
|
+
idleCapabilities: { contacts: true }
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
Omit `contacts` to make no contact contribution. When declared, the provider
|
|
1145
|
+
publishes its authoritative list through `Snapshot.contacts` and replaces it with a
|
|
1146
|
+
`contacts-updated` event when it changes. Every field is optional:
|
|
1147
|
+
|
|
1148
|
+
> **Note:** Omni derives normalized number and email keys for indexing and deduplication while
|
|
1149
|
+
> preserving the original values for display. A number supplied by multiple providers appears only once, with source icons indicating
|
|
1150
|
+
> every provider that contributed it. Sources come from each `Manifest`; they are not repeated
|
|
1151
|
+
> on `Contact`. If those providers supply different names, Omni keeps one as the display name
|
|
1152
|
+
> and adds each distinct alternative to the merged attributes, labelled with its source provider.
|
|
1153
|
+
|
|
1154
|
+
| Field | Contract |
|
|
1155
|
+
| --- | --- |
|
|
1156
|
+
| `name` | Optional agent-facing display name. |
|
|
1157
|
+
| `number` | Optional original dialable address, preserved for display. |
|
|
1158
|
+
| `email` | Optional original email address, preserved for display. |
|
|
1159
|
+
| `attributes` | Optional ordered `Attribute` entries. Keys must be non-empty. |
|
|
1160
|
+
|
|
1161
|
+
**Nothing is required, because every field is genuinely unknown somewhere.** A call from an
|
|
1162
|
+
unrecognised number has an address and no name. A directory seeded from a mailbox has a name and an
|
|
1163
|
+
email and no number. A task's party on a withheld caller ID may have none of them. Requiring any
|
|
1164
|
+
one field would force exactly what **Never report a value you cannot observe** forbids — a
|
|
1165
|
+
fabricated "Unknown caller" that Omni cannot tell from a real one.
|
|
1166
|
+
|
|
1167
|
+
So `Contact` is deliberately broad, and the same shape serves a directory entry the agent reaches
|
|
1168
|
+
out to and the party already on a task. Send what you can see. Omni shows the `name` where there is
|
|
1169
|
+
one and falls back to the number or email where there is not.
|
|
1170
|
+
|
|
1171
|
+
`normalizeContactNumber()` and `normalizeContactEmail()` produce the internal comparison keys.
|
|
1172
|
+
Adapters may use these exported helpers when they need identical indexing behavior, but must keep
|
|
1173
|
+
the original contact values for display.
|
|
1174
|
+
|
|
1175
|
+
`normalizeContactNumber()` applies NFKC, strips whitespace, brackets, slashes, periods, and every
|
|
1176
|
+
Unicode dash, and rewrites a leading `00` to `+`. So `+1 (415) 555-0100`, `+1.415.555.0100`, and
|
|
1177
|
+
`0014155550100` all merge.
|
|
1178
|
+
|
|
1179
|
+
> **Cross-provider merging is reliable only for E.164 input.** A national-format number such as
|
|
1180
|
+
> `4155550100` carries no country context, and nothing in this protocol supplies one, so it will
|
|
1181
|
+
> **not** merge with `+14155550100` from another provider. A provider that wants its contacts merged
|
|
1182
|
+
> with another provider's must publish `+`-prefixed numbers.
|
|
1183
|
+
|
|
1184
|
+
**There is no `type` field, and Omni does not group the directory.** A category is an attribute
|
|
1185
|
+
like any other: send it as one and it is displayed with the rest. A closed set of categories would
|
|
1186
|
+
have to be defined member by member to mean anything across providers — see **Define a vocabulary;
|
|
1187
|
+
do not merely list it** — and an open one is worse than none, because two providers publishing
|
|
1188
|
+
`Lead` and `Prospect` for the same person produce a directory that looks organised and is not.
|
|
1189
|
+
|
|
1190
|
+
That is the line `attributes` stays on the right side of. Omni renders keys and values and does not
|
|
1191
|
+
compute on them, so a provider writing `Dept` where another writes `Department` costs nothing;
|
|
1192
|
+
grouping on those keys would fragment the directory exactly as a free-form `type` did. Search
|
|
1193
|
+
across an agent's own contacts does the work a category was reaching for.
|
|
1194
|
+
|
|
1195
|
+
Merging follows the rule already stated for names. Where providers disagree on an attribute for the
|
|
1196
|
+
same contact, Omni keeps one value and adds each distinct alternative to the merged attributes,
|
|
1197
|
+
labelled with its source provider.
|
|
1198
|
+
|
|
1199
|
+
```ts
|
|
1200
|
+
{
|
|
1201
|
+
name: "Asha Rao",
|
|
1202
|
+
number: "+919876543210",
|
|
1203
|
+
email: "asha@example.com",
|
|
1204
|
+
attributes: [
|
|
1205
|
+
{ key: "Category", value: "Lead" },
|
|
1206
|
+
{ key: "Priority", value: "High" }
|
|
1207
|
+
]
|
|
1208
|
+
}
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
## Authenticating with a provider
|
|
1212
|
+
|
|
1213
|
+
After negotiating a protocol version, Omni creates an authentication session before it calls
|
|
1214
|
+
`connect()`. The authentication session is
|
|
1215
|
+
UI-facing and contains no task or provider transport state.
|
|
1216
|
+
|
|
1217
|
+
### `Adapter.createAuthenticationSession(context)`
|
|
1218
|
+
|
|
1219
|
+
Creates the provider-scoped authentication session.
|
|
1220
|
+
|
|
1221
|
+
| `AuthenticationContext` field | Contract |
|
|
1222
|
+
| --- | --- |
|
|
1223
|
+
| `protocolVersion` | The negotiated version, fixed for this login. |
|
|
1224
|
+
| `sessionId` | Omni-generated identity for this login. The same value Omni later passes as `ConnectContext.sessionId`, and how an adapter ties a connection back to the session that authenticated it. |
|
|
1225
|
+
| `secrets` | Omni-provided `SecretStore`, scoped to this provider's manifest id. |
|
|
1226
|
+
| `signal` | Optional cancellation signal. |
|
|
1227
|
+
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
1228
|
+
|
|
1229
|
+
It carries no identity: who the agent is on this provider is the outcome of authentication, not an
|
|
1230
|
+
input to it.
|
|
1231
|
+
|
|
1232
|
+
Closing this session releases observers and temporary flow state; it does not sign the agent out.
|
|
1233
|
+
Omni keeps the session open while the provider connection is active so refresh and expiry changes
|
|
1234
|
+
remain observable.
|
|
1235
|
+
|
|
1236
|
+
### Authentication state
|
|
1237
|
+
|
|
1238
|
+
`AuthenticationSession.state()` returns the current authoritative state. `subscribe()`
|
|
1239
|
+
reports later changes.
|
|
1240
|
+
|
|
1241
|
+
| Status | Contract |
|
|
1242
|
+
| --- | --- |
|
|
1243
|
+
| `signed-out` | No usable provider session exists. |
|
|
1244
|
+
| `authenticating` | An interactive `browser-sso` or `credentials` flow is active. |
|
|
1245
|
+
| `authenticated` | A usable session exists. Includes the provider identity and optional token expiry time. |
|
|
1246
|
+
| `refreshing` | The adapter is refreshing its session. Existing provider identity remains available. |
|
|
1247
|
+
| `expired` | The session cannot currently be used. It may include an identity and typed failure. |
|
|
1248
|
+
|
|
1249
|
+
Omni calls `connect()` only after authentication reaches `authenticated`. Token refresh remains
|
|
1250
|
+
adapter-owned; the adapter publishes `refreshing`, followed by `authenticated` or `expired`.
|
|
1251
|
+
If authentication expires during active work, Omni preserves the task workspace and shows
|
|
1252
|
+
reauthentication for that provider.
|
|
1253
|
+
|
|
1254
|
+
### Starting authentication
|
|
1255
|
+
|
|
1256
|
+
`AuthenticationSession.start(request)` starts one advertised authentication method. Every
|
|
1257
|
+
request has a stable `requestId`. A successful start returns `interaction-required` with a
|
|
1258
|
+
short-lived, opaque `flowId`; rejection returns `AuthenticationFailure`.
|
|
1259
|
+
|
|
1260
|
+
#### Browser SSO
|
|
1261
|
+
|
|
1262
|
+
For `browser-sso`, Omni allocates a one-time callback URL and passes it to `start()`:
|
|
1263
|
+
|
|
1264
|
+
```ts
|
|
1265
|
+
{
|
|
1266
|
+
requestId: "auth-42",
|
|
1267
|
+
method: "browser-sso",
|
|
1268
|
+
callbackUrl: "omni-agent://auth/acme-voice/auth-42"
|
|
1269
|
+
}
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
The adapter creates the OAuth/OIDC request, including PKCE, `state`, and OIDC `nonce`, and returns:
|
|
1273
|
+
|
|
1274
|
+
```ts
|
|
1275
|
+
{
|
|
1276
|
+
status: "interaction-required",
|
|
1277
|
+
challenge: {
|
|
1278
|
+
flowId: "flow-42",
|
|
1279
|
+
method: "browser-sso",
|
|
1280
|
+
authorizationUrl: "https://identity.example.com/authorize?...",
|
|
1281
|
+
browser: "system"
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
Omni opens the requested `system` or `omni` browser. After redirect, it passes the complete callback
|
|
1287
|
+
URL to `complete()`. The adapter validates the flow, exchanges the authorization code, and returns
|
|
1288
|
+
the authenticated provider identity. Provider tokens never enter Omni UI or general protocol state.
|
|
1289
|
+
An Omni-hosted SSO browser uses a dedicated temporary authentication session and never shares
|
|
1290
|
+
cookies or storage with task or personal browsers.
|
|
1291
|
+
|
|
1292
|
+
#### Credentials
|
|
1293
|
+
|
|
1294
|
+
For `credentials`, `start()` returns the form fields Omni must render:
|
|
1295
|
+
|
|
1296
|
+
```ts
|
|
1297
|
+
{
|
|
1298
|
+
status: "interaction-required",
|
|
1299
|
+
challenge: {
|
|
1300
|
+
flowId: "flow-43",
|
|
1301
|
+
method: "credentials",
|
|
1302
|
+
fields: [
|
|
1303
|
+
{ name: "username", label: "Username", type: "text", required: true, autocomplete: "username" },
|
|
1304
|
+
{ name: "password", label: "Password", type: "password", required: true, autocomplete: "current-password" }
|
|
1305
|
+
]
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
Omni submits a short-lived `values` record to `complete()` and does not retain it after the promise
|
|
1311
|
+
settles. The adapter must not persist raw credentials. Field-specific failures may set
|
|
1312
|
+
`AuthenticationFailure.field` to a declared field name.
|
|
1313
|
+
|
|
1314
|
+
### Cancelling authentication
|
|
1315
|
+
|
|
1316
|
+
`cancelAuthentication(flowId)` cancels an abandoned Browser SSO window or credentials form and
|
|
1317
|
+
releases its temporary state. It does not sign out an already authenticated session. It answers
|
|
1318
|
+
`cancelled`, and a repeat is safe and answers `already-cancelled`.
|
|
1319
|
+
|
|
1320
|
+
### Completion and failures
|
|
1321
|
+
|
|
1322
|
+
`complete()` returns either an authenticated provider identity or a typed failure:
|
|
1323
|
+
|
|
1324
|
+
```ts
|
|
1325
|
+
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" } }
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
The `User` it carries is the **root of this provider's user namespace**. Every other person this
|
|
1329
|
+
provider names — a roster member, the manager on an imposed break, the agent on a handling step —
|
|
1330
|
+
is identified from the same directory and carries the same `UserId` type.
|
|
1331
|
+
|
|
1332
|
+
| Field | Contract |
|
|
1333
|
+
| --- | --- |
|
|
1334
|
+
| `id` | Required `UserId`. Unique within this provider and stable across logins: it is what Omni scopes and stores, so a value that changes between sessions breaks every reference to this person. |
|
|
1335
|
+
| `displayName` | Required agent-facing name. Presentation only — never an identifier, never compared. |
|
|
1336
|
+
|
|
1337
|
+
`AuthenticationFailure` contains a stable `code`, safe agent-facing `message`, `retryable` flag,
|
|
1338
|
+
optional `retryAfterMs`, and optional credential `field`. It must never contain credentials,
|
|
1339
|
+
authorization codes, tokens, or provider responses containing secrets.
|
|
1340
|
+
|
|
1341
|
+
### Sign-out
|
|
1342
|
+
|
|
1343
|
+
`signOut(requestId)` revokes or invalidates the provider session where supported, deletes stored
|
|
1344
|
+
session secrets, and moves state to `signed-out`. It is safe to retry with the same request ID.
|
|
1345
|
+
`close()` stops authentication-state observation but does not sign the agent out.
|
|
1346
|
+
|
|
1347
|
+
### Secure-storage boundary
|
|
1348
|
+
|
|
1349
|
+
Omni provides an OS-backed `SecretStore` scoped to the provider manifest ID. It exposes only
|
|
1350
|
+
`get`, `set`, and `delete`; adapters cannot enumerate another provider's secrets. Adapters may store
|
|
1351
|
+
refresh tokens or equivalent session material, but never raw submitted credentials. Secrets must
|
|
1352
|
+
not appear in manifests, logs, events, snapshots, task attributes, errors, or browser storage.
|
|
1353
|
+
|
|
1354
|
+
## Connecting to a provider
|
|
1355
|
+
|
|
1356
|
+
### `Adapter.connect(context)`
|
|
1357
|
+
|
|
1358
|
+
Creates one live provider connection for the signed-in agent.
|
|
1359
|
+
|
|
1360
|
+
- Called by Omni after validating the manifest.
|
|
1361
|
+
- Must resolve only when the connection can provide a meaningful snapshot.
|
|
1362
|
+
- May reject for authentication, configuration, or startup failure.
|
|
1363
|
+
- Must not create a second agent session merely because the underlying transport reconnects.
|
|
1364
|
+
- The returned connection owns reconnect until Omni calls `disconnect()` or aborts `context.signal`.
|
|
1365
|
+
|
|
1366
|
+
### `ConnectContext`
|
|
1367
|
+
|
|
1368
|
+
| Field | Contract |
|
|
1369
|
+
| --- | --- |
|
|
1370
|
+
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1371
|
+
| `sessionId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.sessionId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
|
|
1372
|
+
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, `task-offered` carries an `acceptanceMode`; when `false`, every task requires agent acceptance. |
|
|
1373
|
+
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1374
|
+
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
1375
|
+
|
|
1376
|
+
### Who the agent is
|
|
1377
|
+
|
|
1378
|
+
`ConnectContext` names no agent. The adapter already knows who is connected: it authenticated them,
|
|
1379
|
+
and `AuthenticationState.identity` holds the result. Omni has nothing to add — it holds no
|
|
1380
|
+
identifier of its own, as **There is no Omni-wide user identity** sets out.
|
|
1381
|
+
|
|
1382
|
+
Omni may still prefill a username into a `credentials` form from the operating-system account,
|
|
1383
|
+
because Omni renders that form itself. That is a local convenience and never reaches an adapter.
|
|
1384
|
+
|
|
1385
|
+
## Provider state
|
|
1386
|
+
|
|
1387
|
+
### `Snapshot`
|
|
1388
|
+
|
|
1389
|
+
`sessionCapabilities` declares provider actions available for the current login rather than for one
|
|
1390
|
+
task. Protocol v1 includes agent break requests and team break control. A session action is
|
|
1391
|
+
available only when both the corresponding session capability and Omni provisioning permit it.
|
|
1392
|
+
The snapshot replaces the set completely, so a resync can grant or withdraw a capability safely.
|
|
1393
|
+
|
|
1394
|
+
| Field | Contract |
|
|
1395
|
+
| --- | --- |
|
|
1396
|
+
| `status` | Current `ConnectionStatus` — whether this provider's transport can serve the session. Defined under **`provider-status`**. |
|
|
1397
|
+
| `sessionId` | Identity of this login session. It must match the connection context. |
|
|
1398
|
+
| `sessionCapabilities` | Complete provider capability set for this login. Effective permission is its intersection with Omni provisioning. |
|
|
1399
|
+
| `break` | Complete break state, including approval, accepting state, reasons, retry details, and any imposed break. |
|
|
1400
|
+
| `tasks` | Complete set of tasks currently offered to or owned by this agent. |
|
|
1401
|
+
| `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
|
|
1402
|
+
| `scheduledActivities` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
|
|
1403
|
+
| `team` | `TeamRoster` for an agent who leads a team. Omitted for everybody else — its presence is the permission. |
|
|
1404
|
+
|
|
1405
|
+
## Live connection
|
|
1406
|
+
|
|
1407
|
+
`Connection` is what `connect()` returns. Its methods are documented in the sections that follow
|
|
1408
|
+
and under **Breaks**, **Team leads**, **Real-time media** and **Task commands**; this is the whole
|
|
1409
|
+
surface in one place, and what obliges an adapter to implement each one.
|
|
1410
|
+
|
|
1411
|
+
| Method | Implement it when |
|
|
1412
|
+
| --- | --- |
|
|
1413
|
+
| `snapshot()` | Always. |
|
|
1414
|
+
| `subscribe(listener)` | Always. |
|
|
1415
|
+
| `disconnect()` | Always. |
|
|
1416
|
+
| `setCapacity(capacity)` | Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it. |
|
|
1417
|
+
| `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
|
|
1418
|
+
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
|
|
1419
|
+
| `dial(request)` | The manifest declares `idleCapabilities.dial`. |
|
|
1420
|
+
| `requestBreak(request)` | `sessionCapabilities.breaks` is declared. |
|
|
1421
|
+
| `commitBreak(requestId)` | `sessionCapabilities.breaks` is declared. Commit and cancel are not optional halves of it. |
|
|
1422
|
+
| `cancelBreak(requestId)` | `sessionCapabilities.breaks` is declared. |
|
|
1423
|
+
| `endBreak()` | `sessionCapabilities.breaks` is declared. |
|
|
1424
|
+
| `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
|
|
1425
|
+
| `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
|
|
1426
|
+
|
|
1427
|
+
**The four break methods stand or fall together.** Declaring `sessionCapabilities.breaks` and then
|
|
1428
|
+
implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can never
|
|
1429
|
+
start, and the two-phase coordination in **Coordinating a multi-provider break** has no way to
|
|
1430
|
+
report that: `granted` is a promise to honour a later commit.
|
|
1431
|
+
|
|
1432
|
+
### `Connection.snapshot()`
|
|
1433
|
+
|
|
1434
|
+
Returns the provider's complete authoritative state at one point in time.
|
|
1435
|
+
|
|
1436
|
+
- Omni registers `subscribe()` before awaiting the initial snapshot and discards anything delivered
|
|
1437
|
+
while the snapshot is read, because the snapshot accounts for it. Events after it are applied in
|
|
1438
|
+
order.
|
|
1439
|
+
- `tasks` must contain every task currently owned by this agent for this provider.
|
|
1440
|
+
- A snapshot replaces Omni's state for this provider; it is not a partial patch.
|
|
1441
|
+
- The adapter may return synchronously when it already holds current live values, as Jema does, or
|
|
1442
|
+
asynchronously when it must obtain state.
|
|
1443
|
+
|
|
1444
|
+
### `Connection.subscribe(listener)`
|
|
1445
|
+
|
|
1446
|
+
Registers a listener for provider changes and returns an idempotent unsubscribe function.
|
|
1447
|
+
|
|
1448
|
+
- Every delivery is a `ProviderEventEnvelope`.
|
|
1449
|
+
- Delivery order must match the order in which the provider observes changes.
|
|
1450
|
+
- Never replay an event. Recovery is a snapshot, and a re-sent event would apply state the snapshot
|
|
1451
|
+
has already superseded.
|
|
1452
|
+
- On reconnect, the adapter must reactivate provider-side subscriptions and emit a `snapshot`
|
|
1453
|
+
event containing the complete refreshed state. This reconciles assignments or endings missed
|
|
1454
|
+
while disconnected without requiring a durable event log.
|
|
1455
|
+
- After unsubscribe, the listener must receive no further events.
|
|
1456
|
+
|
|
1457
|
+
### `Connection.describeUsers(ids)`
|
|
1458
|
+
|
|
1459
|
+
Turns `UserId` values into something an agent can read.
|
|
1460
|
+
|
|
1461
|
+
```ts
|
|
1462
|
+
describeUsers(ids: UserId[]): Promise<User[]>
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
Required of any adapter that publishes a `UserId` — on `ImposedBreak.by`, a team roster, or
|
|
1466
|
+
`handlingHistory[].by`. Publishing an identifier Omni cannot resolve puts a name on screen
|
|
1467
|
+
that reads as a database key.
|
|
1468
|
+
|
|
1469
|
+
- **Omit an id you cannot resolve; do not invent a name for it.** A missing entry says *I do not
|
|
1470
|
+
know this person*, which Omni renders as such. Ordering is not significant and the response may
|
|
1471
|
+
be shorter than the request.
|
|
1472
|
+
- **Take the whole list in one call.** Omni resolves a roster or a handling history as a batch, and
|
|
1473
|
+
a per-id round trip multiplies that by its length.
|
|
1474
|
+
- **Omni caches a result for one hour, then resolves it again.** The identifier is stable across
|
|
1475
|
+
logins but the name behind it is not, so the cache expires on a clock rather than living for the
|
|
1476
|
+
session. One hour is a starting figure and may be tuned; an adapter must not depend on any
|
|
1477
|
+
particular value, or on Omni asking again at any particular moment.
|
|
1478
|
+
|
|
1479
|
+
This is the only place a name comes from. Task data carries identifiers alone —
|
|
1480
|
+
`handlingHistory[].by` is an id and nothing more — so a name is never copied into a task, never
|
|
1481
|
+
duplicated across tasks, and never stale.
|
|
1482
|
+
|
|
1483
|
+
### `Connection.disconnect()`
|
|
1484
|
+
|
|
1485
|
+
Stops the connection and releases adapter-owned resources.
|
|
1486
|
+
|
|
1487
|
+
- Must be safe after partial startup and safe to call once during normal shutdown.
|
|
1488
|
+
- Must stop automatic reconnect.
|
|
1489
|
+
- Must remove event handlers and release media resources owned by the adapter.
|
|
1490
|
+
- Does not imply that active tasks were completed or removed.
|
|
1491
|
+
|
|
1492
|
+
## Task allocation lifecycle
|
|
1493
|
+
|
|
1494
|
+
The task-allocation lifecycle has five ordered stages:
|
|
1495
|
+
|
|
1496
|
+
**1. The agent signs in.** Omni initially places the agent in `not-ready`. The provisioning file's
|
|
1497
|
+
`readyOnLogin` flag determines whether Omni transitions them to `ready` immediately and defaults to
|
|
1498
|
+
`true`. A provider must allocate nothing while the agent remains `not-ready`.
|
|
1499
|
+
|
|
1500
|
+
**2. The agent becomes ready.** With `readyOnLogin: true`, Omni makes the transition automatically.
|
|
1501
|
+
Otherwise, the agent explicitly signals that they are ready to take work. A successful connection,
|
|
1502
|
+
a healthy provider, or the absence of a break does not imply readiness.
|
|
1503
|
+
|
|
1504
|
+
**3. Omni states their concurrent capacity.** Only now does Omni ask providers for work, saying how
|
|
1505
|
+
much the agent can take at once. It is a standing declaration rather than a poll: Omni restates the
|
|
1506
|
+
capacity every time it changes, in either direction, and does not ask again while that value holds.
|
|
1507
|
+
Silence keeps the last one in force, so a provider that waits to be asked a second time will never
|
|
1508
|
+
deliver again once it has gone quiet. Hold the capacity and deliver when work arrives.
|
|
1509
|
+
|
|
1510
|
+
**4. A provider offers a task.** The provider emits `task-offered` within the stated capacity.
|
|
1511
|
+
|
|
1512
|
+
**5. Omni decides how the task is accepted.** When `autoAcceptTasks` is `false`, every task requires
|
|
1513
|
+
agent acceptance. When it is `true`, the event's `acceptanceMode` states the provider's
|
|
1514
|
+
intent.
|
|
1515
|
+
|
|
1516
|
+
### Acceptance modes
|
|
1517
|
+
|
|
1518
|
+
During login, Omni sends the agent's `autoAcceptTasks` value to the provider. When it is `true`, the
|
|
1519
|
+
provider includes an acceptance directive with each allocation:
|
|
1520
|
+
|
|
1521
|
+
| Directive | Contract |
|
|
1522
|
+
| --- | --- |
|
|
1523
|
+
| `no-preference` | The provider leaves acceptance to Omni; with `autoAcceptTasks: true`, Omni accepts automatically. |
|
|
1524
|
+
| `require-agent-acceptance` | Omni presents **Accept** and waits for the agent. |
|
|
1525
|
+
| `require-automatic-acceptance` | Omni accepts immediately without agent interaction. |
|
|
1526
|
+
|
|
1527
|
+
When Omni sent `autoAcceptTasks: false`, the provider omits `acceptanceMode` and every task
|
|
1528
|
+
requires agent acceptance.
|
|
1529
|
+
|
|
1530
|
+
**An absent value means `true`**, as `readyOnLogin` does, because an agent who has signed in and
|
|
1531
|
+
gone ready is telling the deployment they are working. Requiring a press before every contact is
|
|
1532
|
+
the exception a provisioning file asks for, not the state it falls into when a flag is missing.
|
|
1533
|
+
|
|
1534
|
+
Nothing is given away by that default. `acceptanceMode` is the provider's own control and outranks
|
|
1535
|
+
it: `require-agent-acceptance` puts the decision back in the agent's hands for any task where it
|
|
1536
|
+
belongs, whatever the host was configured with.
|
|
1537
|
+
|
|
1538
|
+
An automatically accepted task still arrives through `task-offered`.
|
|
1539
|
+
|
|
1540
|
+
Agent-initiated work arrives through `task-offered` with
|
|
1541
|
+
`acceptanceMode: "require-automatic-acceptance"`.
|
|
1542
|
+
|
|
1543
|
+
### Pending
|
|
1544
|
+
|
|
1545
|
+
A task in the `pending` phase has been **offered to the agent and not yet accepted**. Omni applies
|
|
1546
|
+
`autoAcceptTasks` and the allocation's `acceptanceMode` to decide whether acceptance is
|
|
1547
|
+
automatic or requires the agent. A provider that requires automatic acceptance still emits
|
|
1548
|
+
`task-offered`; it does not introduce new work as `in-progress`.
|
|
1549
|
+
|
|
1550
|
+
```ts
|
|
1551
|
+
declare const task: Task;
|
|
1552
|
+
|
|
1553
|
+
const allocation = {
|
|
1554
|
+
task,
|
|
1555
|
+
acceptanceMode: "require-agent-acceptance",
|
|
1556
|
+
allocationExpiresAt: "2026-08-25T10:41:07.000Z",
|
|
1557
|
+
preparationEndsAt: "2026-08-25T10:40:37.000Z",
|
|
1558
|
+
} satisfies Extract<ProviderEvent, { type: "task-offered" }>;
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
The rule the phase exists to express: **nothing is acquired on the agent's behalf while a task
|
|
1562
|
+
is pending.** A host that carries media must not open the microphone until the task is
|
|
1563
|
+
accepted. Omni does not open the task's browsers either — a task that rings out costs nothing.
|
|
1564
|
+
|
|
1565
|
+
When manual acceptance is required, Omni offers the agent an **Accept** control. The call is the
|
|
1566
|
+
medium the task arrives on, not a separate decision.
|
|
1567
|
+
|
|
1568
|
+
**Once a task is accepted, the call that comes with it is answered.** Omni has no discretion
|
|
1569
|
+
there and the provider is not consulted twice: one decision about the work, and the medium
|
|
1570
|
+
follows it. Where the audio lands is not in question — it opens in Omni, as it always does.
|
|
1571
|
+
|
|
1572
|
+
Automatic acceptance still begins with `task-offered`.
|
|
1573
|
+
|
|
1574
|
+
`allocationExpiresAt` is the deadline after which the offer lapses. Where present, Omni counts
|
|
1575
|
+
down and stops offering **Accept** once it passes. **Omit it unless the provider can observe it.**
|
|
1576
|
+
A provider that reports only elapsed ring time after the fact cannot say when an offer is due
|
|
1577
|
+
to end, and a computed value would have Omni withdraw **Accept** from a task still pending.
|
|
1578
|
+
|
|
1579
|
+
`preparationEndsAt` is the time available for the agent to review the task context before acting.
|
|
1580
|
+
Omni presents the deadline with the allocation so a preview-based dialer can show how long remains
|
|
1581
|
+
before the agent must start the contact. Reaching the timestamp does not imply a transition; the
|
|
1582
|
+
provider reports what happens next through an event.
|
|
1583
|
+
|
|
1584
|
+
A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome.
|
|
1585
|
+
|
|
1586
|
+
### Tasks already in progress
|
|
1587
|
+
|
|
1588
|
+
The provider does not introduce new work as already `in-progress`.
|
|
1589
|
+
|
|
1590
|
+
A task appears already `in-progress` only in a snapshot taken after a reconnect or a resync,
|
|
1591
|
+
reporting work that began earlier in this login and never stopped. A fresh login has none to
|
|
1592
|
+
report: nothing has been allocated yet, and work the agent was handling elsewhere is not carried
|
|
1593
|
+
into a new session.
|
|
1594
|
+
|
|
1595
|
+
## Tasks
|
|
1596
|
+
|
|
1597
|
+
### `Task`
|
|
1598
|
+
|
|
1599
|
+
`Task` is the provider-owned description of one task presented to Omni.
|
|
1600
|
+
|
|
1601
|
+
`task-offered` introduces a new task and does not imply acceptance.
|
|
1602
|
+
|
|
1603
|
+
`Task<C>` is channel-discriminated. For example, `Task<"email">` accepts
|
|
1604
|
+
`browsers` and `dispositions`, but rejects voice-only controls such as `mute` and `hold` at compile
|
|
1605
|
+
time. Runtime conformance checks also require the task channel to match its provider manifest.
|
|
1606
|
+
|
|
1607
|
+
| Field | Contract |
|
|
1608
|
+
| --- | --- |
|
|
1609
|
+
| `id` | Required `TaskId`, unique within the provider. Omni scopes it with the provider ID. |
|
|
1610
|
+
| `title` | Agent-facing task title. |
|
|
1611
|
+
| `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
|
|
1612
|
+
| `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
|
|
1613
|
+
| `capabilities` | Controls and workspace features available for this specific task. |
|
|
1614
|
+
| `browsers` | Named browser definitions for the task workspace; empty when the task does not declare the `browsers` capability. |
|
|
1615
|
+
| `contact` | Optional `Contact` for the person or entity on this task. Often a name and one address; a withheld caller ID may leave nothing to send at all. |
|
|
1616
|
+
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
1617
|
+
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
1618
|
+
| `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
|
|
1619
|
+
| `completionAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. |
|
|
1620
|
+
| `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
|
|
1621
|
+
| `handlingHistory` | Optional ordered handling history for this currently open task. It is live task data, not a permanent archive. |
|
|
1622
|
+
|
|
1623
|
+
`TaskAttribute` entries carry typed detail alongside the task:
|
|
1624
|
+
|
|
1625
|
+
```ts
|
|
1626
|
+
const attributes: TaskAttribute[] = [
|
|
1627
|
+
{
|
|
1628
|
+
key: "related-contact",
|
|
1629
|
+
label: "Related contact",
|
|
1630
|
+
type: "contact",
|
|
1631
|
+
contact: { name: "Asha Rao", number: "+919876543210" },
|
|
1632
|
+
},
|
|
1633
|
+
{
|
|
1634
|
+
key: "answered",
|
|
1635
|
+
label: "Answered",
|
|
1636
|
+
type: "timestamp",
|
|
1637
|
+
at: "2026-08-27T09:30:00.000Z",
|
|
1638
|
+
},
|
|
1639
|
+
];
|
|
1640
|
+
```
|
|
1641
|
+
|
|
1642
|
+
`key` is the stable machine identifier and must be unique within the task's `attributes` array.
|
|
1643
|
+
`label` is the optional agent-facing name.
|
|
1644
|
+
|
|
1645
|
+
The canonical task transitions are:
|
|
1646
|
+
|
|
1647
|
+
| From | Decision or event | To |
|
|
1648
|
+
| --- | --- | --- |
|
|
1649
|
+
| No task | Provider allocates a task | `pending` |
|
|
1650
|
+
| `pending` | Task is accepted | `confirmed` |
|
|
1651
|
+
| `confirmed` | Preparation begins | `preparing` |
|
|
1652
|
+
| `confirmed` | Work begins without preparation | `in-progress` |
|
|
1653
|
+
| `preparing` | Agent starts the contact | `in-progress` |
|
|
1654
|
+
| `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
|
|
1655
|
+
| No task | Snapshot reports work already underway | `in-progress` |
|
|
1656
|
+
| `in-progress` | Provider or agent pauses the task | `paused` |
|
|
1657
|
+
| `paused` | Provider or agent resumes the task | `in-progress` |
|
|
1658
|
+
| `in-progress` or `paused` | Contact handling ends and follow-up work remains | `completing` |
|
|
1659
|
+
| Any phase | Provider emits `task-ended` | Removed |
|
|
1660
|
+
|
|
1661
|
+
Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
|
|
1662
|
+
allocation's `acceptanceMode`, moving the task from `pending` to `confirmed`. The provider reports
|
|
1663
|
+
subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
|
|
1664
|
+
command.
|
|
1665
|
+
|
|
1666
|
+
#### Completion timing
|
|
1667
|
+
|
|
1668
|
+
`completionMode` determines how completion is triggered. With `agent-command`, the provider keeps
|
|
1669
|
+
the task open until Omni sends the channel's `complete` command. With `provider-automatic`, the
|
|
1670
|
+
provider may complete the task without receiving that command.
|
|
1671
|
+
|
|
1672
|
+
`completionAllowance` is independent of that decision. It is fixed, and when it starts depends on
|
|
1673
|
+
whether the channel carries real-time media:
|
|
1674
|
+
|
|
1675
|
+
| Channel | Completion allowance starts at |
|
|
1676
|
+
| --- | --- |
|
|
1677
|
+
| Voice and any channel with real-time media | The `task-media-ended` event |
|
|
1678
|
+
| Chat | When the conversation ends and the task enters `completing` |
|
|
1679
|
+
| Email | After the message is sent and the task enters `completing` |
|
|
1680
|
+
| Other non-media channels | The moment the task enters `completing` |
|
|
1681
|
+
|
|
1682
|
+
```ts
|
|
1683
|
+
const emailCompletion = {
|
|
1684
|
+
completionMode: "agent-command",
|
|
1685
|
+
completionAllowance: 120,
|
|
1686
|
+
} satisfies Pick<Task<"email">, "completionMode" | "completionAllowance">;
|
|
1687
|
+
```
|
|
1688
|
+
|
|
1689
|
+
In this example, the agent has two minutes after sending the email to add notes, select a
|
|
1690
|
+
disposition, and complete the task.
|
|
1691
|
+
|
|
1692
|
+
`0` means completion may happen immediately. With `provider-automatic`, the provider may complete
|
|
1693
|
+
without waiting for a command; with `agent-command`, it still waits for `complete`. There is no
|
|
1694
|
+
value meaning "unlimited": a provider that does not want a deadline keeps the task `in-progress` or
|
|
1695
|
+
`paused` and moves it to `completing` only when the clock should start.
|
|
1696
|
+
|
|
1697
|
+
```ts
|
|
1698
|
+
const immediateProviderCompletion = {
|
|
1699
|
+
completionMode: "provider-automatic",
|
|
1700
|
+
completionAllowance: 0,
|
|
1701
|
+
} satisfies Pick<Task, "completionMode" | "completionAllowance">;
|
|
1702
|
+
```
|
|
1703
|
+
|
|
1704
|
+
### How a task has been handled
|
|
1705
|
+
|
|
1706
|
+
`Task.handlingHistory` is the sequence of steps that brought the task to the agent, oldest first:
|
|
1707
|
+
|
|
1708
|
+
```ts
|
|
1709
|
+
handlingHistory: [
|
|
1710
|
+
{ step: "queued", at: "2026-08-21T00:59:00Z", seconds: 41 },
|
|
1711
|
+
{ step: "answered", at: "2026-08-21T00:59:41Z", by: "a-17" },
|
|
1712
|
+
]
|
|
1713
|
+
```
|
|
1714
|
+
|
|
1715
|
+
**This is not an archive.** It is live data about a task that is still open: it travels with the task
|
|
1716
|
+
to whoever holds it next and ends when the task does. Nothing stores it, nothing queries it, and
|
|
1717
|
+
there is no archive behind it. A provider that keeps a record of *completed* contacts is describing
|
|
1718
|
+
something else, which will arrive under its own name and must not be folded in here.
|
|
1719
|
+
|
|
1720
|
+
It rides in the snapshot and is replaced whole like everything else there.
|
|
1721
|
+
|
|
1722
|
+
Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
|
|
1723
|
+
`unanswered`, and each is defined on `TaskHandlingStep`.
|
|
1724
|
+
|
|
1725
|
+
`muted` is there because Omni performs the mute rather than the provider — see **Where a command
|
|
1726
|
+
executes** — so without a step the one participant that keeps the task's record would have no
|
|
1727
|
+
account of a period the agent could not be heard.
|
|
1728
|
+
|
|
1729
|
+
Four rules a provider has to keep:
|
|
1730
|
+
|
|
1731
|
+
- **Report `seconds`; never expect Omni to derive it.** Omni does not subtract one timestamp from
|
|
1732
|
+
the next. An entry can be written while its leg is still running, so the arithmetic has no second
|
|
1733
|
+
operand, and a provider holding the authoritative number should not have it recomputed from
|
|
1734
|
+
instants that may be rounded or clock-skewed.
|
|
1735
|
+
- **Omit `seconds` while it is unknown. Never send `0`.** A leg still talking is not a zero-second
|
|
1736
|
+
conversation, and on live data that is the ordinary case rather than an edge. A zero is rejected.
|
|
1737
|
+
- **`by` is a bare `UserId`, and not necessarily an agent.** A lead or a manager takes part
|
|
1738
|
+
in handling too — a transfer accepted, a call conferenced in — so the field names whoever it was,
|
|
1739
|
+
the same way `ImposedBreak.by` does. It comes from this provider's own directory, the same
|
|
1740
|
+
namespace as `AuthenticationState.identity.id` and the team roster, so entries pair
|
|
1741
|
+
within a provider and never across one.
|
|
1742
|
+
- **A task carries no names.** Omni resolves what to display with `describeUsers()`. Two people
|
|
1743
|
+
called Arun on one site is ordinary, and anything pairing entries on a display name pairs them
|
|
1744
|
+
wrongly; carrying the name here would also copy it into every task and leave it to go stale.
|
|
1745
|
+
|
|
1746
|
+
**An absent `by` means different things on different steps, and both are legitimate.** On
|
|
1747
|
+
`queued` nobody takes part, so there is nothing to name. On every other step somebody did — see
|
|
1748
|
+
`HANDLING_STEPS_WITH_A_PERSON` and `handlingStepExpectsAPerson()` — so an absent `by` there says
|
|
1749
|
+
*this was handled and the provider cannot say by whom*.
|
|
1750
|
+
|
|
1751
|
+
That case is ordinary rather than theoretical: a leg answered on a shared phone, a manager's
|
|
1752
|
+
handset, or a device the provider cannot resolve to a person. **Report the step without `by`
|
|
1753
|
+
rather than dropping it.** A list missing a real handler looks complete and is wrong, which is
|
|
1754
|
+
worse than one saying plainly it could not attribute a leg — and far better than publishing
|
|
1755
|
+
nothing because a single leg could not be named.
|
|
1756
|
+
|
|
1757
|
+
A host must render the two differently. Showing an unattributed `answered` the same way as
|
|
1758
|
+
`queued` tells the agent nobody was involved, which is not what was said. Omni renders it as
|
|
1759
|
+
*"not recorded"* in the place the name would go.
|
|
1760
|
+
|
|
1761
|
+
Omit `handlingHistory` entirely when the provider cannot observe the steps. An empty array is a different
|
|
1762
|
+
claim — it says the task has had none.
|
|
1763
|
+
|
|
1764
|
+
### Browser capability
|
|
1765
|
+
|
|
1766
|
+
`browsers` is available to voice, chat, and email tasks. When declared, Omni renders the task's
|
|
1767
|
+
`TaskBrowser` entries as named browsers in the task workspace. A task that supplies one or more
|
|
1768
|
+
browser definitions must declare:
|
|
1769
|
+
|
|
1770
|
+
```ts
|
|
1771
|
+
capabilities: { browsers: true }
|
|
1772
|
+
```
|
|
1773
|
+
|
|
1774
|
+
Tasks without browser definitions omit the capability and provide an empty `browsers` array.
|
|
1775
|
+
|
|
1776
|
+
#### `TaskBrowser` and isolation
|
|
1777
|
+
|
|
1778
|
+
Each `TaskBrowser` defines one named browser in the task workspace.
|
|
1779
|
+
|
|
1780
|
+
| Field | Contract |
|
|
1781
|
+
| --- | --- |
|
|
1782
|
+
| `id` | Stable internal selection and update identity within the task. |
|
|
1783
|
+
| `name` | Agent-facing tab label, unique within the task, and an input to schemes containing `TAB_NAME`. |
|
|
1784
|
+
| `purpose` | Human-readable explanation of the browser's role. |
|
|
1785
|
+
| `url` | Initial URL. Must use `http:` or `https:`; see below. Later navigation comes from Chromium. |
|
|
1786
|
+
| `reuse` | Required. `false` creates a task-specific browser session. |
|
|
1787
|
+
| `isolationScheme` | **Required when `reuse` is `true`**, and rejected when it is `false`. There is no default: see below. |
|
|
1788
|
+
|
|
1789
|
+
##### Choosing a reuse scheme
|
|
1790
|
+
|
|
1791
|
+
Every scheme is supported and the provider picks the one its deployment needs. There is no
|
|
1792
|
+
default, and a `reuse: true` browser that declares none is invalid — the type will not compile
|
|
1793
|
+
it and `validateSnapshot` reports `task.browser.isolationScheme.required`.
|
|
1794
|
+
|
|
1795
|
+
That is deliberate. Sharing a signed-in session decides **who else may see those credentials**,
|
|
1796
|
+
and it is not a decision to inherit from whichever value happened to be the default. `TAB_NAME`
|
|
1797
|
+
keys on the tab label alone, so two providers that each publish a browser named "CRM" share one
|
|
1798
|
+
signed-in session — legitimate where a deployment wants exactly that, and a silent credential
|
|
1799
|
+
leak where it does not. It remains available; it has to be asked for.
|
|
1800
|
+
|
|
1801
|
+
`browserSessionKey` fails closed: given a reusing browser with no scheme it returns `undefined`
|
|
1802
|
+
and the browser is isolated. The safe reading of an invalid declaration is "do not share", never
|
|
1803
|
+
"share with everyone named the same".
|
|
1804
|
+
|
|
1805
|
+
##### Permitted URL schemes
|
|
1806
|
+
|
|
1807
|
+
`TaskBrowser.url` is provider-supplied and is loaded inside Omni's managed browser, so it is
|
|
1808
|
+
restricted to the schemes in `ALLOWED_BROWSER_URL_SCHEMES` — currently `http:` and `https:`.
|
|
1809
|
+
`file:`, `chrome:`, `javascript:`, and every other scheme are rejected. Omni substitutes a blank
|
|
1810
|
+
page rather than following a disallowed URL, and `isAllowedBrowserUrl()` is the shared predicate.
|
|
1811
|
+
|
|
1812
|
+
##### Reuse and isolation
|
|
1813
|
+
|
|
1814
|
+
With `reuse: true`, definitions producing the same isolation key share one **storage profile**:
|
|
1815
|
+
cookies, local storage, session storage, permissions, and cached credentials. Different keys are
|
|
1816
|
+
isolated from one another.
|
|
1817
|
+
|
|
1818
|
+
Sharing a profile is not sharing a window. Two browsers in the same task keep their own tab, their
|
|
1819
|
+
own visible label, and their own navigation state and history even when their keys match — a
|
|
1820
|
+
scheme that omits `TAB_NAME`, such as `PROVIDER_NAME__TASK_TYPE_NAME`, deliberately places every
|
|
1821
|
+
named browser of that task type in one signed-in profile without merging them into one page.
|
|
1822
|
+
|
|
1823
|
+
`browserSessionKey()` derives the key. Every part is escaped before the `.` separator is applied,
|
|
1824
|
+
because `encodeURIComponent` leaves `.` untouched and a raw join would let one value forge
|
|
1825
|
+
another key: provider `Acme.Voice` with task type `Support` would otherwise produce the same key
|
|
1826
|
+
as provider `Acme` with task type `Voice.Support`, silently placing two providers in one cookie
|
|
1827
|
+
jar. Hosts that must flatten the key further — for a native window label or a partition name with
|
|
1828
|
+
a restricted charset — must keep the mapping injective, for example by appending a fingerprint of
|
|
1829
|
+
the exact key, since lowercasing or replacing punctuation reintroduces exactly this collision.
|
|
1830
|
+
|
|
1831
|
+
```ts
|
|
1832
|
+
browsers: [
|
|
1833
|
+
{
|
|
1834
|
+
id: "crm",
|
|
1835
|
+
name: "CRM",
|
|
1836
|
+
purpose: "Contact record",
|
|
1837
|
+
url: "https://crm.example.com/contact/42",
|
|
1838
|
+
reuse: true,
|
|
1839
|
+
isolationScheme: BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME,
|
|
1840
|
+
}
|
|
1841
|
+
]
|
|
1842
|
+
```
|
|
1843
|
+
|
|
1844
|
+
The supported `BrowserIsolationScheme` values, declared under **Shapes**, key as follows:
|
|
1845
|
+
|
|
1846
|
+
| Enum member | Example session key |
|
|
1847
|
+
| --- | --- |
|
|
1848
|
+
| `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102.CRM` |
|
|
1849
|
+
| `TAB_NAME` | `CRM` |
|
|
1850
|
+
| `PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME` | `mailflow.Support.CRM` |
|
|
1851
|
+
| `PROVIDER_NAME__TAB_NAME` | `mailflow.CRM` |
|
|
1852
|
+
| `PROVIDER_NAME__TASK_TYPE_NAME` | `mailflow.Support` |
|
|
1853
|
+
| `TASK_TYPE_NAME__TAB_NAME` | `Support.CRM` |
|
|
1854
|
+
|
|
1855
|
+
`TASK_TYPE_NAME` refers to the mandatory `Task.taskType`. The isolation scheme never changes
|
|
1856
|
+
the browser tab label. Serialized values are stable protocol values and must not be renamed or
|
|
1857
|
+
reused.
|
|
1858
|
+
|
|
1859
|
+
**`PROVIDER_NAME` is `manifest.id`, never `manifest.displayName`.** Only the id is required unique
|
|
1860
|
+
across an installation; two providers may legitimately share a display label, and keying a cookie
|
|
1861
|
+
jar on one would put them in the same signed-in session. The id is also stable across launches,
|
|
1862
|
+
where a display name may be re-worded — and a changed key silently signs the agent out of every
|
|
1863
|
+
browser that used it.
|
|
1864
|
+
|
|
1865
|
+
### Task capabilities
|
|
1866
|
+
|
|
1867
|
+
Task capabilities belong to each `Task`. If `hold` is false or omitted on one task, Omni
|
|
1868
|
+
must not show or issue hold for that task even if another task from the same provider supports it.
|
|
1869
|
+
|
|
1870
|
+
```ts
|
|
1871
|
+
const taskCapabilities = {
|
|
1872
|
+
channel: "voice",
|
|
1873
|
+
capabilities: {
|
|
1874
|
+
browsers: true,
|
|
1875
|
+
hold: true,
|
|
1876
|
+
dispositions: true,
|
|
1877
|
+
},
|
|
1878
|
+
browsers: [],
|
|
1879
|
+
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "browsers">;
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
### Voice capabilities
|
|
1883
|
+
|
|
1884
|
+
| Capability | Omni UI | Contract |
|
|
1885
|
+
| --- | --- | --- |
|
|
1886
|
+
| `decline` | Pending-task button: Decline | The provider can decline a pending voice offer. Omni shows it only when provisioning also permits rejection. |
|
|
1887
|
+
| `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
|
|
1888
|
+
| `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
|
|
1889
|
+
| `agentDisconnect` | Primary button: Disconnect | Omni may disconnect real-time media without disposing the task. |
|
|
1890
|
+
| `blindTransfer` | Secondary menu item: Blind transfer | Omni may transfer the caller directly to a destination. |
|
|
1891
|
+
| `conference` | Secondary button: Conference | Omni may add or remove participants from the active call. |
|
|
1892
|
+
| `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
|
|
1893
|
+
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
1894
|
+
|
|
1895
|
+
### Publishing codes and destinations
|
|
1896
|
+
|
|
1897
|
+
Three capabilities accept an object instead of `true` when the provider wants Omni to render real
|
|
1898
|
+
choices. `true` remains valid and means "offer the control with nothing published".
|
|
1899
|
+
|
|
1900
|
+
#### `dispositions`
|
|
1901
|
+
|
|
1902
|
+
```ts
|
|
1903
|
+
capabilities: {
|
|
1904
|
+
dispositions: {
|
|
1905
|
+
required: true,
|
|
1906
|
+
notes: "optional",
|
|
1907
|
+
codes: [
|
|
1908
|
+
{ id: "resolved", label: "Resolved" },
|
|
1909
|
+
{ id: "callback", label: "Callback needed", group: "Follow-up" },
|
|
1910
|
+
],
|
|
1911
|
+
},
|
|
1912
|
+
}
|
|
1913
|
+
```
|
|
1914
|
+
|
|
1915
|
+
| Field | Contract |
|
|
1916
|
+
| --- | --- |
|
|
1917
|
+
| `required` | When `true`, Omni must collect a code before issuing `complete`. A required policy must publish at least one code. |
|
|
1918
|
+
| `notes` | `required`, `optional`, or `hidden`; controls the free-text field beside the code. |
|
|
1919
|
+
| `codes` | Codes Omni offers. `id` values are non-empty and unique; Omni sends the chosen `id` as `TaskCommand.complete.disposition`. |
|
|
1920
|
+
|
|
1921
|
+
With `dispositions: true` Omni shows a Complete control and sends `complete` with no code, because
|
|
1922
|
+
the provider published none.
|
|
1923
|
+
|
|
1924
|
+
#### `blindTransfer` and `conference`
|
|
1925
|
+
|
|
1926
|
+
```ts
|
|
1927
|
+
capabilities: {
|
|
1928
|
+
blindTransfer: {
|
|
1929
|
+
allowManualEntry: false,
|
|
1930
|
+
destinations: [
|
|
1931
|
+
{ id: "tier2", label: "Tier 2 support", address: "+14155550111", kind: "queue" },
|
|
1932
|
+
],
|
|
1933
|
+
},
|
|
1934
|
+
}
|
|
1935
|
+
```
|
|
1936
|
+
|
|
1937
|
+
| Field | Contract |
|
|
1938
|
+
| --- | --- |
|
|
1939
|
+
| `destinations` | Directory Omni renders, with unique `id` values. |
|
|
1940
|
+
| `address` | The value Omni sends as `TaskCommand.transfer.destination` or `conference.participant`. |
|
|
1941
|
+
| `kind` | Where the contact is going. See below. |
|
|
1942
|
+
| `allowManualEntry` | Whether the agent may type a destination that is not in the directory. A directory with no destinations must allow manual entry, or the control has nothing to offer. |
|
|
1943
|
+
|
|
1944
|
+
`kind` says who receives the contact and whether this provider still holds it afterwards:
|
|
1945
|
+
|
|
1946
|
+
| Kind | What it means |
|
|
1947
|
+
| --- | --- |
|
|
1948
|
+
| `queue` | A routing point on this provider. Whoever is next takes it, nobody is named, and the provider keeps the contact. |
|
|
1949
|
+
| `agent` | One named person on this provider. The provider keeps the contact and knows who has it. |
|
|
1950
|
+
| `external` | An address outside this provider — another platform, a PSTN number, a partner's line. The contact leaves, and the provider generally stops being able to report on it. |
|
|
1951
|
+
|
|
1952
|
+
A destination the agent types is not in the directory and has no `kind`. Omni treats it as
|
|
1953
|
+
`external` unless the provider says otherwise in its response, because that is the assumption that
|
|
1954
|
+
does not overstate what the provider can still see.
|
|
1955
|
+
|
|
1956
|
+
### Chat capabilities
|
|
1957
|
+
|
|
1958
|
+
| Capability | Omni UI | Contract |
|
|
1959
|
+
| --- | --- | --- |
|
|
1960
|
+
| `reject` | Pending-task button: Reject | The provider can reject a pending chat offer. Omni shows it only when provisioning also permits rejection. |
|
|
1961
|
+
| `hold` | Primary toggle: Hold | Omni may pause and resume agent handling of the chat. |
|
|
1962
|
+
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
1963
|
+
|
|
1964
|
+
### Email capabilities
|
|
1965
|
+
|
|
1966
|
+
| Capability | Omni UI | Contract |
|
|
1967
|
+
| --- | --- | --- |
|
|
1968
|
+
| `reject` | Pending-task button: Reject | The provider can reject a pending email offer. Omni shows it only when provisioning also permits rejection. |
|
|
1969
|
+
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
1970
|
+
|
|
1971
|
+
### Custom capabilities
|
|
1972
|
+
|
|
1973
|
+
Every task may publish additional provider-specific controls in `capabilities.custom`:
|
|
1974
|
+
|
|
1975
|
+
```ts
|
|
1976
|
+
capabilities: {
|
|
1977
|
+
hold: true,
|
|
1978
|
+
custom: [
|
|
1979
|
+
{ id: "request-supervisor", ui: { kind: "button", label: "Request supervisor", placement: "secondary" } },
|
|
1980
|
+
{ id: "mark-vip", ui: { kind: "toggle", label: "Mark as VIP", placement: "overflow" } },
|
|
1981
|
+
],
|
|
1982
|
+
}
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
Custom capability IDs must be non-empty and unique within the task. `ui.kind` is `button`, `toggle`,
|
|
1986
|
+
or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. Omni renders the control
|
|
1987
|
+
and invokes it with the shared custom task command:
|
|
1988
|
+
|
|
1989
|
+
```ts
|
|
1990
|
+
{
|
|
1991
|
+
type: "custom",
|
|
1992
|
+
name: "request-supervisor",
|
|
1993
|
+
}
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
`name` is the `id` of the custom capability the agent used. There is no `taskId` here: like every
|
|
1997
|
+
other command it travels on the `TaskCommandRequest` around it. A `toggle` carries the state it
|
|
1998
|
+
wants — `{ type: "custom", name: "mark-vip", on: true }` — never a flip, for the reason under
|
|
1999
|
+
**Task commands**.
|
|
2000
|
+
|
|
2001
|
+
Custom capabilities must not redefine the meaning of a standard channel capability.
|
|
2002
|
+
|
|
2003
|
+
## Idle actions
|
|
2004
|
+
|
|
2005
|
+
### `dial(request)`
|
|
2006
|
+
|
|
2007
|
+
Starts one outbound call from the idle dialpad. It is present only when the voice provider
|
|
2008
|
+
declares `dial`.
|
|
2009
|
+
|
|
2010
|
+
- `commandId` remains stable across retries; the provider must place at most one call for it.
|
|
2011
|
+
- `destination` is the original number selected or entered by the agent.
|
|
2012
|
+
- `source` is `contact` or `manual` and must comply with `destinationPolicy`.
|
|
2013
|
+
- `dialled` confirms that outbound call creation completed.
|
|
2014
|
+
- `already-dialled` confirms a retry that placed no second call.
|
|
2015
|
+
- `failed` contains a `ProtocolFailure` and confirms no call was placed.
|
|
2016
|
+
|
|
2017
|
+
The resulting call is offered through the normal `task-offered` event. A successful dial result
|
|
2018
|
+
does not manufacture a task inside Omni.
|
|
2019
|
+
|
|
2020
|
+
## Breaks
|
|
2021
|
+
|
|
2022
|
+
Everything about an agent's breaks on one provider arrives as one object, `Snapshot.break`,
|
|
2023
|
+
replaced whole by a single `break-state` event. These facts are only meaningful together — an
|
|
2024
|
+
approval says nothing without knowing whether the agent chose the break, and a list of reasons says
|
|
2025
|
+
nothing while none are being accepted — so they are not published separately.
|
|
2026
|
+
|
|
2027
|
+
| Field | Contract |
|
|
2028
|
+
| --- | --- |
|
|
2029
|
+
| `approval` | Where the agent's current request stands. See the states below. |
|
|
2030
|
+
| `requestId` | Correlates an agent-requested break while approval is `awaiting-decision`, `granted`, `starting-after-task`, or `in-effect`. Omitted for imposed breaks and when no request is active. |
|
|
2031
|
+
| `accepting` | Whether the agent may ask at all. Distinct from `approval`. |
|
|
2032
|
+
| `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
|
|
2033
|
+
| `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
|
|
2034
|
+
| `retryAfterMs` | How long until the agent may retry, when the provider can say. |
|
|
2035
|
+
| `reasons` | Not-ready codes this provider offers. Omitted when it defines none. |
|
|
2036
|
+
| `activeReasonId` | The `BreakReason.id` the current break is on. Omitted when there is no break, or when the provider cannot say. |
|
|
2037
|
+
| `imposed` | Set when the break was placed on the agent rather than requested. |
|
|
2038
|
+
|
|
2039
|
+
A request can be waiting for two unrelated things, and they are separate values because
|
|
2040
|
+
rendering one as the other tells an agent to wait for somebody who is never coming:
|
|
2041
|
+
|
|
2042
|
+
| `approval` | Meaning |
|
|
2043
|
+
| --- | --- |
|
|
2044
|
+
| `not-requested` | No request outstanding. |
|
|
2045
|
+
| `awaiting-decision` | A person has to decide. The agent is waiting on somebody. |
|
|
2046
|
+
| `granted` | A person decided yes. Omni may now tell this provider to stop the agent; until it does, work continues normally, and this says nothing about why Omni has not. |
|
|
2047
|
+
| `starting-after-task` | Omni has told the provider to stop; the break begins when the current task ends. No new work arrives meanwhile, and nobody needs to act. |
|
|
2048
|
+
| `in-effect` | The agent is on the break now. |
|
|
2049
|
+
|
|
2050
|
+
A denial is a decision, not a standing approval state. The provider transitions the request directly
|
|
2051
|
+
to `not-requested`; Omni returns the agent to idle and never asks again on their behalf. They saw the
|
|
2052
|
+
answer and ask again when they want to. `decisionReason` may carry the words attached to that
|
|
2053
|
+
decision, but `approval` does not remain denied.
|
|
2054
|
+
|
|
2055
|
+
A provider reports `starting-after-task` only after Omni commits a `granted` request while
|
|
2056
|
+
work is still active. Omni does not retry the original request, because asking again would not move
|
|
2057
|
+
it; it retries the commit when its delivery is uncertain.
|
|
2058
|
+
|
|
2059
|
+
`accepting: false` is what lets Omni withdraw the control rather than let an agent ask and be
|
|
2060
|
+
refused. A `BreakReason` marked `alwaysAvailable` survives it: a mandatory rest period is not
|
|
2061
|
+
something a busy hour can cancel, and Omni keeps offering those while the rest are withdrawn.
|
|
2062
|
+
|
|
2063
|
+
### Imposed breaks
|
|
2064
|
+
|
|
2065
|
+
`ImposedBreak` says who placed the break, whether automatic ending is enabled, and, when enabled,
|
|
2066
|
+
when the provider will end it. A break the agent did not choose is not manually resumable by them.
|
|
2067
|
+
|
|
2068
|
+
**Every imposed break has a person behind it.** A lead or a manager placed it; there is no such
|
|
2069
|
+
thing as a break the platform imposed on its own. Where a platform applies one automatically, it is
|
|
2070
|
+
executing a preference somebody configured, and that person is the owner of the action — `by` names
|
|
2071
|
+
them, not the machinery that carried it out.
|
|
2072
|
+
|
|
2073
|
+
Omni resolves the name to show with `describeUsers()`, so a provider sends the identifier and never
|
|
2074
|
+
a display name.
|
|
2075
|
+
|
|
2076
|
+
For example:
|
|
2077
|
+
|
|
2078
|
+
```ts
|
|
2079
|
+
imposed: {
|
|
2080
|
+
by: "manager-1042",
|
|
2081
|
+
endsAutomatically: true,
|
|
2082
|
+
endsAt: "2026-08-21T10:00:00.000Z"
|
|
2083
|
+
}
|
|
2084
|
+
```
|
|
2085
|
+
|
|
2086
|
+
The presence of `imposed` means the agent cannot end the break manually, so Omni withdraws its
|
|
2087
|
+
Resume control from that agent. With `endsAutomatically: true`, the provider ends the break at
|
|
2088
|
+
`endsAt`; with `endsAutomatically: false`, it does not end the break on a timer. An authorized lead
|
|
2089
|
+
may end either form with **Resume**, not only whoever placed it. Omni shows **Stopped by <who>**,
|
|
2090
|
+
resolving the name with `describeUsers()`, and shows when the break will end where automatic ending
|
|
2091
|
+
is enabled.
|
|
2092
|
+
|
|
2093
|
+
A break applies to the **agent**, not to one provider. When a provider imposes one, Omni immediately
|
|
2094
|
+
requests a break on every other connected provider, or they would keep routing work to somebody who
|
|
2095
|
+
is not there. Providers should expect that follow-on request.
|
|
2096
|
+
|
|
2097
|
+
## Capacity and break actions
|
|
2098
|
+
|
|
2099
|
+
**Each method answers in its own words.** A result is read by a person far more often than it is
|
|
2100
|
+
branched on by code — in a log, a support ticket, a conformance failure — so it says what happened
|
|
2101
|
+
rather than that something happened. `failed` is shared, because failing is the same act
|
|
2102
|
+
everywhere; success is not.
|
|
2103
|
+
|
|
2104
|
+
| Method | Succeeded | Retried after uncertain delivery |
|
|
2105
|
+
| --- | --- | --- |
|
|
2106
|
+
| `setCapacity` | `accepted` | — |
|
|
2107
|
+
| `requestBreak` | `requested` | `already-requested` |
|
|
2108
|
+
| `commitBreak` | `committed` | `already-committed` |
|
|
2109
|
+
| `cancelBreak` | `cancelled` | `already-cancelled` |
|
|
2110
|
+
| `endBreak` | `ended` | `already-ended` |
|
|
2111
|
+
|
|
2112
|
+
`failed` carries a typed `ProtocolFailure` and means the provider did not take the action, whether
|
|
2113
|
+
it would not or could not.
|
|
2114
|
+
|
|
2115
|
+
**Succeeding is not the outcome.** `requested` says the provider holds the request, not that a
|
|
2116
|
+
break was granted; `ended` says the provider has the instruction, not that the agent is working
|
|
2117
|
+
again.
|
|
2118
|
+
Every break method reports its real result through `break-state`; `setCapacity` reports none at
|
|
2119
|
+
all, because capacity is a statement rather than a request.
|
|
2120
|
+
|
|
2121
|
+
`setCapacity` has no retry answer because it needs none: a capacity supersedes rather than
|
|
2122
|
+
accumulates, and re-sending the current one changes nothing. The four break methods carry a
|
|
2123
|
+
`requestId`
|
|
2124
|
+
precisely so a retry can be recognised, and `already-committed` is the one commit recovery lives
|
|
2125
|
+
on — retrying `commitBreak` into a partially delivered attempt, it is the difference between *I
|
|
2126
|
+
have committed now* and *I committed before you asked*, which is how Omni knows the attempt has
|
|
2127
|
+
converged rather than only that a message arrived.
|
|
2128
|
+
|
|
2129
|
+
`execute` keeps `applied` and `already-applied` rather than a verb per command, because the command
|
|
2130
|
+
is in the request: `execute({ command: { type: "hold" } })` returning `applied` already says the
|
|
2131
|
+
hold applied. A `held` result would repeat the discriminant that travelled with it.
|
|
2132
|
+
|
|
2133
|
+
### `setCapacity(capacity)`
|
|
2134
|
+
|
|
2135
|
+
States how many tasks this provider may have allocated to the agent **at once**.
|
|
2136
|
+
|
|
2137
|
+
`count` is an absolute ceiling, not an increment and never less than 1. An agent's capacity is a
|
|
2138
|
+
property of the agent, not of the moment: it is stated when the agent is set up and restated only
|
|
2139
|
+
when it genuinely changes, which is a provisioning change rather than a task starting or ending.
|
|
2140
|
+
|
|
2141
|
+
**The provider counts its own outstanding tasks against it.** Allocate while you hold fewer than
|
|
2142
|
+
`count` tasks for this agent, and stop when you hold that many; when one of yours ends you have
|
|
2143
|
+
room again and need no new signal to know it. Omni does not re-state capacity as tasks come and
|
|
2144
|
+
go, and a provider that waits for it will stall.
|
|
2145
|
+
|
|
2146
|
+
Your own tasks are the only ones you count. What the agent holds at other providers is not your
|
|
2147
|
+
concern — Omni set `count` knowing it.
|
|
2148
|
+
|
|
2149
|
+
Capacity supersedes rather than accumulates, so it carries no key and has no `already-` answer:
|
|
2150
|
+
the latest value is the ceiling.
|
|
2151
|
+
|
|
2152
|
+
**Capacity gates what the provider allocates, not what the agent starts.** A call placed from the
|
|
2153
|
+
idle dialpad arrives through `task-offered` like any other task, and a full agent does not forbid
|
|
2154
|
+
it: the ceiling binds allocation, not the agent's own hand.
|
|
2155
|
+
|
|
2156
|
+
### `requestBreak(request)`
|
|
2157
|
+
|
|
2158
|
+
Requests permission to stop the agent later; it does not itself stop work. `requestId` is stable
|
|
2159
|
+
across retries for one agent break attempt. The provider continues offering work and reports
|
|
2160
|
+
`awaiting-decision` or `granted` through `break-state` events. If the request is denied, the
|
|
2161
|
+
provider reports `not-requested` directly, with `decisionReason` when one was supplied.
|
|
2162
|
+
|
|
2163
|
+
#### Break reasons
|
|
2164
|
+
|
|
2165
|
+
A provider that defines not-ready reason codes publishes them on `Snapshot.break.reasons`,
|
|
2166
|
+
and Omni returns the agent's choice as `BreakRequest.reasonId`:
|
|
2167
|
+
|
|
2168
|
+
```ts
|
|
2169
|
+
break: {
|
|
2170
|
+
reasons: [
|
|
2171
|
+
{ id: "lunch", label: "Lunch" },
|
|
2172
|
+
{ id: "training", label: "Training", group: "Scheduled" },
|
|
2173
|
+
]
|
|
2174
|
+
}
|
|
2175
|
+
```
|
|
2176
|
+
|
|
2177
|
+
Reason ids are non-empty and unique within the provider. They live on the snapshot rather than the
|
|
2178
|
+
manifest because a provider may change the codes it offers during a shift; a `snapshot` event
|
|
2179
|
+
replaces the list. `BreakRequest.reason` remains available for free text and is never a substitute
|
|
2180
|
+
for `reasonId` when the provider publishes codes. A provider that defines no codes omits the
|
|
2181
|
+
field.
|
|
2182
|
+
|
|
2183
|
+
#### One break across several providers
|
|
2184
|
+
|
|
2185
|
+
An agent connected to several providers takes **one** break, not one per platform. Omni gathers
|
|
2186
|
+
what every connected provider offers, shows the distinct breaks, and on a choice sends one request
|
|
2187
|
+
per provider: the same `reason`, and each provider's own `reasonId`.
|
|
2188
|
+
|
|
2189
|
+
To do that, Omni has to know when two providers mean the same break. Say so with `kind`:
|
|
2190
|
+
|
|
2191
|
+
```ts
|
|
2192
|
+
break: {
|
|
2193
|
+
reasons: [
|
|
2194
|
+
{ id: "lunch", label: "Lunch", kind: "meal" },
|
|
2195
|
+
{ id: "rest", label: "Mandatory rest period", kind: "rest", alwaysAvailable: true },
|
|
2196
|
+
]
|
|
2197
|
+
}
|
|
2198
|
+
```
|
|
2199
|
+
|
|
2200
|
+
A provider decides which breaks it offers and what it calls them. `BREAK_KINDS` is the list Omni
|
|
2201
|
+
supports mapping them onto, and every member means something specific:
|
|
2202
|
+
|
|
2203
|
+
| Kind | What it means |
|
|
2204
|
+
| --- | --- |
|
|
2205
|
+
| `short-break` | A brief rest between contacts — the comfort break a shift plan allows for. |
|
|
2206
|
+
| `meal` | A meal: lunch, dinner, whatever the shift calls it. |
|
|
2207
|
+
| `rest` | A rest period the agent is entitled to and a busy hour cannot cancel. Usually the reason also marked `alwaysAvailable`, though the two are separate: this says what the break *is*, that says whether policy can withdraw it. |
|
|
2208
|
+
| `training` | Learning something the agent is expected to know afterwards: a course, e-learning, a product walkthrough. However it is delivered, and whoever attends. |
|
|
2209
|
+
| `coaching` | Reviewing this agent's own work with somebody accountable for it: a call listened back, quality feedback, a one-to-one about their handling. Even where the outcome is that they learn something. |
|
|
2210
|
+
| `meeting` | A scheduled gathering that is neither — a team huddle, a project call, a town hall. The agent attends and contributes; nobody is assessing their work and there is nothing they must know by the end. |
|
|
2211
|
+
| `administrative` | Paperwork and follow-up not attached to a particular contact. |
|
|
2212
|
+
| `technical` | Equipment or system trouble stopping the agent taking work: a dead headset, a phone that never registered, a tool that will not load. The one member that is not an activity — it says why the agent *cannot* work rather than what they are doing, which makes it the right home for a not-ready state raised about the agent's equipment. |
|
|
2213
|
+
| `personal` | Personal time the deployment does not classify further. |
|
|
2214
|
+
| `other` | None of the above. Matches nothing, including another provider's `other`. |
|
|
2215
|
+
|
|
2216
|
+
**The definitions are the point, not decoration.** Ten undefined strings would be the label
|
|
2217
|
+
problem one level up: two providers could both declare `technical`, one meaning a dead headset
|
|
2218
|
+
and the other scheduled maintenance, match on it, and nothing could tell the difference.
|
|
2219
|
+
|
|
2220
|
+
`training`, `coaching` and `meeting` can all fit one session, so take them in that order of
|
|
2221
|
+
specificity: about this agent's own work is `coaching`, else something they must know afterwards is
|
|
2222
|
+
`training`, else `meeting`.
|
|
2223
|
+
|
|
2224
|
+
**A break somebody placed on the agent is not automatically one of these.** Where the agent was
|
|
2225
|
+
stopped rather than choosing to stop — see **Imposed breaks** — set `BreakState.imposed` and prefer
|
|
2226
|
+
omitting `kind` to reaching for `other`. None of the ten describes "something was done to this
|
|
2227
|
+
agent", and `other` claims a classification that was never made.
|
|
2228
|
+
|
|
2229
|
+
Omni matches in this order:
|
|
2230
|
+
|
|
2231
|
+
1. **`kind`**, where both providers declare one. This wins over the label, so `{ id: "MEAL",
|
|
2232
|
+
label: "Meal", kind: "meal" }` lines up with `{ id: "lunch", label: "Lunch", kind: "meal" }`
|
|
2233
|
+
and nobody has to word their codes the same way.
|
|
2234
|
+
2. **The label**, folded for case and surrounding spacing, where a kind is missing. `"other"`
|
|
2235
|
+
counts as missing: two providers saying `"other"` have only said their break is not on the
|
|
2236
|
+
list, which is no evidence they mean the same thing.
|
|
2237
|
+
|
|
2238
|
+
Nothing else is matched. A break Omni cannot pair stays on its own, the agent is told how many
|
|
2239
|
+
platforms their break will actually reach, and they can pair the odd one by hand — Omni remembers
|
|
2240
|
+
that for next time.
|
|
2241
|
+
|
|
2242
|
+
Three rules for a provider:
|
|
2243
|
+
|
|
2244
|
+
- **Declare `kind` where you can, and leave it out where you cannot.** Leaving it out costs a
|
|
2245
|
+
little precision. Getting it wrong sends an agent to lunch on one platform and to training on
|
|
2246
|
+
another, and nothing can detect that.
|
|
2247
|
+
- **You may publish several reasons of one kind** — two meal slots, say. Omni will not choose
|
|
2248
|
+
between them for the agent; saying they are the same kind is not saying they are interchangeable.
|
|
2249
|
+
- **Do not read `reason` as your own label.** It is the agent's single choice, in whichever
|
|
2250
|
+
provider's wording they picked it from, sent unchanged to every provider in the break.
|
|
2251
|
+
|
|
2252
|
+
A provider that publishes no reason codes still receives the request, with `reason` set and
|
|
2253
|
+
`reasonId` omitted.
|
|
2254
|
+
|
|
2255
|
+
#### Coordinating a multi-provider break
|
|
2256
|
+
|
|
2257
|
+
Sending the requests is a two-phase operation, not independent best effort. A provider first grants
|
|
2258
|
+
permission for the agent to stop, then Omni either commits or cancels that permission.
|
|
2259
|
+
|
|
2260
|
+
`granted` is provider-visible state. It means only that this provider has granted the active
|
|
2261
|
+
request and is ready to stop the agent when Omni asks. It does not reveal that other providers exist
|
|
2262
|
+
or why Omni has not committed yet. While reporting `granted`, the provider remains available,
|
|
2263
|
+
continues to honour Omni's current capacity, and continues offering work normally.
|
|
2264
|
+
|
|
2265
|
+
Omni separately tracks the aggregate host states `working`, `requesting-break`, `committing-break`,
|
|
2266
|
+
`cancelling-break`, and `on-break`. While `requesting-break`, Omni shows which providers remain
|
|
2267
|
+
outstanding and offers **Cancel break request**, but does not tell the agent that their break has
|
|
2268
|
+
begun.
|
|
2269
|
+
|
|
2270
|
+
Omni offers the aggregate Break control only when every provider currently holding capacity
|
|
2271
|
+
declares `sessionCapabilities.breaks`. If one cannot be stopped, offering a global break would
|
|
2272
|
+
knowingly permit partial availability.
|
|
2273
|
+
|
|
2274
|
+
Omni coordinates one attempt as follows:
|
|
2275
|
+
|
|
2276
|
+
1. Freeze the participant set to every connected provider from which the agent can currently
|
|
2277
|
+
receive work. A provider joining during the attempt is given no capacity until it finishes.
|
|
2278
|
+
2. Enter `requesting-break`. Keep the agent's normal capacity in place throughout this phase.
|
|
2279
|
+
3. Send one `requestBreak` to every participant, using a stable `requestId` per provider for this
|
|
2280
|
+
logical attempt. Retry uncertain delivery with the same ID. A provider reports
|
|
2281
|
+
`awaiting-decision` or `granted`; neither state stops work. A denial transitions directly to
|
|
2282
|
+
`not-requested` and causes Omni to take the cancel path.
|
|
2283
|
+
4. If every participant reports `granted`, durably choose commit, enter `committing-break`,
|
|
2284
|
+
and send `commitBreak(requestId)` to every participant. A provider then stops offering new work
|
|
2285
|
+
and reports `starting-after-task` or `in-effect`. Omni enters `on-break` once every participant
|
|
2286
|
+
it can still reach reports `in-effect`, and no later than the **commit bound** — ten seconds
|
|
2287
|
+
from the decision, tunable per deployment. A participant that has not applied the commit by then
|
|
2288
|
+
is set aside as unreconciled; the break begins without it.
|
|
2289
|
+
5. If any participant fails or denies the request, cannot be reconciled within the bounded
|
|
2290
|
+
decision timeout, or the agent cancels before commit, durably choose cancel and enter
|
|
2291
|
+
`cancelling-break`. Send `cancelBreak(requestId)` to every participant still reporting
|
|
2292
|
+
`awaiting-decision` or `granted`. Work continues during cancellation because no stop was
|
|
2293
|
+
committed. Return to `working` only after no participant retains either state.
|
|
2294
|
+
|
|
2295
|
+
Commit and cancel are mutually exclusive decisions for one attempt. Once Omni chooses commit it
|
|
2296
|
+
never rolls that attempt back: uncertain deliveries are retried with the same ID and reconciled by
|
|
2297
|
+
snapshot until every participant applies the commit. A provider that reports `granted` must
|
|
2298
|
+
therefore preserve the request across reconnects and must honour a later commit or cancel. This
|
|
2299
|
+
durable promise prevents a provider from failing the commit after another provider has already
|
|
2300
|
+
stopped the agent.
|
|
2301
|
+
|
|
2302
|
+
#### Why the commit phase is bounded and the decision is not
|
|
2303
|
+
|
|
2304
|
+
Waiting for unanimity forever is the one way this algorithm can strand an agent. The commit is
|
|
2305
|
+
durable and cannot be rolled back, so a participant that crashes, has its authentication revoked,
|
|
2306
|
+
or is uninstalled between granting and committing would hold Omni in `committing-break` with no
|
|
2307
|
+
exit: the providers that did commit have already stopped the agent, and the agent is neither
|
|
2308
|
+
working nor on a break.
|
|
2309
|
+
|
|
2310
|
+
Unanimity is required for a reason that survives the bound. It exists so the agent is not stopped
|
|
2311
|
+
on one platform while another keeps routing work to them — and **a provider Omni cannot reach is
|
|
2312
|
+
routing nothing.** Setting it aside therefore costs none of the property it was protecting. Waiting
|
|
2313
|
+
for it costs the agent their break.
|
|
2314
|
+
|
|
2315
|
+
Setting a participant aside is not a rollback and not a cancel. The commit stands, the `requestId`
|
|
2316
|
+
stands, and the obligation stands: Omni re-sends `commitBreak(requestId)` when that provider
|
|
2317
|
+
returns, and until it applies the commit that provider has not stopped. Because the commit is idempotent
|
|
2318
|
+
the answer is `already-committed` if it applied the first one after all, and `committed` if it did
|
|
2319
|
+
not — which is how Omni tells a slow delivery from a lost one, and why that pair exists.
|
|
2320
|
+
|
|
2321
|
+
Reconnection reconciles the rest. A returning provider emits a snapshot before anything else, so
|
|
2322
|
+
Omni sees its break state and re-sends the commit if it is missing; it must not offer work in the
|
|
2323
|
+
meantime, and the commit is what stops it. **A new login is a different case**: the
|
|
2324
|
+
`requestId` belonged to the old `sessionId` and the grant did not survive it, so Omni does not
|
|
2325
|
+
recover that attempt against a fresh session. It makes a new request for that provider alone,
|
|
2326
|
+
against an agent who is already on break elsewhere.
|
|
2327
|
+
|
|
2328
|
+
Omni may tell the agent which platforms the break has not yet reached, as it already does when a
|
|
2329
|
+
break cannot be paired across every provider.
|
|
2330
|
+
|
|
2331
|
+
The decision phase needs no such bound, because nothing has stopped. Work continues throughout
|
|
2332
|
+
`requesting-break`, so a participant that never answers costs the agent a wait rather than their
|
|
2333
|
+
availability, and the existing decision timeout resolves it by cancelling — which is safe precisely
|
|
2334
|
+
because no stop was ever committed.
|
|
2335
|
+
|
|
2336
|
+
If cancel races with a late approval, the provider remains on the cancel path. If commit has already
|
|
2337
|
+
won, cancel returns `omni.break-already-committed`, and Omni resumes commit recovery rather than
|
|
2338
|
+
returning the agent to `working`.
|
|
2339
|
+
|
|
2340
|
+
An imposed break is not rolled back by this algorithm. If one appears during either the request or
|
|
2341
|
+
cancellation, Omni follows the imposed-break rule on the other providers and commits each grant as
|
|
2342
|
+
soon as it reaches `granted`; it does not wait for unanimity because the agent has already
|
|
2343
|
+
stopped elsewhere.
|
|
2344
|
+
|
|
2345
|
+
This is two-phase coordination across vendor systems: the approval phase keeps the agent working;
|
|
2346
|
+
the durable commit decision and idempotent retries provide convergence after partial delivery.
|
|
2347
|
+
|
|
2348
|
+
#### Reporting the break the agent is on
|
|
2349
|
+
|
|
2350
|
+
`BreakState.activeReasonId` is the `BreakReason.id` the current break is on. Omni remembers what it
|
|
2351
|
+
asked for, but only until the session ends — after a reload or reconnect, or where the provider put
|
|
2352
|
+
the agent on the break itself, the provider is the only one who knows.
|
|
2353
|
+
|
|
2354
|
+
Omit it when you cannot say, and when there is no break: reporting a reason alongside
|
|
2355
|
+
`approval: "not-requested"` describes a break that is not happening, and is rejected.
|
|
2356
|
+
|
|
2357
|
+
### `cancelBreak(requestId)`
|
|
2358
|
+
|
|
2359
|
+
Cancels the active pre-commit request identified by `requestId` while its approval is
|
|
2360
|
+
`awaiting-decision` or `granted`. It is safe to retry. Cancellation releases the request but
|
|
2361
|
+
does not restore work because work never stopped. If commit already won, the provider returns
|
|
2362
|
+
`omni.break-already-committed`. The resulting state is reported through `break-state`.
|
|
2363
|
+
|
|
2364
|
+
### `commitBreak(requestId)`
|
|
2365
|
+
|
|
2366
|
+
Commits the matching `granted` request. It is safe to retry and, once the provider has
|
|
2367
|
+
reported `granted`, cannot fail for a business reason. On commit the provider stops
|
|
2368
|
+
offering new work and reports `starting-after-task` while existing work finishes, or `in-effect` when
|
|
2369
|
+
the break is in effect.
|
|
2370
|
+
|
|
2371
|
+
### `endBreak()`
|
|
2372
|
+
|
|
2373
|
+
Tells a provider that an agent already on a break wants to become available again. The provider
|
|
2374
|
+
reports the resulting state through `break-state` and provider status events.
|
|
2375
|
+
|
|
2376
|
+
It ends the break, which is the thing that started. Nothing about the connection was ever paused,
|
|
2377
|
+
so there is nothing on it to resume.
|
|
2378
|
+
|
|
2379
|
+
## Team leads
|
|
2380
|
+
|
|
2381
|
+
A lead who also takes calls sees their team on the idle dashboard. `Snapshot.team` carries a
|
|
2382
|
+
`TeamRoster`, replaced whole by `team-updated`.
|
|
2383
|
+
|
|
2384
|
+
| Field | Contract |
|
|
2385
|
+
| --- | --- |
|
|
2386
|
+
| `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **Its presence is the permission** below. |
|
|
2387
|
+
| `breakControl` | Present when this lead decides their team's breaks, absent when they do not. |
|
|
2388
|
+
|
|
2389
|
+
| `TeamMember` field | Contract |
|
|
2390
|
+
| --- | --- |
|
|
2391
|
+
| `id` | Required `UserId`. A task carries no names and neither does a roster: Omni resolves what to display with `describeUsers()`. |
|
|
2392
|
+
| `availability` | Required. What the member is doing now. |
|
|
2393
|
+
| `since` | Optional. When the current `availability` began — not when they signed in, and not when the roster was read. |
|
|
2394
|
+
| `break` | Present only while the member has an outstanding break request. See **A member waiting for a break**. |
|
|
2395
|
+
|
|
2396
|
+
Each availability value means one thing:
|
|
2397
|
+
|
|
2398
|
+
| Value | Meaning |
|
|
2399
|
+
| --- | --- |
|
|
2400
|
+
| `ready` | Signed in, able to take work, none assigned. |
|
|
2401
|
+
| `on-task` | Handling at least one task. It says nothing about how many, and nothing about whether more will fit. |
|
|
2402
|
+
| `on-break` | Stopped and not taking work, whether they asked or somebody stopped them. The reason lives on their own `BreakState`, not here. |
|
|
2403
|
+
| `signed-out` | Known to this team but not signed in to this provider. |
|
|
2404
|
+
|
|
2405
|
+
**Always publish the complete roster, never a change to it.** Team presence typically reaches an
|
|
2406
|
+
adapter over a best-effort channel with no ordering and no delivery guarantee, so a stream of deltas
|
|
2407
|
+
cannot be trusted to reconstruct the truth. The adapter reconciles against its own authoritative
|
|
2408
|
+
read and publishes the result.
|
|
2409
|
+
|
|
2410
|
+
**Omit `since` rather than inventing one.** Omni renders it as a duration, so a timestamp
|
|
2411
|
+
synthesised from the adapter's own clock at seed time reads as "on task for 0 seconds" for
|
|
2412
|
+
everybody — worse than showing nothing, because it looks like data. Send it only when the provider
|
|
2413
|
+
knows when the state actually began. It times the current `availability`, so it moves every time
|
|
2414
|
+
that value does.
|
|
2415
|
+
|
|
2416
|
+
**Its presence is the permission.** Publish a roster only to an agent entitled to one. Omni never
|
|
2417
|
+
decides who leads a team: no roster means nothing is shown, which is the correct rendering for an
|
|
2418
|
+
agent who leads nobody. The same rule governs `TeamRoster.breakControl` — present when this lead
|
|
2419
|
+
decides their team's breaks, absent when they do not.
|
|
2420
|
+
|
|
2421
|
+
### Lead commands
|
|
2422
|
+
|
|
2423
|
+
One method, `executeTeamBreak`, taking a discriminated command exactly as `execute` takes a
|
|
2424
|
+
`TaskCommand`:
|
|
2425
|
+
|
|
2426
|
+
| Command | Effect |
|
|
2427
|
+
| --- | --- |
|
|
2428
|
+
| `{ type: "decide", memberId: UserId, 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`. |
|
|
2429
|
+
| `{ type: "policy", policy }` | `ask`, `auto-approve`, or `suspended`. |
|
|
2430
|
+
| `{ type: "place", memberId: UserId, reason? }` | Puts a member on a break they did not ask for. |
|
|
2431
|
+
| `{ type: "release", memberId: UserId }` | Ends an imposed break on that member, whoever placed it. |
|
|
2432
|
+
|
|
2433
|
+
`memberId` is this provider's own identifier for the member, as published on its roster. It is
|
|
2434
|
+
never an identifier from another provider, and Omni does not translate between them; names come
|
|
2435
|
+
from `describeUsers()`.
|
|
2436
|
+
|
|
2437
|
+
`suspended` means requests are **rejected outright** rather than left pending — nobody is coming to
|
|
2438
|
+
approve them. A provider that suspends breaks must also publish `accepting: false` to the team's
|
|
2439
|
+
agents so they see it before asking. A `place` must likewise reach that member as an `imposed` break
|
|
2440
|
+
on their own `BreakState`, or they are stopped from working with no way to see why.
|
|
2441
|
+
|
|
2442
|
+
What happens when no lead is online — auto-approving, for instance — is the provider's decision and is
|
|
2443
|
+
never expressed here.
|
|
2444
|
+
|
|
2445
|
+
### A member waiting for a break
|
|
2446
|
+
|
|
2447
|
+
A member who has asked for a break **keeps working** until Omni commits it, so asking is not an
|
|
2448
|
+
availability of its own — it rides alongside one on `break`. That a request exists says nothing
|
|
2449
|
+
about whether anybody has to act on it, and the difference is a lead's entire action list:
|
|
2450
|
+
|
|
2451
|
+
| `break` | Means |
|
|
2452
|
+
| --- | --- |
|
|
2453
|
+
| `awaiting-decision` | Somebody has to decide. This is the lead's queue. |
|
|
2454
|
+
| `granted` | Decided yes, but Omni has not told the provider to stop yet. Work continues and nobody needs to decide. |
|
|
2455
|
+
| `starting-after-task` | Already granted; it begins when their current task ends. Nobody needs to act. |
|
|
2456
|
+
|
|
2457
|
+
Those three are the only values that appear here. `not-requested` is absence — omit `break`
|
|
2458
|
+
instead. `in-effect` is `availability: "on-break"`, and a denial transitions to `not-requested`,
|
|
2459
|
+
so neither survives to be reported. It is otherwise the same `BreakApproval` the member's own
|
|
2460
|
+
break state uses, rather than a parallel vocabulary for the lead's view, so the two cannot drift
|
|
2461
|
+
apart.
|
|
2462
|
+
|
|
2463
|
+
Omni offers Approve and Deny only while a member is `awaiting-decision`, shows `granted` as agreed
|
|
2464
|
+
but not started, and shows `starting-after-task` as settled.
|
|
2465
|
+
|
|
2466
|
+
**Live status, not a record.** The provider derives it from what is true now — not stored, not
|
|
2467
|
+
historical, carrying no decision made earlier. Like the roster it belongs to, it is published
|
|
2468
|
+
whole and replaced whole, and a provider that cannot say omits it.
|
|
2469
|
+
|
|
2470
|
+
**An agent is not waiting on one person.** Authority is held by several, everyone who holds it
|
|
2471
|
+
sees the request on their own console, and **any one of them settles it**. Omni offers the
|
|
2472
|
+
decision to whoever is reading a roster that carries `breakControl` — which is how the provider
|
|
2473
|
+
already says who may decide — and does not try to work out whose turn it is.
|
|
2474
|
+
|
|
2475
|
+
A request needing *more than one* approval is not something this contract describes. There is
|
|
2476
|
+
no partial state to report and no progress to display: a request is either still owed a
|
|
2477
|
+
decision or it is not.
|
|
2478
|
+
|
|
2479
|
+
## Real-time media
|
|
2480
|
+
|
|
2481
|
+
Every voice provider has media. There is nothing to announce, no capability to declare and no
|
|
2482
|
+
endpoint to choose: `channel: "voice"` says audio exists, and **Omni is the device it lands on**.
|
|
2483
|
+
|
|
2484
|
+
Other endpoints exist in a deployment — desk phones, the provider's own hardware, whatever the
|
|
2485
|
+
platform already rings — and none of them is the agent's. Omni does not enumerate them, map the
|
|
2486
|
+
agent onto one, or follow a change made to one. There is no device list, no device selection and
|
|
2487
|
+
no device on the snapshot, because there is no choice to record: audio for this agent arrives in
|
|
2488
|
+
Omni, and Omni registers the endpoint for it.
|
|
2489
|
+
|
|
2490
|
+
That removes a whole class of state the provider would otherwise own and Omni would have to track,
|
|
2491
|
+
and it removes the branch that came with it: no command has to ask where the audio went before
|
|
2492
|
+
deciding who performs it.
|
|
2493
|
+
|
|
2494
|
+
### Capacity around setup
|
|
2495
|
+
|
|
2496
|
+
Connecting is not the same as being able to take a call. A provider that treats a live connection
|
|
2497
|
+
as reachability opens a window where it believes the agent is available and Omni cannot yet carry
|
|
2498
|
+
audio — its endpoint unregistered, the microphone permission not yet granted.
|
|
2499
|
+
|
|
2500
|
+
Nothing closes that window, because nothing opens it: **Omni states no capacity until the agent is
|
|
2501
|
+
set up**, and **Work is pulled, never pushed** makes an allocation with none stated a violation. A
|
|
2502
|
+
voice connection therefore carries no capacity from the moment it opens until its media is ready,
|
|
2503
|
+
and the provider allocates nothing in between.
|
|
2504
|
+
|
|
2505
|
+
Capacity follows **automatically** once setup completes; the agent does not press anything to
|
|
2506
|
+
become available.
|
|
2507
|
+
|
|
2508
|
+
| Situation | What Omni sends |
|
|
2509
|
+
| --- | --- |
|
|
2510
|
+
| Connected, media not ready | Nothing. No capacity has been stated, so nothing may be allocated. |
|
|
2511
|
+
| Set up and idle | `setCapacity({ count: n })` |
|
|
2512
|
+
| A task starts or ends | Nothing. The provider counts its own against the ceiling. |
|
|
2513
|
+
| The agent's provisioned capacity changes | `setCapacity({ count: n })` |
|
|
2514
|
+
| Agent asks for a break | `requestBreak`. Capacity is unchanged and work continues. |
|
|
2515
|
+
| Omni commits a break | `commitBreak`. The break stops allocation, not the ceiling. |
|
|
2516
|
+
| Agent returns from break | `endBreak` |
|
|
2517
|
+
|
|
2518
|
+
**Stopping is a break, not a capacity of zero.** Capacity says how much this agent can carry at
|
|
2519
|
+
once; a break says they are not working. Collapsing the two would leave a provider unable to tell
|
|
2520
|
+
an agent at their limit from an agent who has gone to lunch, and only one of those needs a reason,
|
|
2521
|
+
a decision and a return.
|
|
2522
|
+
|
|
2523
|
+
### Opening the audio
|
|
2524
|
+
|
|
2525
|
+
`openMedia` hands Omni the remote audio for one task. Every voice adapter implements it, because
|
|
2526
|
+
every voice task's audio lands in Omni:
|
|
2527
|
+
|
|
2528
|
+
```ts
|
|
2529
|
+
openMedia({ taskId, localAudio }): Promise<OpenMediaResult>
|
|
2530
|
+
// { status: "opened", session } | { status: "unavailable", failure }
|
|
2531
|
+
```
|
|
2532
|
+
|
|
2533
|
+
The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor SDK, plain
|
|
2534
|
+
WebRTC — and **none of that appears in this contract**. Registration, signalling, credential
|
|
2535
|
+
renewal and reconnect are the adapter's, exactly as its authentication and transport already
|
|
2536
|
+
are. Omni owns what belongs to the host: the microphone, the output element, mute, and when a
|
|
2537
|
+
session ends.
|
|
2538
|
+
|
|
2539
|
+
| Member | Contract |
|
|
2540
|
+
| --- | --- |
|
|
2541
|
+
| `remoteAudio` | `MediaStream` Omni plays. |
|
|
2542
|
+
| `setMuted(muted)` | Mutes the agent's microphone on this session. |
|
|
2543
|
+
| `close()` | Releases the session. Omni calls it when the task ends. |
|
|
2544
|
+
|
|
2545
|
+
`localAudio` is the agent's microphone, captured once by Omni as the voice connection opens so the
|
|
2546
|
+
permission prompt lands while the agent is signing in rather than over a ringing contact. A
|
|
2547
|
+
provider that bridges audio without a host-side input may ignore it.
|
|
2548
|
+
|
|
2549
|
+
**A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
|
|
2550
|
+
leg for a whole shift may return the same session for every task and release the underlying
|
|
2551
|
+
path only when the connection closes. A platform placing a call per contact returns a new one
|
|
2552
|
+
each time. Omni asks when it needs audio and closes when it is done; how that maps to the
|
|
2553
|
+
platform is the adapter's business.
|
|
2554
|
+
|
|
2555
|
+
## Task commands
|
|
2556
|
+
|
|
2557
|
+
Command names follow the channel's operational vocabulary, and each channel's command is a union
|
|
2558
|
+
discriminated by `type` — the same discriminant `executeTeamBreak` and `custom` already use. The
|
|
2559
|
+
unions are declared under **Shapes**.
|
|
2560
|
+
|
|
2561
|
+
`taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it, with
|
|
2562
|
+
`commandId`.
|
|
2563
|
+
|
|
2564
|
+
**A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot be
|
|
2565
|
+
idempotent, and **Commands are idempotent** admits no exception: a retried flip turns something on
|
|
2566
|
+
and then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
|
|
2567
|
+
boolean. `hold` and `resume`, `pause` and `resume` need no flag, being pairs rather than toggles.
|
|
2568
|
+
|
|
2569
|
+
**`complete` sends a disposition only where one was published.** `disposition` is a
|
|
2570
|
+
`DispositionCode.id` from the task's own `dispositions` capability, and `notes` obeys that
|
|
2571
|
+
capability's `notes` setting. A task publishing no codes still receives `complete`, with neither.
|
|
2572
|
+
|
|
2573
|
+
### Where a command executes
|
|
2574
|
+
|
|
2575
|
+
Every command reaches the provider through `execute`, with no branch at the call site. What differs
|
|
2576
|
+
is what the provider is being asked for: to **perform** the command, or to **record** that Omni
|
|
2577
|
+
already did.
|
|
2578
|
+
|
|
2579
|
+
| Command | The provider's part |
|
|
2580
|
+
| --- | --- |
|
|
2581
|
+
| `mute` | **Record it, and keep the history.** The microphone is the host's, so Omni has already stopped the audio through `VoiceMediaSession.setMuted()` — no adapter can do that on the host's behalf. The command still arrives because the provider owns the task's record: it holds the current state for supervision, and each change as a `muted` handling step, exactly as it does for `held`. A platform that never hears about it shows a supervisor an agent who sounds absent for no reason, and reports a call with a silence it cannot explain. |
|
|
2582
|
+
| Every other command | **Perform it.** `hold`, `transfer`, `conference`, `recording`, `disconnect` and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them. |
|
|
2583
|
+
|
|
2584
|
+
**A failed `mute` does not unmute the agent.** The agent asked, Omni holds the microphone, and it
|
|
2585
|
+
is already done; a failure means only that the provider did not record it, leaving its view stale
|
|
2586
|
+
until the next snapshot. That is the safe direction to fail in, and it is the one place where
|
|
2587
|
+
`failed` does not mean *nothing happened* — everywhere else it does.
|
|
2588
|
+
|
|
2589
|
+
`mute` carries `muted` rather than flipping, so a retry and a stale view converge on the same
|
|
2590
|
+
state instead of cancelling each other — see **Task commands**.
|
|
2591
|
+
|
|
2592
|
+
### Which commands need a capability
|
|
2593
|
+
|
|
2594
|
+
**Presence is the permission** gates the controls a provider chooses to offer. Four commands are
|
|
2595
|
+
not among them, because every task has them; each is authorized by a different field the provider
|
|
2596
|
+
declared:
|
|
2597
|
+
|
|
2598
|
+
| Command | What makes it available |
|
|
2599
|
+
| --- | --- |
|
|
2600
|
+
| `answer`, `accept` | Nothing. A task that was offered can be accepted, or offering it meant nothing. |
|
|
2601
|
+
| `decline`, `reject` | The channel's decline or reject capability, **and** Omni provisioning permitting rejection. |
|
|
2602
|
+
| `start-call` | The `preparing` phase. It starts the contact a preview gave the agent time to read, so the phase is the gate and there is no capability. |
|
|
2603
|
+
| `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
|
|
2604
|
+
| Everything else | Its own named capability. |
|
|
2605
|
+
|
|
2606
|
+
Declining or rejecting a pending offer ends it without accepting or completing it. The provider
|
|
2607
|
+
confirms the end with `task-ended` and a `cancelled` outcome.
|
|
2608
|
+
|
|
2609
|
+
### `execute(request)`
|
|
2610
|
+
|
|
2611
|
+
Applies a `TaskCommandRequest` to one provider-local task.
|
|
2612
|
+
|
|
2613
|
+
- `commandId` is globally unique, generated by Omni, and remains stable across retries.
|
|
2614
|
+
- Omni serializes commands per task, never sends one command ID concurrently, records pending and
|
|
2615
|
+
completed commands, retries only after an uncertain result, and stops retrying when `task-ended`
|
|
2616
|
+
arrives.
|
|
2617
|
+
- On an uncertain retry while the task remains active, the provider must apply each
|
|
2618
|
+
`(taskId, commandId)` at most once.
|
|
2619
|
+
- A repeated successfully applied command returns `already-applied` without repeating side
|
|
2620
|
+
effects.
|
|
2621
|
+
- `applied` confirms the command side effect completed.
|
|
2622
|
+
- `failed` contains a typed `ProtocolFailure` and confirms the command was **not** applied. A
|
|
2623
|
+
command either took effect or it did not; a provider that will not and a provider that cannot
|
|
2624
|
+
report the same shape, and `code` says which.
|
|
2625
|
+
- **A settled result is a fact; an unsettled promise is not.** Transport uncertainty may reject the
|
|
2626
|
+
promise with no result at all, and that means *unknown*, not *failed*. Omni retries with the same
|
|
2627
|
+
command ID, which is why idempotency is required — and why `failed` must never be returned for
|
|
2628
|
+
something the provider is unsure of.
|
|
2629
|
+
|
|
2630
|
+
### `ProtocolFailure`
|
|
2631
|
+
|
|
2632
|
+
| Field | Contract |
|
|
2633
|
+
| --- | --- |
|
|
2634
|
+
| `code` | Required stable machine-readable value. See the reserved codes below. |
|
|
2635
|
+
| `message` | Required, and safe for logs or agent display. |
|
|
2636
|
+
| `retryable` | Whether repeating the action can succeed at all. |
|
|
2637
|
+
| `retryAfterMs` | Optional minimum suggested delay before a retry. A suggestion, not a guarantee. |
|
|
2638
|
+
|
|
2639
|
+
The `omni.` prefix is **reserved**. Adapters must not invent codes under it; every other value is
|
|
2640
|
+
provider-private and Omni treats it as opaque. Using a reserved code where it applies lets Omni
|
|
2641
|
+
react rather than only display the message:
|
|
2642
|
+
|
|
2643
|
+
| Code | Meaning |
|
|
2644
|
+
| --- | --- |
|
|
2645
|
+
| `omni.not-authenticated` | The provider session is no longer usable. Omni surfaces reauthentication. |
|
|
2646
|
+
| `omni.capability-not-enabled` | The action targets a capability this task or manifest did not declare. |
|
|
2647
|
+
| `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
|
|
2648
|
+
| `omni.destination-not-permitted` | The dial or transfer destination violates the provider's policy. |
|
|
2649
|
+
| `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
|
|
2650
|
+
| `omni.unavailable` | The provider is temporarily unable to serve the action. |
|
|
2651
|
+
| `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
|
|
2652
|
+
|
|
2653
|
+
They are published as `OMNI_FAILURE_CODES`.
|
|
2654
|
+
|
|
2655
|
+
## Event delivery
|
|
2656
|
+
|
|
2657
|
+
### `ProviderEventEnvelope`
|
|
2658
|
+
|
|
2659
|
+
| Field | Contract |
|
|
2660
|
+
| --- | --- |
|
|
2661
|
+
| `id` | Required identifier for this event, unique within the login. Omni does not act on it; it exists so a host log line and an adapter log line can be matched when something has to be traced. |
|
|
2662
|
+
| `sessionId` | Login session that produced the event. Omni rejects any other value, which only reaches it if an adapter kept an old connection emitting after a re-login. |
|
|
2663
|
+
| `occurredAt` | Valid RFC-3339 timestamp with an explicit timezone, representing provider observation time. |
|
|
2664
|
+
| `event` | Typed `ProviderEvent` payload. |
|
|
2665
|
+
|
|
2666
|
+
#### Provider instants are read against a provider clock
|
|
2667
|
+
|
|
2668
|
+
Every deadline in this contract is a provider instant that Omni counts down: `allocationExpiresAt`,
|
|
2669
|
+
`preparationEndsAt`, and the wrap deadline of `task-media-ended` plus `completionAllowance`.
|
|
2670
|
+
Comparing those against the host clock is wrong by whatever the two machines disagree by, and the
|
|
2671
|
+
damaging direction is early — **Accept** withdrawn from an offer still ringing, a wrap timer
|
|
2672
|
+
expiring before the agent has finished.
|
|
2673
|
+
|
|
2674
|
+
`occurredAt` is what fixes it. Omni notes the host time at which each envelope arrives, keeps the
|
|
2675
|
+
running offset against the `occurredAt` inside it, and translates provider instants through that
|
|
2676
|
+
offset before counting down. What remains is network delay, which biases every deadline later —
|
|
2677
|
+
the direction that costs a second rather than an action.
|
|
2678
|
+
|
|
2679
|
+
**Report `seconds`; never expect Omni to derive it** is the same hazard from the provider's side:
|
|
2680
|
+
neither party recomputes a duration across a clock it does not own.
|
|
2681
|
+
|
|
2682
|
+
#### Nothing is lost until the connection drops
|
|
2683
|
+
|
|
2684
|
+
There is no sequence number and no gap to detect. The transport delivers in order and does not
|
|
2685
|
+
silently lose a message, so while the connection is up Omni has seen everything the provider sent.
|
|
2686
|
+
|
|
2687
|
+
Loss has exactly one shape: the connection went away. The adapter reports `connecting` or `error`,
|
|
2688
|
+
reconnects, and emits a `snapshot` event carrying complete state. That snapshot is the repair —
|
|
2689
|
+
whatever was missed while the connection was down is in it, and Omni replaces its provider view
|
|
2690
|
+
rather than reasoning about what it did not receive.
|
|
2691
|
+
|
|
2692
|
+
A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
|
|
2693
|
+
everything emitted when it was requested. Omni discards events buffered during the read on that
|
|
2694
|
+
promise; an adapter that serves a stale snapshot and then lets an earlier event through will have
|
|
2695
|
+
Omni apply state the snapshot already superseded.
|
|
2696
|
+
|
|
2697
|
+
#### Liveness
|
|
2698
|
+
|
|
2699
|
+
`provider-status` is the only signal Omni has that a transport died. An adapter must emit
|
|
2700
|
+
`provider-status` with `connecting` or `error` as soon as it loses its transport, rather than
|
|
2701
|
+
leaving a stale `active` in place while it retries internally; Omni cannot distinguish a quiet
|
|
2702
|
+
healthy provider from a dead one.
|
|
2703
|
+
|
|
2704
|
+
#### Requesting a resync
|
|
2705
|
+
|
|
2706
|
+
Omni may call `snapshot()` at any time, not only at connect, and must do so on any loss of
|
|
2707
|
+
confidence in its provider state. `reason: "provider-requested"` covers the
|
|
2708
|
+
opposite direction — the provider asking Omni to reconcile — and neither replaces the other.
|
|
2709
|
+
|
|
2710
|
+
### `snapshot`
|
|
2711
|
+
|
|
2712
|
+
Carries a complete `Snapshot` after reconnect or when the provider explicitly requests
|
|
2713
|
+
reconciliation. `reason` is `reconnected` or `provider-requested`. Omni replaces the provider's
|
|
2714
|
+
current status, session capabilities, break state, tasks, contacts, scheduled activities and team
|
|
2715
|
+
roster with this snapshot. A roster absent from the snapshot withdraws one previously published,
|
|
2716
|
+
exactly as it would withdraw a capability.
|
|
2717
|
+
|
|
2718
|
+
### `provider-status`
|
|
2719
|
+
|
|
2720
|
+
Updates `ConnectionStatus`, and carries an optional `message` that may explain an error but must be
|
|
2721
|
+
safe for the agent to see.
|
|
2722
|
+
|
|
2723
|
+
| Value | Contract |
|
|
2724
|
+
| --- | --- |
|
|
2725
|
+
| `connecting` | No usable transport right now, and the adapter expects to recover on its own. Nobody needs to act. Startup and every reconnect pass through this value. |
|
|
2726
|
+
| `active` | The transport is up and the provider is serving this session. It is the only value under which work arrives. |
|
|
2727
|
+
| `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message`. It is not terminal — an adapter that recovers reports `connecting` and then `active`. |
|
|
2728
|
+
|
|
2729
|
+
**Status is about the transport, nothing else.** It does not say whether the agent is available,
|
|
2730
|
+
whether they are on a break, or how much work they can take: capacity travels on `setCapacity`,
|
|
2731
|
+
availability on `BreakState`. Nor does it carry authentication — a session that expired reports
|
|
2732
|
+
`expired` on `AuthenticationState` and fails actions with `omni.not-authenticated`, while
|
|
2733
|
+
the transport underneath may be perfectly `active`. Provider login identity likewise belongs to
|
|
2734
|
+
authentication state, not here.
|
|
2735
|
+
|
|
2736
|
+
**Only `active` means work can arrive.** Omni stops expecting allocations in any other value, so an
|
|
2737
|
+
adapter that leaves a stale `active` in place is telling Omni to keep waiting for work that cannot
|
|
2738
|
+
come — see **Liveness**.
|
|
2739
|
+
|
|
2740
|
+
### `break-state`
|
|
2741
|
+
|
|
2742
|
+
Replaces this provider's complete `break` object. Its `approval` uses the canonical
|
|
2743
|
+
`not-requested`, `awaiting-decision`, `granted`, `starting-after-task` and
|
|
2744
|
+
`in-effect` states defined under Breaks; the event also carries the corresponding accepting state,
|
|
2745
|
+
reasons, retry details, and any imposed break.
|
|
2746
|
+
|
|
2747
|
+
For a multi-provider attempt, "every provider" is the participant set frozen when the attempt
|
|
2748
|
+
entered `requesting-break`. Omni commits only after every participant reports `granted` —
|
|
2749
|
+
that one is unconditional, because nothing has stopped yet and waiting costs only time. It enters
|
|
2750
|
+
`on-break` once every participant it can still reach reports `in-effect`, and no later than the
|
|
2751
|
+
commit bound: past that a participant is set aside as unreconciled rather than holding a break that
|
|
2752
|
+
has already begun elsewhere. Otherwise it follows the two-phase rules under **Coordinating a
|
|
2753
|
+
multi-provider break**.
|
|
2754
|
+
|
|
2755
|
+
### `task-offered`
|
|
2756
|
+
|
|
2757
|
+
Offers a task to Omni without a separate offer acknowledgement. An offer does not accept
|
|
2758
|
+
the task: when its phase is `pending`, Omni applies `autoAcceptTasks` and the event's
|
|
2759
|
+
`acceptanceMode`. `task-offered` must not introduce a task as `in-progress`; only a reconnect or
|
|
2760
|
+
resync snapshot may report work already in progress. The provider should include the task in later
|
|
2761
|
+
snapshots until it ends.
|
|
2762
|
+
|
|
2763
|
+
### `task-updated`
|
|
2764
|
+
|
|
2765
|
+
Replaces the current representation of one provider-local task. It is a full task value, not a
|
|
2766
|
+
partial patch.
|
|
2767
|
+
|
|
2768
|
+
### `task-media-ended`
|
|
2769
|
+
|
|
2770
|
+
Signals that a task's real-time media ended. For voice and similar channels, this starts the fixed
|
|
2771
|
+
completion timer. It does not remove the task.
|
|
2772
|
+
|
|
2773
|
+
### `task-ended`
|
|
2774
|
+
|
|
2775
|
+
Every outcome ends the task for this agent. On `task-ended`, Omni:
|
|
2776
|
+
|
|
2777
|
+
- removes the task from its current provider view;
|
|
2778
|
+
- clears the task workspace when it is selected;
|
|
2779
|
+
- stops task timers and media;
|
|
2780
|
+
- releases task-scoped resources; and
|
|
2781
|
+
- selects another task or returns to the idle workspace.
|
|
2782
|
+
|
|
2783
|
+
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
|
|
2784
|
+
The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
|
|
2785
|
+
snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
|
|
2786
|
+
same envelope ID is harmless.
|
|
2787
|
+
|
|
2788
|
+
### `announcement`
|
|
2789
|
+
|
|
2790
|
+
Publishes an agent-facing message. `text` is always required and is the accessible fallback.
|
|
2791
|
+
Optional HTML is sanitized by Omni. `announcedAt` and optional `expiresAt` are RFC-3339 times with
|
|
2792
|
+
explicit timezones.
|
|
2793
|
+
|
|
2794
|
+
### `provider-summary`
|
|
2795
|
+
|
|
2796
|
+
Publishes the provider's current dashboard contribution. Omni combines only the latest summary from
|
|
2797
|
+
each connected provider.
|
|
2798
|
+
|
|
2799
|
+
| Field | Contract |
|
|
2800
|
+
| --- | --- |
|
|
2801
|
+
| `title` | Required agent-facing heading for this provider's contribution. |
|
|
2802
|
+
| `subtitle` | Optional second line. |
|
|
2803
|
+
| `waitingCount` | Required non-negative count of work waiting at this provider. `0` says the queue is empty; omit the summary entirely rather than guessing. |
|
|
2804
|
+
| `updatedAt` | Required time the provider observed these figures, not the time it sent them. |
|
|
2805
|
+
| `metrics` | Optional `SummaryMetric` entries the provider chooses to display. `id` values are stable and unique within that provider's summary. |
|
|
2806
|
+
|
|
2807
|
+
### `team-updated`
|
|
2808
|
+
|
|
2809
|
+
Replaces this provider's complete `TeamRoster`. It is emitted only for an agent the provider
|
|
2810
|
+
publishes a roster to, and it carries the whole team every time — never a change to it, for the
|
|
2811
|
+
reason set out under **Team leads**. Omitting the roster on a later snapshot withdraws it.
|
|
2812
|
+
|
|
2813
|
+
### `contacts-updated`
|
|
2814
|
+
|
|
2815
|
+
Replaces this provider's complete contact contribution. It is emitted only when the manifest declares
|
|
2816
|
+
the `contacts` idle capability.
|
|
2817
|
+
|
|
2818
|
+
### `calendar-updated`
|
|
2819
|
+
|
|
2820
|
+
Replaces this provider's complete scheduled-activity contribution. It is emitted only when the manifest
|
|
2821
|
+
declares the `calendar` idle capability.
|
|
2822
|
+
|
|
2823
|
+
## Utilities
|
|
2824
|
+
|
|
2825
|
+
### `taskKey(providerId, taskId)`
|
|
2826
|
+
|
|
2827
|
+
Returns a collision-safe global task key by encoding and joining the provider-local identifiers.
|
|
2828
|
+
Use this key in Omni state; never assume task IDs are unique across providers.
|
|
2829
|
+
|
|
2830
|
+
### `userKey(providerId, userId)`
|
|
2831
|
+
|
|
2832
|
+
The same treatment for a `UserId`, and needed for the same reason: user identifiers are
|
|
2833
|
+
issued by each provider independently, so two providers will eventually issue the same string for
|
|
2834
|
+
different people. Encode and join before storing or comparing.
|
|
2835
|
+
|
|
2836
|
+
Use it for every `UserId` — `handlingHistory[].by`, roster members, `memberId` on a
|
|
2837
|
+
lead command, `ImposedBreak.by`. A bare one is only ever compared against another from the **same** provider; anything
|
|
2838
|
+
wider goes through this key.
|
|
2839
|
+
|
|
2840
|
+
## Runtime validation
|
|
2841
|
+
|
|
2842
|
+
Structural rules in this document are executable through the runtime validators Omni applies to
|
|
2843
|
+
adapter output. Behavioral rules are exercised through deterministic conformance scenarios. The
|
|
2844
|
+
same exported checks are used by Omni and adapter tests so their interpretations do not drift.
|
|
2845
|
+
|
|
2846
|
+
| Function | Validates |
|
|
2847
|
+
| --- | --- |
|
|
2848
|
+
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
2849
|
+
| `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
|
|
2850
|
+
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including capability gating. |
|
|
2851
|
+
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
2852
|
+
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
2853
|
+
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
2854
|
+
| `validateAuthenticationState(state)` | The identity each state must carry, and the expiry that only `authenticated` may. |
|
|
2855
|
+
|
|
2856
|
+
Each returns `ProtocolViolation[]` rather than throwing, so a caller can report every problem at
|
|
2857
|
+
once. A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was
|
|
2858
|
+
found at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
2859
|
+
|
|
2860
|
+
`assertNoViolations(violations)` throws `ProtocolConformanceError` — which carries the full
|
|
2861
|
+
`violations` array — when the list is non-empty.
|
|
2862
|
+
|
|
2863
|
+
**Omni must validate at runtime, not only in tests.** An adapter is loaded from a separate package
|
|
2864
|
+
and may be compiled against a different protocol version, so its output is untrusted input.
|
|
2865
|
+
Validating a snapshot before it replaces provider state is what stops a malformed task from
|
|
2866
|
+
reaching the workspace.
|
|
2867
|
+
|
|
2868
|
+
## Conformance helpers
|
|
2869
|
+
|
|
2870
|
+
### `exerciseAdapter(adapter, context, options?)`
|
|
2871
|
+
|
|
2872
|
+
Adapter conformance exercise from `@xema/omni-protocol/testing`.
|
|
2873
|
+
|
|
2874
|
+
It validates the manifest, opens an authenticated session, connects, checks required capability
|
|
2875
|
+
methods, subscribes, validates the snapshot and every delivered event, states a capacity, then
|
|
2876
|
+
unsubscribes and disconnects. Provider packages should run it with a deterministic
|
|
2877
|
+
test transport and authentication state.
|
|
2878
|
+
|
|
2879
|
+
By default it throws `ProtocolConformanceError` listing every violation. Pass
|
|
2880
|
+
`{ collectOnly: true }` to receive them on the result instead:
|
|
2881
|
+
|
|
2882
|
+
```ts
|
|
2883
|
+
const result = await exerciseAdapter(adapter, context, { collectOnly: true });
|
|
2884
|
+
expect(result.violations).toEqual([]);
|
|
2885
|
+
expect(result.disconnectWasClean).toBe(true);
|
|
2886
|
+
```
|
|
2887
|
+
|
|
2888
|
+
Two properties of the harness matter to adapter authors:
|
|
2889
|
+
|
|
2890
|
+
- **Violations are collected, never thrown from inside the subscribe listener.** Throwing there
|
|
2891
|
+
would unwind through the provider's own dispatch for a synchronous emitter, and would be
|
|
2892
|
+
swallowed as an unhandled rejection for an asynchronous one — letting a non-conforming async
|
|
2893
|
+
adapter pass.
|
|
2894
|
+
- **Resources are released even when the adapter fails.** `unsubscribe()`, `disconnect()`, and
|
|
2895
|
+
`close()` run in a `finally` block, and a throw from any of them is reported as
|
|
2896
|
+
`disconnectWasClean: false` rather than being hidden.
|
|
2897
|
+
|
|
2898
|
+
### `assertCommandIdempotency(connection, request)`
|
|
2899
|
+
|
|
2900
|
+
Issues the same command twice and verifies that the first call applies (or was already applied)
|
|
2901
|
+
and the retry returns `already-applied`. Use a deterministic test task because this helper invokes
|
|
2902
|
+
the adapter command method twice.
|
|
2903
|
+
|
|
2904
|
+
### Contract scenarios
|
|
2905
|
+
|
|
2906
|
+
The testing entry point also exports deterministic, reusable checks for lifecycle behavior that
|
|
2907
|
+
cannot be established from TypeScript structure alone.
|
|
2908
|
+
|
|
2909
|
+
| Helper | Contract checked |
|
|
2910
|
+
| --- | --- |
|
|
2911
|
+
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. |
|
|
2912
|
+
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
2913
|
+
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|
|
2914
|
+
| `assertCommandIdempotency(connection, request)` | Retrying a task command does not repeat its side effect. |
|
|
2915
|
+
| `assertDialIdempotency(connection, request)` | Retrying a dial command does not place another call. |
|
|
2916
|
+
| `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms. |
|
|
2917
|
+
| `assertBrowserIsolationAndReuse(left, right, expected)` | Browser reuse follows only the declared isolation scheme. |
|
|
2918
|
+
| `assertNoBrowserSessionKeyCollisions(scenarios)` | No two distinct scenarios derive the same session key. Feed it adversarial names. |
|
|
2919
|
+
|
|
2920
|
+
Adapters should run the relevant scenarios against deterministic test state before publishing.
|
|
2921
|
+
|
|
2922
|
+
> **Assert both directions.** Each helper above rejects a violating input as well as accepting a
|
|
2923
|
+
> conforming one. A suite that only ever asserts "this conforming case does not throw" passes
|
|
2924
|
+
> unchanged if the helper is gutted, so pair every positive case with the violating twin.
|
|
2925
|
+
|
|
2926
|
+
## A provider does not style the workspace
|
|
2927
|
+
|
|
2928
|
+
The package ships a `design` entry point, and none of it is for adapters. It is host UI
|
|
2929
|
+
extensibility — how a deployment themes Omni — and it is specified with the host, not here, which
|
|
2930
|
+
is why no part of it is declared under **Shapes**.
|
|
2931
|
+
|
|
2932
|
+
What belongs in this contract is the boundary. A provider says what a control **is** through its
|
|
2933
|
+
capabilities and what its work is **called** through `phaseLabels` and `taskTypePresentation`; how
|
|
2934
|
+
any of it is drawn is Omni's. A task cannot select a design language, inject a component, or
|
|
2935
|
+
override the agent's theme and font preferences.
|