@xema/omni-protocol 0.1.45 → 0.1.47

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/dist/index.d.ts CHANGED
@@ -36,9 +36,21 @@ export type DialId = string;
36
36
  export type DurationSeconds = number;
37
37
  /** New channels require a later protocol version. */
38
38
  export type Channel = "voice" | "chat" | "email";
39
+ /**
40
+ * An IANA time zone name, such as `Asia/Kolkata` -- never an offset, which cannot survive a
41
+ * daylight-saving boundary, and a day boundary is exactly where that bites.
42
+ */
43
+ export type TimeZone = string;
39
44
  export interface User {
40
45
  id: UserId;
41
46
  displayName: string;
47
+ /**
48
+ * The zone this person's day is reckoned in, as the provider keeps it: stated by the host at
49
+ * authentication, stored on the agent, and carried on every identity and every described user
50
+ * so a day-scoped figure -- theirs or a colleague's a lead is reading -- is bucketed by the
51
+ * right day. Never absent: a person whose day nobody can name is not on this wire.
52
+ */
53
+ timeZone: TimeZone;
42
54
  }
43
55
  /** Key/value detail on a `Contact` or a `ScheduledActivity`. A task's attributes are typed. */
44
56
  export interface Attribute {
@@ -84,6 +96,14 @@ export interface DialCapability {
84
96
  */
85
97
  export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";
86
98
  export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
99
+ /**
100
+ * How the agent hears the call: on a `softphone`, where the host owns the audio and opens it
101
+ * through `openMedia`, or on a `deskPhone`, a handset the platform rings, where the host opens
102
+ * nothing and only shows the call. A voice manifest lists the phones its platform supports, and
103
+ * the host picks one for the login at authentication.
104
+ */
105
+ export type Phone = "softphone" | "deskPhone";
106
+ export declare const PHONES: readonly ["softphone", "deskPhone"];
87
107
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
88
108
  export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
89
109
  export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
@@ -135,6 +155,11 @@ export interface Manifest<C extends Channel = Channel> {
135
155
  * a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
136
156
  */
137
157
  dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
158
+ /**
159
+ * The phones this platform can put an agent on. Required of every voice manifest, and forbidden
160
+ * off voice, where there is no call to hear. The host picks one of these per login.
161
+ */
162
+ phones?: C extends "voice" ? Phone[] : never;
138
163
  /**
139
164
  * The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
140
165
  * so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
@@ -151,6 +176,17 @@ export interface AuthenticationContext {
151
176
  protocolVersion: number;
152
177
  /** Omni's identity for this login. The same value arrives later as `ConnectContext.loginId`. */
153
178
  loginId: string;
179
+ /**
180
+ * The zone the agent's day is reckoned in, as the host's clock has it, stated before any
181
+ * identity exists so the first `authenticated` state already carries it. The same value arrives
182
+ * later as `ConnectContext.timeZone`.
183
+ */
184
+ timeZone: TimeZone;
185
+ /**
186
+ * How this login hears its calls, chosen by the host from the manifest's `phones`. Required for
187
+ * a voice provider and absent for any other. The same value arrives later as `ConnectContext.phone`.
188
+ */
189
+ phone?: Phone;
154
190
  /** Scoped to this provider's manifest id. */
155
191
  secrets: SecretStore;
156
192
  signal?: AbortSignal;
@@ -366,6 +402,14 @@ export interface ConnectContext {
366
402
  loginId: string;
367
403
  /** Omni-side policy: whether the agent's tasks are accepted without asking them. */
368
404
  autoAcceptTasks?: boolean;
405
+ /**
406
+ * The zone the agent's day is reckoned in, the same value passed as
407
+ * `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the
408
+ * identity; a roaming agent corrects it by signing in.
409
+ */
410
+ timeZone: TimeZone;
411
+ /** The same value passed as `AuthenticationContext.phone`: how this login hears its calls. */
412
+ phone?: Phone;
369
413
  /**
370
414
  * The host's report of the agent's station, to consult before declaring the agent ready to the
371
415
  * platform and whenever it changes. Omni reports; the adapter decides.
@@ -1367,7 +1411,7 @@ export interface Connection<C extends Channel = Channel> {
1367
1411
  executeTeamMonitor?(request: TeamMonitorCommandRequest): Promise<TeamCommandResult>;
1368
1412
  /** Required when the login declares `capabilities.team.policyControl`. */
1369
1413
  executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
1370
- /** Required of every voice adapter: all voice audio lands in Omni. */
1414
+ /** Required of a voice adapter whose manifest lists `softphone`: on one, the call's audio lands in Omni. */
1371
1415
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
1372
1416
  /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
1373
1417
  setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
16
16
  return shared.length === 0 ? undefined : Math.max(...shared);
17
17
  }
18
18
  export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
19
+ export const PHONES = ["softphone", "deskPhone"];
19
20
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
20
21
  export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
21
22
  /** What Omni calls each idle capability. */
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, isTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -255,14 +255,21 @@ export async function exerciseAdapter(adapter, context, options = {}) {
255
255
  // voice connection's host reports its audio and no other does, and the host the adapter
256
256
  // receives is wrapped so the harness can tell whether the adapter ever asked.
257
257
  violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
258
+ // The zone is the host's to state, so a context without one is a host that cannot exist.
259
+ violations.push(...validateTimeZone(context.timeZone, "context.timeZone"));
260
+ // How the agent hears the call decides what the host owns: on a softphone the host has the
261
+ // audio and reports it; on a desk phone, or off voice, there is none for it to report.
262
+ violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
263
+ const softphone = adapter.manifest.channel === "voice" && context.phone === "softphone";
258
264
  const first = context.host.report();
259
265
  violations.push(...validateHostReport(first, "context.host"));
260
266
  const hasAudio = isRecord(first) && first.audio !== undefined;
261
- if (adapter.manifest.channel === "voice" && !hasAudio) {
262
- violations.push({ rule: "context.host.audio.required", path: "context.host.audio", message: "a voice connection's host reports its audio" });
267
+ if (softphone && !hasAudio) {
268
+ violations.push({ rule: "context.host.audio.required", path: "context.host.audio", message: "a softphone login's host reports its audio" });
263
269
  }
264
- if (adapter.manifest.channel !== "voice" && hasAudio) {
265
- violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio", message: `a ${adapter.manifest.channel} connection has no audio for the host to report` });
270
+ if (!softphone && hasAudio) {
271
+ violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio",
272
+ message: adapter.manifest.channel === "voice" ? "a desk-phone login has no audio in the host to report" : `a ${adapter.manifest.channel} connection has no audio for the host to report` });
266
273
  }
267
274
  unsubscribeHost = context.host.subscribe(report => {
268
275
  violations.push(...validateHostReport(report, "context.host"));
@@ -279,9 +286,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
279
286
  // than an `enabled` flag, so its presence is the declaration.
280
287
  if (adapter.manifest.idleCapabilities?.dial !== undefined)
281
288
  requireMethod(live, "dial", "the manifest declares dial");
282
- // Every voice task's audio lands in Omni, so there is no voice adapter that does not open it.
283
- if (adapter.manifest.channel === "voice")
284
- requireMethod(live, "openMedia", "the manifest channel is voice");
289
+ // On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
290
+ if (softphone)
291
+ requireMethod(live, "openMedia", "the login is on a softphone");
285
292
  const eventIds = new Set();
286
293
  unsubscribe = connection.subscribe(envelope => {
287
294
  observeEvent(envelope, seen);
@@ -332,6 +339,16 @@ export async function exerciseAdapter(adapter, context, options = {}) {
332
339
  const capacity = await connection.setCapacity({ count: 1 });
333
340
  const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
334
341
  violations.push(...malformed);
342
+ // The provider republishes the agent's day: once connected, the identity carries the zone the
343
+ // host sent. That proves the round trip, not the store -- an echo passes it -- and the name
344
+ // says only what it tests; storage is proved by a colleague's zone arriving from describeUsers().
345
+ if (isTimeZone(context.timeZone) && current().identity.timeZone !== context.timeZone) {
346
+ violations.push({
347
+ rule: "authentication.identity.timeZone.republished",
348
+ path: "authentication.identity.timeZone",
349
+ message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
350
+ });
351
+ }
335
352
  // A refusal is read only from a result that has the shape of one.
336
353
  if (malformed.length === 0 && capacity.status === "failed") {
337
354
  violations.push({
@@ -9,6 +9,11 @@ export declare function assertNoViolations(violations: readonly ProtocolViolatio
9
9
  /** Every field is optional, so this checks what is present rather than what is missing. */
10
10
  export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
11
11
  export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
12
+ /**
13
+ * The host's choice of phone for a login, held to the manifest: one the platform listed, required
14
+ * on voice, absent elsewhere.
15
+ */
16
+ export declare function validatePhone(value: unknown, manifest: unknown, path?: string): ProtocolViolation[];
12
17
  export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
13
18
  export interface TaskValidationContext {
14
19
  /** The provider's channel, from its manifest. A task must agree with it. */
@@ -67,6 +72,10 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
67
72
  * failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
68
73
  */
69
74
  export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
75
+ /** An IANA zone name the runtime knows: `Asia/Kolkata` is one, an offset or a made-up name is not. */
76
+ export declare function isTimeZone(value: unknown): value is string;
77
+ /** The zone a host sends at connect: required, and an IANA name. */
78
+ export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
70
79
  /** What a login is validated against beyond its own shape. */
71
80
  export interface LoginValidationContext {
72
81
  /** The level ids in force, from the manifest. The defaults when absent. */
@@ -84,6 +84,7 @@ const OFFERABLE_PHASES = membersOf({
84
84
  pending: true, confirmed: true, preview: true,
85
85
  });
86
86
  const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
87
+ const PHONES = membersOf({ softphone: true, deskPhone: true });
87
88
  const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
88
89
  const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
89
90
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
@@ -307,6 +308,45 @@ function validateDialOutcomes(manifest, path, into) {
307
308
  into.require(declared.some((outcome) => EXPLAINED_FAILURES.includes(outcome)), "manifest.dialOutcomes.unexplained.alone", path, "unexplained says the switch gave no cause, which is a claim only a provider that reports causes can make: declare at least one of busy, no-answer, unreachable, rejected, cancelled beside it");
308
309
  }
309
310
  }
311
+ /**
312
+ * A voice platform says which phones it can put an agent on -- a softphone in the host, a desk
313
+ * phone it rings -- and nothing off voice does, since there is no call to hear.
314
+ */
315
+ function validatePhones(manifest, path, into) {
316
+ const declared = manifest.phones;
317
+ if (manifest.channel !== "voice") {
318
+ into.require(declared === undefined, "manifest.phones.channel", path, `a ${String(manifest.channel)} provider has no call to hear and declares no phones`);
319
+ return;
320
+ }
321
+ if (!Array.isArray(declared) || declared.length === 0) {
322
+ into.add("manifest.phones.required", path, "a voice manifest lists the phones its platform can put an agent on: softphone, deskPhone, or both");
323
+ return;
324
+ }
325
+ declared.forEach((phone, index) => {
326
+ if (into.oneOf(phone, PHONES, "manifest.phone", `${path}[${index}]`) && declared.indexOf(phone) !== index) {
327
+ into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${String(phone)}`);
328
+ }
329
+ });
330
+ }
331
+ /**
332
+ * The host's choice of phone for a login, held to the manifest: one the platform listed, required
333
+ * on voice, absent elsewhere.
334
+ */
335
+ export function validatePhone(value, manifest, path = "context.phone") {
336
+ const into = new Collector();
337
+ const channel = isPlainObject(manifest) ? manifest.channel : undefined;
338
+ const phones = isPlainObject(manifest) && Array.isArray(manifest.phones) ? manifest.phones : [];
339
+ if (channel !== "voice") {
340
+ into.require(value === undefined, "context.phone.unexpected", path, `a ${String(channel)} login has no call to hear and chooses no phone`);
341
+ return into.violations;
342
+ }
343
+ if (!into.require(value !== undefined, "context.phone.required", path, "a voice login says how the agent hears the call: one of the manifest's phones"))
344
+ return into.violations;
345
+ if (into.oneOf(value, PHONES, "context.phone", path)) {
346
+ into.require(phones.includes(value), "context.phone.unsupported", path, `the host chose ${String(value)} and the manifest lists ${phones.length === 0 ? "no phones" : phones.join(", ")}`);
347
+ }
348
+ return into.violations;
349
+ }
310
350
  /** Whether a manifest says how a dial ends, and so may publish tasks that dial. `undefined` where there is no manifest to ask. */
311
351
  function manifestDials(manifest) {
312
352
  return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
@@ -347,6 +387,7 @@ export function validateManifest(manifest, path = "manifest") {
347
387
  if (channelValid)
348
388
  validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
349
389
  validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
390
+ validatePhones(manifest, `${path}.phones`, into);
350
391
  if (manifest.phaseLabels !== undefined) {
351
392
  if (!isPlainObject(manifest.phaseLabels)) {
352
393
  into.add("manifest.phaseLabels.shape", `${path}.phaseLabels`, "phaseLabels must be an object when present");
@@ -1733,6 +1774,26 @@ export function validateResult(result, method, path = "result", dialId) {
1733
1774
  // ---------------------------------------------------------------------------
1734
1775
  // Authentication.
1735
1776
  // ---------------------------------------------------------------------------
1777
+ /** An IANA zone name the runtime knows: `Asia/Kolkata` is one, an offset or a made-up name is not. */
1778
+ export function isTimeZone(value) {
1779
+ // The runtime accepts a bare offset such as +05:30 as a zone; this wire does not, since an
1780
+ // offset cannot say what day it is on either side of a daylight-saving change.
1781
+ if (typeof value !== "string" || value.trim() === "" || /^[+-]/.test(value))
1782
+ return false;
1783
+ try {
1784
+ new Intl.DateTimeFormat("en", { timeZone: value });
1785
+ return true;
1786
+ }
1787
+ catch {
1788
+ return false;
1789
+ }
1790
+ }
1791
+ /** The zone a host sends at connect: required, and an IANA name. */
1792
+ export function validateTimeZone(value, path = "context.timeZone") {
1793
+ const into = new Collector();
1794
+ into.require(isTimeZone(value), "context.timeZone", path, "a host states the agent's time zone at connect as an IANA name, such as Asia/Kolkata; an offset cannot survive a daylight-saving boundary");
1795
+ return into.violations;
1796
+ }
1736
1797
  function validateUser(value, rule, path, into) {
1737
1798
  if (!isPlainObject(value)) {
1738
1799
  into.add(rule, path, "an identity must be an object");
@@ -1740,6 +1801,8 @@ function validateUser(value, rule, path, into) {
1740
1801
  }
1741
1802
  into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
1742
1803
  into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
1804
+ // Never absent: the host stated the zone at authentication, before this identity existed.
1805
+ into.require(isTimeZone(value.timeZone), `${rule}.timeZone`, `${path}.timeZone`, "every identity carries the agent's time zone as an IANA name, such as Asia/Kolkata; the host stated it at authentication");
1743
1806
  }
1744
1807
  function validateUserCapabilitiesInto(value, path, into, levels) {
1745
1808
  if (!isPlainObject(value)) {
package/guide.md CHANGED
@@ -135,9 +135,12 @@ adapter**, **Live connection**, and **Authenticating with a provider**.
135
135
  ```ts
136
136
  type Channel = "voice" | "chat" | "email";
137
137
 
138
+ type TimeZone = string;
139
+
138
140
  type User = {
139
141
  id: UserId;
140
142
  displayName: string;
143
+ timeZone: TimeZone;
141
144
  };
142
145
 
143
146
  type Attribute = { key: string; value: string };
@@ -168,6 +171,8 @@ type DialCapability = { destinations: DialDestinations };
168
171
 
169
172
  type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";
170
173
 
174
+ type Phone = "softphone" | "deskPhone";
175
+
171
176
  type IdleCapabilities<C extends Channel = Channel> = {
172
177
  personalBrowser?: PersonalBrowserCapability;
173
178
  calendar?: true;
@@ -185,6 +190,7 @@ type Manifest<C extends Channel = Channel> = {
185
190
  taskTypePresentation?: Record<string, TaskTypePresentation>;
186
191
  orgLevels?: LevelDeclaration[];
187
192
  dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
193
+ phones?: C extends "voice" ? Phone[] : never;
188
194
  runningStepReports?: true;
189
195
  };
190
196
  ```
@@ -225,6 +231,8 @@ type SecretStore = {
225
231
  type AuthenticationContext = {
226
232
  protocolVersion: number;
227
233
  loginId: string;
234
+ timeZone: TimeZone;
235
+ phone?: Phone;
228
236
  secrets: SecretStore;
229
237
  signal?: AbortSignal;
230
238
  log?: (entry: unknown) => void;
@@ -296,6 +304,8 @@ type ConnectContext = {
296
304
  protocolVersion: number;
297
305
  loginId: string;
298
306
  autoAcceptTasks?: boolean;
307
+ timeZone: TimeZone;
308
+ phone?: Phone;
299
309
  host: Host;
300
310
  signal?: AbortSignal;
301
311
  log?: (entry: unknown) => void;
@@ -1445,6 +1455,7 @@ compile time.
1445
1455
  | `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
1446
1456
  | `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
1447
1457
  | `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label a desk shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_LEVELS`. See **Who decides what an agent may do**. |
1458
+ | `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the host) and/or `deskPhone` (a handset the platform rings; the host shows the call and opens nothing). The host picks one per login. See **How the agent hears the call**. |
1448
1459
  | `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that transfer, conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
1449
1460
  | `runningStepReports` | The provider takes running reports of a host-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The host records what it performs**. |
1450
1461
 
@@ -1700,6 +1711,8 @@ Creates the provider-scoped authentication session.
1700
1711
  | --- | --- |
1701
1712
  | `protocolVersion` | The negotiated version, fixed for this login. |
1702
1713
  | `loginId` | Omni-generated identity for this login. The same value Omni later passes as `ConnectContext.loginId`, and how an adapter ties a connection back to the session that authenticated it. |
1714
+ | `timeZone` | The zone the agent's day is reckoned in, as an IANA name from the host's clock, stated before any identity exists so the first `authenticated` state already carries it. The same value Omni later passes as `ConnectContext.timeZone`. See **The agent's day**. |
1715
+ | `phone` | How this login hears its calls, chosen by the host from the manifest's `phones`: required for a voice provider, absent for any other. The same value Omni later passes as `ConnectContext.phone`. See **How the agent hears the call**. |
1703
1716
  | `secrets` | Omni-provided `SecretStore`, scoped to this provider's manifest id. |
1704
1717
  | `signal` | Optional cancellation signal. |
1705
1718
  | `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
@@ -1847,7 +1860,7 @@ too, by the rule that a command asking for a state answers success when that sta
1847
1860
  failure:
1848
1861
 
1849
1862
  ```ts
1850
- { status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" }, capabilities: { breaks: true } }
1863
+ { status: "authenticated", identity: { id: "1042", displayName: "Asha Rao", timeZone: "Asia/Kolkata" }, capabilities: { breaks: true } }
1851
1864
  ```
1852
1865
 
1853
1866
  The `User` it carries is the **root of this provider's user namespace**. Every other person this
@@ -1898,10 +1911,40 @@ Creates one live provider connection for the signed-in agent.
1898
1911
  | `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
1899
1912
  | `loginId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.loginId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
1900
1913
  | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
1914
+ | `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
1915
+ | `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. |
1901
1916
  | `host` | The host's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The host reports, the adapter decides**. |
1902
1917
  | `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
1903
1918
  | `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
1904
1919
 
1920
+ ### The agent's day
1921
+
1922
+ Every instant on this wire carries an explicit offset, so a moment is unambiguous everywhere and
1923
+ a desk renders it in the viewer's clock without help. A **day** is different: hours toward
1924
+ target, an answer streak, a per-queue count for today -- anything bucketed by day -- is bucketed
1925
+ by somebody's day, and a platform that was never told whose uses its own. An agent in Chennai
1926
+ then finds their day rolling at 05:30, and a night shift in Chicago lands in two buckets.
1927
+
1928
+ So the host says whose day it is, and it says so first. `AuthenticationContext.timeZone` is the
1929
+ agent's zone as an IANA name -- `Asia/Kolkata`, `America/Chicago` -- never an offset, since an
1930
+ offset cannot survive a daylight-saving boundary and a day boundary is exactly where that bites
1931
+ (`context.timeZone`). It is stated before any identity exists, and `ConnectContext.timeZone` is the
1932
+ same value again. The provider **stores it on the agent** and carries it as `identity.timeZone` on
1933
+ every `authenticated` state and on every `User` it returns from `describeUsers()`, so a lead
1934
+ reading a colleague's yesterday sees the colleague's yesterday and a summary is bucketed by the
1935
+ right day after a session has ended. A roaming agent corrects it by signing in from where they are.
1936
+
1937
+ **The zone is never absent.** Time zone awareness is a first-party property of this wire, not a
1938
+ field to fill in later: an identity without one is refused (`authentication.identity.timeZone`), and
1939
+ so is a zone that is not an IANA name, wherever it appears. Nothing is ever assumed in its place --
1940
+ not the viewer's browser, which gives a different answer per reader for the same record, and not the
1941
+ provider's clock. The harness holds a provider to the round trip: the identity carries the zone the
1942
+ host stated (`authentication.identity.timeZone.republished`). **The round trip is not the store.**
1943
+ An adapter that echoes the stated zone back onto the identity passes that check with nothing kept,
1944
+ and a lead reading a colleague's day would still get the wrong one. What proves the store is a zone
1945
+ the run never sent: a colleague's `User` from `describeUsers()` carrying theirs. A provider's own
1946
+ tests are where that is shown, with a second agent whose zone arrived through another session.
1947
+
1905
1948
  ### Who the agent is
1906
1949
 
1907
1950
  `ConnectContext` names no agent. The adapter already knows who is connected: it authenticated them,
@@ -1943,7 +1986,7 @@ surface in one place, and what obliges an adapter to implement each one.
1943
1986
  | `disconnect()` | Always. |
1944
1987
  | `setCapacity(capacity)` | Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it. |
1945
1988
  | `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
1946
- | `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
1989
+ | `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. Each `User` carries its `timeZone` where the provider knows it. |
1947
1990
  | `dial(request)` | The manifest declares `idleCapabilities.dial`, and with it `dialOutcomes`. |
1948
1991
  | `requestBreak(request)` | The login declares `capabilities.breaks`. |
1949
1992
  | `commitBreak()` | The login declares `capabilities.breaks`. Commit and cancel are not optional halves of it. |
@@ -1955,7 +1998,7 @@ surface in one place, and what obliges an adapter to implement each one.
1955
1998
  | `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
1956
1999
  | `recordStep(report)` | A task declares `mute`: the host performs that leg, and the provider's record has to have somewhere to take it. See **The host records what it performs**. |
1957
2000
  | `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
1958
- | `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. |
2001
+ | `openMedia(request)` | The manifest lists `softphone` among its `phones`. On a softphone the call's audio lands in Omni, so the adapter has to open it; a platform of desk phones alone never does. |
1959
2002
 
1960
2003
  **The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
1961
2004
  implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can never
@@ -2104,7 +2147,8 @@ medium the task arrives on, not a separate decision.
2104
2147
 
2105
2148
  **Once a task is accepted, the call that comes with it is answered.** Omni has no discretion
2106
2149
  there and the provider is not consulted twice: one decision about the work, and the medium
2107
- follows it. Where the audio lands is not in question — it opens in Omni, as it always does.
2150
+ follows it. Where the audio lands was settled at sign-in — in Omni on a softphone, on the handset
2151
+ on a desk phone — and is not in question per call. See **How the agent hears the call**.
2108
2152
 
2109
2153
  Automatic acceptance still begins with `task-offered`.
2110
2154
 
@@ -2249,6 +2293,12 @@ ends with an `expired` outcome naming `preview`. The two travel together
2249
2293
  "expires in 12s" from the provider's word. With neither, the agent has as long as they need.
2250
2294
  Reaching the instant is not itself a transition: the provider reports what it did, as an event.
2251
2295
 
2296
+ **Drop both fields when the phase moves.** A provider that builds the `in-progress` task by
2297
+ spreading the `preview` one carries `previewEndsAt` and `atDeadline` with it, and the host refuses
2298
+ the update (`task.preview.deadline.unexpected`) while the provider's own state looks right: the
2299
+ desk hears nothing and the provider sees nothing wrong. The task past preview has no deadline to
2300
+ wait for, so it carries neither field.
2301
+
2252
2302
  **A task is never its audio.** A voice task is the allocation: the call is offered when it is
2253
2303
  routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
2254
2304
  the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
@@ -3628,8 +3678,9 @@ is the one that knows whether an agent without a microphone, or without a speake
3628
3678
  The host's own obligations here — asking at connect, never publishing `not-asked` when it does,
3629
3679
  publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
3630
3680
  other side: it validates the shape of whatever host a test hands the adapter, requires `audio` on
3631
- a voice connection and none elsewhere (`context.host.audio.required` / `.unexpected`), and
3632
- refuses a voice adapter that never asked the host anything (`connection.host.consulted`).
3681
+ a softphone login and none elsewhere -- not on a desk phone, not off voice
3682
+ (`context.host.audio.required` / `.unexpected`) -- and refuses a voice adapter that never asked the
3683
+ host anything (`connection.host.consulted`).
3633
3684
 
3634
3685
  ### Capacity around setup
3635
3686
 
@@ -3661,10 +3712,53 @@ once; a break says they are not working. Collapsing the two would leave a provid
3661
3712
  an agent at their limit from an agent who has gone to lunch, and only one of those needs a reason,
3662
3713
  a decision and a return.
3663
3714
 
3715
+ ### How the agent hears the call
3716
+
3717
+ A voice platform can put an agent on a **softphone**, where the call's audio lands in the host
3718
+ and Omni owns the microphone and the playback, or on a **desk phone**, a handset on the platform's
3719
+ switch that the platform rings, where the host owns no audio and only shows the call. Which of
3720
+ these a platform can do is a fact about the platform, and which one a login is on is a choice the
3721
+ host makes for that agent -- so the manifest declares and the host chooses.
3722
+
3723
+ ```ts
3724
+ // Manifest: what the platform can do
3725
+ phones: ["softphone", "deskPhone"]
3726
+ // The host, at authentication and again at connect: which one this login is on
3727
+ { phone: "softphone" }
3728
+ ```
3729
+
3730
+ A voice manifest lists its `phones` and any other channel lists none (`manifest.phones.required`,
3731
+ `manifest.phones.channel`). The host's `phone` is required on a voice login, absent on any other,
3732
+ and one the manifest listed (`context.phone.required`, `.unexpected`, `.unsupported`). Everything
3733
+ about audio then follows the phone rather than the channel: on a softphone the host reports its
3734
+ audio, the adapter implements `openMedia`, and `task-media-started` is the word to open it; on a
3735
+ desk phone the host reports no audio, opens nothing, and `task-media-started` still marks the
3736
+ moment the call is live so the desk shows it, with the sound on the handset. A platform that lists
3737
+ `deskPhone` alone never implements `openMedia`; one that lists `softphone` always does.
3738
+
3739
+ **The mode comes from the host; the status comes from the provider.** The host knows which kind
3740
+ of station the agent signed in at, because the person chose it, and nothing else can know that.
3741
+ The provider knows whether that station can carry a call right now -- whether a handset is
3742
+ registered -- which a host in a browser cannot see. So the host's `phone` selects the branch, and
3743
+ the provider's own knowledge of the device decides readiness within it. A provider never overrides
3744
+ the host's declaration from its device record: treating a desk-phone login as a softphone because
3745
+ the handset is momentarily unregistered, and demanding a microphone of it, is the reading this
3746
+ sentence exists to refuse. What the provider does when the handset is not registered is what it
3747
+ does for any station that cannot take a call -- hold the agent not-ready and say why.
3748
+
3749
+ **On a desk-phone login the host never calls `openMedia`.** There is no stream to hand over and
3750
+ no audio to attach; a host that calls it anyway is in error, and an adapter that receives the call
3751
+ answers `unavailable` with a non-retryable failure, since waiting changes nothing about a station
3752
+ that is a telephone. The harness requires no `openMedia` of such an adapter and never calls it.
3753
+
3754
+ **Which handset a desk-phone login rings is the platform's configuration for that agent**, and
3755
+ this wire never asks the agent for it: a host declares `phone` and nothing more. What a platform
3756
+ asks on surfaces of its own is its own decision.
3757
+
3664
3758
  ### Opening the audio
3665
3759
 
3666
- `openMedia` hands Omni the remote audio for one task. Every voice adapter implements it, because
3667
- every voice task's audio lands in Omni:
3760
+ `openMedia` hands Omni the remote audio for one task. Every adapter whose manifest lists
3761
+ `softphone` implements it, because on a softphone the call's audio lands in Omni:
3668
3762
 
3669
3763
  ```ts
3670
3764
  openMedia({ taskId, localAudio }): Promise<OpenMediaResult>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",