@xema/omni-protocol 0.1.44 → 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>;
@@ -561,10 +563,10 @@ export interface TaskHandlingStep {
561
563
  at: IsoTimestamp;
562
564
  /**
563
565
  * On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
564
- * 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.
565
567
  */
566
568
  dialId?: DialId;
567
- destination?: string;
569
+ destinationId?: string;
568
570
  /**
569
571
  * Reported, never derived. An entry may be written while its leg is still running, so there is
570
572
  * no end to subtract from -- and omitted is the honest report of that, where nought would
@@ -616,7 +618,7 @@ export type OnCall = {
616
618
  userId: UserId;
617
619
  } | {
618
620
  role: "consulted" | "conferenced";
619
- destination: string;
621
+ destinationId: string;
620
622
  stage: OnCallStage;
621
623
  dialId?: DialId;
622
624
  label?: string;
@@ -751,7 +753,7 @@ export type TaskOutcome = {
751
753
  by: "agent" | "provider";
752
754
  } | {
753
755
  type: "transferred";
754
- destination?: string;
756
+ destinationId?: string;
755
757
  } | {
756
758
  type: "cancelled";
757
759
  reason?: string;
@@ -802,19 +804,19 @@ export type VoiceTaskCommand = {
802
804
  type: "connect-back";
803
805
  dialId: DialId;
804
806
  }
805
- /** 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`. */
806
808
  | {
807
809
  type: "transfer";
808
810
  action: "cold";
809
811
  dialId: DialId;
810
- destination: string;
812
+ destinationId: string;
811
813
  }
812
- /** 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`. */
813
815
  | {
814
816
  type: "transfer";
815
817
  action: "warm";
816
818
  dialId: DialId;
817
- destination: string;
819
+ destinationId: string;
818
820
  }
819
821
  /** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
820
822
  | {
@@ -847,18 +849,18 @@ export type VoiceTaskCommand = {
847
849
  type: "lead-assist";
848
850
  action: "leave";
849
851
  }
850
- /** Dial `destination` into the call. Gated by `conference`. */
852
+ /** Dial the directory item `destinationId` into the call. Gated by `conference`. */
851
853
  | {
852
854
  type: "conference";
853
855
  action: "add";
854
856
  dialId: DialId;
855
- destination: string;
857
+ destinationId: string;
856
858
  }
857
- /** 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. */
858
860
  | {
859
861
  type: "conference";
860
862
  action: "remove";
861
- destination: string;
863
+ destinationId: string;
862
864
  } | {
863
865
  type: "recording";
864
866
  action: "start" | "pause" | "resume" | "stop";
@@ -1296,7 +1298,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
1296
1298
  dialId: DialId;
1297
1299
  outcome: DialOutcome;
1298
1300
  taskId?: TaskId;
1299
- destination?: string;
1301
+ destinationId?: string;
1300
1302
  reason?: string;
1301
1303
  } | {
1302
1304
  type: "announcement";
@@ -1443,7 +1445,7 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
1443
1445
  export declare const userKey: (providerId: string, userId: UserId) => string;
1444
1446
  /** Every handling step somebody takes part in. `queued` is the one nobody does. */
1445
1447
  export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
1446
- /** 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`. */
1447
1449
  export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
1448
1450
  export declare const handlingStepDials: (step: HandlingStep) => boolean;
1449
1451
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
package/dist/index.js CHANGED
@@ -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
@@ -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 });
@@ -418,23 +417,18 @@ export function validateManifest(manifest, path = "manifest") {
418
417
  // Task.
419
418
  // ---------------------------------------------------------------------------
420
419
  function validateDestinationDirectory(value, path, into) {
421
- if (value === true)
422
- return;
423
420
  if (!isPlainObject(value)) {
424
- 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: [...] }");
425
422
  return;
426
423
  }
427
- into.require(typeof value.allowManualEntry === "boolean", "task.destinations.allowManualEntry", `${path}.allowManualEntry`, "a directory must say whether manual entry is allowed");
428
- // A directory with nothing in it and no typing is a control with nothing to offer.
429
- if (value.allowManualEntry === false && !(Array.isArray(value.destinations) && value.destinations.length > 0)) {
430
- into.add("task.destinations.offer", path, "a directory with no destinations must allow manual entry, or the control has nothing to offer");
431
- }
432
- if (value.destinations === undefined)
433
- return;
434
424
  if (!Array.isArray(value.destinations)) {
435
- 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");
436
426
  return;
437
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
+ }
438
432
  const seen = new Set();
439
433
  value.destinations.forEach((destination, index) => {
440
434
  const at = `${path}.destinations[${index}]`;
@@ -448,8 +442,6 @@ function validateDestinationDirectory(value, path, into) {
448
442
  seen.add(destination.id);
449
443
  }
450
444
  into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
451
- into.filled(destination.address, "task.destination.address", `${at}.address`, "a destination needs an address");
452
- into.oneOf(destination.kind, DESTINATION_KINDS, "task.destination.kind", `${at}.kind`);
453
445
  });
454
446
  }
455
447
  function validateDispositions(value, path, into) {
@@ -724,7 +716,7 @@ function validateHandlingHistory(value, path, into) {
724
716
  }
725
717
  // A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
726
718
  const dialled = typeof entry.step === "string" && HANDLING_STEPS_THAT_DIAL.includes(entry.step);
727
- for (const field of ["dialId", "destination"]) {
719
+ for (const field of ["dialId", "destinationId"]) {
728
720
  if (entry[field] === undefined)
729
721
  continue;
730
722
  if (into.filled(entry[field], `task.handlingHistory.${field}`, `${at}.${field}`, `${field} must not be empty when present`)) {
@@ -780,7 +772,7 @@ function validateOnCall(value, channel, path, into) {
780
772
  into.require(entry.userId === undefined, "task.onCall.userId.unexpected", `${at}.userId`, `a ${role} is not named by a user id`);
781
773
  }
782
774
  if (role === "consulted" || role === "conferenced") {
783
- 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`);
784
776
  // Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
785
777
  if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
786
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");
@@ -791,7 +783,7 @@ function validateOnCall(value, channel, path, into) {
791
783
  into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
792
784
  }
793
785
  else {
794
- for (const field of ["destination", "dialId", "label", "stage"]) {
786
+ for (const field of ["destinationId", "dialId", "label", "stage"]) {
795
787
  into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
796
788
  }
797
789
  }
@@ -1297,8 +1289,8 @@ function validateTaskOutcome(value, path, into) {
1297
1289
  into.oneOf(value.by, COMPLETED_BY, "event.taskEnded.outcome.completed", `${path}.by`);
1298
1290
  break;
1299
1291
  case "transferred":
1300
- if (value.destination !== undefined) {
1301
- 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");
1302
1294
  }
1303
1295
  break;
1304
1296
  case "cancelled":
@@ -1433,8 +1425,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1433
1425
  }
1434
1426
  if (event.taskId !== undefined)
1435
1427
  into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
1436
- if (event.destination !== undefined)
1437
- 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");
1438
1430
  if (event.reason !== undefined)
1439
1431
  into.filled(event.reason, "event.dialOutcome.reason", `${at}.reason`, "a reason must not be empty when present");
1440
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. |
@@ -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"
@@ -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 = {
@@ -667,7 +664,7 @@ type AcceptanceMode =
667
664
 
668
665
  type TaskOutcome =
669
666
  | { type: "completed"; by: "agent" | "provider" }
670
- | { type: "transferred"; destination?: string }
667
+ | { type: "transferred"; destinationId?: string }
671
668
  | { type: "cancelled"; reason?: string }
672
669
  | { type: "expired"; phase: "pending" | "confirmed" | "preview" }
673
670
  | { type: "left" }
@@ -711,16 +708,16 @@ type VoiceTaskCommand =
711
708
  | { type: "resume" }
712
709
  | { type: "disconnect" }
713
710
  | { type: "connect-back"; dialId: DialId }
714
- | { type: "transfer"; action: "cold"; dialId: DialId; destination: string }
715
- | { 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 }
716
713
  | { type: "transfer"; action: "complete" }
717
714
  | { type: "transfer"; action: "cancel" }
718
715
  | { type: "lead-assist"; action: "request"; note?: string }
719
716
  | { type: "lead-assist"; action: "cancel" }
720
717
  | { type: "lead-assist"; action: "take-over" }
721
718
  | { type: "lead-assist"; action: "leave" }
722
- | { type: "conference"; action: "add"; dialId: DialId; destination: string }
723
- | { type: "conference"; action: "remove"; destination: string }
719
+ | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
720
+ | { type: "conference"; action: "remove"; destinationId: string }
724
721
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
725
722
  | ({ type: "complete" } & DispositionPayload);
726
723
 
@@ -1006,7 +1003,7 @@ type ProviderEvent =
1006
1003
  | { type: "task-media-started"; taskId: TaskId }
1007
1004
  | { type: "task-media-ended"; taskId: TaskId }
1008
1005
  | { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
1009
- | { 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 }
1010
1007
  | { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
1011
1008
  | { type: "queue-summary"; summary: QueueSummary }
1012
1009
  | { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId }
@@ -2161,7 +2158,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2161
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. |
2162
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. |
2163
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**. |
2164
- | `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**. |
2165
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**. |
2166
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**. |
2167
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**. |
@@ -2414,7 +2411,7 @@ executes** — so without a step the provider, which alone keeps the task's reco
2414
2411
  account of a period the agent could not be heard.
2415
2412
 
2416
2413
  **A step that dialled says which dial and where.** `transferred`, `conferenced` and `unanswered`
2417
- 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
2418
2415
  `dialId` the host minted; no other step dialled, and either field on one is refused
2419
2416
  (`task.handlingHistory.dialId.unexpected`). This is how a dial made before a transfer is placeable
2420
2417
  by whoever holds the task now: a `dial-outcome` naming a `dialId` the host never minted is looked
@@ -2674,9 +2671,9 @@ the provider published none.
2674
2671
  ```ts
2675
2672
  capabilities: {
2676
2673
  coldTransfer: {
2677
- allowManualEntry: false,
2678
2674
  destinations: [
2679
- { 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" },
2680
2677
  ],
2681
2678
  },
2682
2679
  }
@@ -2684,22 +2681,23 @@ capabilities: {
2684
2681
 
2685
2682
  | Field | Contract |
2686
2683
  | --- | --- |
2687
- | `destinations` | Directory Omni renders, with unique `id` values. |
2688
- | `address` | The value Omni sends as `destination` on a `transfer` or a `conference` command. |
2689
- | `kind` | Where the contact is going. See below. |
2690
- | `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. |
2691
-
2692
- `kind` says who receives the contact and whether this provider still holds it afterwards:
2693
-
2694
- | Kind | What it means |
2695
- | --- | --- |
2696
- | `queue` | A routing point on this provider. Whoever is next takes it, nobody is named, and the provider keeps the contact. |
2697
- | `agent` | One named person on this provider. The provider keeps the contact and knows who has it. |
2698
- | `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. |
2699
-
2700
- A destination the agent types is not in the directory and has no `kind`. Omni treats it as
2701
- `external` unless the provider says otherwise in its response, because that is the assumption that
2702
- 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.
2703
2701
 
2704
2702
  #### Warm transfer
2705
2703
 
@@ -2713,7 +2711,7 @@ offer any of the three without the others, and each is declared on its own.
2713
2711
  // 1. Warm. A dial: the provider parks the customer and calls the destination, answers
2714
2712
  // `dialling`, and the task reports `paused` with the destination `consulted` on `onCall`
2715
2713
  // while the call to it stands.
2716
- { type: "transfer", action: "warm", dialId: "dial-7f2", destination: "+14155550111" }
2714
+ { type: "transfer", action: "warm", dialId: "dial-7f2", destinationId: "tier2" }
2717
2715
 
2718
2716
  // 2a. Hand the customer to the consulted destination and leave.
2719
2717
  { type: "transfer", action: "complete" }
@@ -2722,7 +2720,7 @@ offer any of the three without the others, and each is declared on its own.
2722
2720
  { type: "transfer", action: "cancel" }
2723
2721
  ```
2724
2722
 
2725
- `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
2726
2724
  transfer does, from the same kind of directory. It is a dial, so it carries the host's `dialId`
2727
2725
  and is answered `dialling`, and its `dial-outcome` says whether the destination was reached. While
2728
2726
  the destination is on the line the task carries a `consulted` entry on `onCall`, and that presence
@@ -2832,16 +2830,16 @@ declare const connection: Connection<"voice">;
2832
2830
  declare const taskId: TaskId;
2833
2831
 
2834
2832
  // 1. Out, minted by the host.
2835
- 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" } });
2836
2834
 
2837
2835
  // 2. Back on the result, restated, so the host compares and confirms.
2838
2836
  expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
2839
2837
 
2840
2838
  // 3. On the outcome, however late, against the task it belonged to.
2841
- 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" };
2842
2840
 
2843
2841
  // 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
2844
- 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" };
2845
2843
  ```
2846
2844
 
2847
2845
  The identity is a correlation handle between host and provider and **is never shown to the
@@ -2857,7 +2855,7 @@ nothing, is refused (`result.status`). `validateResult(result, method, path, dia
2857
2855
  `dialId` the host sent for that reason.
2858
2856
 
2859
2857
  **The outcome is stated, once, either way.** A `dial-outcome` names the `dialId`, the task where
2860
- 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
2861
2859
  set: `answered`, `busy`, `no-answer`, `unreachable`, `rejected`, `cancelled`, `unexplained`.
2862
2860
  `answered` is stated rather than read off somebody appearing on the call, because a host that infers
2863
2861
  success cannot tell "reached" from "still ringing". `cancelled` is the dialler calling the dial off
@@ -2910,15 +2908,15 @@ knows the room:
2910
2908
  const onCall: OnCall[] = [
2911
2909
  { role: "party", since: "2026-08-21T09:12:00Z" },
2912
2910
  { role: "agent", userId: "A-12", since: "2026-08-21T09:12:04Z" },
2913
- { role: "conferenced", destination: "+14155550111", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
2914
- { 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" },
2915
2913
  ];
2916
2914
  ```
2917
2915
 
2918
2916
  `party` is the customer and `agent` a person by user id; neither was dialled from anywhere, so
2919
- neither carries a destination. `consulted` and `conferenced` were, so both carry the `destination`
2920
- and, where a host's dial brought them, its `dialId`; a person the platform added itself carries
2921
- 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
2922
2920
  after a transfer reads the room from here rather than inferring it from a sequence of outcomes it
2923
2921
  never saw.
2924
2922
 
@@ -2927,7 +2925,7 @@ what makes `transfer` `cancel` issuable and what `conference` `remove` names, an
2927
2925
  agent cannot call off while it rings is a parked customer with no way back -- which is the very case
2928
2926
  `cancelled` exists for. Consult and conference behave alike here: the `consulted` or `conferenced`
2929
2927
  entry appears with its `dialId` when the dial is placed, and a `conference` `remove` naming a
2930
- 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.
2931
2929
 
2932
2930
  **The entry says where it stands.** A dialled entry carries `stage`: `ringing` until its dial is
2933
2931
  answered, `joined` after. The `dial-outcome` is the transition and the stage is the state, the same
@@ -3806,7 +3804,7 @@ react rather than only display the message:
3806
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. |
3807
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. |
3808
3806
  | `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
3809
- | `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. |
3810
3808
  | `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
3811
3809
  | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
3812
3810
  | `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.44",
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",