@xema/omni-protocol 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -293,6 +293,10 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
293
293
  /** Reach the party again while `completing`; the task returns to `in-progress`. */
294
294
  callback?: true;
295
295
  blindTransfer?: true | DestinationDirectory;
296
+ /** Park the customer and call a destination first; then `complete` or `cancel`. */
297
+ consultTransfer?: true | DestinationDirectory;
298
+ /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.lead`. */
299
+ consultLead?: true;
296
300
  conference?: true | DestinationDirectory;
297
301
  recording?: true;
298
302
  } : C extends "chat" ? SharedTaskCapabilities & {
@@ -392,6 +396,35 @@ export type TaskCompletion = {
392
396
  completionMode: "provider-automatic";
393
397
  completionAllowance: DurationSeconds;
394
398
  };
399
+ /**
400
+ * A consultation in progress on a task: who is being consulted, and since when where the provider
401
+ * records it. Present between `transfer` `consult` and whichever of `complete` or `cancel`
402
+ * follows; its presence is what makes those two issuable.
403
+ */
404
+ export interface TaskConsultation {
405
+ destination: string;
406
+ label?: string;
407
+ since?: IsoTimestamp;
408
+ }
409
+ /**
410
+ * The agent's request for a lead, from asking until the lead leaves or the request ends.
411
+ * `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
412
+ */
413
+ export interface TaskLead {
414
+ status: "requested" | "joined";
415
+ leadId?: UserId;
416
+ note?: string;
417
+ since: IsoTimestamp;
418
+ }
419
+ /**
420
+ * On the lead's own task for a call they joined: which member asked, with their note. Its
421
+ * presence is what makes `lead` `take-over` and `leave` issuable.
422
+ */
423
+ export interface TaskAssisting {
424
+ memberId: UserId;
425
+ note?: string;
426
+ since: IsoTimestamp;
427
+ }
395
428
  export type Task<C extends Channel = Channel> = {
396
429
  id: TaskId;
397
430
  title: string;
@@ -406,7 +439,15 @@ export type Task<C extends Channel = Channel> = {
406
439
  reference?: string;
407
440
  attributes?: TaskAttribute[];
408
441
  handlingHistory?: TaskHandlingStep[];
409
- } & TaskCompletion;
442
+ } & TaskCompletion & (C extends "voice" ? {
443
+ consultation?: TaskConsultation;
444
+ lead?: TaskLead;
445
+ assisting?: TaskAssisting;
446
+ } : {
447
+ consultation?: never;
448
+ lead?: never;
449
+ assisting?: never;
450
+ });
410
451
  /** What the provider wants of Omni's acceptance policy for one offer. */
411
452
  export type AcceptanceMode = "no-preference" | "require-agent-acceptance" | "require-automatic-acceptance";
412
453
  export type TaskOutcome = {
@@ -423,12 +464,16 @@ export type TaskOutcome = {
423
464
  | {
424
465
  type: "expired";
425
466
  phase: "pending" | "confirmed" | "preparing";
467
+ }
468
+ /** This agent left a call that continues without them: a lead who joined and dropped. */
469
+ | {
470
+ type: "left";
426
471
  } | {
427
472
  type: "failed";
428
473
  failure: ProtocolFailure;
429
474
  };
430
475
  export declare const TASK_COMMAND_NAMES: {
431
- readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "callback", "transfer", "conference", "recording", "complete"];
476
+ readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "callback", "transfer", "lead", "conference", "recording", "complete"];
432
477
  readonly chat: readonly ["accept", "reject", "pause", "resume", "complete"];
433
478
  readonly email: readonly ["accept", "reject", "complete"];
434
479
  };
@@ -456,9 +501,49 @@ export type VoiceTaskCommand = {
456
501
  /** Issuable only in `completing`, under the `callback` capability. Carries no destination. */
457
502
  | {
458
503
  type: "callback";
459
- } | {
504
+ }
505
+ /** Blind: hand the customer to `destination` with nobody consulted. Gated by `blindTransfer`. */
506
+ | {
507
+ type: "transfer";
508
+ destination: string;
509
+ action?: never;
510
+ }
511
+ /** Park the customer and call `destination` first. Gated by `consultTransfer`. */
512
+ | {
460
513
  type: "transfer";
514
+ action: "consult";
461
515
  destination: string;
516
+ }
517
+ /** Hand the customer to the consulted destination and leave. Needs `Task.consultation`. */
518
+ | {
519
+ type: "transfer";
520
+ action: "complete";
521
+ }
522
+ /** Drop the consulted destination and return to the customer. Needs `Task.consultation`. */
523
+ | {
524
+ type: "transfer";
525
+ action: "cancel";
526
+ }
527
+ /** Ask a lead to join, with a note. Gated by `consultLead`. */
528
+ | {
529
+ type: "lead";
530
+ action: "request";
531
+ note?: string;
532
+ }
533
+ /** Withdraw a standing request. Needs `Task.lead` with status `requested`. */
534
+ | {
535
+ type: "lead";
536
+ action: "cancel";
537
+ }
538
+ /** The lead keeps the customer; the agent's task ends `transferred`. Needs `Task.assisting`. */
539
+ | {
540
+ type: "lead";
541
+ action: "take-over";
542
+ }
543
+ /** The lead drops; the agent continues. The lead's task ends `left`. Needs `Task.assisting`. */
544
+ | {
545
+ type: "lead";
546
+ action: "leave";
462
547
  } | {
463
548
  type: "conference";
464
549
  participant: string;
@@ -636,9 +721,33 @@ export interface TeamMember {
636
721
  * Published only to an agent entitled to one. Its presence is the permission -- nothing else
637
722
  * makes somebody a lead, and there is no separate flag to fall out of step with the data.
638
723
  */
724
+ /** A member asking this lead to join their call. */
725
+ export interface LeadRequest {
726
+ id: string;
727
+ memberId: UserId;
728
+ taskId: TaskId;
729
+ note?: string;
730
+ since: IsoTimestamp;
731
+ }
639
732
  export interface TeamRoster {
640
733
  members: TeamMember[];
641
734
  breakControl?: true;
735
+ /** Present when this lead may join a member's call on request. */
736
+ consultControl?: true;
737
+ /** Omitted when the lead may not be asked; `[]` when nobody is asking. */
738
+ requests?: LeadRequest[];
739
+ }
740
+ export type TeamConsultCommand = {
741
+ type: "join";
742
+ requestId: string;
743
+ } | {
744
+ type: "decline";
745
+ requestId: string;
746
+ reason?: string;
747
+ };
748
+ export interface TeamConsultCommandRequest {
749
+ commandId: string;
750
+ command: TeamConsultCommand;
642
751
  }
643
752
  export type TeamBreakCommand = {
644
753
  type: "decide";
@@ -805,6 +914,8 @@ export interface Connection<C extends Channel = Channel> {
805
914
  endBreak?(): Promise<BreakEndResult>;
806
915
  /** Required when the adapter publishes a `TeamRoster` carrying `breakControl`. */
807
916
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
917
+ /** Required when the adapter publishes a `TeamRoster` carrying `consultControl`. */
918
+ executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
808
919
  /** Required of every voice adapter: all voice audio lands in Omni. */
809
920
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
810
921
  }
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ export function isAllowedBrowserUrl(url) {
55
55
  // ---------------------------------------------------------------------------
56
56
  export const TASK_COMMAND_NAMES = {
57
57
  voice: ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect",
58
- "callback", "transfer", "conference", "recording", "complete"],
58
+ "callback", "transfer", "lead", "conference", "recording", "complete"],
59
59
  chat: ["accept", "reject", "pause", "resume", "complete"],
60
60
  email: ["accept", "reject", "complete"],
61
61
  };
package/dist/testing.js CHANGED
@@ -76,6 +76,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
76
76
  }
77
77
  if (snapshot?.team?.breakControl === true)
78
78
  requireMethod("executeTeamBreak", "the roster carries breakControl");
79
+ if (snapshot?.team?.consultControl === true)
80
+ requireMethod("executeTeamConsult", "the roster carries consultControl");
79
81
  if (publishesUserIds(snapshot))
80
82
  requireMethod("describeUsers", "the snapshot publishes a UserId");
81
83
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
@@ -79,7 +79,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
79
79
  const TASK_CAPABILITIES = {
80
80
  voice: membersOf({
81
81
  browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
82
- agentDisconnect: true, callback: true, blindTransfer: true, conference: true, recording: true,
82
+ agentDisconnect: true, callback: true, blindTransfer: true, consultTransfer: true, consultLead: true, conference: true, recording: true,
83
83
  }),
84
84
  chat: membersOf({ browsers: true, dispositions: true, custom: true, reject: true, hold: true }),
85
85
  email: membersOf({ browsers: true, dispositions: true, custom: true, reject: true }),
@@ -502,6 +502,61 @@ function validateHandlingHistory(value, path, into) {
502
502
  }
503
503
  });
504
504
  }
505
+ /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
506
+ function validateConsultation(value, channel, path, into) {
507
+ if (value === undefined)
508
+ return;
509
+ if (!into.require(channel === "voice", "task.consultation.channel", path, `a ${channel} task cannot carry a consultation`))
510
+ return;
511
+ if (!isPlainObject(value)) {
512
+ into.add("task.consultation.shape", path, "a consultation must be an object when present");
513
+ return;
514
+ }
515
+ into.filled(value.destination, "task.consultation.destination", `${path}.destination`, "a consultation names the destination being consulted");
516
+ if (value.label !== undefined) {
517
+ into.filled(value.label, "task.consultation.label", `${path}.label`, "a label must not be empty when present");
518
+ }
519
+ if (value.since !== undefined)
520
+ into.timestamp(value.since, "task.consultation.since", `${path}.since`);
521
+ }
522
+ const LEAD_STATUSES = membersOf({ requested: true, joined: true });
523
+ /** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
524
+ function validateLead(value, channel, path, into) {
525
+ if (value === undefined)
526
+ return;
527
+ if (!into.require(channel === "voice", "task.lead.channel", path, `a ${channel} task cannot carry a lead request`))
528
+ return;
529
+ if (!isPlainObject(value)) {
530
+ into.add("task.lead.shape", path, "lead must be an object when present");
531
+ return;
532
+ }
533
+ if (into.oneOf(value.status, LEAD_STATUSES, "task.lead.status", `${path}.status`)) {
534
+ if (value.status === "joined") {
535
+ into.require(isUserId(value.leadId), "task.lead.leadId", `${path}.leadId`, "a joined lead is named by their user id");
536
+ }
537
+ else {
538
+ into.require(value.leadId === undefined, "task.lead.leadId.unexpected", `${path}.leadId`, "nobody has joined a requested lead, so there is no lead to name");
539
+ }
540
+ }
541
+ if (value.note !== undefined)
542
+ into.filled(value.note, "task.lead.note", `${path}.note`, "a note must not be empty when present");
543
+ into.timestamp(value.since, "task.lead.since", `${path}.since`);
544
+ }
545
+ /** The lead's own task for a call they joined. Voice only. */
546
+ function validateAssisting(value, channel, path, into) {
547
+ if (value === undefined)
548
+ return;
549
+ if (!into.require(channel === "voice", "task.assisting.channel", path, `a ${channel} task cannot be a joined call`))
550
+ return;
551
+ if (!isPlainObject(value)) {
552
+ into.add("task.assisting.shape", path, "assisting must be an object when present");
553
+ return;
554
+ }
555
+ into.require(isUserId(value.memberId), "task.assisting.memberId", `${path}.memberId`, "a joined call names the member who asked");
556
+ if (value.note !== undefined)
557
+ into.filled(value.note, "task.assisting.note", `${path}.note`, "a note must not be empty when present");
558
+ into.timestamp(value.since, "task.assisting.since", `${path}.since`);
559
+ }
505
560
  export function validateTask(task, context, path = "task") {
506
561
  const into = new Collector();
507
562
  validateTaskInto(task, context, path, into);
@@ -537,6 +592,9 @@ function validateTaskInto(task, context, path, into) {
537
592
  validateBrowsers(task.browsers, `${path}.browsers`, into);
538
593
  validateTaskAttributes(task.attributes, `${path}.attributes`, into);
539
594
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
595
+ validateConsultation(task.consultation, context.channel, `${path}.consultation`, into);
596
+ validateLead(task.lead, context.channel, `${path}.lead`, into);
597
+ validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
540
598
  const capabilities = task.capabilities;
541
599
  if (!isPlainObject(capabilities)) {
542
600
  into.add("task.capabilities.shape", `${path}.capabilities`, "a task needs a capabilities object");
@@ -556,6 +614,7 @@ function validateTaskInto(task, context, path, into) {
556
614
  validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
557
615
  break;
558
616
  case "blindTransfer":
617
+ case "consultTransfer":
559
618
  case "conference":
560
619
  validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
561
620
  break;
@@ -650,6 +709,36 @@ function validateTeamRosterInto(roster, path, into) {
650
709
  if (roster.breakControl !== undefined) {
651
710
  into.require(roster.breakControl === true, "team.breakControl", `${path}.breakControl`, "breakControl is declared by presence: send true or omit it");
652
711
  }
712
+ if (roster.consultControl !== undefined) {
713
+ into.require(roster.consultControl === true, "team.consultControl", `${path}.consultControl`, "consultControl is declared by presence: send true or omit it");
714
+ }
715
+ if (roster.requests !== undefined) {
716
+ // Requests are what a lead acts on, so a lead who may not act has no business receiving them.
717
+ into.require(roster.consultControl === true, "team.requests.capability", `${path}.requests`, "requests require consultControl: a lead who may not join has nothing to decide");
718
+ if (!Array.isArray(roster.requests)) {
719
+ into.add("team.requests.shape", `${path}.requests`, "requests must be an array when present");
720
+ }
721
+ else {
722
+ const seenRequests = new Set();
723
+ roster.requests.forEach((request, index) => {
724
+ const at = `${path}.requests[${index}]`;
725
+ if (!isPlainObject(request)) {
726
+ into.add("team.request.shape", at, "each request must be an object");
727
+ return;
728
+ }
729
+ if (into.filled(request.id, "team.request.id", `${at}.id`, "a request needs an id")) {
730
+ if (seenRequests.has(request.id))
731
+ into.add("team.request.unique", `${at}.id`, `duplicate request id: ${request.id}`);
732
+ seenRequests.add(request.id);
733
+ }
734
+ into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking");
735
+ into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
736
+ if (request.note !== undefined)
737
+ into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
738
+ into.timestamp(request.since, "team.request.since", `${at}.since`);
739
+ });
740
+ }
741
+ }
653
742
  if (!Array.isArray(roster.members)) {
654
743
  into.add("team.members.shape", `${path}.members`, "a roster must carry a members array");
655
744
  return;
@@ -770,6 +859,8 @@ function validateTaskOutcome(value, path, into) {
770
859
  // Only the phases in which a task is still waiting on somebody can expire.
771
860
  into.oneOf(value.phase, EXPIRABLE_PHASES, "event.taskEnded.outcome.expired", `${path}.phase`);
772
861
  break;
862
+ case "left":
863
+ break;
773
864
  case "failed":
774
865
  if (!isPlainObject(value.failure)) {
775
866
  into.add("event.taskEnded.outcome.failed", `${path}.failure`, "a failed outcome must carry a failure");
package/guide.md CHANGED
@@ -337,6 +337,8 @@ type TaskCapabilities<C extends Channel = Channel> =
337
337
  agentDisconnect?: true;
338
338
  callback?: true;
339
339
  blindTransfer?: true | DestinationDirectory;
340
+ consultTransfer?: true | DestinationDirectory;
341
+ consultLead?: true;
340
342
  conference?: true | DestinationDirectory;
341
343
  recording?: true;
342
344
  }
@@ -421,6 +423,25 @@ type TaskCompletion =
421
423
  | { completionMode: "agent-command"; completionAllowance?: DurationSeconds }
422
424
  | { completionMode: "provider-automatic"; completionAllowance: DurationSeconds };
423
425
 
426
+ type TaskConsultation = {
427
+ destination: string;
428
+ label?: string;
429
+ since?: IsoTimestamp;
430
+ };
431
+
432
+ type TaskLead = {
433
+ status: "requested" | "joined";
434
+ leadId?: UserId;
435
+ note?: string;
436
+ since: IsoTimestamp;
437
+ };
438
+
439
+ type TaskAssisting = {
440
+ memberId: UserId;
441
+ note?: string;
442
+ since: IsoTimestamp;
443
+ };
444
+
424
445
  type Task<C extends Channel = Channel> = {
425
446
  id: TaskId;
426
447
  title: string;
@@ -433,7 +454,11 @@ type Task<C extends Channel = Channel> = {
433
454
  reference?: string;
434
455
  attributes?: TaskAttribute[];
435
456
  handlingHistory?: TaskHandlingStep[];
436
- } & TaskCompletion;
457
+ } & TaskCompletion & (
458
+ C extends "voice"
459
+ ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
460
+ : { consultation?: never; lead?: never; assisting?: never }
461
+ );
437
462
 
438
463
  type AcceptanceMode =
439
464
  | "no-preference"
@@ -445,6 +470,7 @@ type TaskOutcome =
445
470
  | { type: "transferred"; destination?: string }
446
471
  | { type: "cancelled"; reason?: string }
447
472
  | { type: "expired"; phase: "pending" | "confirmed" | "preparing" }
473
+ | { type: "left" }
448
474
  | { type: "failed"; failure: ProtocolFailure };
449
475
  ```
450
476
 
@@ -462,6 +488,7 @@ const TASK_COMMAND_NAMES = {
462
488
  "disconnect",
463
489
  "callback",
464
490
  "transfer",
491
+ "lead",
465
492
  "conference",
466
493
  "recording",
467
494
  "complete",
@@ -484,7 +511,14 @@ type VoiceTaskCommand =
484
511
  | { type: "resume" }
485
512
  | { type: "disconnect" }
486
513
  | { type: "callback" }
487
- | { type: "transfer"; destination: string }
514
+ | { type: "transfer"; destination: string; action?: never }
515
+ | { type: "transfer"; action: "consult"; destination: string }
516
+ | { type: "transfer"; action: "complete" }
517
+ | { type: "transfer"; action: "cancel" }
518
+ | { type: "lead"; action: "request"; note?: string }
519
+ | { type: "lead"; action: "cancel" }
520
+ | { type: "lead"; action: "take-over" }
521
+ | { type: "lead"; action: "leave" }
488
522
  | { type: "conference"; participant: string; action: "add" | "remove" }
489
523
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
490
524
  | ({ type: "complete" } & DispositionPayload);
@@ -567,11 +601,25 @@ type TeamMember = {
567
601
  break?: BreakApproval;
568
602
  };
569
603
 
604
+ type LeadRequest = {
605
+ id: string;
606
+ memberId: UserId;
607
+ taskId: TaskId;
608
+ note?: string;
609
+ since: IsoTimestamp;
610
+ };
611
+
570
612
  type TeamRoster = {
571
613
  members: TeamMember[];
572
614
  breakControl?: true;
615
+ consultControl?: true;
616
+ requests?: LeadRequest[];
573
617
  };
574
618
 
619
+ type TeamConsultCommand =
620
+ | { type: "join"; requestId: string }
621
+ | { type: "decline"; requestId: string; reason?: string };
622
+
575
623
  type TeamBreakCommand =
576
624
  | { type: "decide"; memberId: UserId; decision: "granted" | "denied"; reason?: string }
577
625
  | { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
@@ -1427,6 +1475,7 @@ surface in one place, and what obliges an adapter to implement each one.
1427
1475
  | `cancelBreak(requestId)` | `sessionCapabilities.breaks` is declared. |
1428
1476
  | `endBreak()` | `sessionCapabilities.breaks` is declared. |
1429
1477
  | `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
1478
+ | `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
1430
1479
  | `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
1431
1480
 
1432
1481
  **The four break methods stand or fall together.** Declaring `sessionCapabilities.breaks` and then
@@ -1624,6 +1673,9 @@ time. Runtime conformance checks also require the task channel to match its prov
1624
1673
  | `completionAllowance` | 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. |
1625
1674
  | `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. |
1626
1675
  | `handlingHistory` | Optional ordered handling history for this currently open task. It is live task data, not a permanent archive. |
1676
+ | `consultation` | Voice only. Present while the agent is consulting a transfer destination: who is being consulted, and since when where the provider records it. Its presence is what makes `transfer` `complete` and `cancel` issuable. See **Consult transfer**. |
1677
+ | `lead` | 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 **Consulting a lead**. |
1678
+ | `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` `take-over` and `leave` issuable. See **Consulting a lead**. |
1627
1679
 
1628
1680
  `TaskAttribute` entries carry typed detail alongside the task:
1629
1681
 
@@ -1659,7 +1711,9 @@ The canonical task transitions are:
1659
1711
  | `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
1660
1712
  | No task | Snapshot reports work already underway | `in-progress` |
1661
1713
  | `in-progress` | Provider or agent pauses the task | `paused` |
1714
+ | `in-progress` | Agent consults a transfer destination (`transfer` `consult`); the customer is parked | `paused` |
1662
1715
  | `paused` | Provider or agent resumes the task | `in-progress` |
1716
+ | `paused` | Agent cancels a consultation (`transfer` `cancel`) | `in-progress` |
1663
1717
  | `in-progress` or `paused` | Contact handling ends and follow-up work remains | `completing` |
1664
1718
  | `completing` | Agent calls the party back (`callback`) | `in-progress` |
1665
1719
  | Any phase | Provider emits `task-ended` | Removed |
@@ -1947,13 +2001,15 @@ const taskCapabilities = {
1947
2001
  | `agentDisconnect` | Primary button: Disconnect | Omni may disconnect real-time media without disposing the task. |
1948
2002
  | `callback` | Completing-task button: Call back | Omni may have the provider call the task's party back while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Calling back during completion**. |
1949
2003
  | `blindTransfer` | Secondary menu item: Blind transfer | Omni may transfer the caller directly to a destination. |
2004
+ | `consultTransfer` | Secondary menu item: Consult transfer | Omni may park the customer and call a destination first, then hand the customer over or cancel back. See **Consult transfer**. |
2005
+ | `consultLead` | Secondary menu item: Consult lead | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.lead`. See **Consulting a lead**. |
1950
2006
  | `conference` | Secondary button: Conference | Omni may add or remove participants from the active call. |
1951
2007
  | `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
1952
2008
  | `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
1953
2009
 
1954
2010
  ### Publishing codes and destinations
1955
2011
 
1956
- Three capabilities accept an object instead of `true` when the provider wants Omni to render real
2012
+ Four capabilities accept an object instead of `true` when the provider wants Omni to render real
1957
2013
  choices. `true` remains valid and means "offer the control with nothing published".
1958
2014
 
1959
2015
  #### `dispositions`
@@ -1980,7 +2036,7 @@ capabilities: {
1980
2036
  With `dispositions: true` Omni shows a Complete control and sends `complete` with no code, because
1981
2037
  the provider published none.
1982
2038
 
1983
- #### `blindTransfer` and `conference`
2039
+ #### `blindTransfer`, `consultTransfer` and `conference`
1984
2040
 
1985
2041
  ```ts
1986
2042
  capabilities: {
@@ -2012,6 +2068,42 @@ A destination the agent types is not in the directory and has no `kind`. Omni tr
2012
2068
  `external` unless the provider says otherwise in its response, because that is the assumption that
2013
2069
  does not overstate what the provider can still see.
2014
2070
 
2071
+ #### Consult transfer
2072
+
2073
+ A consult transfer parks the customer, calls the destination so the agent can speak to it first,
2074
+ and then either hands the customer over or returns to them. It is its own capability, distinct
2075
+ from `blindTransfer` (a hand-over with nobody consulted) and from `conference` (everybody on one
2076
+ call): a queue may offer any of the three without the others, and each is declared on its own.
2077
+
2078
+ ```ts
2079
+ // 1. Consult. The provider parks the customer and calls the destination; the task reports
2080
+ // `paused` and carries `consultation` while the call to the destination stands.
2081
+ { type: "transfer", action: "consult", destination: "+14155550111" }
2082
+
2083
+ // 2a. Hand the customer to the consulted destination and leave.
2084
+ { type: "transfer", action: "complete" }
2085
+
2086
+ // 2b. Or drop the destination and return to the customer.
2087
+ { type: "transfer", action: "cancel" }
2088
+ ```
2089
+
2090
+ `consult` is gated by the `consultTransfer` capability and takes a destination exactly as a blind
2091
+ transfer does, from the same kind of directory. While the consultation stands the task carries
2092
+ `consultation`, and that presence is what makes `complete` and `cancel` issuable -- they name no
2093
+ destination because there is exactly one they could mean. A consultation that could be started
2094
+ but not finished would strand the customer and the destination both, which is why all three are
2095
+ commands and a provider that offers `consultTransfer` implements all three.
2096
+
2097
+ `applied` on `complete` says the provider is bridging the customer to the destination and
2098
+ dropping the agent's leg. What follows is what follows any transfer: the agent's media ends and
2099
+ the provider reports `task-media-ended`, any completion allowance runs, and the task ends with a
2100
+ `transferred` outcome naming the destination. `applied` on `cancel` says the destination is
2101
+ dropped; the task returns to `in-progress` with `consultation` gone. Omni waits for the
2102
+ provider's report of both, as it does for every command.
2103
+
2104
+ A destination that does not answer is a consultation that ended: the provider clears
2105
+ `consultation`, returns the task to `in-progress`, and the agent is back with the customer.
2106
+
2015
2107
  ### Chat capabilities
2016
2108
 
2017
2109
  | Capability | Omni UI | Contract |
@@ -2444,6 +2536,8 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
2444
2536
  | --- | --- |
2445
2537
  | `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **Its presence is the permission** below. |
2446
2538
  | `breakControl` | Present when this lead decides their team's breaks, absent when they do not. |
2539
+ | `consultControl` | Present when this lead may join a member's call on request, absent when they may not. |
2540
+ | `requests` | The members currently asking this lead to join a call, each with the task and the note. Omitted when the lead may not be asked; `[]` when nobody is asking. See **Consulting a lead**. |
2447
2541
 
2448
2542
  | `TeamMember` field | Contract |
2449
2543
  | --- | --- |
@@ -2501,6 +2595,70 @@ on their own `BreakState`, or they are stopped from working with no way to see w
2501
2595
  What happens when no lead is online — auto-approving, for instance — is the provider's decision and is
2502
2596
  never expressed here.
2503
2597
 
2598
+ ### Consulting a lead
2599
+
2600
+ An agent on a call may ask a lead to join it -- a dispute that needs approval, a customer who
2601
+ asks for a manager, a moment the agent wants a second pair of ears. The capability is
2602
+ `consultLead` on the task; the lead's side is the roster, which is already the lead's view of the
2603
+ team, and a second lead method beside `executeTeamBreak`:
2604
+
2605
+ ```ts
2606
+ executeTeamConsult({ commandId, command: TeamConsultCommand }): Promise<TeamCommandResult>
2607
+ ```
2608
+
2609
+ Required when the roster carries `consultControl`, and gated by it exactly as `executeTeamBreak`
2610
+ is by `breakControl`. The flow, in order:
2611
+
2612
+ ```ts
2613
+ // 1. The agent asks, with a small note. Their task carries `lead` from here on.
2614
+ execute({ commandId, taskId: "call-42", command: { type: "lead", action: "request", note: "Refund dispute, needs approval" } })
2615
+ // task.lead = { status: "requested", note: "Refund dispute, needs approval", since }
2616
+
2617
+ // 2. Every lead entitled to it sees the request on their roster.
2618
+ // team-updated: requests: [{ id: "req-7", memberId: "A-1", taskId: "call-42", note, since }]
2619
+
2620
+ // 3. A lead joins, or declines.
2621
+ executeTeamConsult({ commandId, command: { type: "join", requestId: "req-7" } })
2622
+ executeTeamConsult({ commandId, command: { type: "decline", requestId: "req-7", reason: "In a call" } })
2623
+ ```
2624
+
2625
+ **On `join` the provider bridges three parties and the lead is on a task of their own**, on the
2626
+ same task id, arriving on the lead's connection as `task-offered` with `require-automatic-acceptance`
2627
+ -- the way a call an agent placed themselves arrives -- and carrying `assisting`. The agent's task
2628
+ moves to `lead: { status: "joined", leadId }`. A join is the lead's own act, so capacity does not
2629
+ trigger it; but from then on it is an outstanding task the provider counts against the lead's
2630
+ stated ceiling like any other, nothing more is allocated to the lead while it stands, and a
2631
+ provider whose lead is already at the ceiling answers the join `failed`.
2632
+
2633
+ **On `decline`, or a request the agent withdraws with `{ type: "lead", action: "cancel" }`, the
2634
+ provider clears `lead` from the agent's task** and drops the request from every roster. Nothing
2635
+ else changes; the agent is still on the call.
2636
+
2637
+ The lead then has two commands on their copy, gated by `assisting` being present, and a third
2638
+ choice that is no command at all:
2639
+
2640
+ | The lead | The agent's task | The lead's task |
2641
+ | --- | --- | --- |
2642
+ | `{ type: "lead", action: "take-over" }` | `task-ended` with `{ type: "transferred", destination: leadId }`, straight from `in-progress`: **no `completing` window**, the agent is idle at once | Continues alone, and ends as any call does |
2643
+ | `{ type: "lead", action: "leave" }` | Continues; `lead` is cleared | `task-ended` with `{ type: "left" }` -- the call goes on without them |
2644
+ | Stays until the customer hangs up | `task-media-ended`, `completing`, its own disposition | The same, independently: **both have the disposal window** |
2645
+
2646
+ `left` is the one outcome that ends a task without ending the call: this agent left a call that
2647
+ continues without them. It reads as neither a completion nor a cancellation, because it is
2648
+ neither.
2649
+
2650
+ ```ts
2651
+ const consultLeadCapable = {
2652
+ channel: "voice",
2653
+ capabilities: { hold: true, consultLead: true, dispositions: true },
2654
+ phase: "in-progress",
2655
+ lead: { status: "joined", leadId: "L-9", note: "Refund dispute, needs approval", since: "2026-08-21T09:04:00Z" },
2656
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "lead">;
2657
+ ```
2658
+
2659
+ Lead and member alike are `UserId`s of this provider, so an adapter publishing them implements
2660
+ `describeUsers()`; names never travel on a task or a roster.
2661
+
2504
2662
  ### A member waiting for a break
2505
2663
 
2506
2664
  A member who has asked for a break **keeps working** until Omni commits it, so asking is not an
@@ -2661,6 +2819,10 @@ declared:
2661
2819
  | `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. |
2662
2820
  | `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. |
2663
2821
  | `callback` | The `callback` 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. |
2822
+ | `transfer` with `action: "consult"` | The `consultTransfer` capability. Blind `transfer` is gated by `blindTransfer`; the two are declared and offered separately. |
2823
+ | `transfer` with `action: "complete"` or `"cancel"` | A consultation in progress -- `Task.consultation` present. Without one there is nothing to complete or cancel, and a provider that receives either answers `failed`. |
2824
+ | `lead` with `action: "request"` or `"cancel"` | The `consultLead` capability. `cancel` needs a request standing -- `Task.lead` with status `requested`. |
2825
+ | `lead` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
2664
2826
  | Everything else | Its own named capability. |
2665
2827
 
2666
2828
  Declining or rejecting a pending offer ends it without accepting or completing it. The provider
@@ -2841,6 +3003,9 @@ Every outcome ends the task for this agent. On `task-ended`, Omni:
2841
3003
  - releases task-scoped resources; and
2842
3004
  - selects another task or returns to the idle workspace.
2843
3005
 
3006
+ A `left` outcome ends the task for this agent alone: the call continues without them, as it does
3007
+ when a lead who joined it leaves -- see **Consulting a lead**.
3008
+
2844
3009
  A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
2845
3010
  The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
2846
3011
  snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",