@xema/omni-protocol 0.1.102 → 0.1.103

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
@@ -179,12 +179,12 @@ export interface Manifest<C extends Channel = Channel> {
179
179
  */
180
180
  runningStepReports?: true;
181
181
  /**
182
- * How long after an applied completion -- `complete`, or a lead's `take-over-call` -- the provider's
183
- * `task-ended` is owed, in milliseconds. `applied` says the provider has completed the task;
184
- * the ending follows within this, or the host resyncs and shows the task as unsettled. Stated
185
- * per provider, since platforms settle at different speeds.
182
+ * How long after `applied` the wire shows what the provider did, in milliseconds: the
183
+ * `task-ended` after a `complete` or a lead's `take-over-call`, the `calendar-updated` after a
184
+ * `schedule`. Past it the host resyncs and shows the thing as unsettled. Stated per provider,
185
+ * since platforms settle at different speeds.
186
186
  */
187
- completionSettleMs: number;
187
+ settleMs: number;
188
188
  }
189
189
  export interface SecretStore {
190
190
  get(key: string): Promise<string | undefined>;
@@ -666,8 +666,10 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
666
666
  } : C extends "chat" ? SharedTaskCapabilities & {
667
667
  decline?: Lockable<true>;
668
668
  hold?: Lockable<true>;
669
+ schedule?: Lockable<true>;
669
670
  } : SharedTaskCapabilities & {
670
671
  decline?: Lockable<true>;
672
+ schedule?: Lockable<true>;
671
673
  };
672
674
  /**
673
675
  * How a reusing browser's session is keyed.
@@ -948,6 +950,13 @@ export type Task<C extends Channel = Channel> = {
948
950
  * Omni said not, and absent past `pending`.
949
951
  */
950
952
  acceptance?: AcceptanceMode;
953
+ /**
954
+ * In `pending` only: how long the offer has left, in whole seconds from this publication,
955
+ * restated on every publication that carries it so a reconnect snapshot says what is actually
956
+ * left. Absent, the offer stands until the provider says otherwise. Runs out: the provider ends
957
+ * the offer `expired` naming `pending`.
958
+ */
959
+ expiresInSeconds?: DurationSeconds;
951
960
  /**
952
961
  * In `preview` only, and together: how long the preparation has left, in whole seconds from
953
962
  * this publication -- restated on every publication that carries it, so a resync starts the
@@ -999,18 +1008,18 @@ export type TaskOutcome = {
999
1008
  by: "agent" | "provider" | "party";
1000
1009
  reason?: string;
1001
1010
  }
1002
- /** Only the phases in which somebody is still being waited on can expire; an offer whose `expiresInSeconds` runs out names `pending`. */
1011
+ /** Only the phases in which somebody is still being waited on can expire: an offer whose `expiresInSeconds` runs out names `pending`, accepted work the platform gave up on names `confirmed`. A preview never lapses: withdrawn, it is `cancelled` by the provider. */
1003
1012
  | {
1004
1013
  type: "expired";
1005
- phase: "pending" | "confirmed" | "preview";
1014
+ phase: "pending" | "confirmed";
1006
1015
  } | {
1007
1016
  type: "failed";
1008
1017
  failure: ProtocolFailure;
1009
1018
  };
1010
1019
  export declare const TASK_COMMAND_NAMES: {
1011
1020
  readonly voice: readonly ["answer", "decline", "dial", "hold", "resume", "end-call", "terminate-call", "connect-back", "lead-assist", "conference", "recording", "schedule", "complete"];
1012
- readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
1013
- readonly email: readonly ["accept", "decline", "complete"];
1021
+ readonly chat: readonly ["accept", "decline", "pause", "resume", "schedule", "complete"];
1022
+ readonly email: readonly ["accept", "decline", "schedule", "complete"];
1014
1023
  };
1015
1024
  export type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> = (typeof TASK_COMMAND_NAMES)[C][number];
1016
1025
  export interface OutcomePayload {
@@ -1043,17 +1052,7 @@ export type VoiceTaskCommand = {
1043
1052
  | {
1044
1053
  type: "connect-back";
1045
1054
  dialId: DialId;
1046
- }
1047
- /**
1048
- * Put a time on the calendar for this party: a follow-up promised on the call. Gated by
1049
- * `schedule`, issuable in `in-progress`, `paused` or `completing`; the activity arrives on
1050
- * `calendar-updated`, which is the provider's word that it stands.
1051
- */
1052
- | {
1053
- type: "schedule";
1054
- at: IsoTimestamp;
1055
- note?: string;
1056
- }
1055
+ } | ScheduleCommand
1057
1056
  /** Ask a lead to join, with a note. Gated by `leadAssist`. */
1058
1057
  | {
1059
1058
  type: "lead-assist";
@@ -1093,6 +1092,18 @@ export type VoiceTaskCommand = {
1093
1092
  }) | ({
1094
1093
  type: "complete";
1095
1094
  } & OutcomePayload);
1095
+ /**
1096
+ * Put a time on the calendar for this party: a follow-up promised on the call or in the
1097
+ * conversation, or written up in wrap. Gated by `schedule` on any channel, issuable in
1098
+ * `in-progress`, `paused` or `completing`, under a manifest that declares `calendar` for it to
1099
+ * land on; `applied` says it is on the calendar, and the `calendar-updated` that shows it follows
1100
+ * within the manifest's `settleMs`.
1101
+ */
1102
+ export type ScheduleCommand = {
1103
+ type: "schedule";
1104
+ at: IsoTimestamp;
1105
+ note?: string;
1106
+ };
1096
1107
  export type ChatTaskCommand = {
1097
1108
  type: "accept";
1098
1109
  } | {
@@ -1101,14 +1112,14 @@ export type ChatTaskCommand = {
1101
1112
  type: "pause";
1102
1113
  } | {
1103
1114
  type: "resume";
1104
- } | ({
1115
+ } | ScheduleCommand | ({
1105
1116
  type: "complete";
1106
1117
  } & OutcomePayload);
1107
1118
  export type EmailTaskCommand = {
1108
1119
  type: "accept";
1109
1120
  } | {
1110
1121
  type: "decline";
1111
- } | ({
1122
+ } | ScheduleCommand | ({
1112
1123
  type: "complete";
1113
1124
  } & OutcomePayload);
1114
1125
  export interface CustomTaskCommand {
@@ -1187,9 +1198,11 @@ export interface BreakRequest {
1187
1198
  reasonId?: string;
1188
1199
  }
1189
1200
  /**
1190
- * A break forced on the agent; the agent must explicitly resume when ready. `by` is the lead who
1191
- * forced it, or `provider` where the platform itself did, on its own rule or a schedule: the agent
1192
- * sees who, and a person is named only when there is one.
1201
+ * A break forced on the agent; the agent must explicitly resume when ready. A lead's `force-break`
1202
+ * is a command to the provider, which forces the break: `by` names the lead who asked it to, or
1203
+ * `provider` where the platform itself did, on its own rule or a schedule. The agent sees who, a
1204
+ * person is named only when there is one, and a break the platform imposed is the platform's to
1205
+ * lift: a lead's `end-forced-break` on it fails with `omni.break-forced-by-provider`.
1193
1206
  */
1194
1207
  export type ForcedBreak = {
1195
1208
  by: UserId | "provider";
@@ -1322,6 +1335,7 @@ export interface MemberShift {
1322
1335
  signedOutAt?: IsoTimestamp;
1323
1336
  talkSeconds?: DurationSeconds;
1324
1337
  holdSeconds?: DurationSeconds;
1338
+ /** Every break the member was on today, including time held elsewhere for a break another provider forced: the provider counts it under a break kind of its own. */
1325
1339
  breakSeconds?: DurationSeconds;
1326
1340
  tasksHandled?: number;
1327
1341
  events?: ShiftEvent[];
@@ -1590,8 +1604,6 @@ export type ProviderEvent<C extends Channel = Channel> = {
1590
1604
  } | {
1591
1605
  type: "task-offered";
1592
1606
  task: Task<C>;
1593
- /** How long the agent has to answer, in whole seconds from this event; the offer expires when it runs out. Absent, it stands until the provider says otherwise. */
1594
- expiresInSeconds?: DurationSeconds;
1595
1607
  } | {
1596
1608
  type: "task-updated";
1597
1609
  task: Task<C>;
@@ -1669,7 +1681,7 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
1669
1681
  occurredAt: IsoTimestamp;
1670
1682
  event: ProviderEvent<C>;
1671
1683
  }
1672
- export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled"];
1684
+ export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled", "omni.break-forced-by-provider"];
1673
1685
  export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1674
1686
  /**
1675
1687
  * What the host refused, told to the adapter that published it: a snapshot the host would not
package/dist/index.js CHANGED
@@ -78,8 +78,8 @@ export const CAPABILITY_SOURCES = ["queue", "nobody", "not-yet-read"];
78
78
  export const TASK_COMMAND_NAMES = {
79
79
  voice: ["answer", "decline", "dial", "hold", "resume", "end-call", "terminate-call",
80
80
  "connect-back", "lead-assist", "conference", "recording", "schedule", "complete"],
81
- chat: ["accept", "decline", "pause", "resume", "complete"],
82
- email: ["accept", "decline", "complete"],
81
+ chat: ["accept", "decline", "pause", "resume", "schedule", "complete"],
82
+ email: ["accept", "decline", "schedule", "complete"],
83
83
  };
84
84
  /** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
85
85
  export function commandDialId(command) {
@@ -105,6 +105,8 @@ export const OMNI_FAILURE_CODES = [
105
105
  "omni.break-already-committed",
106
106
  /** A recording command the provider could not settle either way: no start, stop, pause or resume it can vouch for. */
107
107
  "omni.recording-unsettled",
108
+ /** A lead asked to lift a break the platform itself imposed; the platform lifts its own. */
109
+ "omni.break-forced-by-provider",
108
110
  ];
109
111
  /**
110
112
  * Preserves the adapter's inferred concrete type while checking it implements `Adapter`.
package/dist/testing.d.ts CHANGED
@@ -249,6 +249,13 @@ export interface BreakCandidate {
249
249
  /** Whether the agent can currently receive work from it: connected, with a capacity stated. */
250
250
  holdsCapacity: boolean;
251
251
  }
252
+ /**
253
+ * A break one provider forces stops the agent everywhere: the host states a capacity of zero on
254
+ * every other usable provider holding capacity -- taken, never refused, and nothing the agent can
255
+ * cancel -- and on the forcing provider states nothing, since the break itself holds the agent
256
+ * there. A provider whose login is not usable is left alone, as a break attempt leaves it.
257
+ */
258
+ export declare function assertForcedBreakStopsTheRest(forcedOn: string, candidates: readonly BreakCandidate[], stopped: readonly string[]): void;
252
259
  /**
253
260
  * A break attempt asks every connected provider from which the agent can currently receive work.
254
261
  * A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
package/dist/testing.js CHANGED
@@ -1626,6 +1626,7 @@ async function driveOneCall(drive) {
1626
1626
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`, {
1627
1627
  levels: effectiveLevels(drive.manifest.orgLevels).map(level => level.id),
1628
1628
  dialOutcomesDeclared: drive.manifest.dialOutcomes !== undefined,
1629
+ calendarDeclared: drive.manifest.idleCapabilities?.calendar === true,
1629
1630
  autoAcceptTasks: drive.context.autoAcceptTasks,
1630
1631
  });
1631
1632
  found.push(...own);
@@ -1798,6 +1799,38 @@ async function driveOneCall(drive) {
1798
1799
  }
1799
1800
  }
1800
1801
  }
1802
+ // 4a. Schedule a follow-up, where the task offers it: applied says it is on the calendar, and the
1803
+ // calendar-updated that shows it is owed within the manifest's bound. The time is the provider's
1804
+ // own: a day after the instant it published the offer, so no clock of the runner's is read.
1805
+ if (latestTask().phase === "in-progress" && offers("schedule")) {
1806
+ const publishedAt = drive.events.slice(0, cursor).reverse().find(envelope => {
1807
+ const event = envelope.event;
1808
+ return (event.type === "task-offered" || event.type === "task-updated") && isTask(event.task) && event.task.assignmentId === taskId;
1809
+ })?.occurredAt;
1810
+ const at = new Date(Date.parse(String(publishedAt ?? "2026-01-01T00:00:00Z")) + 24 * 60 * 60 * 1000).toISOString();
1811
+ if (await send({ type: "schedule", at, note: "Follow-up promised on the call" }) !== undefined) {
1812
+ ruleEvaluated("drive.schedule.unsettled");
1813
+ const settle = Number(drive.manifest.settleMs);
1814
+ const carries = (calendar) => Array.isArray(calendar) && calendar.some(activity => isRecord(activity) && activity.startsAt === at);
1815
+ let unsettled = false;
1816
+ const shown = await waitFor("a calendar-updated carrying the follow-up", envelope => {
1817
+ const event = envelope.event;
1818
+ return event.type === "calendar-updated" && carries(event.calendar) ? event : undefined;
1819
+ }, cursor, { ms: settle, onExpiry: () => { unsettled = true; } });
1820
+ if (shown === undefined && unsettled) {
1821
+ let resync;
1822
+ try {
1823
+ resync = await drive.connection.snapshot();
1824
+ }
1825
+ catch (error) {
1826
+ refuse("drive.command.rejected", "drive.schedule", `snapshot() after an unsettled schedule rejected: ${String(error)}`);
1827
+ }
1828
+ if (!(isRecord(resync) && carries(resync.calendar))) {
1829
+ refuse("drive.schedule.unsettled", "drive.schedule", `schedule was applied and ${settle}ms later no calendar-updated carries a follow-up at ${at}, and a snapshot does not either: applied says the follow-up is on the calendar, and it is not`);
1830
+ }
1831
+ }
1832
+ }
1833
+ }
1801
1834
  // 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
1802
1835
  // open at audio end is the provider's to close, since the provider knows the instant the audio
1803
1836
  // ended; the host's own closing report can only follow what it hears, is answered recorded, and
@@ -1853,7 +1886,7 @@ async function driveOneCall(drive) {
1853
1886
  // provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
1854
1887
  // email, and a voice task offering no end-call, is completed from where it stands.
1855
1888
  const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
1856
- if (latestTask().wrapAllowance !== 0 && completable) {
1889
+ if (!(latestTask().completionMode === "provider-automatic" && latestTask().wrapAllowance === 0) && completable) {
1857
1890
  const command = { type: "complete" };
1858
1891
  const outcomes = isRecord(latestTask().capabilities) ? latestTask().capabilities.outcomes : undefined;
1859
1892
  if (isRecord(outcomes) && outcomes.required === true && Array.isArray(outcomes.codes) && isRecord(outcomes.codes[0])) {
@@ -1864,7 +1897,7 @@ async function driveOneCall(drive) {
1864
1897
  // the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
1865
1898
  // held open by a provider that said it was done, and the desk shows it as unsettled.
1866
1899
  ruleEvaluated("drive.completion.unsettled");
1867
- const settle = Number(drive.manifest.completionSettleMs);
1900
+ const settle = Number(drive.manifest.settleMs);
1868
1901
  let unsettled = false;
1869
1902
  const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
1870
1903
  if (end !== undefined)
@@ -2083,6 +2116,35 @@ export function stillHost(report = { online: true }, guarantees = {}, mute) {
2083
2116
  return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
2084
2117
  }
2085
2118
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
2119
+ /**
2120
+ * A break one provider forces stops the agent everywhere: the host states a capacity of zero on
2121
+ * every other usable provider holding capacity -- taken, never refused, and nothing the agent can
2122
+ * cancel -- and on the forcing provider states nothing, since the break itself holds the agent
2123
+ * there. A provider whose login is not usable is left alone, as a break attempt leaves it.
2124
+ */
2125
+ export function assertForcedBreakStopsTheRest(forcedOn, candidates, stopped) {
2126
+ if (!candidates.some(candidate => candidate.id === forcedOn))
2127
+ throw new Error(`${forcedOn} is not a provider the host knows`);
2128
+ const chosen = new Set(stopped);
2129
+ for (const id of stopped) {
2130
+ if (!candidates.some(candidate => candidate.id === id))
2131
+ throw new Error(`${id} is not a provider the host knows`);
2132
+ }
2133
+ if (chosen.has(forcedOn))
2134
+ throw new Error(`${forcedOn} forced the break and holds the agent itself: its capacity is not restated`);
2135
+ for (const candidate of candidates) {
2136
+ if (candidate.id === forcedOn)
2137
+ continue;
2138
+ const expected = usableLogin(candidate.authentication) && candidate.holdsCapacity;
2139
+ if (expected && !chosen.has(candidate.id)) {
2140
+ throw new Error(`${candidate.id} can give the agent work and must be stopped: capacity 0 stated`);
2141
+ }
2142
+ if (!expected && chosen.has(candidate.id)) {
2143
+ const why = usableLogin(candidate.authentication) ? "holds no capacity" : `is ${candidate.authentication}`;
2144
+ throw new Error(`${candidate.id} ${why} and is not stopped: there is nothing to stop`);
2145
+ }
2146
+ }
2147
+ }
2086
2148
  /**
2087
2149
  * A break attempt asks every connected provider from which the agent can currently receive work.
2088
2150
  * A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
@@ -39,6 +39,8 @@ export interface TaskValidationContext {
39
39
  autoAcceptTasks?: boolean;
40
40
  /** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
41
41
  dialOutcomesDeclared?: boolean;
42
+ /** Whether the manifest declares the `calendar` idle capability. A task that may schedule needs it to; absent, the question is not asked. */
43
+ calendarDeclared?: boolean;
42
44
  /**
43
45
  * The values the queue locked on this login's tasks -- a party's number or email -- as whoever
44
46
  * runs the validator knows them. Where a task's party stands locked, no other field of it may
@@ -91,7 +91,7 @@ const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true
91
91
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
92
92
  const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
93
93
  const EXPIRABLE_PHASES = membersOf({
94
- pending: true, confirmed: true, preview: true,
94
+ pending: true, confirmed: true,
95
95
  });
96
96
  const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
97
97
  /** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
@@ -100,8 +100,8 @@ const TASK_CAPABILITIES = {
100
100
  browsers: true, outcomes: true, custom: true, decline: true, hold: true,
101
101
  endCall: true, terminateCall: true, connectBack: true, schedule: true, leadAssist: true, conference: true, recording: true,
102
102
  }),
103
- chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
104
- email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
103
+ chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true, schedule: true }),
104
+ email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, schedule: true }),
105
105
  };
106
106
  // The published list and the type's keys are the same set, or one of them is wrong.
107
107
  true;
@@ -393,6 +393,10 @@ export function validatePhone(value, manifest, path = "context.phone") {
393
393
  function manifestDials(manifest) {
394
394
  return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
395
395
  }
396
+ /** Whether a manifest declares a calendar, and so may publish tasks that schedule onto it. `undefined` where there is no manifest to ask. */
397
+ function manifestCalendar(manifest) {
398
+ return isPlainObject(manifest) ? isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.calendar === true : undefined;
399
+ }
396
400
  /** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
397
401
  export function validateProviderTimeEstimate(value, scope, path = "estimate") {
398
402
  const into = new Collector();
@@ -520,8 +524,10 @@ export function validateManifest(manifest, path = "manifest") {
520
524
  if (manifest.runningStepReports !== undefined) {
521
525
  into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
522
526
  }
523
- into.require(!Object.hasOwn(manifest, "disposalSettleMs"), "manifest.completionSettleMs.renamed", `${path}.disposalSettleMs`, "use completionSettleMs; the former field is not accepted");
524
- into.require(typeof manifest.completionSettleMs === "number" && Number.isInteger(manifest.completionSettleMs) && manifest.completionSettleMs > 0, "manifest.completionSettleMs", `${path}.completionSettleMs`, "completionSettleMs is how long after an applied completion the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
527
+ for (const former of ["disposalSettleMs", "completionSettleMs"]) {
528
+ into.require(!Object.hasOwn(manifest, former), "manifest.settleMs.renamed", `${path}.${former}`, "use settleMs; the former field is not accepted");
529
+ }
530
+ into.require(typeof manifest.settleMs === "number" && Number.isInteger(manifest.settleMs) && manifest.settleMs > 0, "manifest.settleMs", `${path}.settleMs`, "settleMs is how long after applied the wire shows what the provider did -- the task-ended after a complete, the calendar-updated after a schedule -- a positive whole number of milliseconds, stated by every provider");
525
531
  if (manifest.orgLevels !== undefined) {
526
532
  if (!Array.isArray(manifest.orgLevels)) {
527
533
  into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
@@ -1120,6 +1126,11 @@ function validateTaskInto(task, context, path, into) {
1120
1126
  into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
1121
1127
  into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
1122
1128
  }
1129
+ // How long the offer has left travels on the pending task, so a reconnect snapshot restates it.
1130
+ if (task.expiresInSeconds !== undefined) {
1131
+ into.require(task.phase === "pending", "task.pending.expiresInSeconds.unexpected", `${path}.expiresInSeconds`, "expiresInSeconds is how long an offer has left, and belongs to a pending task; accepted, there is no offer to run out");
1132
+ into.require(isDurationSeconds(task.expiresInSeconds), "task.pending.expiresInSeconds", `${path}.expiresInSeconds`, "expiresInSeconds is how long the offer has left: a whole number of seconds, zero or more, counted from this publication");
1133
+ }
1123
1134
  // The deadline and what happens at it travel together, and only while the record is being previewed.
1124
1135
  if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
1125
1136
  into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsInSeconds`, "previewEndsInSeconds and atDeadline belong to a task in preview; past it there is nothing to wait for");
@@ -1239,8 +1250,10 @@ function validateTaskInto(task, context, path, into) {
1239
1250
  if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
1240
1251
  into.require(task.completionMode !== "provider-automatic", "task.outcomes.required.mode", `${path}.capabilities.outcomes.required`, "a required outcome is the agent's to give, so the task completes on the agent's command; provider-automatic completion cannot wait for it");
1241
1252
  }
1253
+ // Under agent-command the allowance is the expected wrap, shown and never acted on: the agent
1254
+ // completes whenever they are done, so there is always a wrap to collect the code in.
1242
1255
  if (capabilities.outcomes !== undefined) {
1243
- into.require(task.wrapAllowance !== 0, "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and wrapAllowance 0 gives none: publish a wrap or no outcomes");
1256
+ into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and provider-automatic with wrapAllowance 0 gives none: publish a wrap or no outcomes");
1244
1257
  }
1245
1258
  for (const [name, declared] of Object.entries(capabilities)) {
1246
1259
  if (declared === undefined)
@@ -1256,6 +1269,10 @@ function validateTaskInto(task, context, path, into) {
1256
1269
  if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
1257
1270
  into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
1258
1271
  }
1272
+ // A follow-up lands on the calendar, so the manifest has to have one for it to land on.
1273
+ if (name === "schedule" && context.calendarDeclared === false) {
1274
+ into.add("task.capability.calendar.required", `${path}.capabilities.schedule`, "schedule puts a follow-up on the calendar, and the manifest declares no calendar for it to land on");
1275
+ }
1259
1276
  // A control the queue could allow may stand locked in its place, saying whose. What the
1260
1277
  // queue provides -- browsers, outcomes, custom controls -- is content, not a control.
1261
1278
  if (isLocked(declared)) {
@@ -1399,8 +1416,11 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1399
1416
  else if (command.type === "force-break" || command.type === "end-forced-break") {
1400
1417
  validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
1401
1418
  const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
1402
- if (command.type === "end-forced-break")
1419
+ if (command.type === "end-forced-break") {
1403
1420
  into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
1421
+ // The platform lifts a break it imposed itself; a lead lifts one a lead asked for.
1422
+ into.require(!(isPlainObject(state.forced) && state.forced.by === "provider"), "team.command.endForcedBreak.provider", path, "the platform put this member on the break, and it lifts it: a lead ends a break a lead forced");
1423
+ }
1404
1424
  if (command.type === "force-break") {
1405
1425
  if (command.expectedDurationMs !== undefined)
1406
1426
  into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
@@ -1717,7 +1737,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1717
1737
  else {
1718
1738
  const seen = new Set();
1719
1739
  snapshot.tasks.forEach((task, index) => {
1720
- validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1740
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1721
1741
  if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1722
1742
  if (seen.has(task.assignmentId))
1723
1743
  into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
@@ -1899,16 +1919,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1899
1919
  validateBreakState(event.break, `${at}.break`, into);
1900
1920
  break;
1901
1921
  case "task-offered":
1902
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1922
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
1903
1923
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1904
1924
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1905
1925
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
1906
1926
  }
1907
- if (event.expiresInSeconds !== undefined)
1908
- into.require(isDurationSeconds(event.expiresInSeconds), "event.taskOffered.expiresInSeconds", `${at}.expiresInSeconds`, "expiresInSeconds is how long the offer stands: a whole number of seconds, zero or more, counted from this event");
1927
+ into.require(event.expiresInSeconds === undefined, "event.taskOffered.expiresInSeconds.unexpected", `${at}.expiresInSeconds`, "expiresInSeconds travels on the pending task, where a reconnect snapshot restates it, not on the event");
1909
1928
  break;
1910
1929
  case "task-updated":
1911
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1930
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
1912
1931
  break;
1913
1932
  case "task-audio-started":
1914
1933
  into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
@@ -2543,7 +2562,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2543
2562
  case "complete": {
2544
2563
  // The agent may finish early under either mode; the provider is free to end at once. With
2545
2564
  // no wrap at all there is no window to cut short, and nothing for the command to do.
2546
- into.require(task.wrapAllowance !== 0, "command.complete.wrapAllowance", path, "the task has no wrap: with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
2565
+ into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "command.complete.wrapAllowance", path, "the task has no wrap: under provider-automatic with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
2547
2566
  // What travels with complete is what the outcomes capability published: a code from its
2548
2567
  // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2549
2568
  const outcomes = capabilities.outcomes;
package/guide.md CHANGED
@@ -203,7 +203,7 @@ type Manifest<C extends Channel = Channel> = {
203
203
  dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
204
204
  phones?: C extends "voice" ? Phone[] : never;
205
205
  runningStepReports?: true;
206
- completionSettleMs: number;
206
+ settleMs: number;
207
207
  };
208
208
  ```
209
209
 
@@ -569,8 +569,8 @@ type TaskCapabilities<C extends Channel = Channel> =
569
569
  recording?: Lockable<TaskRecordingPolicy>;
570
570
  }
571
571
  : C extends "chat"
572
- ? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
573
- : SharedTaskCapabilities & { decline?: Lockable<true> };
572
+ ? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true>; schedule?: Lockable<true> }
573
+ : SharedTaskCapabilities & { decline?: Lockable<true>; schedule?: Lockable<true> };
574
574
  ```
575
575
 
576
576
  The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
@@ -723,6 +723,7 @@ type Task<C extends Channel = Channel> = {
723
723
  party?: Contact;
724
724
  phase: TaskPhase;
725
725
  acceptance?: AcceptanceMode;
726
+ expiresInSeconds?: DurationSeconds;
726
727
  previewEndsInSeconds?: DurationSeconds;
727
728
  atDeadline?: PreviewDeadline;
728
729
  reference?: string;
@@ -745,7 +746,7 @@ type TaskOutcome =
745
746
  | { type: "completed"; by: "agent" | "provider" }
746
747
  | { type: "taken-over"; leadId: UserId }
747
748
  | { type: "cancelled"; by: "agent" | "provider" | "party"; reason?: string }
748
- | { type: "expired"; phase: "pending" | "confirmed" | "preview" }
749
+ | { type: "expired"; phase: "pending" | "confirmed" }
749
750
  | { type: "failed"; failure: ProtocolFailure };
750
751
  ```
751
752
 
@@ -768,8 +769,8 @@ const TASK_COMMAND_NAMES = {
768
769
  "schedule",
769
770
  "complete",
770
771
  ],
771
- chat: ["accept", "decline", "pause", "resume", "complete"],
772
- email: ["accept", "decline", "complete"],
772
+ chat: ["accept", "decline", "pause", "resume", "schedule", "complete"],
773
+ email: ["accept", "decline", "schedule", "complete"],
773
774
  } as const;
774
775
 
775
776
  type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> =
@@ -786,7 +787,7 @@ type VoiceTaskCommand =
786
787
  | { type: "end-call" }
787
788
  | { type: "terminate-call" }
788
789
  | { type: "connect-back"; dialId: DialId }
789
- | { type: "schedule"; at: IsoTimestamp; note?: string }
790
+ | ScheduleCommand
790
791
  | { type: "lead-assist"; action: "request"; note?: string }
791
792
  | { type: "lead-assist"; action: "cancel" }
792
793
  | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
@@ -795,16 +796,20 @@ type VoiceTaskCommand =
795
796
  | (RecordingCommand & { source: "provider" })
796
797
  | ({ type: "complete" } & OutcomePayload);
797
798
 
799
+ type ScheduleCommand = { type: "schedule"; at: IsoTimestamp; note?: string };
800
+
798
801
  type ChatTaskCommand =
799
802
  | { type: "accept" }
800
803
  | { type: "decline" }
801
804
  | { type: "pause" }
802
805
  | { type: "resume" }
806
+ | ScheduleCommand
803
807
  | ({ type: "complete" } & OutcomePayload);
804
808
 
805
809
  type EmailTaskCommand =
806
810
  | { type: "accept" }
807
811
  | { type: "decline" }
812
+ | ScheduleCommand
808
813
  | ({ type: "complete" } & OutcomePayload);
809
814
 
810
815
  type CustomTaskCommand = { type: "custom"; name: string; [key: string]: unknown };
@@ -1115,7 +1120,6 @@ type ProviderEvent =
1115
1120
  | {
1116
1121
  type: "task-offered";
1117
1122
  task: Task;
1118
- expiresInSeconds?: DurationSeconds;
1119
1123
  }
1120
1124
  | { type: "task-updated"; task: Task }
1121
1125
  | { type: "task-audio-started"; assignmentId: AssignmentId }
@@ -1254,6 +1258,7 @@ const OMNI_FAILURE_CODES = [
1254
1258
  "omni.unavailable",
1255
1259
  "omni.break-already-committed",
1256
1260
  "omni.recording-unsettled",
1261
+ "omni.break-forced-by-provider",
1257
1262
  ] as const;
1258
1263
  type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1259
1264
  ```
@@ -1586,7 +1591,7 @@ export default defineAdapter({
1586
1591
  channel: "voice",
1587
1592
  supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
1588
1593
  authenticationMethods: ["browser-sso"],
1589
- completionSettleMs: 5000,
1594
+ settleMs: 5000,
1590
1595
  idleCapabilities: {
1591
1596
  dial: { destinations: "any-number" },
1592
1597
  },
@@ -1624,7 +1629,7 @@ compile time.
1624
1629
  | `timeCheck` | Optional `true`: implements `checkTime` for fresh provider-clock samples; agent application polling is independently opt-in. |
1625
1630
  | `timestampAuthority` | Optional `"provider"`: provider timestamps are final; agent application instants are advisory. Omission makes no trust promise. |
1626
1631
  | `runningStepReports` | The provider takes running reports of an agent application-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the agent application sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The agent application records what it performs**. |
1627
- | `completionSettleMs` | Required. How long after an applied `complete` the provider's `task-ended` is owed, a positive whole number of milliseconds (`manifest.completionSettleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
1632
+ | `settleMs` | Required. How long after `applied` the wire shows what the provider did: the `task-ended` after a `complete`, the `calendar-updated` after a `schedule`. A positive whole number of milliseconds (`manifest.settleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
1628
1633
 
1629
1634
  ### Authentication methods
1630
1635
 
@@ -2318,8 +2323,7 @@ declare const task: Task;
2318
2323
 
2319
2324
  const assignment = {
2320
2325
  type: "task-offered",
2321
- task: { ...task, phase: "pending", acceptance: "consent" },
2322
- expiresInSeconds: 30,
2326
+ task: { ...task, phase: "pending", acceptance: "consent", expiresInSeconds: 30 },
2323
2327
  } satisfies Extract<ProviderEvent, { type: "task-offered" }>;
2324
2328
  ```
2325
2329
 
@@ -2337,10 +2341,12 @@ on a desk phone — and is not in question per call. See **How the agent hears t
2337
2341
 
2338
2342
  Automatic acceptance still begins with `task-offered`.
2339
2343
 
2340
- `expiresInSeconds` is how long the offer stands, in whole seconds from this event. Where present,
2341
- Omni counts down from receipt and stops offering **Accept** once it runs out; the provider ends
2342
- the lapsed offer with `task-ended` and an `expired` outcome naming `pending`, since nobody
2343
- cancelled it. **Omit it unless the provider can observe it.** A provider that reports only elapsed
2344
+ `expiresInSeconds` is how long the offer has left, in whole seconds from the publication that
2345
+ carries it. It rides on the pending task, as `acceptance` does, so a reconnect snapshot restates
2346
+ it from what is actually left; it belongs to `pending` alone and is gone once the task is accepted
2347
+ (`task.pending.expiresInSeconds`, `.unexpected`). Where present, Omni counts down from receipt and
2348
+ stops offering **Accept** once it runs out; the provider ends the lapsed offer with `task-ended`
2349
+ and an `expired` outcome naming `pending`, since nobody cancelled it. **Omit it unless the provider can observe it.** A provider that reports only elapsed
2344
2350
  ring time after the fact cannot say how long an offer has left, and a computed value would have
2345
2351
  Omni withdraw **Accept** from a task still pending.
2346
2352
 
@@ -2349,7 +2355,7 @@ different clocks, and a countdown that compares one against the other is a count
2349
2355
  fast or slow by their skew. Seconds from the publication that carries them are the provider's own
2350
2356
  arithmetic, counted down on the desk from the moment they arrive, and the transport delay is the
2351
2357
  only error. Restated on every publication that carries them, so a snapshot after a reconnect
2352
- starts the countdown afresh from what is actually left (`event.taskOffered.expiresInSeconds`,
2358
+ starts the countdown afresh from what is actually left (`task.pending.expiresInSeconds`,
2353
2359
  `task.preview.previewEndsInSeconds`: a whole number, zero or more).
2354
2360
 
2355
2361
  A preview's deadline is not on the offer. It travels on the task, as `previewEndsInSeconds` with
@@ -2396,6 +2402,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2396
2402
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preview`, `in-progress`, `paused`, or `completing`. `preview` is voice only. |
2397
2403
  | `audio` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-audio-started`**. Audio names a task whose work has begun, or whose party the agent application is dialling: on a `pending`, `confirmed` or `preview` task with nobody ringing it is refused (`task.audio.beforeWork`), on a snapshot as on the event, since an agent application opens the microphone on it; with the party ringing by an agent application dial or provider-triggered preview dial, actual ring-back audio may precede answer. |
2398
2404
  | `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
2405
+ | `expiresInSeconds` | In `pending` only: how long the offer has left, in whole seconds from this publication, restated on every publication that carries it. Absent, the offer stands until the provider says otherwise. See **Pending**. |
2399
2406
  | `previewEndsInSeconds` | Voice only, in `preview`: how long the preparation has left, in whole seconds from this publication, restated on every publication that carries it. Absent, the agent has as long as they need without a preparation countdown. Always with `atDeadline`. See **Preview: the agent presses Call**. |
2400
2407
  | `atDeadline` | Voice only, in `preview`, with `previewEndsInSeconds`: what the system does when it runs out -- `provider-dials` makes the provider initiate dialing, `host-dials` makes the agent application issue Call, `waits` keeps the task in preview awaiting the agent. |
2401
2408
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
@@ -2618,18 +2625,26 @@ provider may complete the task without receiving that command.
2618
2625
  **The agent may finish early under either mode.** `complete` is issuable wherever there is a wrap
2619
2626
  to cut short: under `agent-command` it is the end, and under `provider-automatic` it says the agent
2620
2627
  is done before the allowance ran out, and the provider is free to end the task at once. Only a task
2621
- with no wrap at all -- `wrapAllowance: 0` -- has nothing to complete, and the command is refused
2622
- there (`command.complete.wrapAllowance`). An ending the agent asked for says so, `completed` with
2623
- `by: "agent"`, whichever mode the task was under.
2628
+ with no wrap at all -- `provider-automatic` with `wrapAllowance: 0` -- has nothing to complete, and
2629
+ the command is refused there (`command.complete.wrapAllowance`). An ending the agent asked for says
2630
+ so, `completed` with `by: "agent"`, whichever mode the task was under.
2624
2631
 
2625
2632
  **A required outcome is the agent's to give.** A task whose `outcomes` says `required: true` waits
2626
2633
  for a code the agent chooses, so it completes on the agent's command: `provider-automatic` cannot
2627
2634
  wait for it, and the pair is refused (`task.outcomes.required.mode`). Optional outcomes may ride on
2628
2635
  a task the provider completes itself; the agent gives one if in time. And a code is given in wrap,
2629
- so a task with `wrapAllowance: 0` publishes no `outcomes` at all (`task.outcomes.wrapAllowance`).
2636
+ so a `provider-automatic` task with `wrapAllowance: 0` publishes no `outcomes` at all
2637
+ (`task.outcomes.wrapAllowance`).
2638
+
2639
+ **The allowance means one thing per mode.** Under `provider-automatic` it is what the provider
2640
+ acts on: the task ends when it runs out. Under `agent-command` it is the expected wrap, stated so
2641
+ the agent can see it: the desk counts it down and, past zero, shows how far over they are, and
2642
+ nothing acts on it -- the task waits for `complete` however long that takes, with its outcomes
2643
+ collected whenever the agent gives them. A provider that would end the task at a time is
2644
+ `provider-automatic`.
2630
2645
 
2631
- `wrapAllowance` is independent of that decision. It is fixed, and when it starts depends on
2632
- whether the channel carries real-time audio:
2646
+ `wrapAllowance` is fixed, and when it starts depends on whether the channel carries real-time
2647
+ audio:
2633
2648
 
2634
2649
  | Channel | Wrap allowance starts at |
2635
2650
  | --- | --- |
@@ -2658,9 +2673,9 @@ const emailCompletion = {
2658
2673
  In this example, the agent has two minutes after sending the email to add notes, select a
2659
2674
  outcome, and complete the task.
2660
2675
 
2661
- `0` means there is no wrap: under `provider-automatic` the provider completes the task at the
2662
- end of the interaction, and under `agent-command` it completes on the interaction's end without
2663
- waiting for `complete`, which has nothing to cut short and is refused.
2676
+ `0` means no wrap under `provider-automatic`: the provider completes the task at the end of the
2677
+ interaction, and `complete` has nothing to cut short. Under `agent-command` it is an expected wrap
2678
+ of nothing -- the desk shows the overrun from the start -- and the task still waits for `complete`.
2664
2679
 
2665
2680
  There is no value meaning "unlimited", because a number that is not a duration would be read as
2666
2681
  one. A provider that imposes no deadline says so by **omitting** `wrapAllowance`, which
@@ -2748,7 +2763,7 @@ The record is the call's, not the task's: most of what it holds happened before
2748
2763
  *Interaction* keeps the one meaning **Terms** gives it, the agent's time on the call, which is why
2749
2764
  the total of earlier agents' time is `interactionSeconds` and the phase rules are
2750
2765
  `command.phase.interaction`. Hosts and providers adopt these names together; no legacy aliases
2751
- are provided. The manifest's `completionSettleMs` bounds final task completion, not entry into
2766
+ are provided. The manifest's `settleMs` bounds final task completion, not entry into
2752
2767
  the `completing` wrap-up phase. The `recordStep` method and `complete` command keep their names.
2753
2768
 
2754
2769
  Migration from the earlier spellings:
@@ -2765,7 +2780,7 @@ Migration from the earlier spellings:
2765
2780
  | Task.allocationId, AllocationId, allocationExpiresAt | `Task.assignmentId`, `AssignmentId`, `expiresInSeconds` |
2766
2781
  | Task.id, TaskId, and `taskId` on every event, command, report and lead request | gone: a task is named by `assignmentId` alone, and `assignmentKey(providerId, assignmentId)` scopes it |
2767
2782
  | taskKey, omni.task-not-found, PROVIDER_NAME__TASK_ID__TAB_NAME (ProviderName.TaskId.TabName) | `assignmentKey`, `omni.assignment-not-found`, `PROVIDER_NAME__ASSIGNMENT_ID__TAB_NAME` (`ProviderName.AssignmentId.TabName`) |
2768
- | Manifest.disposalSettleMs | `Manifest.completionSettleMs` |
2783
+ | Manifest.disposalSettleMs, Manifest.completionSettleMs | `Manifest.settleMs` (`manifest.settleMs`, `manifest.settleMs.renamed`): the bound after `applied` for `complete` and `schedule` alike |
2769
2784
  | the call command, atDeadline calls and host-calls | `dial`, `"provider-dials"`, `"host-dials"` |
2770
2785
  | media: task-media-started/-ended, team-media-*, Task.media, TaskMediaState, openMedia, OpenMediaRequest/Result, VoiceMediaSession and its session field | audio: `task-audio-started`, `task-audio-ended`, `audio` on the task, `TaskAudioState`, `openAudio`, `OpenAudioRequest`, `OpenAudioResult`, `CallAudio` on the result's `audio`; there is no team audio, the lead's follows `listening` on the member |
2771
2786
  | capabilitySource values ungoverned and undetermined | `nobody`, `not-yet-read` (`capabilitySource.notYetRead`) |
@@ -2777,7 +2792,12 @@ Migration from the earlier spellings:
2777
2792
  | TeamMembers.requests, LeadRequest | `TeamMember.request` (`MemberRequest`): the ask rides on the member (`team.member.request.*`) |
2778
2793
  | team-updated on every change | `team-updated` whole once after the switch and on snapshots; `team-member-updated`, `team-member-removed`, `team-policies-updated` after (`stream.team.baseline`, `stream.teamMember.unknown`) |
2779
2794
  | RecordingCommand.requestId | gone: no request identity; a partial effect is a settled `failed` under `omni.recording-unsettled` |
2780
- | command.complete.mode | `command.complete.wrapAllowance`: `complete` under either mode, refused only with no wrap |
2795
+ | command.complete.mode | `command.complete.wrapAllowance`: `complete` under either mode, refused only on `provider-automatic` with no wrap |
2796
+ | expiresInSeconds on task-offered | `Task.expiresInSeconds`, in `pending` (`task.pending.expiresInSeconds`, `.unexpected`; `event.taskOffered.expiresInSeconds.unexpected`) |
2797
+ | the expired outcome naming preview | gone: nothing expires a preview; withdrawn, it is `cancelled` by the provider |
2798
+ | schedule on voice alone | `schedule` on every channel, under a manifest that declares `calendar` (`task.capability.calendar.required`), bounded by `settleMs` (`drive.schedule.unsettled`) |
2799
+ | a forced break requesting breaks on the other providers | `setCapacity({ count: 0 })` on every other usable provider (`assertForcedBreakStopsTheRest`) |
2800
+ | end-forced-break on a break the platform imposed | refused: `team.command.endForcedBreak.provider`, `omni.break-forced-by-provider` |
2781
2801
 
2782
2802
  Update producers, consumers, saved task snapshots, and validation-rule assertions together.
2783
2803
  History and report rule names use `history` and `historyReport`; assignment rules use
@@ -3377,28 +3397,39 @@ caller to somebody else.
3377
3397
 
3378
3398
  An agent on a call promises to call back on Thursday, or in wrap writes up that the customer wants
3379
3399
  a callback once the refund lands. The follow-up goes on the calendar, and the platform owns the
3380
- calendar: `schedule` asks the provider to put it there.
3400
+ calendar: `schedule` asks the provider to put it there. A chat or an email agent promises a
3401
+ callback as often, so the control is on every channel.
3381
3402
 
3382
3403
  ```ts
3383
- // From the call, or from its wrap: the time, and a note where the agent wrote one.
3404
+ // From the call or the conversation, or from its wrap: the time, and a note where the agent wrote one.
3384
3405
  { type: "schedule", at: "2026-08-28T10:00:00Z", note: "Call back about the refund" }
3385
3406
  ```
3386
3407
 
3387
3408
  `schedule` is gated by the `schedule` capability and issuable in `in-progress`, `paused` or
3388
3409
  `completing` (`command.phase.interaction`): a follow-up is promised on the call and written up in
3389
- wrap alike, and the task names the party it is for. `applied` says the provider has taken it; the
3390
- activity itself arrives on `calendar-updated`, which is the provider's word that it stands, as
3391
- every calendar entry does. The command carries a time and a note and nothing else
3410
+ wrap alike, and the task names the party it is for. It lands on the calendar, so a task may offer
3411
+ it only under a manifest that declares the `calendar` idle capability
3412
+ (`task.capability.calendar.required`), as a control that dials needs `dialOutcomes`; a locked
3413
+ `schedule` is still a declared one. The command carries a time and a note and nothing else
3392
3414
  (`command.schedule.at`, `command.schedule.note`, `command.field`): what the platform makes of a
3393
3415
  follow-up -- a campaign record, a reminder, a scheduled dial -- is its own, and the agent sees it
3394
3416
  on the calendar.
3395
3417
 
3418
+ **`applied` says the follow-up is on the calendar**, and the `calendar-updated` that shows it
3419
+ follows within the manifest's `settleMs`, as a `task-ended` follows an applied `complete`. Past
3420
+ the bound the agent application calls `snapshot()`: a calendar carrying an activity at that instant
3421
+ clears the wait; one without it shows the follow-up as unsettled -- "Scheduled... the provider has
3422
+ not confirmed" -- naming the command. The drive schedules one follow-up where the task offers it,
3423
+ at a time it takes from the provider's own publication, and holds the provider to the same bound
3424
+ (`drive.schedule.unsettled`).
3425
+
3396
3426
  ### Chat capabilities
3397
3427
 
3398
3428
  | Capability | Omni UI | Contract |
3399
3429
  | --- | --- | --- |
3400
3430
  | `decline` | Pending-task button: Decline | The provider can decline a pending chat offer. Omni shows it only when local policy also permits declining. |
3401
3431
  | `hold` | Primary toggle: Hold | Omni may pause and resume the agent’s interaction in the chat. |
3432
+ | `schedule` | Secondary menu item: Schedule | Omni may put a follow-up for this party on the calendar, from the conversation or from its wrap. See **Scheduling a follow-up**. |
3402
3433
  | `outcomes` | Primary button: Complete | Omni may request task completion with a provider outcome and notes. |
3403
3434
 
3404
3435
  ### Email capabilities
@@ -3406,6 +3437,7 @@ on the calendar.
3406
3437
  | Capability | Omni UI | Contract |
3407
3438
  | --- | --- | --- |
3408
3439
  | `decline` | Pending-task button: Decline | The provider can decline a pending email offer. Omni shows it only when local policy also permits declining. |
3440
+ | `schedule` | Secondary menu item: Schedule | Omni may put a follow-up for this party on the calendar, from the message or from its wrap. See **Scheduling a follow-up**. |
3409
3441
  | `outcomes` | Primary button: Complete | Omni may request task completion with a provider outcome and notes. |
3410
3442
 
3411
3443
  ### Custom capabilities
@@ -3748,6 +3780,14 @@ entry into `on-break`, excluding any `starting-after-task` wait. Omission means
3748
3780
  duration was supplied. Neither duration expiry nor an overdue indication authorizes the agent application
3749
3781
  or provider to end the break, restore availability, or route work to the agent.
3750
3782
 
3783
+ **A forced break is the provider's act.** A lead's `force-break` is a command to the provider,
3784
+ which forces the break on the member; `by` names the lead who asked it to. A break the platform
3785
+ imposed on its own -- a schedule, a compliance hold -- says `by: "provider"`, and is the platform's
3786
+ to lift: a lead's `end-forced-break` on it is refused before it is sent
3787
+ (`team.command.endForcedBreak.provider`), and a provider that receives one answers `failed` with
3788
+ `omni.break-forced-by-provider`. The desk offers End forced break on a member only where
3789
+ `forced.by` names a lead.
3790
+
3751
3791
  **A lead lifting the restriction does not resume the agent.** On an applied
3752
3792
  `end-forced-break`, the provider clears `BreakState.forced` while preserving the current
3753
3793
  `on-break` or `starting-after-task` status and `activeReasonId`. This command cannot publish
@@ -3766,14 +3806,21 @@ An agent application may display the expected duration. A countdown requires an
3766
3806
  a received snapshot, replay or reconnect is not a new start and cannot restart the duration.
3767
3807
  Without that evidence, show the duration without inventing a start or return time.
3768
3808
 
3769
- A break applies to the **agent**, not to one provider. When a provider forces one, Omni immediately
3770
- requests a break on every other connected provider, or they would keep routing work to somebody who
3771
- is not there. On those providers it is the agent's own request, made through `requestBreak` like
3772
- any other, and the provider grants or denies it as its policy says. **The agent application
3773
- guarantees the agent cannot cancel those requests, or end the breaks they become,** while the
3774
- forced break stands: the agent did not ask for them, and a break one provider imposed is not one
3775
- the agent may lift on another. When the forcing provider ends the forced break, the agent
3776
- application ends the follow-on breaks it made. Providers should expect that follow-on request.
3809
+ A break applies to the **agent**, not to one provider. When a provider forces one, Omni stops the
3810
+ agent everywhere else at once, and not by asking: it states `setCapacity({ count: 0 })` on every
3811
+ other usable provider holding capacity, which is taken and never refused, and which their team
3812
+ lists show as `elsewhere`. Nobody on those providers can deny it, and the agent cannot cancel it,
3813
+ because there is nothing to cancel: a break request on them would be the agent's own to withdraw
3814
+ and a lead's to refuse, and a forced break is neither. When the forcing provider ends the forced
3815
+ break and the agent resumes there, the agent application restates its capacity on the rest. A
3816
+ provider whose login is not usable is left alone, as a break attempt leaves it;
3817
+ `assertForcedBreakStopsTheRest` holds an agent application to this set. **The provider keeps its
3818
+ shift totals true.** A member held elsewhere is one the provider sees -- `elsewhere` is a state it
3819
+ publishes -- and the day's `shift` is its own account of the day, so the time the agent was
3820
+ stopped for a break forced on another provider is the provider's to count, as a break of a kind
3821
+ its platform has, or introduces, for exactly this. A `breakSeconds` that leaves it out is a total
3822
+ the provider knew to be short, and **Never report a value you cannot observe** cuts the other way
3823
+ here: the provider observed it.
3777
3824
 
3778
3825
  `ForcedBreak.by` says who forced it: a lead, by user id, or `provider` where the platform itself
3779
3826
  did -- on its own rule, a schedule, a compliance hold. The agent sees who, and `getUserDetails()`
@@ -4241,7 +4288,7 @@ assignment from the member alone.
4241
4288
  | `{ type: "decide-break-request", memberId, decision, reason? }` | Settles one pending request. `decision` is `granted` or `denied`. A grant moves the member to `granted`; a denial ends the request and moves it directly to `not-requested`. |
4242
4289
  | `{ type: "set-break-policy", policy }` | `approval-required`, `automatically-approved`, or `requests-suspended`. |
4243
4290
  | `{ type: "force-break", memberId, reasonId?, reason?, expectedDurationMs? }` | Puts a member on a break they did not ask for. `reasonId` names a published `BreakReason.id` and is required whenever the provider publishes `reasons`; the member's forced break carries it as `activeReasonId`, so its kind is known. Optional `expectedDurationMs` is advisory and is published on the resulting forced break. |
4244
- | `{ type: "end-forced-break", memberId }` | Lifts the forced-break restriction, whoever forced it, by clearing `BreakState.forced`. The committed break continues; only the agent resumes work. |
4291
+ | `{ type: "end-forced-break", memberId }` | Asks the provider to lift a forced-break restriction a lead asked for, by clearing `BreakState.forced`; one the platform imposed is its own to lift (`team.command.endForcedBreak.provider`, `omni.break-forced-by-provider`). The committed break continues; only the agent resumes work. |
4245
4292
  | `{ type: "join", memberId, assignmentId? }` | Answers the member's request: the provider bridges the lead's channel into the call. See **Lead assist**. |
4246
4293
  | `{ type: "decline", memberId, assignmentId?, reason? }` | Refuses the member's request. |
4247
4294
  | `{ type: "listen", memberId, assignmentId? }` | The lead's channel joins the member's call unasked, in silence. See **Listening to a call**. |
@@ -4756,9 +4803,9 @@ declared:
4756
4803
  | `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
4757
4804
  | `decline` | The `decline` capability on any channel, **and** Omni local policy permitting it. One word for refusing an offer, whatever the channel. |
4758
4805
  | `dial` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
4759
- | `complete` | A wrap to cut short: any task but one with `wrapAllowance: 0`, under either completion mode (`command.complete.wrapAllowance`). The `outcomes` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.outcome.unknown`), a code at all where it requires one (`.outcome.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no outcomes (`.outcome.unexpected`). |
4806
+ | `complete` | A wrap to cut short: any task but a `provider-automatic` one with `wrapAllowance: 0`, under either completion mode (`command.complete.wrapAllowance`). The `outcomes` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.outcome.unknown`), a code at all where it requires one (`.outcome.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no outcomes (`.outcome.unexpected`). |
4760
4807
  | `connect-back` | The `connectBack` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
4761
- | `schedule` | The `schedule` capability, in `in-progress`, `paused` or `completing`: a follow-up is promised on the call and written up in wrap alike. |
4808
+ | `schedule` | The `schedule` capability, on any channel, in `in-progress`, `paused` or `completing`: a follow-up is promised on the call and written up in wrap alike. The manifest declares `calendar` for it to land on. |
4762
4809
  | `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with status `requested`. |
4763
4810
  | `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
4764
4811
  | `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying a non-empty string for each `required` prompt field and strings for any optional fields supplied (`command.custom.prompt`). A toggle carries its target `on` boolean (`command.custom.on`). |
@@ -5170,7 +5217,7 @@ take-over as it follows an `end-call` -- see **Lead assist**.
5170
5217
  A successful `complete` command does not clear the task. Omni waits for `task-ended`,
5171
5218
  and not for ever: `applied` to a `complete` says the
5172
5219
  provider has completed the task, and its `task-ended` follows within the
5173
- manifest's `completionSettleMs`. A provider never answers `applied` for a completion it has not yet
5220
+ manifest's `settleMs`. A provider never answers `applied` for a completion it has not yet
5174
5221
  performed. Past the bound the agent application calls `snapshot()`: a snapshot still carrying the task is a task
5175
5222
  held open by a provider that said it was done, and the desk shows it as unsettled -- "Completing...
5176
5223
  the provider has not confirmed" -- naming the command; a snapshot no longer carrying it clears the
@@ -5515,6 +5562,7 @@ cannot be established from TypeScript structure alone.
5515
5562
  | `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a forced break arriving in effect with `forced`. The harness applies the same rules after the connect snapshot. |
5516
5563
  | `assertAudioFollowsTheTask(envelopes, snapshot?)` | The audio follows the task and never decides it: every task is introduced once, `task-audio-started` and `task-audio-ended` alternate on work that has begun, audio ends only where it arrived, and what follows the audio ending is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no audio satisfies it by never testing it — pair it with the assertion that the audio end is present. |
5517
5564
  | `assertBreakAttemptProviders(candidates, asked)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
5565
+ | `assertForcedBreakStopsTheRest(forcedOn, candidates, stopped)` | A forced break on one provider stops the agent everywhere else: capacity zero stated on every other usable provider holding capacity, nothing on the forcing one, nothing on a dead login. See **Forced breaks**. |
5518
5566
  | `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `on-break` only once nothing is outstanding — never beside a task, never later than the step that has none. |
5519
5567
  | `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
5520
5568
  | `assertWrapTimeout(task, audioEndedAt, deadline, toleranceMs?)` | The wrap deadline equals audio end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.102",
3
+ "version": "0.1.103",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",