@xema/omni-protocol 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,12 +99,12 @@ const task = {
99
99
 
100
100
  **Switch on `command.action` with no `default`.** A `default` turns a future action into whatever
101
101
  the default does, silently; naming every arm means dropping one fails the build — indirectly, as a
102
- missing return — and keeps the narrowing that lets you read `destination` without a cast.
102
+ missing return — and keeps the narrowing that lets you read `destinationId` without a cast.
103
103
 
104
104
  ```ts
105
105
  switch (command.action) {
106
- case "cold": return coldTransfer(command.destination);
107
- case "warm": return warmTransfer(command.destination);
106
+ case "cold": return coldTransfer(command.destinationId);
107
+ case "warm": return warmTransfer(command.destinationId);
108
108
  case "complete": return completeWarmTransfer();
109
109
  case "cancel": return cancelWarmTransfer();
110
110
  }
package/dist/index.d.ts CHANGED
@@ -409,16 +409,18 @@ export interface DispositionRules {
409
409
  notes?: "required" | "optional" | "none";
410
410
  codes?: DispositionCode[];
411
411
  }
412
+ /**
413
+ * One item the queue configured for the agent to send a contact to: a button or a menu item. The
414
+ * protocol does not say what it does -- a queue, a menu, a line -- the provider executes it when a
415
+ * command names its `id`. Never a named agent: who takes a contact next is the queue's decision.
416
+ */
412
417
  export interface Destination {
413
418
  id: string;
414
419
  label: string;
415
- address: string;
416
- /** `agent` here is a routing target, not the person signed in. */
417
- kind: "queue" | "agent" | "external";
418
420
  }
421
+ /** The items a transfer or conference control offers. Nothing is typed; the agent picks one. */
419
422
  export interface DestinationDirectory {
420
- destinations?: Destination[];
421
- allowManualEntry: boolean;
423
+ destinations: Destination[];
422
424
  }
423
425
  export interface CustomCapability {
424
426
  id: string;
@@ -451,12 +453,12 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
451
453
  agentDisconnect?: Lockable<true>;
452
454
  /** Connect back to the party while `completing`, whoever placed the call; the task returns to `in-progress`. */
453
455
  connectBack?: Lockable<true>;
454
- coldTransfer?: Lockable<true | DestinationDirectory>;
456
+ coldTransfer?: Lockable<DestinationDirectory>;
455
457
  /** Park the customer and call a destination first; then `complete` or `cancel`. */
456
- warmTransfer?: Lockable<true | DestinationDirectory>;
458
+ warmTransfer?: Lockable<DestinationDirectory>;
457
459
  /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.leadAssist`. */
458
460
  leadAssist?: Lockable<true>;
459
- conference?: Lockable<true | DestinationDirectory>;
461
+ conference?: Lockable<DestinationDirectory>;
460
462
  recording?: Lockable<true>;
461
463
  } : C extends "chat" ? SharedTaskCapabilities & {
462
464
  decline?: Lockable<true>;
@@ -512,10 +514,15 @@ export type TaskPhase =
512
514
  "pending"
513
515
  /** Accepted, and not yet started. */
514
516
  | "confirmed"
515
- /** Being made ready -- a preview the agent reads before the work begins. */
516
- | "preparing" | "in-progress" | "paused"
517
+ /**
518
+ * Voice only: the customer's record is on the agent's screen and no call has gone out. The agent
519
+ * presses Call, or `previewEndsAt` arrives and `atDeadline` says what the system does instead.
520
+ */
521
+ | "preview" | "in-progress" | "paused"
517
522
  /** The work is over and the agent is finishing up. */
518
523
  | "completing";
524
+ /** What the system does when a preview's deadline passes with no Call pressed. */
525
+ export type PreviewDeadline = "calls" | "expires";
519
526
  /** Who ends the task: the agent issuing `complete`, or the provider deciding it is over. */
520
527
  export type CompletionMode = "agent-command" | "provider-automatic";
521
528
  export interface TaskAttributeBase {
@@ -556,10 +563,10 @@ export interface TaskHandlingStep {
556
563
  at: IsoTimestamp;
557
564
  /**
558
565
  * On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
559
- * that dial when a host placed it, and the address it went to. Neither belongs on any other step.
566
+ * that dial when a host placed it, and the directory item it went to. Neither belongs on any other step.
560
567
  */
561
568
  dialId?: DialId;
562
- destination?: string;
569
+ destinationId?: string;
563
570
  /**
564
571
  * Reported, never derived. An entry may be written while its leg is still running, so there is
565
572
  * no end to subtract from -- and omitted is the honest report of that, where nought would
@@ -611,7 +618,7 @@ export type OnCall = {
611
618
  userId: UserId;
612
619
  } | {
613
620
  role: "consulted" | "conferenced";
614
- destination: string;
621
+ destinationId: string;
615
622
  stage: OnCallStage;
616
623
  dialId?: DialId;
617
624
  label?: string;
@@ -710,6 +717,13 @@ export type Task<C extends Channel = Channel> = {
710
717
  * (`autoAcceptTasks: true`), forbidden when it said not, and absent past `pending`.
711
718
  */
712
719
  acceptance?: AcceptanceMode;
720
+ /**
721
+ * In `preview` only, and together: when the system stops waiting for the agent to press Call,
722
+ * and what it does then -- `calls` places the call itself, `expires` takes the record back and
723
+ * the task ends `expired`. Absent, the agent has as long as they need.
724
+ */
725
+ previewEndsAt?: IsoTimestamp;
726
+ atDeadline?: PreviewDeadline;
713
727
  /** The identifier an agent reads back to a customer, where the provider has one. */
714
728
  reference?: string;
715
729
  attributes?: TaskAttribute[];
@@ -739,7 +753,7 @@ export type TaskOutcome = {
739
753
  by: "agent" | "provider";
740
754
  } | {
741
755
  type: "transferred";
742
- destination?: string;
756
+ destinationId?: string;
743
757
  } | {
744
758
  type: "cancelled";
745
759
  reason?: string;
@@ -747,7 +761,7 @@ export type TaskOutcome = {
747
761
  /** Only the phases in which somebody is still being waited on can expire. */
748
762
  | {
749
763
  type: "expired";
750
- phase: "pending" | "confirmed" | "preparing";
764
+ phase: "pending" | "confirmed" | "preview";
751
765
  }
752
766
  /** This agent left a call that continues without them: a lead who joined and dropped. */
753
767
  | {
@@ -757,7 +771,7 @@ export type TaskOutcome = {
757
771
  failure: ProtocolFailure;
758
772
  };
759
773
  export declare const TASK_COMMAND_NAMES: {
760
- readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
774
+ readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "disconnect", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
761
775
  readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
762
776
  readonly email: readonly ["accept", "decline", "complete"];
763
777
  };
@@ -770,8 +784,11 @@ export type VoiceTaskCommand = {
770
784
  type: "answer";
771
785
  } | {
772
786
  type: "decline";
773
- } | {
774
- type: "start-call";
787
+ }
788
+ /** In `preview`: place the call to the party whose record the agent has read. A dial, gated by the phase alone. */
789
+ | {
790
+ type: "call";
791
+ dialId: DialId;
775
792
  } | {
776
793
  type: "mute";
777
794
  muted: boolean;
@@ -787,19 +804,19 @@ export type VoiceTaskCommand = {
787
804
  type: "connect-back";
788
805
  dialId: DialId;
789
806
  }
790
- /** Cold: hand the customer to `destination` with nobody spoken to first. Gated by `coldTransfer`. */
807
+ /** Cold: hand the customer to the directory item `destinationId` with nobody spoken to first. Gated by `coldTransfer`. */
791
808
  | {
792
809
  type: "transfer";
793
810
  action: "cold";
794
811
  dialId: DialId;
795
- destination: string;
812
+ destinationId: string;
796
813
  }
797
- /** Warm: park the customer and call `destination` first. Gated by `warmTransfer`. */
814
+ /** Warm: park the customer and call the directory item `destinationId` first. Gated by `warmTransfer`. */
798
815
  | {
799
816
  type: "transfer";
800
817
  action: "warm";
801
818
  dialId: DialId;
802
- destination: string;
819
+ destinationId: string;
803
820
  }
804
821
  /** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
805
822
  | {
@@ -832,18 +849,18 @@ export type VoiceTaskCommand = {
832
849
  type: "lead-assist";
833
850
  action: "leave";
834
851
  }
835
- /** Dial `destination` into the call. Gated by `conference`. */
852
+ /** Dial the directory item `destinationId` into the call. Gated by `conference`. */
836
853
  | {
837
854
  type: "conference";
838
855
  action: "add";
839
856
  dialId: DialId;
840
- destination: string;
857
+ destinationId: string;
841
858
  }
842
- /** Drop the `conferenced` entry on `Task.onCall` with this destination; while it still rings, this calls the dial off. */
859
+ /** Drop the `conferenced` entry on `Task.onCall` with this `destinationId`; while it still rings, this calls the dial off. */
843
860
  | {
844
861
  type: "conference";
845
862
  action: "remove";
846
- destination: string;
863
+ destinationId: string;
847
864
  } | {
848
865
  type: "recording";
849
866
  action: "start" | "pause" | "resume" | "stop";
@@ -1255,7 +1272,6 @@ export type ProviderEvent<C extends Channel = Channel> = {
1255
1272
  type: "task-offered";
1256
1273
  task: Task<C>;
1257
1274
  allocationExpiresAt?: IsoTimestamp;
1258
- preparationEndsAt?: IsoTimestamp;
1259
1275
  } | {
1260
1276
  type: "task-updated";
1261
1277
  task: Task<C>;
@@ -1282,7 +1298,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
1282
1298
  dialId: DialId;
1283
1299
  outcome: DialOutcome;
1284
1300
  taskId?: TaskId;
1285
- destination?: string;
1301
+ destinationId?: string;
1286
1302
  reason?: string;
1287
1303
  } | {
1288
1304
  type: "announcement";
@@ -1372,7 +1388,7 @@ export declare const DEFAULT_TASK_PHASE_LABELS: {
1372
1388
  readonly voice: {
1373
1389
  readonly pending: "Offered";
1374
1390
  readonly confirmed: "Accepted";
1375
- readonly preparing: "Preview";
1391
+ readonly preview: "Preview";
1376
1392
  readonly "in-progress": "On Call";
1377
1393
  readonly paused: "On Hold";
1378
1394
  readonly completing: "After Call Work";
@@ -1380,7 +1396,7 @@ export declare const DEFAULT_TASK_PHASE_LABELS: {
1380
1396
  readonly chat: {
1381
1397
  readonly pending: "Incoming Chat";
1382
1398
  readonly confirmed: "Accepted";
1383
- readonly preparing: "Preparing";
1399
+ readonly preview: "Preview";
1384
1400
  readonly "in-progress": "In Chat";
1385
1401
  readonly paused: "Paused";
1386
1402
  readonly completing: "Wrap-up";
@@ -1388,7 +1404,7 @@ export declare const DEFAULT_TASK_PHASE_LABELS: {
1388
1404
  readonly email: {
1389
1405
  readonly pending: "Assigned";
1390
1406
  readonly confirmed: "Accepted";
1391
- readonly preparing: "Reviewing";
1407
+ readonly preview: "Preview";
1392
1408
  readonly "in-progress": "Working";
1393
1409
  readonly paused: "Paused";
1394
1410
  readonly completing: "Completing";
@@ -1429,7 +1445,7 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
1429
1445
  export declare const userKey: (providerId: string, userId: UserId) => string;
1430
1446
  /** Every handling step somebody takes part in. `queued` is the one nobody does. */
1431
1447
  export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
1432
- /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
1448
+ /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
1433
1449
  export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
1434
1450
  export declare const handlingStepDials: (step: HandlingStep) => boolean;
1435
1451
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
package/dist/index.js CHANGED
@@ -71,14 +71,14 @@ export function effectiveLevels(declared) {
71
71
  // Task commands.
72
72
  // ---------------------------------------------------------------------------
73
73
  export const TASK_COMMAND_NAMES = {
74
- voice: ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect",
74
+ voice: ["answer", "decline", "call", "mute", "hold", "resume", "disconnect",
75
75
  "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"],
76
76
  chat: ["accept", "decline", "pause", "resume", "complete"],
77
77
  email: ["accept", "decline", "complete"],
78
78
  };
79
79
  /** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
80
80
  export function commandDialId(command) {
81
- if (command.type === "connect-back")
81
+ if (command.type === "connect-back" || command.type === "call")
82
82
  return command.dialId;
83
83
  if (command.type === "transfer" && (command.action === "cold" || command.action === "warm"))
84
84
  return command.dialId;
@@ -117,15 +117,15 @@ export function defineAdapter(adapter) {
117
117
  // ---------------------------------------------------------------------------
118
118
  export const DEFAULT_TASK_PHASE_LABELS = {
119
119
  voice: {
120
- pending: "Offered", confirmed: "Accepted", preparing: "Preview",
120
+ pending: "Offered", confirmed: "Accepted", preview: "Preview",
121
121
  "in-progress": "On Call", paused: "On Hold", completing: "After Call Work",
122
122
  },
123
123
  chat: {
124
- pending: "Incoming Chat", confirmed: "Accepted", preparing: "Preparing",
124
+ pending: "Incoming Chat", confirmed: "Accepted", preview: "Preview",
125
125
  "in-progress": "In Chat", paused: "Paused", completing: "Wrap-up",
126
126
  },
127
127
  email: {
128
- pending: "Assigned", confirmed: "Accepted", preparing: "Reviewing",
128
+ pending: "Assigned", confirmed: "Accepted", preview: "Preview",
129
129
  "in-progress": "Working", paused: "Paused", completing: "Completing",
130
130
  },
131
131
  };
@@ -157,7 +157,7 @@ export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)
157
157
  export const HANDLING_STEPS_WITH_A_PERSON = [
158
158
  "offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
159
159
  ];
160
- /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
160
+ /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
161
161
  export const HANDLING_STEPS_THAT_DIAL = ["transferred", "conferenced", "unanswered"];
162
162
  export const handlingStepDials = (step) => HANDLING_STEPS_THAT_DIAL.includes(step);
163
163
  // Pinned both ways: a step added to `HandlingStep` has to be placed here, and a step listed here
@@ -38,7 +38,7 @@ const membersOf = (members) => Object.keys(members);
38
38
  const CHANNELS = membersOf({ voice: true, chat: true, email: true });
39
39
  const TRANSPORT_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
40
40
  const TASK_PHASES = membersOf({
41
- pending: true, confirmed: true, preparing: true, "in-progress": true, paused: true, completing: true,
41
+ pending: true, confirmed: true, preview: true, "in-progress": true, paused: true, completing: true,
42
42
  });
43
43
  const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic": true });
44
44
  const ACCEPTANCE_MODES = membersOf({
@@ -58,7 +58,6 @@ const TEAM_AVAILABILITIES = membersOf({
58
58
  const HANDLING_STEPS = membersOf({
59
59
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
60
60
  });
61
- const DESTINATION_KINDS = membersOf({ queue: true, agent: true, external: true });
62
61
  const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
63
62
  const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
64
63
  const NOTES_RULES = membersOf({ required: true, optional: true, none: true });
@@ -82,13 +81,14 @@ const MEMBER_BREAKS = membersOf({
82
81
  "awaiting-decision": true, granted: true, "starting-after-task": true,
83
82
  });
84
83
  const OFFERABLE_PHASES = membersOf({
85
- pending: true, confirmed: true, preparing: true,
84
+ pending: true, confirmed: true, preview: true,
86
85
  });
86
+ const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
87
87
  const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
88
88
  const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
89
89
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
90
90
  const EXPIRABLE_PHASES = membersOf({
91
- pending: true, confirmed: true, preparing: true,
91
+ pending: true, confirmed: true, preview: true,
92
92
  });
93
93
  const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
94
94
  /** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
@@ -417,23 +417,18 @@ export function validateManifest(manifest, path = "manifest") {
417
417
  // Task.
418
418
  // ---------------------------------------------------------------------------
419
419
  function validateDestinationDirectory(value, path, into) {
420
- if (value === true)
421
- return;
422
420
  if (!isPlainObject(value)) {
423
- into.add("task.destinations.shape", path, "must be true or a destination directory");
421
+ into.add("task.destinations.shape", path, "a transfer or conference control carries the directory it offers: { destinations: [...] }");
424
422
  return;
425
423
  }
426
- into.require(typeof value.allowManualEntry === "boolean", "task.destinations.allowManualEntry", `${path}.allowManualEntry`, "a directory must say whether manual entry is allowed");
427
- // A directory with nothing in it and no typing is a control with nothing to offer.
428
- if (value.allowManualEntry === false && !(Array.isArray(value.destinations) && value.destinations.length > 0)) {
429
- into.add("task.destinations.offer", path, "a directory with no destinations must allow manual entry, or the control has nothing to offer");
430
- }
431
- if (value.destinations === undefined)
432
- return;
433
424
  if (!Array.isArray(value.destinations)) {
434
- into.add("task.destinations.list", `${path}.destinations`, "destinations must be an array when present");
425
+ into.add("task.destinations.list", `${path}.destinations`, "destinations must be an array");
435
426
  return;
436
427
  }
428
+ // Nothing is typed, so a directory with nothing in it is a control with nothing to offer.
429
+ if (value.destinations.length === 0) {
430
+ into.add("task.destinations.offer", path, "a directory offers at least one item; with none, omit the capability");
431
+ }
437
432
  const seen = new Set();
438
433
  value.destinations.forEach((destination, index) => {
439
434
  const at = `${path}.destinations[${index}]`;
@@ -447,8 +442,6 @@ function validateDestinationDirectory(value, path, into) {
447
442
  seen.add(destination.id);
448
443
  }
449
444
  into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
450
- into.filled(destination.address, "task.destination.address", `${at}.address`, "a destination needs an address");
451
- into.oneOf(destination.kind, DESTINATION_KINDS, "task.destination.kind", `${at}.kind`);
452
445
  });
453
446
  }
454
447
  function validateDispositions(value, path, into) {
@@ -723,7 +716,7 @@ function validateHandlingHistory(value, path, into) {
723
716
  }
724
717
  // A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
725
718
  const dialled = typeof entry.step === "string" && HANDLING_STEPS_THAT_DIAL.includes(entry.step);
726
- for (const field of ["dialId", "destination"]) {
719
+ for (const field of ["dialId", "destinationId"]) {
727
720
  if (entry[field] === undefined)
728
721
  continue;
729
722
  if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
@@ -779,7 +772,7 @@ function validateOnCall(value, channel, path, into) {
779
772
  into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
780
773
  }
781
774
  if (role === "consulted" || role === "conferenced") {
782
- into.filled(entry.destination, "task.onCall.destination", `${at}.destination`, `a ${role} entry names the destination that was dialled`);
775
+ into.filled(entry.destinationId, "task.onCall.destinationId", `${at}.destinationId`, `a ${role} entry names the directory item that was dialled`);
783
776
  // Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
784
777
  if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
785
778
  into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
@@ -790,7 +783,7 @@ function validateOnCall(value, channel, path, into) {
790
783
  into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
791
784
  }
792
785
  else {
793
- for (const field of ["destination", "dialId", "label", "stage"]) {
786
+ for (const field of ["destinationId", "dialId", "label", "stage"]) {
794
787
  into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
795
788
  }
796
789
  }
@@ -864,7 +857,28 @@ function validateTaskInto(task, context, path, into) {
864
857
  into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
865
858
  into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
866
859
  into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
867
- into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`);
860
+ if (into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`) && task.phase === "preview") {
861
+ // A preview is a record waiting for the agent to press Call: only voice has a call to place,
862
+ // and pressing Call is a dial, so the manifest has to have said how a dial ends.
863
+ into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
864
+ into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
865
+ }
866
+ // The deadline and what happens at it travel together, and only while the record is being previewed.
867
+ if (task.previewEndsAt !== undefined || task.atDeadline !== undefined) {
868
+ into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsAt`, "previewEndsAt and atDeadline belong to a task in preview; past it there is nothing to wait for");
869
+ if (task.previewEndsAt === undefined) {
870
+ into.add("task.preview.previewEndsAt.required", `${path}.previewEndsAt`, "atDeadline says what happens at a deadline, so there has to be one");
871
+ }
872
+ else {
873
+ into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
874
+ }
875
+ if (task.atDeadline === undefined) {
876
+ into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what the system does at it: calls, or expires -- an agent counting down has to know which");
877
+ }
878
+ else {
879
+ into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
880
+ }
881
+ }
868
882
  // Acceptance is the offer's word, carried on the pending task so a snapshot can say it: it
869
883
  // travels exactly when Omni said tasks may be auto-accepted, and only while the task is pending.
870
884
  if (task.acceptance !== undefined) {
@@ -1275,8 +1289,8 @@ function validateTaskOutcome(value, path, into) {
1275
1289
  into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
1276
1290
  break;
1277
1291
  case "transferred":
1278
- if (value.destination !== undefined) {
1279
- into.filled(value.destination, "event.taskEnded.outcome.transferred", `${path}.destination`, "a destination must not be empty when present");
1292
+ if (value.destinationId !== undefined) {
1293
+ into.filled(value.destinationId, "event.taskEnded.outcome.transferred", `${path}.destinationId`, "a destinationId must not be empty when present");
1280
1294
  }
1281
1295
  break;
1282
1296
  case "cancelled":
@@ -1386,10 +1400,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1386
1400
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1387
1401
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
1388
1402
  }
1389
- for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
1390
- if (event[field] !== undefined)
1391
- into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
1392
- }
1403
+ if (event.allocationExpiresAt !== undefined)
1404
+ into.timestamp(event.allocationExpiresAt, "event.taskOffered.allocationExpiresAt", `${at}.allocationExpiresAt`);
1393
1405
  break;
1394
1406
  case "task-updated":
1395
1407
  validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
@@ -1413,8 +1425,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1413
1425
  }
1414
1426
  if (event.taskId !== undefined)
1415
1427
  into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
1416
- if (event.destination !== undefined)
1417
- into.filled(event.destination, "event.dialOutcome.destination", `${at}.destination`, "a destination must not be empty when present");
1428
+ if (event.destinationId !== undefined)
1429
+ into.filled(event.destinationId, "event.dialOutcome.destinationId", `${at}.destinationId`, "a destinationId must not be empty when present");
1418
1430
  if (event.reason !== undefined)
1419
1431
  into.filled(event.reason, "event.dialOutcome.reason", `${at}.reason`, "a reason must not be empty when present");
1420
1432
  break;
package/guide.md CHANGED
@@ -20,7 +20,7 @@ are used precisely throughout and mean nothing looser here.
20
20
  | **Host** | Omni, named that way where the contrast with a provider is the point — *the host carries the audio*, *host-side input*. |
21
21
  | **Provider** | One independently connected external system: a voice platform, a chat platform, a mail platform. |
22
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. |
23
+ | **Agent** | The person signed in and taking work. On `onCall`, `agent` is a person on the call by user id; a transfer never names one, since a contact goes back to a queue. |
24
24
  | **Lead** | An agent whose login declares `capabilities.team`. The provider publishes a `TeamRoster` to them and to nobody else: **the login is the permission**. |
25
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
26
  | **Task** | One unit of assigned work — a call, a chat, a mail. |
@@ -45,7 +45,7 @@ transport, a task, a break and a call each have a word of their own:
45
45
 
46
46
  | Word | Belongs to | Values |
47
47
  | --- | --- | --- |
48
- | `phase` | A task | `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, `completing` |
48
+ | `phase` | A task | `pending`, `confirmed`, `preview`, `in-progress`, `paused`, `completing` |
49
49
  | `media` | A task's audio | `started`, `ended` |
50
50
  | `transport` | A connection | `connecting`, `active`, `error` |
51
51
  | `status` | An authentication session | `signed-out`, `authenticating`, `authenticated`, `refreshing`, `expired` |
@@ -447,13 +447,10 @@ type DispositionRules = {
447
447
  type Destination = {
448
448
  id: string;
449
449
  label: string;
450
- address: string;
451
- kind: "queue" | "agent" | "external";
452
450
  };
453
451
 
454
452
  type DestinationDirectory = {
455
- destinations?: Destination[];
456
- allowManualEntry: boolean;
453
+ destinations: Destination[];
457
454
  };
458
455
 
459
456
  type CustomCapability = {
@@ -481,10 +478,10 @@ type TaskCapabilities<C extends Channel = Channel> =
481
478
  hold?: Lockable<true>;
482
479
  agentDisconnect?: Lockable<true>;
483
480
  connectBack?: Lockable<true>;
484
- coldTransfer?: Lockable<true | DestinationDirectory>;
485
- warmTransfer?: Lockable<true | DestinationDirectory>;
481
+ coldTransfer?: Lockable<DestinationDirectory>;
482
+ warmTransfer?: Lockable<DestinationDirectory>;
486
483
  leadAssist?: Lockable<true>;
487
- conference?: Lockable<true | DestinationDirectory>;
484
+ conference?: Lockable<DestinationDirectory>;
488
485
  recording?: Lockable<true>;
489
486
  }
490
487
  : C extends "chat"
@@ -542,7 +539,7 @@ default — see **Choosing an isolation scheme**.
542
539
  type TaskPhase =
543
540
  | "pending"
544
541
  | "confirmed"
545
- | "preparing"
542
+ | "preview"
546
543
  | "in-progress"
547
544
  | "paused"
548
545
  | "completing";
@@ -582,7 +579,7 @@ type TaskHandlingStep = {
582
579
  step: HandlingStep;
583
580
  at: IsoTimestamp;
584
581
  dialId?: DialId;
585
- destination?: string;
582
+ destinationId?: string;
586
583
  seconds?: DurationSeconds;
587
584
  by?: UserId;
588
585
  };
@@ -598,7 +595,7 @@ type OnCallStage = "ringing" | "joined";
598
595
  type OnCall = { since: IsoTimestamp; held?: true } & (
599
596
  | { role: "party" }
600
597
  | { role: "agent"; userId: UserId }
601
- | { role: "consulted" | "conferenced"; destination: string; stage: OnCallStage; dialId?: DialId; label?: string }
598
+ | { role: "consulted" | "conferenced"; destinationId: string; stage: OnCallStage; dialId?: DialId; label?: string }
602
599
  );
603
600
 
604
601
  type TaskLeadAssist = {
@@ -647,6 +644,8 @@ type Task<C extends Channel = Channel> = {
647
644
  party?: Contact;
648
645
  phase: TaskPhase;
649
646
  acceptance?: AcceptanceMode;
647
+ previewEndsAt?: IsoTimestamp;
648
+ atDeadline?: PreviewDeadline;
650
649
  reference?: string;
651
650
  attributes?: TaskAttribute[];
652
651
  handlingHistory?: TaskHandlingHistory;
@@ -656,6 +655,8 @@ type Task<C extends Channel = Channel> = {
656
655
  : { onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
657
656
  );
658
657
 
658
+ type PreviewDeadline = "calls" | "expires";
659
+
659
660
  type AcceptanceMode =
660
661
  | "no-preference"
661
662
  | "consent"
@@ -663,9 +664,9 @@ type AcceptanceMode =
663
664
 
664
665
  type TaskOutcome =
665
666
  | { type: "completed"; by: "agent" | "provider" }
666
- | { type: "transferred"; destination?: string }
667
+ | { type: "transferred"; destinationId?: string }
667
668
  | { type: "cancelled"; reason?: string }
668
- | { type: "expired"; phase: "pending" | "confirmed" | "preparing" }
669
+ | { type: "expired"; phase: "pending" | "confirmed" | "preview" }
669
670
  | { type: "left" }
670
671
  | { type: "failed"; failure: ProtocolFailure };
671
672
  ```
@@ -677,7 +678,7 @@ const TASK_COMMAND_NAMES = {
677
678
  voice: [
678
679
  "answer",
679
680
  "decline",
680
- "start-call",
681
+ "call",
681
682
  "mute",
682
683
  "hold",
683
684
  "resume",
@@ -701,22 +702,22 @@ type DispositionPayload = { disposition?: string; notes?: string };
701
702
  type VoiceTaskCommand =
702
703
  | { type: "answer" }
703
704
  | { type: "decline" }
704
- | { type: "start-call" }
705
+ | { type: "call"; dialId: DialId }
705
706
  | { type: "mute"; muted: boolean }
706
707
  | { type: "hold" }
707
708
  | { type: "resume" }
708
709
  | { type: "disconnect" }
709
710
  | { type: "connect-back"; dialId: DialId }
710
- | { type: "transfer"; action: "cold"; dialId: DialId; destination: string }
711
- | { type: "transfer"; action: "warm"; dialId: DialId; destination: string }
711
+ | { type: "transfer"; action: "cold"; dialId: DialId; destinationId: string }
712
+ | { type: "transfer"; action: "warm"; dialId: DialId; destinationId: string }
712
713
  | { type: "transfer"; action: "complete" }
713
714
  | { type: "transfer"; action: "cancel" }
714
715
  | { type: "lead-assist"; action: "request"; note?: string }
715
716
  | { type: "lead-assist"; action: "cancel" }
716
717
  | { type: "lead-assist"; action: "take-over" }
717
718
  | { type: "lead-assist"; action: "leave" }
718
- | { type: "conference"; action: "add"; dialId: DialId; destination: string }
719
- | { type: "conference"; action: "remove"; destination: string }
719
+ | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
720
+ | { type: "conference"; action: "remove"; destinationId: string }
720
721
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
721
722
  | ({ type: "complete" } & DispositionPayload);
722
723
 
@@ -997,13 +998,12 @@ type ProviderEvent =
997
998
  type: "task-offered";
998
999
  task: Task;
999
1000
  allocationExpiresAt?: IsoTimestamp;
1000
- preparationEndsAt?: IsoTimestamp;
1001
1001
  }
1002
1002
  | { type: "task-updated"; task: Task }
1003
1003
  | { type: "task-media-started"; taskId: TaskId }
1004
1004
  | { type: "task-media-ended"; taskId: TaskId }
1005
1005
  | { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
1006
- | { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; destination?: string; reason?: string }
1006
+ | { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; destinationId?: string; reason?: string }
1007
1007
  | { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
1008
1008
  | { type: "queue-summary"; summary: QueueSummary }
1009
1009
  | { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId }
@@ -1163,7 +1163,7 @@ const DEFAULT_TASK_PHASE_LABELS = {
1163
1163
  voice: {
1164
1164
  pending: "Offered",
1165
1165
  confirmed: "Accepted",
1166
- preparing: "Preview",
1166
+ preview: "Preview",
1167
1167
  "in-progress": "On Call",
1168
1168
  paused: "On Hold",
1169
1169
  completing: "After Call Work",
@@ -1171,7 +1171,7 @@ const DEFAULT_TASK_PHASE_LABELS = {
1171
1171
  chat: {
1172
1172
  pending: "Incoming Chat",
1173
1173
  confirmed: "Accepted",
1174
- preparing: "Preparing",
1174
+ preview: "Preview",
1175
1175
  "in-progress": "In Chat",
1176
1176
  paused: "Paused",
1177
1177
  completing: "Wrap-up",
@@ -1179,7 +1179,7 @@ const DEFAULT_TASK_PHASE_LABELS = {
1179
1179
  email: {
1180
1180
  pending: "Assigned",
1181
1181
  confirmed: "Accepted",
1182
- preparing: "Reviewing",
1182
+ preview: "Preview",
1183
1183
  "in-progress": "Working",
1184
1184
  paused: "Paused",
1185
1185
  completing: "Completing",
@@ -1283,7 +1283,7 @@ only where the corresponding capability is declared; an absent capability means
1283
1283
  there is no separate permission flag.
1284
1284
 
1285
1285
  The commands every task has are authorized by other fields the provider declared — a task that was
1286
- offered can be accepted, one in `preparing` can be started, one whose `completionMode` is
1286
+ offered can be accepted, one in `preview` can be called, one whose `completionMode` is
1287
1287
  `agent-command` can be completed. Nothing is issuable that the provider did not publish; only
1288
1288
  which field says so varies. See **Which commands need a capability**.
1289
1289
 
@@ -2092,7 +2092,6 @@ const allocation = {
2092
2092
  type: "task-offered",
2093
2093
  task: { ...task, phase: "pending", acceptance: "consent" },
2094
2094
  allocationExpiresAt: "2026-08-25T10:41:07.000Z",
2095
- preparationEndsAt: "2026-08-25T10:40:37.000Z",
2096
2095
  } satisfies Extract<ProviderEvent, { type: "task-offered" }>;
2097
2096
  ```
2098
2097
 
@@ -2114,10 +2113,8 @@ down and stops offering **Accept** once it passes. **Omit it unless the provider
2114
2113
  A provider that reports only elapsed ring time after the fact cannot say when an offer is due
2115
2114
  to end, and a computed value would have Omni withdraw **Accept** from a task still pending.
2116
2115
 
2117
- `preparationEndsAt` is the time available for the agent to review the task context before acting.
2118
- Omni presents the deadline with the allocation so a preview-based dialer can show how long remains
2119
- before the agent must start the contact. Reaching the timestamp does not imply a transition; the
2120
- provider reports what happens next through an event.
2116
+ A preview's deadline is not on the offer. It travels on the task, as `previewEndsAt` with
2117
+ `atDeadline`, so a snapshot carries it too -- see **Preview: the agent presses Call**.
2121
2118
 
2122
2119
  A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome.
2123
2120
 
@@ -2151,15 +2148,17 @@ time. Runtime conformance checks also require the task channel to match its prov
2151
2148
  | `capabilities` | Controls and workspace features available for this specific task. |
2152
2149
  | `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
2153
2150
  | `party` | The person or entity on the other end of this task, as a `Contact`: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is *with*; `contacts` is the directory. |
2154
- | `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
2151
+ | `phase` | Current canonical task phase: `pending`, `confirmed`, `preview`, `in-progress`, `paused`, or `completing`. `preview` is voice only. |
2155
2152
  | `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. |
2156
2153
  | `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
2154
+ | `previewEndsAt` | Voice only, in `preview`: when the system stops waiting for the agent to press Call. Absent, the agent has as long as they need. Always with `atDeadline`. See **Preview: the agent presses Call**. |
2155
+ | `atDeadline` | Voice only, in `preview`, with `previewEndsAt`: what the system does at the deadline -- `calls` places the call itself, `expires` takes the record back and the task ends `expired`. |
2157
2156
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
2158
2157
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
2159
2158
  | `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
2160
2159
  | `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. |
2161
2160
  | `handlingHistory` | The call record: `steps` — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `handleSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **How a task has been handled**. |
2162
- | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destination` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
2161
+ | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
2163
2162
  | `leadAssist` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Lead assist**. |
2164
2163
  | `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead-assist` `take-over` and `leave` issuable. See **Lead assist**. |
2165
2164
  | `monitoring` | Voice only, on the lead's own task while they listen to a member's call: whose call, which call, and the `mode` they are heard in, restated on every change. Never on the member's task, and never together with `assisting`. See **Monitoring a call**. |
@@ -2192,9 +2191,11 @@ The canonical task transitions are:
2192
2191
  | --- | --- | --- |
2193
2192
  | No task | Provider allocates a task | `pending` |
2194
2193
  | `pending` | Task is accepted | `confirmed` |
2195
- | `confirmed` | Preparation begins | `preparing` |
2196
- | `confirmed` | Work begins without preparation | `in-progress` |
2197
- | `preparing` | Agent starts the contact | `in-progress` |
2194
+ | `confirmed` | The customer's record is put in front of the agent before any call goes out (voice) | `preview` |
2195
+ | `confirmed` | Work begins | `in-progress` |
2196
+ | `preview` | Agent presses Call (`call`) and the customer answers, or the deadline `calls` and they answer | `in-progress` |
2197
+ | `preview` | The call goes out and nobody answers | `completing` |
2198
+ | `preview` | The deadline `expires` | Removed by `task-ended` with `expired` outcome |
2198
2199
  | `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
2199
2200
  | No task | Snapshot reports work already underway | `in-progress` |
2200
2201
  | `in-progress` | Provider or agent pauses the task | `paused` |
@@ -2207,9 +2208,47 @@ The canonical task transitions are:
2207
2208
 
2208
2209
  Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
2209
2210
  task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
2210
- subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
2211
+ subsequent transitions to `preview` or `in-progress`; Omni does not infer them from the acceptance
2211
2212
  command.
2212
2213
 
2214
+ #### Preview: the agent presses Call
2215
+
2216
+ An outbound campaign that lets the agent see who they are about to call is a **preview**: the
2217
+ record arrives as a task, the task sits in `preview` with the party on it, and no call has gone
2218
+ out. The agent reads and presses Call. That is the whole phase, and it exists on voice alone --
2219
+ chat and email have no call to place, and their reading is ordinary work in `in-progress`.
2220
+
2221
+ ```ts
2222
+ const previewed = {
2223
+ channel: "voice",
2224
+ capabilities: {},
2225
+ phase: "preview",
2226
+ party: { name: "Maya Rao", number: "+919876543210" },
2227
+ previewEndsAt: "2026-08-25T10:40:37.000Z",
2228
+ atDeadline: "calls",
2229
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "party" | "previewEndsAt" | "atDeadline">;
2230
+
2231
+ // The agent presses Call. It is a dial like any other.
2232
+ const pressed: TaskCommand<"voice"> = { type: "call", dialId: "dial-7f2" };
2233
+ ```
2234
+
2235
+ **Call is a dial.** It carries the host's `dialId`, is answered `dialling`, and ends in exactly one
2236
+ `dial-outcome`; the phase is its gate and there is no capability, since a record put in front of an
2237
+ agent is there to be called. On `answered` the task is `in-progress` and its media follows on
2238
+ `task-media-started`. On any other outcome the task goes to `completing`, so the agent records the
2239
+ no-answer as the disposition it is -- in a campaign that is the commonest outcome there is, and it
2240
+ is work, not a cancellation. A `preview` task therefore needs a manifest that says how a dial ends
2241
+ (`task.preview.dialOutcomes.required`).
2242
+
2243
+ **The deadline says what the system will do.** A provider that will not wait forever states
2244
+ `previewEndsAt`, and with it `atDeadline`: `calls` means the system places the call itself when the
2245
+ time runs out, which is progressive dialling; `expires` means the record is taken back and the task
2246
+ ends with an `expired` outcome naming `preview`. The two travel together
2247
+ (`task.preview.atDeadline.required`, `task.preview.previewEndsAt.required`), only while the task is in
2248
+ `preview` (`task.preview.deadline.unexpected`), and Omni's countdown reads "calling in 12s" or
2249
+ "expires in 12s" from the provider's word. With neither, the agent has as long as they need.
2250
+ Reaching the instant is not itself a transition: the provider reports what it did, as an event.
2251
+
2213
2252
  **A task is never its audio.** A voice task is the allocation: the call is offered when it is
2214
2253
  routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
2215
2254
  the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
@@ -2372,7 +2411,7 @@ executes** — so without a step the provider, which alone keeps the task's reco
2372
2411
  account of a period the agent could not be heard.
2373
2412
 
2374
2413
  **A step that dialled says which dial and where.** `transferred`, `conferenced` and `unanswered`
2375
- each end a dial, so the entry carries the `destination` it went to and, where a host placed it, the
2414
+ each end a dial, so the entry carries the `destinationId` it went to and, where a host placed it, the
2376
2415
  `dialId` the host minted; no other step dialled, and either field on one is refused
2377
2416
  (`task.handlingHistory.dialId.unexpected`). This is how a dial made before a transfer is placeable
2378
2417
  by whoever holds the task now: a `dial-outcome` naming a `dialId` the host never minted is looked
@@ -2632,9 +2671,9 @@ the provider published none.
2632
2671
  ```ts
2633
2672
  capabilities: {
2634
2673
  coldTransfer: {
2635
- allowManualEntry: false,
2636
2674
  destinations: [
2637
- { id: "tier2", label: "Tier 2 support", address: "+14155550111", kind: "queue" },
2675
+ { id: "tier2", label: "Tier 2 support" },
2676
+ { id: "main-menu", label: "Back to the main menu" },
2638
2677
  ],
2639
2678
  },
2640
2679
  }
@@ -2642,22 +2681,23 @@ capabilities: {
2642
2681
 
2643
2682
  | Field | Contract |
2644
2683
  | --- | --- |
2645
- | `destinations` | Directory Omni renders, with unique `id` values. |
2646
- | `address` | The value Omni sends as `destination` on a `transfer` or a `conference` command. |
2647
- | `kind` | Where the contact is going. See below. |
2648
- | `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. |
2649
-
2650
- `kind` says who receives the contact and whether this provider still holds it afterwards:
2651
-
2652
- | Kind | What it means |
2653
- | --- | --- |
2654
- | `queue` | A routing point on this provider. Whoever is next takes it, nobody is named, and the provider keeps the contact. |
2655
- | `agent` | One named person on this provider. The provider keeps the contact and knows who has it. |
2656
- | `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. |
2657
-
2658
- A destination the agent types is not in the directory and has no `kind`. Omni treats it as
2659
- `external` unless the provider says otherwise in its response, because that is the assumption that
2660
- does not overstate what the provider can still see.
2684
+ | `destinations` | The items the control offers, at least one, with unique `id` values. Each is a button or a menu item on the desk. |
2685
+ | `id` | What Omni sends as `destinationId` on a `transfer` or `conference` command, and what the provider executes. |
2686
+ | `label` | What the agent reads. |
2687
+
2688
+ **The protocol does not say what an item does.** A queue, an IVR menu, an outside line, a
2689
+ skill group -- that is the queue's configuration and the provider's business. The agent sees a
2690
+ list of labels, picks one, and Omni sends its `id`; the provider executes whatever the item is. No
2691
+ kind travels, nothing is typed, and a control with an empty directory has nothing to offer and is
2692
+ refused (`task.destinations.offer`). A control declared as bare `true` is refused for the same
2693
+ reason (`task.destinations.shape`): once nothing is typed, the directory is the control.
2694
+
2695
+ **An agent never transfers to a named agent**, warm or cold. A contact that needs another pair of
2696
+ hands goes back to a queue or a menu the queue configured, and who takes it next is the queue's
2697
+ decision: routing by skill, availability and fairness is what the queue is for, and a hand-off to a
2698
+ chosen colleague bypasses all three. The desk shows no agent picker and no dial pad on a transfer.
2699
+ The one way a named person ends up with an agent's call is a lead taking it over through lead
2700
+ assist, which is the lead's act, not a transfer the agent chose.
2661
2701
 
2662
2702
  #### Warm transfer
2663
2703
 
@@ -2671,7 +2711,7 @@ offer any of the three without the others, and each is declared on its own.
2671
2711
  // 1. Warm. A dial: the provider parks the customer and calls the destination, answers
2672
2712
  // `dialling`, and the task reports `paused` with the destination `consulted` on `onCall`
2673
2713
  // while the call to it stands.
2674
- { type: "transfer", action: "warm", dialId: "dial-7f2", destination: "+14155550111" }
2714
+ { type: "transfer", action: "warm", dialId: "dial-7f2", destinationId: "tier2" }
2675
2715
 
2676
2716
  // 2a. Hand the customer to the consulted destination and leave.
2677
2717
  { type: "transfer", action: "complete" }
@@ -2680,7 +2720,7 @@ offer any of the three without the others, and each is declared on its own.
2680
2720
  { type: "transfer", action: "cancel" }
2681
2721
  ```
2682
2722
 
2683
- `warm` is gated by the `warmTransfer` capability and takes a destination exactly as a cold
2723
+ `warm` is gated by the `warmTransfer` capability and names a directory item exactly as a cold
2684
2724
  transfer does, from the same kind of directory. It is a dial, so it carries the host's `dialId`
2685
2725
  and is answered `dialling`, and its `dial-outcome` says whether the destination was reached. While
2686
2726
  the destination is on the line the task carries a `consulted` entry on `onCall`, and that presence
@@ -2790,16 +2830,16 @@ declare const connection: Connection<"voice">;
2790
2830
  declare const taskId: TaskId;
2791
2831
 
2792
2832
  // 1. Out, minted by the host.
2793
- const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destination: "+14155550111" } });
2833
+ const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
2794
2834
 
2795
2835
  // 2. Back on the result, restated, so the host compares and confirms.
2796
2836
  expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
2797
2837
 
2798
2838
  // 3. On the outcome, however late, against the task it belonged to.
2799
- const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, destination: "+14155550111", outcome: "no-answer", reason: "No route to destination" };
2839
+ const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
2800
2840
 
2801
2841
  // 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
2802
- const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destination: "+14155550111" };
2842
+ const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };
2803
2843
  ```
2804
2844
 
2805
2845
  The identity is a correlation handle between host and provider and **is never shown to the
@@ -2815,7 +2855,7 @@ nothing, is refused (`result.status`). `validateResult(result, method, path, dia
2815
2855
  `dialId` the host sent for that reason.
2816
2856
 
2817
2857
  **The outcome is stated, once, either way.** A `dial-outcome` names the `dialId`, the task where
2818
- there was one, the destination where the provider knows it, and how the dial ended -- from a closed
2858
+ there was one, the directory item it went to where there was one, and how the dial ended -- from a closed
2819
2859
  set: `answered`, `busy`, `no-answer`, `unreachable`, `rejected`, `cancelled`, `unexplained`.
2820
2860
  `answered` is stated rather than read off somebody appearing on the call, because a host that infers
2821
2861
  success cannot tell "reached" from "still ringing". `cancelled` is the dialler calling the dial off
@@ -2868,15 +2908,15 @@ knows the room:
2868
2908
  const onCall: OnCall[] = [
2869
2909
  { role: "party", since: "2026-08-21T09:12:00Z" },
2870
2910
  { role: "agent", userId: "A-12", since: "2026-08-21T09:12:04Z" },
2871
- { role: "conferenced", destination: "+14155550111", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
2872
- { role: "conferenced", destination: "+14155550199", dialId: "dial-3c9", stage: "ringing", since: "2026-08-21T09:16:02Z" },
2911
+ { role: "conferenced", destinationId: "tier2", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
2912
+ { role: "conferenced", destinationId: "main-menu", dialId: "dial-3c9", stage: "ringing", since: "2026-08-21T09:16:02Z" },
2873
2913
  ];
2874
2914
  ```
2875
2915
 
2876
2916
  `party` is the customer and `agent` a person by user id; neither was dialled from anywhere, so
2877
- neither carries a destination. `consulted` and `conferenced` were, so both carry the `destination`
2878
- and, where a host's dial brought them, its `dialId`; a person the platform added itself carries
2879
- none. `held: true` is presence as claim. A snapshot establishes state, so a host that attaches
2917
+ neither carries a destination. `consulted` and `conferenced` were, so both carry the
2918
+ `destinationId` of the directory item and, where a host's dial brought them, its `dialId`; a person
2919
+ the platform added itself carries none. `held: true` is presence as claim. A snapshot establishes state, so a host that attaches
2880
2920
  after a transfer reads the room from here rather than inferring it from a sequence of outcomes it
2881
2921
  never saw.
2882
2922
 
@@ -2885,7 +2925,7 @@ what makes `transfer` `cancel` issuable and what `conference` `remove` names, an
2885
2925
  agent cannot call off while it rings is a parked customer with no way back -- which is the very case
2886
2926
  `cancelled` exists for. Consult and conference behave alike here: the `consulted` or `conferenced`
2887
2927
  entry appears with its `dialId` when the dial is placed, and a `conference` `remove` naming a
2888
- still-ringing `destination` calls that dial off, with `cancelled` as its outcome.
2928
+ still-ringing `destinationId` calls that dial off, with `cancelled` as its outcome.
2889
2929
 
2890
2930
  **The entry says where it stands.** A dialled entry carries `stage`: `ringing` until its dial is
2891
2931
  answered, `joined` after. The `dial-outcome` is the transition and the stage is the state, the same
@@ -3707,7 +3747,7 @@ declared:
3707
3747
  | --- | --- |
3708
3748
  | `answer`, `accept` | Nothing. A task that was offered can be accepted, or offering it meant nothing. |
3709
3749
  | `decline` | The `decline` capability on any channel, **and** Omni provisioning permitting it. One word for refusing an offer, whatever the channel. |
3710
- | `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. |
3750
+ | `call` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
3711
3751
  | `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. |
3712
3752
  | `connect-back` | The `connectBack` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
3713
3753
  | `transfer` with `action: "warm"` | The `warmTransfer` capability. `action: "cold"` is gated by `coldTransfer`; the two are declared and offered separately. |
@@ -3764,7 +3804,7 @@ react rather than only display the message:
3764
3804
  | `omni.not-authenticated` | The provider session is no longer usable. The adapter has published `expired` at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal. |
3765
3805
  | `omni.capability-not-enabled` | The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose `capabilities` no longer carry it. |
3766
3806
  | `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
3767
- | `omni.destination-not-permitted` | The dial or transfer destination violates the provider's policy. |
3807
+ | `omni.destination-not-permitted` | The dialled number, or the `destinationId` named, is not one the provider offers this agent. |
3768
3808
  | `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
3769
3809
  | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
3770
3810
  | `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
@@ -3785,7 +3825,7 @@ They are published as `OMNI_FAILURE_CODES`.
3785
3825
  #### Provider instants are read against a provider clock
3786
3826
 
3787
3827
  Every deadline in this contract is a provider instant that Omni counts down: `allocationExpiresAt`,
3788
- `preparationEndsAt`, and the wrap deadline of `task-media-ended` plus `wrapAllowance` where
3828
+ `previewEndsAt`, and the wrap deadline of `task-media-ended` plus `wrapAllowance` where
3789
3829
  one is stated.
3790
3830
  Comparing those against the host clock is wrong by whatever the two machines disagree by, and the
3791
3831
  damaging direction is early — **Accept** withdrawn from an offer still ringing, a wrap timer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",