@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/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.