@xema/omni-protocol 0.1.2 → 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
@@ -290,7 +290,13 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
290
290
  mute?: true;
291
291
  hold?: true;
292
292
  agentDisconnect?: true;
293
+ /** Reach the party again while `completing`; the task returns to `in-progress`. */
294
+ callback?: true;
293
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;
294
300
  conference?: true | DestinationDirectory;
295
301
  recording?: true;
296
302
  } : C extends "chat" ? SharedTaskCapabilities & {
@@ -390,6 +396,35 @@ export type TaskCompletion = {
390
396
  completionMode: "provider-automatic";
391
397
  completionAllowance: DurationSeconds;
392
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
+ }
393
428
  export type Task<C extends Channel = Channel> = {
394
429
  id: TaskId;
395
430
  title: string;
@@ -404,7 +439,15 @@ export type Task<C extends Channel = Channel> = {
404
439
  reference?: string;
405
440
  attributes?: TaskAttribute[];
406
441
  handlingHistory?: TaskHandlingStep[];
407
- } & 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
+ });
408
451
  /** What the provider wants of Omni's acceptance policy for one offer. */
409
452
  export type AcceptanceMode = "no-preference" | "require-agent-acceptance" | "require-automatic-acceptance";
410
453
  export type TaskOutcome = {
@@ -421,12 +464,16 @@ export type TaskOutcome = {
421
464
  | {
422
465
  type: "expired";
423
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";
424
471
  } | {
425
472
  type: "failed";
426
473
  failure: ProtocolFailure;
427
474
  };
428
475
  export declare const TASK_COMMAND_NAMES: {
429
- readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "transfer", "conference", "recording", "complete"];
476
+ readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "callback", "transfer", "lead", "conference", "recording", "complete"];
430
477
  readonly chat: readonly ["accept", "reject", "pause", "resume", "complete"];
431
478
  readonly email: readonly ["accept", "reject", "complete"];
432
479
  };
@@ -450,9 +497,53 @@ export type VoiceTaskCommand = {
450
497
  type: "resume";
451
498
  } | {
452
499
  type: "disconnect";
453
- } | {
500
+ }
501
+ /** Issuable only in `completing`, under the `callback` capability. Carries no destination. */
502
+ | {
503
+ type: "callback";
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
+ | {
454
513
  type: "transfer";
514
+ action: "consult";
455
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";
456
547
  } | {
457
548
  type: "conference";
458
549
  participant: string;
@@ -630,9 +721,33 @@ export interface TeamMember {
630
721
  * Published only to an agent entitled to one. Its presence is the permission -- nothing else
631
722
  * makes somebody a lead, and there is no separate flag to fall out of step with the data.
632
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
+ }
633
732
  export interface TeamRoster {
634
733
  members: TeamMember[];
635
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;
636
751
  }
637
752
  export type TeamBreakCommand = {
638
753
  type: "decide";
@@ -799,6 +914,8 @@ export interface Connection<C extends Channel = Channel> {
799
914
  endBreak?(): Promise<BreakEndResult>;
800
915
  /** Required when the adapter publishes a `TeamRoster` carrying `breakControl`. */
801
916
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
917
+ /** Required when the adapter publishes a `TeamRoster` carrying `consultControl`. */
918
+ executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
802
919
  /** Required of every voice adapter: all voice audio lands in Omni. */
803
920
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
804
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
- "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.d.ts CHANGED
@@ -39,6 +39,10 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
39
39
  * There is no `denied` approval: a refusal returns the agent to `not-requested`, because a
40
40
  * pending request nobody is coming to decide is worse than none. So the scenario is a request
41
41
  * that goes back to not-requested, and a later one that is granted.
42
+ *
43
+ * A request shows as `awaiting-decision` where a person decides, and as `granted` at once where
44
+ * the provider decides alone; either is the request being made, and the rule is the same for
45
+ * both.
42
46
  */
43
47
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
44
48
  /** Verifies that retrying one dial command cannot place a second call. */
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
@@ -209,9 +211,13 @@ export function assertReconnectWithMissedAssignments(before, reconnect, missedTa
209
211
  * There is no `denied` approval: a refusal returns the agent to `not-requested`, because a
210
212
  * pending request nobody is coming to decide is worse than none. So the scenario is a request
211
213
  * that goes back to not-requested, and a later one that is granted.
214
+ *
215
+ * A request shows as `awaiting-decision` where a person decides, and as `granted` at once where
216
+ * the provider decides alone; either is the request being made, and the rule is the same for
217
+ * both.
212
218
  */
213
219
  export function assertDeniedAndRetriedBreak(approvals) {
214
- const asked = approvals.indexOf("awaiting-decision");
220
+ const asked = approvals.findIndex(approval => approval === "awaiting-decision" || approval === "granted");
215
221
  if (asked < 0)
216
222
  throw new Error("Break retry scenario requires an initial request");
217
223
  const refused = approvals.indexOf("not-requested", asked + 1);
@@ -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, 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
@@ -335,7 +335,10 @@ type TaskCapabilities<C extends Channel = Channel> =
335
335
  mute?: true;
336
336
  hold?: true;
337
337
  agentDisconnect?: true;
338
+ callback?: true;
338
339
  blindTransfer?: true | DestinationDirectory;
340
+ consultTransfer?: true | DestinationDirectory;
341
+ consultLead?: true;
339
342
  conference?: true | DestinationDirectory;
340
343
  recording?: true;
341
344
  }
@@ -420,6 +423,25 @@ type TaskCompletion =
420
423
  | { completionMode: "agent-command"; completionAllowance?: DurationSeconds }
421
424
  | { completionMode: "provider-automatic"; completionAllowance: DurationSeconds };
422
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
+
423
445
  type Task<C extends Channel = Channel> = {
424
446
  id: TaskId;
425
447
  title: string;
@@ -432,7 +454,11 @@ type Task<C extends Channel = Channel> = {
432
454
  reference?: string;
433
455
  attributes?: TaskAttribute[];
434
456
  handlingHistory?: TaskHandlingStep[];
435
- } & TaskCompletion;
457
+ } & TaskCompletion & (
458
+ C extends "voice"
459
+ ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
460
+ : { consultation?: never; lead?: never; assisting?: never }
461
+ );
436
462
 
437
463
  type AcceptanceMode =
438
464
  | "no-preference"
@@ -444,6 +470,7 @@ type TaskOutcome =
444
470
  | { type: "transferred"; destination?: string }
445
471
  | { type: "cancelled"; reason?: string }
446
472
  | { type: "expired"; phase: "pending" | "confirmed" | "preparing" }
473
+ | { type: "left" }
447
474
  | { type: "failed"; failure: ProtocolFailure };
448
475
  ```
449
476
 
@@ -459,7 +486,9 @@ const TASK_COMMAND_NAMES = {
459
486
  "hold",
460
487
  "resume",
461
488
  "disconnect",
489
+ "callback",
462
490
  "transfer",
491
+ "lead",
463
492
  "conference",
464
493
  "recording",
465
494
  "complete",
@@ -481,7 +510,15 @@ type VoiceTaskCommand =
481
510
  | { type: "hold" }
482
511
  | { type: "resume" }
483
512
  | { type: "disconnect" }
484
- | { type: "transfer"; destination: string }
513
+ | { type: "callback" }
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" }
485
522
  | { type: "conference"; participant: string; action: "add" | "remove" }
486
523
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
487
524
  | ({ type: "complete" } & DispositionPayload);
@@ -564,11 +601,25 @@ type TeamMember = {
564
601
  break?: BreakApproval;
565
602
  };
566
603
 
604
+ type LeadRequest = {
605
+ id: string;
606
+ memberId: UserId;
607
+ taskId: TaskId;
608
+ note?: string;
609
+ since: IsoTimestamp;
610
+ };
611
+
567
612
  type TeamRoster = {
568
613
  members: TeamMember[];
569
614
  breakControl?: true;
615
+ consultControl?: true;
616
+ requests?: LeadRequest[];
570
617
  };
571
618
 
619
+ type TeamConsultCommand =
620
+ | { type: "join"; requestId: string }
621
+ | { type: "decline"; requestId: string; reason?: string };
622
+
572
623
  type TeamBreakCommand =
573
624
  | { type: "decide"; memberId: UserId; decision: "granted" | "denied"; reason?: string }
574
625
  | { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
@@ -1424,6 +1475,7 @@ surface in one place, and what obliges an adapter to implement each one.
1424
1475
  | `cancelBreak(requestId)` | `sessionCapabilities.breaks` is declared. |
1425
1476
  | `endBreak()` | `sessionCapabilities.breaks` is declared. |
1426
1477
  | `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
1478
+ | `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
1427
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. |
1428
1480
 
1429
1481
  **The four break methods stand or fall together.** Declaring `sessionCapabilities.breaks` and then
@@ -1621,6 +1673,9 @@ time. Runtime conformance checks also require the task channel to match its prov
1621
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. |
1622
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. |
1623
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**. |
1624
1679
 
1625
1680
  `TaskAttribute` entries carry typed detail alongside the task:
1626
1681
 
@@ -1656,8 +1711,11 @@ The canonical task transitions are:
1656
1711
  | `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
1657
1712
  | No task | Snapshot reports work already underway | `in-progress` |
1658
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` |
1659
1715
  | `paused` | Provider or agent resumes the task | `in-progress` |
1716
+ | `paused` | Agent cancels a consultation (`transfer` `cancel`) | `in-progress` |
1660
1717
  | `in-progress` or `paused` | Contact handling ends and follow-up work remains | `completing` |
1718
+ | `completing` | Agent calls the party back (`callback`) | `in-progress` |
1661
1719
  | Any phase | Provider emits `task-ended` | Removed |
1662
1720
 
1663
1721
  Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
@@ -1712,6 +1770,42 @@ and the agent takes as long as the work needs. Moving the task to `completing` l
1712
1770
  deadline is not an alternative on a media channel: the clock starts at a real event, and delaying
1713
1771
  that event would falsify the phase and everything timed from it.
1714
1772
 
1773
+ #### Calling back during completion
1774
+
1775
+ A task that declares `callback` lets the agent reach the party again while the task is
1776
+ `completing` -- to finish what the call left unfinished, on the same task rather than a new one.
1777
+ Omni issues `{ type: "callback" }`; it is issuable only in `completing`, and only where the
1778
+ capability is declared. The provider knows who the party is; the command carries no destination.
1779
+
1780
+ On `applied` the provider is placing the call and the task returns to `in-progress`: the agent is
1781
+ working again, and the completion allowance is **discarded, not paused**. From there the call is
1782
+ reported as any call is -- `paused`, `in-progress`, and when its media ends, `task-media-ended`
1783
+ again, which starts a fresh allowance from that instant. A party who does not answer is a call
1784
+ whose media ended: the task returns to `completing` through the same event and the clock starts
1785
+ again from there. At no point is an agent dialling against a deadline.
1786
+
1787
+ **The control exists only while there is a window to use it in.** Under `agent-command` the task
1788
+ stays `completing` until the agent completes it, so the window is open for as long as they need.
1789
+ Under `provider-automatic` the window is the allowance -- and with `completionAllowance: 0` there
1790
+ is none: the provider disposes the task at provider end, and Omni does not offer Call back, whatever
1791
+ the task declares. A capability names a control that can be used; on a task with no `completing`
1792
+ window it cannot, and declaring it there changes nothing.
1793
+
1794
+ ```ts
1795
+ const callbackCapable = {
1796
+ channel: "voice",
1797
+ capabilities: { hold: true, callback: true, dispositions: true },
1798
+ phase: "completing",
1799
+ completionMode: "provider-automatic",
1800
+ completionAllowance: 30,
1801
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "completionMode" | "completionAllowance">;
1802
+ ```
1803
+
1804
+ With ten seconds of the thirty left, the agent presses Call back: `execute({ command: { type:
1805
+ "callback" } })` returns `applied`, the task is `in-progress`, and the thirty seconds are gone.
1806
+ The second call ends: `task-media-ended`, the task is `completing`, and a new thirty seconds runs
1807
+ from that instant.
1808
+
1715
1809
  ```ts
1716
1810
  const immediateProviderCompletion = {
1717
1811
  completionMode: "provider-automatic",
@@ -1905,14 +1999,17 @@ const taskCapabilities = {
1905
1999
  | `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
1906
2000
  | `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
1907
2001
  | `agentDisconnect` | Primary button: Disconnect | Omni may disconnect real-time media without disposing the task. |
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**. |
1908
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**. |
1909
2006
  | `conference` | Secondary button: Conference | Omni may add or remove participants from the active call. |
1910
2007
  | `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
1911
2008
  | `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
1912
2009
 
1913
2010
  ### Publishing codes and destinations
1914
2011
 
1915
- 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
1916
2013
  choices. `true` remains valid and means "offer the control with nothing published".
1917
2014
 
1918
2015
  #### `dispositions`
@@ -1939,7 +2036,7 @@ capabilities: {
1939
2036
  With `dispositions: true` Omni shows a Complete control and sends `complete` with no code, because
1940
2037
  the provider published none.
1941
2038
 
1942
- #### `blindTransfer` and `conference`
2039
+ #### `blindTransfer`, `consultTransfer` and `conference`
1943
2040
 
1944
2041
  ```ts
1945
2042
  capabilities: {
@@ -1971,6 +2068,42 @@ A destination the agent types is not in the directory and has no `kind`. Omni tr
1971
2068
  `external` unless the provider says otherwise in its response, because that is the assumption that
1972
2069
  does not overstate what the provider can still see.
1973
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
+
1974
2107
  ### Chat capabilities
1975
2108
 
1976
2109
  | Capability | Omni UI | Contract |
@@ -2403,6 +2536,8 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
2403
2536
  | --- | --- |
2404
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. |
2405
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**. |
2406
2541
 
2407
2542
  | `TeamMember` field | Contract |
2408
2543
  | --- | --- |
@@ -2460,6 +2595,70 @@ on their own `BreakState`, or they are stopped from working with no way to see w
2460
2595
  What happens when no lead is online — auto-approving, for instance — is the provider's decision and is
2461
2596
  never expressed here.
2462
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
+
2463
2662
  ### A member waiting for a break
2464
2663
 
2465
2664
  A member who has asked for a break **keeps working** until Omni commits it, so asking is not an
@@ -2619,6 +2818,11 @@ declared:
2619
2818
  | `decline`, `reject` | The channel's decline or reject capability, **and** Omni provisioning permitting rejection. |
2620
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. |
2621
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. |
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`. |
2622
2826
  | Everything else | Its own named capability. |
2623
2827
 
2624
2828
  Declining or rejecting a pending offer ends it without accepting or completing it. The provider
@@ -2799,6 +3003,9 @@ Every outcome ends the task for this agent. On `task-ended`, Omni:
2799
3003
  - releases task-scoped resources; and
2800
3004
  - selects another task or returns to the idle workspace.
2801
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
+
2802
3009
  A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
2803
3010
  The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
2804
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.2",
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",