@xema/omni-protocol 0.1.18 → 0.1.20

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
@@ -111,6 +111,8 @@ export interface Manifest<C extends Channel = Channel> {
111
111
  phaseLabels?: TaskPhaseLabels;
112
112
  /** Keyed by `taskType`. An entry replaces the channel default outright rather than merging. */
113
113
  taskTypePresentation?: Record<string, TaskTypePresentation>;
114
+ /** The structure's tiers, relabelling or extending `DEFAULT_TIERS` by id. Omitted for the typical four. */
115
+ tiers?: TierDeclaration[];
114
116
  }
115
117
  export interface SecretStore {
116
118
  get(key: string): Promise<string | undefined>;
@@ -145,6 +147,8 @@ export interface TeamCapabilities {
145
147
  breakControl?: true;
146
148
  /** This lead may join a member's call on request. Requires `executeTeamConsult`. */
147
149
  consultControl?: true;
150
+ /** This lead sets the team's policy per capability -- on, off, or the agent's -- within what the queue allows. Requires `executeTeamPolicy`. */
151
+ policyControl?: true;
148
152
  }
149
153
  /**
150
154
  * What this login may do, beyond any one task. It travels with the identity because it is part
@@ -154,6 +158,8 @@ export interface TeamCapabilities {
154
158
  export interface SessionCapabilities {
155
159
  /** This login may request a break. Requires the four break methods. */
156
160
  breaks?: true;
161
+ /** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
162
+ preferences?: AgentPreference[];
157
163
  /** This login leads a team: a `TeamRoster` is published to it on every snapshot, `[]` included. */
158
164
  team?: TeamCapabilities;
159
165
  }
@@ -286,7 +292,13 @@ export type HostAudioOutput = {
286
292
  * network. Omni reports it; the adapter decides what any of it means for its platform and what
287
293
  * to relay. `audio` is present on a voice connection.
288
294
  */
295
+ /** What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it. */
296
+ export type UrlVisibility = "full" | "domain" | "hidden";
289
297
  export interface HostReport {
298
+ /** What the agent can see of a task browser's URL in Omni's chrome. Task browsers only; the personal browser is the agent's. */
299
+ browsers: {
300
+ urlVisibility: UrlVisibility;
301
+ };
290
302
  /** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
291
303
  online: boolean;
292
304
  audio?: {
@@ -317,8 +329,10 @@ export type ConnectionStatus = "connecting" | "active" | "error";
317
329
  /** Every field is optional: a provider sends what it knows and omits what it does not. */
318
330
  export interface Contact {
319
331
  name?: string;
320
- number?: string;
321
- email?: string;
332
+ /** The number, or `{ lockedBy }` where the queue says the agent may not see it: the last digits or nothing are sent, never a flag to honour. */
333
+ number?: Lockable<string>;
334
+ /** The email, or `{ lockedBy }` where the agent may not see it; it identifies a person as a number does. */
335
+ email?: Lockable<string>;
322
336
  attributes?: Attribute[];
323
337
  }
324
338
  export interface ScheduledActivity {
@@ -356,6 +370,12 @@ export interface CustomCapability {
356
370
  kind: "button" | "toggle" | "menu-item";
357
371
  label: string;
358
372
  placement: "primary" | "secondary" | "overflow";
373
+ /** Where the control's work renders: inline in the workspace, or as a page of its own. Inline when absent. */
374
+ render?: "inline" | "page";
375
+ };
376
+ /** What the agent supplies before the action runs; the values travel on the custom command. */
377
+ prompt?: {
378
+ fields: CredentialField[];
359
379
  };
360
380
  }
361
381
  export interface SharedTaskCapabilities {
@@ -369,24 +389,24 @@ export interface SharedTaskCapabilities {
369
389
  * The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
370
390
  */
371
391
  export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
372
- decline?: true;
373
- mute?: true;
374
- hold?: true;
375
- agentDisconnect?: true;
392
+ decline?: Lockable<true>;
393
+ mute?: Lockable<true>;
394
+ hold?: Lockable<true>;
395
+ agentDisconnect?: Lockable<true>;
376
396
  /** Reach the party again while `completing`; the task returns to `in-progress`. */
377
- callback?: true;
378
- blindTransfer?: true | DestinationDirectory;
397
+ callback?: Lockable<true>;
398
+ blindTransfer?: Lockable<true | DestinationDirectory>;
379
399
  /** Park the customer and call a destination first; then `complete` or `cancel`. */
380
- consultTransfer?: true | DestinationDirectory;
400
+ consultTransfer?: Lockable<true | DestinationDirectory>;
381
401
  /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.lead`. */
382
- consultLead?: true;
383
- conference?: true | DestinationDirectory;
384
- recording?: true;
402
+ consultLead?: Lockable<true>;
403
+ conference?: Lockable<true | DestinationDirectory>;
404
+ recording?: Lockable<true>;
385
405
  } : C extends "chat" ? SharedTaskCapabilities & {
386
- reject?: true;
387
- hold?: true;
406
+ reject?: Lockable<true>;
407
+ hold?: Lockable<true>;
388
408
  } : SharedTaskCapabilities & {
389
- reject?: true;
409
+ reject?: Lockable<true>;
390
410
  };
391
411
  /**
392
412
  * How a reusing browser's session is keyed.
@@ -508,6 +528,50 @@ export interface TaskAssisting {
508
528
  note?: string;
509
529
  since: IsoTimestamp;
510
530
  }
531
+ /**
532
+ * A tier of the organisation's structure, by the id its manifest declares -- or one of the four
533
+ * every organisation has, `DEFAULT_TIERS`, which a manifest relabels or adds to. The protocol
534
+ * never describes the chain: which tiers a person passes through is the structure's to know.
535
+ */
536
+ export type Tier = string;
537
+ /** A tier the structure has, with the label a desk shows for "who decided". */
538
+ export interface TierDeclaration {
539
+ id: Tier;
540
+ label: string;
541
+ }
542
+ /**
543
+ * The tiers a typical organisation has. A manifest that declares `tiers` relabels any of these
544
+ * by id and may add its own; one that declares none has exactly these.
545
+ */
546
+ export declare const DEFAULT_TIERS: readonly [{
547
+ readonly id: "org";
548
+ readonly label: "Your organisation";
549
+ }, {
550
+ readonly id: "site";
551
+ readonly label: "Your site";
552
+ }, {
553
+ readonly id: "team";
554
+ readonly label: "Your team";
555
+ }, {
556
+ readonly id: "person";
557
+ readonly label: "You";
558
+ }];
559
+ /** The tiers in force for a manifest: the defaults, relabelled or extended by what it declares. */
560
+ export declare function effectiveTiers(declared: readonly TierDeclaration[] | undefined): TierDeclaration[];
561
+ /**
562
+ * Something the queue could allow, locked above the person: the tier that made it unchangeable,
563
+ * and why if they said. `person` never locks their own value, and the queue is not a tier --
564
+ * what the queue does not allow at all is simply absent.
565
+ */
566
+ export interface Locked {
567
+ lockedBy: Tier;
568
+ reason?: string;
569
+ }
570
+ /**
571
+ * A value, or `{ lockedBy }` in its place: present without permission, saying whose. `lockedBy`
572
+ * is the discriminant: a value that carries it is the lock, so no `T` may carry that key.
573
+ */
574
+ export type Lockable<T> = T | Locked;
511
575
  export type Task<C extends Channel = Channel> = {
512
576
  id: TaskId;
513
577
  title: string;
@@ -804,6 +868,25 @@ export interface TeamRoster {
804
868
  members: TeamMember[];
805
869
  /** Omitted when the login lacks `team.consultControl`; `[]` when nobody is asking. */
806
870
  requests?: LeadRequest[];
871
+ /** The team's policy per capability, as it stands. Present exactly when the login declares `team.policyControl`. */
872
+ policies?: TeamPolicies;
873
+ }
874
+ /** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
875
+ export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
876
+ /** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
877
+ export type TeamPolicySetting = "on" | "off" | "agent";
878
+ /** One policy as the lead sees it: the setting, who set it, and `lockedBy` when a tier above the team made it theirs to keep. */
879
+ export interface TeamPolicy extends Resolved {
880
+ setting: TeamPolicySetting;
881
+ }
882
+ export type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;
883
+ export type TeamPolicyCommand = {
884
+ type: "set";
885
+ capability: PolicyKey;
886
+ setting: TeamPolicySetting;
887
+ };
888
+ export interface TeamPolicyCommandRequest {
889
+ command: TeamPolicyCommand;
807
890
  }
808
891
  export type TeamConsultCommand = {
809
892
  type: "join";
@@ -859,6 +942,50 @@ export type OpenMediaResult = {
859
942
  failure: ProtocolFailure;
860
943
  };
861
944
  /** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
945
+ /**
946
+ * What the team may leave to the person: a capability by its own name -- `hold`, `mute` -- or a
947
+ * skill by its provider id. The same key as in `Task.capabilities`, because it is the same
948
+ * capability seen at another tier. Callback and new call are never the person's; they are the
949
+ * team's, on or off, within what the queue allows.
950
+ */
951
+ export type PreferenceId = "hold" | "mute" | `skill:${string}`;
952
+ /**
953
+ * Who stated a value as it stands: a tier -- `person` among them -- or `provisioning`, the
954
+ * protocol's own word for "no tier has said anything and the provider's default applies".
955
+ * Nothing is hidden for want of a row.
956
+ */
957
+ export type SetBy = Tier | "provisioning";
958
+ /** What every resolved value carries: who set it, and who locked it if anyone did. */
959
+ export interface Resolved {
960
+ setBy: SetBy;
961
+ lockedBy?: Tier;
962
+ /** Given with `lockedBy`, where whoever locked it said why. */
963
+ reason?: string;
964
+ }
965
+ /**
966
+ * One choice the team may leave to the person, with where it stands and who set it. The provider
967
+ * keeps it: it is the person's across sessions, written through `setPreference`. Listed whether
968
+ * or not anyone has stated it, and even when a tier above has since locked it.
969
+ */
970
+ export interface AgentPreference extends Resolved {
971
+ id: PreferenceId;
972
+ label: string;
973
+ enabled: boolean;
974
+ }
975
+ /** The person's act: set their own value, or give it up and inherit again. */
976
+ export type SetPreferenceRequest = {
977
+ id: PreferenceId;
978
+ enabled: boolean;
979
+ } | {
980
+ id: PreferenceId;
981
+ inherit: true;
982
+ };
983
+ export type PreferenceResult = {
984
+ status: "applied";
985
+ } | {
986
+ status: "failed";
987
+ failure: ProtocolFailure;
988
+ };
862
989
  export interface Snapshot<C extends Channel = Channel> {
863
990
  status: ConnectionStatus;
864
991
  sessionId: string;
@@ -976,8 +1103,12 @@ export interface Connection<C extends Channel = Channel> {
976
1103
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
977
1104
  /** Required when the login declares `capabilities.team.consultControl`. */
978
1105
  executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
1106
+ /** Required when the login declares `capabilities.team.policyControl`. */
1107
+ executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
979
1108
  /** Required of every voice adapter: all voice audio lands in Omni. */
980
1109
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
1110
+ /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
1111
+ setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
981
1112
  }
982
1113
  export interface Adapter<C extends Channel = Channel> {
983
1114
  manifest: Manifest<C>;
package/dist/index.js CHANGED
@@ -50,6 +50,23 @@ export function isAllowedBrowserUrl(url) {
50
50
  return false;
51
51
  }
52
52
  }
53
+ /**
54
+ * The tiers a typical organisation has. A manifest that declares `tiers` relabels any of these
55
+ * by id and may add its own; one that declares none has exactly these.
56
+ */
57
+ export const DEFAULT_TIERS = [
58
+ { id: "org", label: "Your organisation" },
59
+ { id: "site", label: "Your site" },
60
+ { id: "team", label: "Your team" },
61
+ { id: "person", label: "You" },
62
+ ];
63
+ /** The tiers in force for a manifest: the defaults, relabelled or extended by what it declares. */
64
+ export function effectiveTiers(declared) {
65
+ const byId = new Map(DEFAULT_TIERS.map(tier => [tier.id, tier]));
66
+ for (const tier of declared ?? [])
67
+ byId.set(tier.id, tier);
68
+ return [...byId.values()];
69
+ }
53
70
  // ---------------------------------------------------------------------------
54
71
  // Task commands.
55
72
  // ---------------------------------------------------------------------------
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
7
7
  * rules -- each optional part of a task, each optional part of the break state and roster, each
8
8
  * declared contribution, and each event type.
9
9
  */
10
- declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities"];
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
11
11
  export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
12
12
  export interface AdapterContractResult {
13
13
  events: ProviderEventEnvelope[];
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { browserSessionKey, sameCapabilities, } from "./index.js";
1
+ import { browserSessionKey, effectiveTiers, sameCapabilities, } from "./index.js";
2
2
  import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
@@ -18,12 +18,14 @@ const STATE_SUBJECTS = [
18
18
  "task.dispositions",
19
19
  "task.destinations",
20
20
  "task.custom",
21
+ "task.locked",
21
22
  "break.reasons",
22
23
  "break.imposed",
23
24
  "team.members",
24
25
  "team.requests",
25
26
  "contacts",
26
27
  "scheduledActivities",
28
+ "team.policies",
27
29
  ];
28
30
  // Pinned to the event union the way validation pins its closed sets: a type added to
29
31
  // `ProviderEvent` without a row here, or a row it lacks, is a compile error.
@@ -66,6 +68,8 @@ function observeTask(value, seen) {
66
68
  }
67
69
  if (some(capabilities.custom))
68
70
  seen.add("task.custom");
71
+ if (Object.values(capabilities).some(declared => isRecord(declared) && declared.lockedBy !== undefined))
72
+ seen.add("task.locked");
69
73
  }
70
74
  function observeBreak(value, seen) {
71
75
  if (!isRecord(value))
@@ -82,6 +86,8 @@ function observeTeam(value, seen) {
82
86
  seen.add("team.members");
83
87
  if (some(value.requests))
84
88
  seen.add("team.requests");
89
+ if (isRecord(value.policies) && Object.keys(value.policies).length > 0)
90
+ seen.add("team.policies");
85
91
  }
86
92
  function observeSnapshot(value, seen) {
87
93
  if (!isRecord(value))
@@ -163,7 +169,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
163
169
  let disconnectWasClean = false;
164
170
  try {
165
171
  authenticationState = await authentication.state();
166
- violations.push(...validateAuthenticationState(authenticationState));
172
+ const tiers = effectiveTiers(adapter.manifest.tiers).map(tier => tier.id);
173
+ violations.push(...validateAuthenticationState(authenticationState, "authentication", { tiers }));
167
174
  if (authenticationState.status !== "authenticated") {
168
175
  throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
169
176
  }
@@ -212,9 +219,13 @@ export async function exerciseAdapter(adapter, context, options = {}) {
212
219
  requireMethod(on, "executeTeamBreak", "the login declares capabilities.team.breakControl");
213
220
  if (capabilities.team?.consultControl === true)
214
221
  requireMethod(on, "executeTeamConsult", "the login declares capabilities.team.consultControl");
222
+ if (capabilities.team?.policyControl === true)
223
+ requireMethod(on, "executeTeamPolicy", "the login declares capabilities.team.policyControl");
224
+ if (some(capabilities.preferences))
225
+ requireMethod(on, "setPreference", "the login declares capabilities.preferences");
215
226
  };
216
227
  unsubscribeAuthentication = authentication.subscribe(state => {
217
- const own = validateAuthenticationState(state);
228
+ const own = validateAuthenticationState(state, "authentication", { tiers });
218
229
  violations.push(...own);
219
230
  if (own.length > 0)
220
231
  return;
@@ -723,7 +734,7 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
723
734
  assertNoViolations(found, "The media follows the task");
724
735
  }
725
736
  /** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
726
- export function stillHost(report = { online: true }) {
737
+ export function stillHost(report = { online: true, browsers: { urlVisibility: "hidden" } }) {
727
738
  return { report: () => report, subscribe: () => () => undefined };
728
739
  }
729
740
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
@@ -13,6 +13,8 @@ export declare function validateManifest(manifest: unknown, path?: string): Prot
13
13
  export interface TaskValidationContext {
14
14
  /** The provider's channel, from its manifest. A task must agree with it. */
15
15
  channel: string;
16
+ /** The tier ids in force, from the manifest. The defaults when absent. */
17
+ tiers?: readonly string[];
16
18
  }
17
19
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
18
20
  /**
@@ -30,6 +32,8 @@ export interface ReaderContext {
30
32
  * snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
31
33
  */
32
34
  capabilities?: SessionCapabilities;
35
+ /** The tier ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
36
+ tiers?: readonly string[];
33
37
  /** The login's `sessionId`. A snapshot or event naming another belongs to a login that is gone. */
34
38
  sessionId?: string;
35
39
  /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
@@ -45,7 +49,7 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
45
49
  */
46
50
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
47
51
  /** The connection methods whose results `validateResult` knows. */
48
- export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
52
+ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
49
53
  /**
50
54
  * Validates what a connection method answered. A result is untrusted for the same reason a
51
55
  * snapshot is: it comes from an adapter that may be compiled against another version, and Omni
@@ -53,4 +57,9 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
53
57
  * failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
54
58
  */
55
59
  export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
56
- export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
60
+ /** What a login is validated against beyond its own shape. */
61
+ export interface LoginValidationContext {
62
+ /** The tier ids in force, from the manifest. The defaults when absent. */
63
+ tiers?: readonly string[];
64
+ }
65
+ export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
@@ -9,7 +9,7 @@
9
9
  // Each list is pinned to its type both ways -- a member the type lacks, or a member the list
10
10
  // lacks, fails to compile -- so what the validators accept cannot drift from what the
11
11
  // declarations say.
12
- import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
12
+ import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_TIERS, effectiveTiers, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -69,14 +69,14 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
69
69
  const SNAPSHOT_REASONS = membersOf({
70
70
  reconnected: true, "provider-requested": true,
71
71
  });
72
- const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
72
+ const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true, preferences: true });
73
73
  const MEMBER_BREAKS = membersOf({
74
74
  "awaiting-decision": true, granted: true, "starting-after-task": true,
75
75
  });
76
76
  const OFFERABLE_PHASES = membersOf({
77
77
  pending: true, confirmed: true, preparing: true,
78
78
  });
79
- const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
79
+ const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true, policyControl: true });
80
80
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
81
81
  const EXPIRABLE_PHASES = membersOf({
82
82
  pending: true, confirmed: true, preparing: true,
@@ -164,15 +164,19 @@ export function validateContact(contact, path = "contact") {
164
164
  validateContactInto(contact, path, into);
165
165
  return into.violations;
166
166
  }
167
- function validateContactInto(contact, path, into) {
167
+ function validateContactInto(contact, path, into, tiers) {
168
168
  if (!isPlainObject(contact)) {
169
169
  into.add("contact.shape", path, "a contact must be an object");
170
170
  return;
171
171
  }
172
172
  for (const field of ["name", "number", "email"]) {
173
- if (contact[field] !== undefined) {
174
- into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
173
+ if (contact[field] === undefined)
174
+ continue;
175
+ if ((field === "number" || field === "email") && isLocked(contact[field])) {
176
+ validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`, tiers, into);
177
+ continue;
175
178
  }
179
+ into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
176
180
  }
177
181
  validateAttributes(contact.attributes, `${path}.attributes`, into);
178
182
  }
@@ -307,6 +311,27 @@ export function validateManifest(manifest, path = "manifest") {
307
311
  }
308
312
  }
309
313
  }
314
+ if (manifest.tiers !== undefined) {
315
+ if (!Array.isArray(manifest.tiers)) {
316
+ into.add("manifest.tiers.shape", `${path}.tiers`, "tiers must be an array when present");
317
+ }
318
+ else {
319
+ const ids = new Set();
320
+ manifest.tiers.forEach((tier, index) => {
321
+ const at = `${path}.tiers[${index}]`;
322
+ if (!isPlainObject(tier)) {
323
+ into.add("manifest.tier.shape", at, "each tier must be an object with an id and a label");
324
+ return;
325
+ }
326
+ if (into.filled(tier.id, "manifest.tier.id", `${at}.id`, "a tier needs an id")) {
327
+ if (ids.has(tier.id))
328
+ into.add("manifest.tier.unique", `${at}.id`, `duplicate tier: ${tier.id}`);
329
+ ids.add(tier.id);
330
+ }
331
+ into.filled(tier.label, "manifest.tier.label", `${at}.label`, "a tier needs the label a desk shows for it");
332
+ });
333
+ }
334
+ }
310
335
  if (manifest.taskTypePresentation !== undefined) {
311
336
  if (!isPlainObject(manifest.taskTypePresentation)) {
312
337
  into.add("manifest.taskTypePresentation.shape", `${path}.taskTypePresentation`, "taskTypePresentation must be an object when present");
@@ -427,8 +452,73 @@ function validateCustomCapabilities(value, path, into) {
427
452
  into.oneOf(custom.ui.kind, CUSTOM_UI_KINDS, "task.custom.ui.kind", `${at}.ui.kind`);
428
453
  into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
429
454
  into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
455
+ if (custom.ui.render !== undefined)
456
+ into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
457
+ if (custom.prompt !== undefined) {
458
+ if (!isPlainObject(custom.prompt) || !Array.isArray(custom.prompt.fields)) {
459
+ into.add("task.custom.prompt.shape", `${at}.prompt`, "a prompt is an object with the fields the agent fills");
460
+ }
461
+ else {
462
+ into.require(custom.prompt.fields.length > 0, "task.custom.prompt.fields", `${at}.prompt.fields`, "a prompt with no fields asks for nothing; omit it");
463
+ custom.prompt.fields.forEach((field, fieldIndex) => {
464
+ const where = `${at}.prompt.fields[${fieldIndex}]`;
465
+ if (!isPlainObject(field)) {
466
+ into.add("task.custom.prompt.field.shape", where, "each prompt field must be an object");
467
+ return;
468
+ }
469
+ into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
470
+ into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
471
+ into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "task.custom.prompt.field.type", `${where}.type`);
472
+ });
473
+ }
474
+ }
430
475
  });
431
476
  }
477
+ const DEFAULT_TIER_IDS = DEFAULT_TIERS.map(tier => tier.id);
478
+ const POLICY_SETTINGS = membersOf({ on: true, off: true, agent: true });
479
+ const POLICY_KEYS = new Set([
480
+ ...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
481
+ ].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
482
+ const AGENT_SETTABLE = /^(hold|mute|skill:.+)$/;
483
+ const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
484
+ /** The tier ids in force: the manifest's, or the defaults when the caller holds no manifest. */
485
+ const tierIds = (tiers) => tiers ?? DEFAULT_TIER_IDS;
486
+ /** `lockedBy`: a declared tier other than `person`, who never locks their own value. */
487
+ function validateLockedByInto(value, rule, path, tiers, into) {
488
+ if (!into.filled(value, rule, path, "lockedBy names the tier that locked it"))
489
+ return;
490
+ into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
491
+ into.require(tierIds(tiers).includes(value), `${rule}.unknown`, path, `${String(value)} is not a tier this manifest declares: the defaults are ${DEFAULT_TIER_IDS.join(", ")}`);
492
+ }
493
+ /** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
494
+ function validateLockedInto(value, rule, path, tiers, into) {
495
+ validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
496
+ if (value.reason !== undefined)
497
+ into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
498
+ }
499
+ /** What every resolved value carries: who set it, and who locked it if anyone. */
500
+ function validateResolvedInto(value, rule, path, tiers, into) {
501
+ if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a tier, or provisioning")) {
502
+ into.require(value.setBy === "provisioning" || tierIds(tiers).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provisioning nor a tier this manifest declares`);
503
+ }
504
+ if (value.lockedBy !== undefined)
505
+ validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
506
+ if (value.reason !== undefined) {
507
+ into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
508
+ into.require(value.lockedBy !== undefined, `${rule}.reason.unexpected`, `${path}.reason`, "a reason goes with lockedBy: it says why it was locked");
509
+ }
510
+ }
511
+ /** The tier ids a manifest puts in force, for validators that receive one. */
512
+ function manifestTiers(manifest) {
513
+ if (!isPlainObject(manifest))
514
+ return undefined;
515
+ const declared = Array.isArray(manifest.tiers)
516
+ ? manifest.tiers.filter((tier) => isPlainObject(tier) && typeof tier.id === "string")
517
+ : [];
518
+ return effectiveTiers(declared).map(tier => tier.id);
519
+ }
520
+ const CUSTOM_RENDERS = membersOf({ inline: true, page: true });
521
+ const CREDENTIAL_FIELD_TYPES = membersOf({ text: true, password: true });
432
522
  function validateBrowsers(value, path, into) {
433
523
  if (!Array.isArray(value)) {
434
524
  into.add("task.browsers.shape", path, "browsers must be an array");
@@ -633,7 +723,7 @@ function validateTaskInto(task, context, path, into) {
633
723
  into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
634
724
  }
635
725
  if (task.contact !== undefined)
636
- validateContactInto(task.contact, `${path}.contact`, into);
726
+ validateContactInto(task.contact, `${path}.contact`, into, context.tiers);
637
727
  validateBrowsers(task.browsers, `${path}.browsers`, into);
638
728
  validateTaskAttributes(task.attributes, `${path}.attributes`, into);
639
729
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
@@ -660,6 +750,14 @@ function validateTaskInto(task, context, path, into) {
660
750
  continue;
661
751
  if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
662
752
  continue;
753
+ // A control the queue could allow may stand locked in its place, saying whose. What the
754
+ // queue provides -- browsers, dispositions, custom controls -- is content, not a control.
755
+ if (isLocked(declared)) {
756
+ if (into.require(name !== "browsers" && name !== "dispositions" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
757
+ validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.tiers, into);
758
+ }
759
+ continue;
760
+ }
663
761
  switch (name) {
664
762
  case "dispositions":
665
763
  validateDispositions(declared, `${path}.capabilities.dispositions`, into);
@@ -780,6 +878,18 @@ function validateTeamRosterInto(roster, path, context, into) {
780
878
  if (context.capabilities !== undefined && context.capabilities.team === undefined) {
781
879
  into.add("team.unentitled", path, "a roster published to a login that does not declare capabilities.team: the login is the permission");
782
880
  }
881
+ // The team's policies travel with the roster exactly when the login may set them.
882
+ if (context.capabilities !== undefined) {
883
+ const may = context.capabilities.team?.policyControl === true;
884
+ if (may && roster.policies === undefined) {
885
+ into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the roster carries the team's policies");
886
+ }
887
+ if (!may && roster.policies !== undefined) {
888
+ into.add("team.policies.capability", `${path}.policies`, "policies require team.policyControl on the login: a lead who may not set them has nothing to see");
889
+ }
890
+ }
891
+ if (roster.policies !== undefined)
892
+ validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.tiers);
783
893
  if (roster.requests === undefined) {
784
894
  // `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
785
895
  // asked therefore always carries the list.
@@ -855,6 +965,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
855
965
  return into.violations;
856
966
  }
857
967
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
968
+ const tiers = context.tiers ?? manifestTiers(manifest);
858
969
  into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
859
970
  if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
860
971
  && context.sessionId !== undefined) {
@@ -868,7 +979,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
868
979
  const seen = new Set();
869
980
  let assisting;
870
981
  snapshot.tasks.forEach((task, index) => {
871
- validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
982
+ validateTaskInto(task, { channel, tiers }, `${path}.tasks[${index}]`, into);
872
983
  // A lead assists one call at a time.
873
984
  if (isPlainObject(task) && task.assisting !== undefined) {
874
985
  if (assisting !== undefined)
@@ -933,7 +1044,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
933
1044
  into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
934
1045
  }
935
1046
  if (snapshot.team !== undefined)
936
- validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
1047
+ validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, tiers }, into);
937
1048
  return into.violations;
938
1049
  }
939
1050
  // ---------------------------------------------------------------------------
@@ -1017,6 +1128,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1017
1128
  return into.violations;
1018
1129
  }
1019
1130
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1131
+ const tiers = context.tiers ?? manifestTiers(manifest);
1020
1132
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
1021
1133
  if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
1022
1134
  && context.sessionId !== undefined) {
@@ -1033,7 +1145,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1033
1145
  switch (event.type) {
1034
1146
  case "snapshot":
1035
1147
  into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
1036
- into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, context));
1148
+ into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, tiers }));
1037
1149
  break;
1038
1150
  case "provider-status":
1039
1151
  into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
@@ -1045,7 +1157,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1045
1157
  validateBreakState(event.break, `${at}.break`, into);
1046
1158
  break;
1047
1159
  case "task-offered":
1048
- validateTaskInto(event.task, { channel }, `${at}.task`, into);
1160
+ validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1049
1161
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1050
1162
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1051
1163
  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}`);
@@ -1066,7 +1178,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1066
1178
  }
1067
1179
  break;
1068
1180
  case "task-updated":
1069
- validateTaskInto(event.task, { channel }, `${at}.task`, into);
1181
+ validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1070
1182
  break;
1071
1183
  case "task-media-ended":
1072
1184
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
@@ -1088,7 +1200,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1088
1200
  validateProviderSummary(event.summary, `${at}.summary`, into);
1089
1201
  break;
1090
1202
  case "team-updated":
1091
- validateTeamRosterInto(event.team, `${at}.team`, context, into);
1203
+ validateTeamRosterInto(event.team, `${at}.team`, { ...context, tiers }, into);
1092
1204
  break;
1093
1205
  case "contacts-updated":
1094
1206
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
@@ -1125,6 +1237,53 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1125
1237
  // Results. A result crosses the same boundary a snapshot does, from an adapter that may be
1126
1238
  // compiled against another version, and Omni shows the agent what it says.
1127
1239
  // ---------------------------------------------------------------------------
1240
+ const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
1241
+ /** The choices left to the person, each with where it stands. */
1242
+ function validatePreferencesInto(value, path, into, tiers) {
1243
+ if (!Array.isArray(value)) {
1244
+ into.add("preferences.shape", path, "preferences must be an array");
1245
+ return;
1246
+ }
1247
+ const seen = new Set();
1248
+ value.forEach((preference, index) => {
1249
+ const at = `${path}[${index}]`;
1250
+ if (!isPlainObject(preference)) {
1251
+ into.add("preference.shape", at, "each preference must be an object");
1252
+ return;
1253
+ }
1254
+ if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold, mute, or skill:<id>: nothing else is the person's to set")) {
1255
+ if (seen.has(preference.id))
1256
+ into.add("preference.unique", `${at}.id`, `duplicate preference: ${preference.id}`);
1257
+ seen.add(preference.id);
1258
+ }
1259
+ into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
1260
+ into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
1261
+ validateResolvedInto(preference, "preference", at, tiers, into);
1262
+ });
1263
+ }
1264
+ /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
1265
+ function validateTeamPoliciesInto(value, path, into, tiers) {
1266
+ if (!isPlainObject(value)) {
1267
+ into.add("team.policies.shape", path, "policies must be an object keyed by capability");
1268
+ return;
1269
+ }
1270
+ for (const [key, policy] of Object.entries(value)) {
1271
+ if (policy === undefined)
1272
+ continue;
1273
+ const at = `${path}.${key}`;
1274
+ if (!into.require(POLICY_KEYS.has(key) || /^skill:.+$/.test(key), "team.policy.key", at, `${key} is not a control a policy can name: a task control, dial, or skill:<id>`))
1275
+ continue;
1276
+ if (!isPlainObject(policy)) {
1277
+ into.add("team.policy.shape", at, "each policy carries its setting, who set it, and who locked it if anyone");
1278
+ continue;
1279
+ }
1280
+ if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
1281
+ into.require(policy.setting !== "agent" || AGENT_SETTABLE.test(key), "team.policy.agent", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
1282
+ }
1283
+ validateResolvedInto(policy, "team.policy", at, tiers, into);
1284
+ into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
1285
+ }
1286
+ }
1128
1287
  /** A `ProtocolFailure`, wherever one appears: on a result, or on a task's failed outcome. */
1129
1288
  function validateFailureInto(value, path, into) {
1130
1289
  if (!isPlainObject(value)) {
@@ -1144,6 +1303,7 @@ function validateFailureInto(value, path, into) {
1144
1303
  into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
1145
1304
  }
1146
1305
  }
1306
+ const URL_VISIBILITIES = membersOf({ full: true, domain: true, hidden: true });
1147
1307
  const HOST_AUDIO_REASONS = membersOf({ "no-device": true, denied: true, "not-asked": true, "in-use": true, lost: true });
1148
1308
  const HOST_OUTPUT_REASONS = membersOf({ "no-device": true, lost: true });
1149
1309
  function validateUnavailable(value, rule, path, into) {
@@ -1166,6 +1326,12 @@ export function validateHostReport(report, path = "host") {
1166
1326
  return into.violations;
1167
1327
  }
1168
1328
  into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
1329
+ if (!isPlainObject(report.browsers)) {
1330
+ into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
1331
+ }
1332
+ else {
1333
+ into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
1334
+ }
1169
1335
  if (report.audio === undefined)
1170
1336
  return into.violations;
1171
1337
  if (!isPlainObject(report.audio)) {
@@ -1223,6 +1389,8 @@ const RESULT_STATUSES = {
1223
1389
  executeTeamBreak: { success: "applied", failure: "failed" },
1224
1390
  executeTeamConsult: { success: "applied", failure: "failed" },
1225
1391
  openMedia: { success: "opened", failure: "unavailable" },
1392
+ setPreference: { success: "applied", failure: "failed" },
1393
+ executeTeamPolicy: { success: "applied", failure: "failed" },
1226
1394
  };
1227
1395
  /**
1228
1396
  * Validates what a connection method answered. A result is untrusted for the same reason a
@@ -1267,7 +1435,7 @@ function validateUser(value, rule, path, into) {
1267
1435
  into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
1268
1436
  into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
1269
1437
  }
1270
- function validateSessionCapabilitiesInto(value, path, into) {
1438
+ function validateSessionCapabilitiesInto(value, path, into, tiers) {
1271
1439
  if (!isPlainObject(value)) {
1272
1440
  into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
1273
1441
  return;
@@ -1277,6 +1445,13 @@ function validateSessionCapabilitiesInto(value, path, into) {
1277
1445
  continue;
1278
1446
  if (!into.require(SESSION_CAPABILITIES.includes(name), "authentication.capability.unknown", `${path}.${name}`, `unsupported session capability: ${name}`))
1279
1447
  continue;
1448
+ if (name === "preferences") {
1449
+ if (Array.isArray(declared) && declared.length === 0) {
1450
+ into.add("authentication.capability.preferences.empty", `${path}.preferences`, "a login with nothing left to the person omits preferences rather than declaring an empty list");
1451
+ }
1452
+ validatePreferencesInto(declared, `${path}.preferences`, into, tiers);
1453
+ continue;
1454
+ }
1280
1455
  if (name === "team") {
1281
1456
  if (!isPlainObject(declared)) {
1282
1457
  into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
@@ -1294,7 +1469,7 @@ function validateSessionCapabilitiesInto(value, path, into) {
1294
1469
  into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
1295
1470
  }
1296
1471
  }
1297
- export function validateAuthenticationState(state, path = "authentication") {
1472
+ export function validateAuthenticationState(state, path = "authentication", context = {}) {
1298
1473
  const into = new Collector();
1299
1474
  if (!isPlainObject(state)) {
1300
1475
  into.add("authentication.shape", path, "an authentication state must be an object");
@@ -1307,7 +1482,7 @@ export function validateAuthenticationState(state, path = "authentication") {
1307
1482
  // may carry an identity. Anything else is a state claiming knowledge it does not have.
1308
1483
  if (state.status === "authenticated" || state.status === "refreshing") {
1309
1484
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
1310
- validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into);
1485
+ validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.tiers);
1311
1486
  }
1312
1487
  else if (state.status === "expired") {
1313
1488
  if (state.identity !== undefined)
package/guide.md CHANGED
@@ -176,6 +176,7 @@ type Manifest<C extends Channel = Channel> = {
176
176
  idleCapabilities?: IdleCapabilities<C>;
177
177
  phaseLabels?: TaskPhaseLabels;
178
178
  taskTypePresentation?: Record<string, TaskTypePresentation>;
179
+ tiers?: TierDeclaration[];
179
180
  };
180
181
  ```
181
182
 
@@ -223,10 +224,12 @@ type AuthenticationContext = {
223
224
  type TeamCapabilities = {
224
225
  breakControl?: true;
225
226
  consultControl?: true;
227
+ policyControl?: true;
226
228
  };
227
229
 
228
230
  type SessionCapabilities = {
229
231
  breaks?: true;
232
+ preferences?: AgentPreference[];
230
233
  team?: TeamCapabilities;
231
234
  };
232
235
 
@@ -256,7 +259,10 @@ type HostAudioOutput =
256
259
  | { status: "ready" }
257
260
  | { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
258
261
 
262
+ type UrlVisibility = "full" | "domain" | "hidden";
263
+
259
264
  type HostReport = {
265
+ browsers: { urlVisibility: UrlVisibility };
260
266
  online: boolean;
261
267
  audio?: {
262
268
  input: HostAudioInput;
@@ -279,11 +285,79 @@ type ConnectContext = {
279
285
  };
280
286
 
281
287
  type ConnectionStatus = "connecting" | "active" | "error";
288
+
289
+ type CredentialField = {
290
+ name: string;
291
+ label: string;
292
+ type: "text" | "password";
293
+ required?: boolean;
294
+ autocomplete?: string;
295
+ };
296
+
297
+ type AuthenticationChallenge =
298
+ | { flowId: string; method: "browser-sso"; authorizationUrl: string; browser: "system" | "omni" }
299
+ | { flowId: string; method: "credentials"; fields: CredentialField[] };
300
+
301
+ type StartAuthenticationRequest =
302
+ | { requestId: string; method: "browser-sso"; callbackUrl: string }
303
+ | { requestId: string; method: "credentials" };
304
+
305
+ type StartAuthenticationResult =
306
+ | { status: "interaction-required"; challenge: AuthenticationChallenge }
307
+ | { status: "rejected"; failure: AuthenticationFailure };
308
+
309
+ type CompleteAuthenticationRequest =
310
+ | { flowId: string; method: "browser-sso"; callbackUrl: string }
311
+ | { flowId: string; method: "credentials"; values: Readonly<Record<string, string>> };
312
+
313
+ type CompleteAuthenticationResult =
314
+ | { status: "authenticated"; identity: User; capabilities: SessionCapabilities; expiresAt?: IsoTimestamp }
315
+ | { status: "rejected"; failure: AuthenticationFailure };
316
+
317
+ type AuthenticationActionResult =
318
+ | { status: "accepted" }
319
+ | { status: "failed"; failure: AuthenticationFailure };
320
+
321
+ type Unsubscribe = () => void;
322
+
323
+ type AuthenticationSession = {
324
+ state(): AuthenticationState | Promise<AuthenticationState>;
325
+ subscribe(listener: (state: AuthenticationState) => void): Unsubscribe;
326
+ start(request: StartAuthenticationRequest): Promise<StartAuthenticationResult>;
327
+ complete(request: CompleteAuthenticationRequest): Promise<CompleteAuthenticationResult>;
328
+ cancelAuthentication(flowId: string): Promise<AuthenticationActionResult>;
329
+ signOut(): Promise<AuthenticationActionResult>;
330
+ close(): Promise<void>;
331
+ };
282
332
  ```
283
333
 
284
334
  ### Provider state
285
335
 
286
336
  ```ts
337
+ type PreferenceId = "hold" | "mute" | `skill:${string}`;
338
+
339
+ type SetBy = Tier | "provisioning";
340
+
341
+ type Resolved = {
342
+ setBy: SetBy;
343
+ lockedBy?: Tier;
344
+ reason?: string;
345
+ };
346
+
347
+ type AgentPreference = Resolved & {
348
+ id: PreferenceId;
349
+ label: string;
350
+ enabled: boolean;
351
+ };
352
+
353
+ type SetPreferenceRequest =
354
+ | { id: PreferenceId; enabled: boolean }
355
+ | { id: PreferenceId; inherit: true };
356
+
357
+ type PreferenceResult =
358
+ | { status: "applied" }
359
+ | { status: "failed"; failure: ProtocolFailure };
360
+
287
361
  type Snapshot = {
288
362
  status: ConnectionStatus;
289
363
  sessionId: string;
@@ -297,6 +371,10 @@ type Snapshot = {
297
371
  type AgentCapacity = {
298
372
  count: number; // absolute ceiling, at least 1
299
373
  };
374
+
375
+ type CapacityResult =
376
+ | { status: "accepted" }
377
+ | { status: "failed"; failure: ProtocolFailure };
300
378
  ```
301
379
 
302
380
  ### Idle contributions
@@ -304,8 +382,8 @@ type AgentCapacity = {
304
382
  ```ts
305
383
  type Contact = {
306
384
  name?: string;
307
- number?: string;
308
- email?: string;
385
+ number?: Lockable<string>;
386
+ email?: Lockable<string>;
309
387
  attributes?: Attribute[];
310
388
  };
311
389
 
@@ -317,6 +395,14 @@ type ScheduledActivity = {
317
395
  contact?: Contact;
318
396
  attributes?: Attribute[];
319
397
  };
398
+
399
+ type DialRequest = {
400
+ destination: string;
401
+ };
402
+
403
+ type DialResult =
404
+ | { status: "dialled" }
405
+ | { status: "failed"; failure: ProtocolFailure };
320
406
  ```
321
407
 
322
408
  ### Task capabilities
@@ -348,7 +434,9 @@ type CustomCapability = {
348
434
  kind: "button" | "toggle" | "menu-item";
349
435
  label: string;
350
436
  placement: "primary" | "secondary" | "overflow";
437
+ render?: "inline" | "page";
351
438
  };
439
+ prompt?: { fields: CredentialField[] };
352
440
  };
353
441
 
354
442
  type SharedTaskCapabilities = {
@@ -360,20 +448,20 @@ type SharedTaskCapabilities = {
360
448
  type TaskCapabilities<C extends Channel = Channel> =
361
449
  C extends "voice"
362
450
  ? SharedTaskCapabilities & {
363
- decline?: true;
364
- mute?: true;
365
- hold?: true;
366
- agentDisconnect?: true;
367
- callback?: true;
368
- blindTransfer?: true | DestinationDirectory;
369
- consultTransfer?: true | DestinationDirectory;
370
- consultLead?: true;
371
- conference?: true | DestinationDirectory;
372
- recording?: true;
451
+ decline?: Lockable<true>;
452
+ mute?: Lockable<true>;
453
+ hold?: Lockable<true>;
454
+ agentDisconnect?: Lockable<true>;
455
+ callback?: Lockable<true>;
456
+ blindTransfer?: Lockable<true | DestinationDirectory>;
457
+ consultTransfer?: Lockable<true | DestinationDirectory>;
458
+ consultLead?: Lockable<true>;
459
+ conference?: Lockable<true | DestinationDirectory>;
460
+ recording?: Lockable<true>;
373
461
  }
374
462
  : C extends "chat"
375
- ? SharedTaskCapabilities & { reject?: true; hold?: true }
376
- : SharedTaskCapabilities & { reject?: true };
463
+ ? SharedTaskCapabilities & { reject?: Lockable<true>; hold?: Lockable<true> }
464
+ : SharedTaskCapabilities & { reject?: Lockable<true> };
377
465
  ```
378
466
 
379
467
  The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
@@ -393,15 +481,24 @@ const BROWSER_ISOLATION_SCHEMES = {
393
481
  type BrowserIsolationScheme =
394
482
  (typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];
395
483
 
396
- type TaskBrowser = {
484
+ type TaskBrowserBase = {
397
485
  id: string;
398
486
  name: string;
399
487
  purpose: string;
400
488
  url: string;
401
- } & (
489
+ };
490
+
491
+ type TaskBrowser = TaskBrowserBase & (
402
492
  | { reuse: false; isolationScheme?: never }
403
493
  | { reuse: true; isolationScheme: BrowserIsolationScheme }
404
494
  );
495
+
496
+ type BrowserSessionKeyInput = {
497
+ providerId: string;
498
+ taskId: TaskId;
499
+ taskType: string;
500
+ browser: TaskBrowser;
501
+ };
405
502
  ```
406
503
 
407
504
  That union is what makes a reusing browser with no scheme fail to compile rather than inherit a
@@ -471,6 +568,20 @@ type TaskAssisting = {
471
568
  since: IsoTimestamp;
472
569
  };
473
570
 
571
+ type Tier = string;
572
+
573
+ type TierDeclaration = {
574
+ id: Tier;
575
+ label: string;
576
+ };
577
+
578
+ type Locked = {
579
+ lockedBy: Tier;
580
+ reason?: string;
581
+ };
582
+
583
+ type Lockable<T> = T | Locked;
584
+
474
585
  type Task<C extends Channel = Channel> = {
475
586
  id: TaskId;
476
587
  title: string;
@@ -574,9 +685,80 @@ type TaskCommandRequest<C extends Channel = Channel> = {
574
685
  taskId: TaskId;
575
686
  command: TaskCommand<C>;
576
687
  };
688
+
689
+ type TaskCommandResult =
690
+ | { status: "applied" }
691
+ | { status: "failed"; failure: ProtocolFailure };
577
692
  ```
578
693
 
579
- ### Breaks
694
+ ### Who decides what an agent may do
695
+
696
+ An agent desk has two managers, not one. **The queue** — a process, a work type — is owned by a
697
+ process manager and **allows** a set of capabilities: hold, mute, callback, new call, conference,
698
+ whether the number is visible, the actions it offers, the skills it needs. **The people** are
699
+ managed through the organisation's structure — a team, a location, the organisation itself, in
700
+ whatever combination the structure defines for a person — and **decide** per capability within
701
+ what the queue allows: on for everyone, off for everyone, or left to the person. What you do to
702
+ your team is a **policy**; what you do to yourself is a **preference**. A person belongs to one
703
+ team and many queues, so a policy applies across every queue the person works.
704
+
705
+ **The provider resolves; the protocol carries the result and who decided.** The structure's tiers
706
+ are the provider's ladder: each tier states only what it sets, an enforcing policy at a tier above
707
+ the person settles the value for everyone below it, and where nothing enforces the most specific
708
+ tier that says anything wins. The protocol names a tier by the id the manifest declares for it and
709
+ never describes the chain between them: which tiers a person passes through is the structure's to
710
+ know. A typical organisation has four, and they are the defaults — `DEFAULT_TIERS`: `org`, `site`,
711
+ `team`, `person`, each with the label a desk shows — which `Manifest.tiers` relabels by id (a
712
+ provider whose "site" means something else says what it means) or extends with tiers of its own;
713
+ a manifest that declares none has exactly the four. `lockedBy` is any tier in force except
714
+ `person`, who never locks their own value; `setBy` is any tier in force, or `provisioning`, the
715
+ protocol's own word for "no tier has said anything and the provider's default applies". A host
716
+ renders "who decided" from the declared labels and needs no others. What the wire carries is the
717
+ resolution:
718
+
719
+ - **`lockedBy`** — a tier above the person made this value theirs to keep. A person never locks
720
+ their own value, and the queue is not a tier: what the queue does not allow at all is absent.
721
+ - **`setBy`** — who stated the value as it stands: a tier, the `person` themself, or
722
+ `provisioning` where no tier has said anything. Provenance, not a lock: a value that came from a
723
+ broad tier as a default is still the person's to change.
724
+
725
+ **On a task, a control the queue could allow may stand locked in its place.** `Task.capabilities`
726
+ is the effective set. What the queue does not allow is absent and nothing is shown. What the queue
727
+ allows and a tier above the person locked is present as `{ lockedBy, reason? }` where the control's
728
+ value would be — `mute: { lockedBy: "team", reason: "Nobody on this team mutes" }` — and Omni
729
+ renders that control disabled, saying who decided, so an agent who cannot press Mute knows whether
730
+ to ask their lead or their site. `lockedBy` is the discriminant: a value that carries it is the
731
+ lock, so nothing that can be locked — a directory, a number — may carry that key itself. A
732
+ contact's number and email are the same, since each identifies a person: where the queue says
733
+ the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
734
+ nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
735
+ A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
736
+ controls — is content, and is never locked.
737
+
738
+ **A lead sets the team's policy from their roster.** A login that declares
739
+ `capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
740
+ with `on`, `off`, or `agent`, for any task control, `dial`, or a skill — and only `hold`, `mute`
741
+ and skills may be `agent`; callback and new call are the team's, on or off, within what the queue
742
+ allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
743
+ `lockedBy` where a tier above the team made it theirs to keep, which the lead sees and cannot
744
+ change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
745
+
746
+ **What the team left to the person is the person's, and the provider keeps it.** The login's
747
+ `capabilities.preferences` lists every preference the person may hold — `hold`, `mute`, a skill —
748
+ with where it stands and who set it: `setBy: "team"` while they inherit the team's default,
749
+ `"person"` once they have set their own, `"provisioning"` where no tier has said anything. Nothing
750
+ is hidden for want of a row, and a preference a tier above has since locked is listed with
751
+ `lockedBy`. `setPreference` is the person's act — `{ id, enabled }` to set their own, `{ id,
752
+ inherit: true }` to give it up and inherit again — answered `applied` and republished as a new
753
+ `authenticated` state when something changed, a state and not a flicker, as every republish of
754
+ `authenticated` is; and it is durable: the person's across sessions. A lead may also set a
755
+ person's preference from their own screen, which arrives the same way. A preference is keyed by
756
+ the capability's own name because it is the same capability at another tier: effective in
757
+ `Task.capabilities`, set for the team in `policies`, left to the person in `preferences`. The
758
+ command `mute` acts on one call; the preference `mute` says whether the person wants the control
759
+ at all, and a host renders it in its settings, never as the button on a call.
760
+
761
+ ## Breaks
580
762
 
581
763
  ```ts
582
764
  type BreakApproval =
@@ -613,6 +795,22 @@ type BreakState = {
613
795
  activeReasonId?: string;
614
796
  imposed?: ImposedBreak;
615
797
  };
798
+
799
+ type BreakRequestResult =
800
+ | { status: "requested" }
801
+ | { status: "failed"; failure: ProtocolFailure };
802
+
803
+ type BreakCommitResult =
804
+ | { status: "committed" }
805
+ | { status: "failed"; failure: ProtocolFailure };
806
+
807
+ type BreakCancelResult =
808
+ | { status: "cancelled" }
809
+ | { status: "failed"; failure: ProtocolFailure };
810
+
811
+ type BreakEndResult =
812
+ | { status: "ended" }
813
+ | { status: "failed"; failure: ProtocolFailure };
616
814
  ```
617
815
 
618
816
  ### Team
@@ -638,6 +836,28 @@ type LeadRequest = {
638
836
  type TeamRoster = {
639
837
  members: TeamMember[];
640
838
  requests?: LeadRequest[];
839
+ policies?: TeamPolicies;
840
+ };
841
+
842
+ type PolicyKey =
843
+ | Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities>
844
+ | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities>
845
+ | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities>
846
+ | "dial"
847
+ | `skill:${string}`;
848
+
849
+ type TeamPolicySetting = "on" | "off" | "agent";
850
+
851
+ type TeamPolicy = Resolved & {
852
+ setting: TeamPolicySetting;
853
+ };
854
+
855
+ type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;
856
+
857
+ type TeamPolicyCommand = { type: "set"; capability: PolicyKey; setting: TeamPolicySetting };
858
+
859
+ type TeamPolicyCommandRequest = {
860
+ command: TeamPolicyCommand;
641
861
  };
642
862
 
643
863
  type TeamConsultCommand =
@@ -649,6 +869,18 @@ type TeamBreakCommand =
649
869
  | { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
650
870
  | { type: "place"; memberId: UserId; reason?: string }
651
871
  | { type: "release"; memberId: UserId };
872
+
873
+ type TeamBreakCommandRequest = {
874
+ command: TeamBreakCommand;
875
+ };
876
+
877
+ type TeamConsultCommandRequest = {
878
+ command: TeamConsultCommand;
879
+ };
880
+
881
+ type TeamCommandResult =
882
+ | { status: "applied" }
883
+ | { status: "failed"; failure: ProtocolFailure };
652
884
  ```
653
885
 
654
886
  ### Media
@@ -663,6 +895,11 @@ type VoiceMediaSession = {
663
895
  type OpenMediaResult =
664
896
  | { status: "opened"; session: VoiceMediaSession }
665
897
  | { status: "unavailable"; failure: ProtocolFailure };
898
+
899
+ type OpenMediaRequest = {
900
+ taskId: TaskId;
901
+ localAudio?: MediaStream;
902
+ };
666
903
  ```
667
904
 
668
905
  ### Events
@@ -710,12 +947,52 @@ type ProviderEventEnvelope = {
710
947
  reason rather than a naming one: `Event` is a DOM global, and a bare one would shadow it for every
711
948
  adapter compiled against the browser lib.
712
949
 
950
+ ### Adapter and connection
951
+
952
+ ```ts
953
+ type Connection<C extends Channel = Channel> = {
954
+ snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
955
+ subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
956
+ setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
957
+ execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
958
+ disconnect(): Promise<void>;
959
+
960
+ describeUsers?(ids: UserId[]): Promise<User[]>;
961
+ dial?(request: DialRequest): Promise<DialResult>;
962
+
963
+ requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
964
+ commitBreak?(): Promise<BreakCommitResult>;
965
+ cancelBreak?(): Promise<BreakCancelResult>;
966
+ endBreak?(): Promise<BreakEndResult>;
967
+
968
+ executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
969
+ executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
970
+ openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
971
+ setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
972
+ executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
973
+ };
974
+
975
+ type Adapter<C extends Channel = Channel> = {
976
+ manifest: Manifest<C>;
977
+ createAuthenticationSession(context: AuthenticationContext): Promise<AuthenticationSession> | AuthenticationSession;
978
+ connect(context: ConnectContext): Promise<Connection<C>>;
979
+ };
980
+ ```
981
+
713
982
  ### Published constants
714
983
 
715
984
  ```ts
716
985
  const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;
717
986
 
718
987
  const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
988
+ type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
989
+
990
+ const DEFAULT_TIERS = [
991
+ { id: "org", label: "Your organisation" },
992
+ { id: "site", label: "Your site" },
993
+ { id: "team", label: "Your team" },
994
+ { id: "person", label: "You" },
995
+ ] as const satisfies readonly TierDeclaration[];
719
996
 
720
997
  const IDLE_CAPABILITY_UI = {
721
998
  dial: "Dialpad",
@@ -758,6 +1035,7 @@ const OMNI_FAILURE_CODES = [
758
1035
  "omni.unavailable",
759
1036
  "omni.break-already-committed",
760
1037
  ] as const;
1038
+ type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
761
1039
  ```
762
1040
 
763
1041
  `HANDLING_STEPS_WITH_A_PERSON` is every `HandlingStep` except `queued`, which is the one nobody
@@ -1071,6 +1349,7 @@ compile time.
1071
1349
  | `idleCapabilities` | Declares actions Omni may offer while the agent has no active task, such as voice dialing. Task controls do not belong here. |
1072
1350
  | `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
1073
1351
  | `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
1352
+ | `tiers` | The structure's tiers as the provider calls them, each with the label a desk shows for "who decided". Relabels any of `DEFAULT_TIERS` by id and may add others; omitted for the typical four. See **Who decides what an agent may do**. |
1074
1353
 
1075
1354
  ### Authentication methods
1076
1355
 
@@ -1378,6 +1657,8 @@ them from what arrives later.
1378
1657
  | `team` | This login leads a team. The provider publishes a `TeamRoster` to it on every snapshot — `[]` when nobody is in it — and to nobody else. |
1379
1658
  | `team.breakControl` | This lead may act on their team's breaks through `executeTeamBreak` — place, release, decide, set policy — as far as the provider supports; a command it lacks answers `omni.capability-not-enabled`. Omni asks for a decision only against a member whose `break` is `awaiting-decision`, so a provider that grants on request is never asked to decide. Requires `executeTeamBreak`. |
1380
1659
  | `team.consultControl` | This lead may join a member's call on request. Requires `executeTeamConsult`. |
1660
+ | `team.policyControl` | This lead sets the team's policy per capability — on, off, or the agent's — within what the queue allows. Requires `executeTeamPolicy`; the roster carries `policies`. |
1661
+ | `preferences` | What the team left to this person, with where each stands and who set it. Omitted when nothing was. Requires `setPreference`. See **Who decides what an agent may do**. |
1381
1662
 
1382
1663
  A session action is available only when both the capability and Omni provisioning permit it.
1383
1664
 
@@ -1568,6 +1849,8 @@ surface in one place, and what obliges an adapter to implement each one.
1568
1849
  | `endBreak()` | The login declares `capabilities.breaks`. |
1569
1850
  | `executeTeamBreak(command)` | The login declares `capabilities.team.breakControl`. |
1570
1851
  | `executeTeamConsult(command)` | The login declares `capabilities.team.consultControl`. |
1852
+ | `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
1853
+ | `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
1571
1854
  | `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. |
1572
1855
 
1573
1856
  **The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
@@ -2245,8 +2528,15 @@ capabilities: {
2245
2528
  ```
2246
2529
 
2247
2530
  Custom capability IDs must be non-empty and unique within the task. `ui.kind` is `button`, `toggle`,
2248
- or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. Omni renders the control
2249
- and invokes it with the shared custom task command:
2531
+ or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. `ui.render` says where the
2532
+ control's work appears: `inline`, in the workspace beside the task, or `page`, as a page of its own
2533
+ — a tab in the same work area as the task's browsers, beside them, and alone on a task that has
2534
+ none; inline when absent. `prompt.fields` are what the agent supplies before the action runs — a
2535
+ destination number, a reference — as `CredentialField`s Omni renders as a form; the values travel
2536
+ on the custom command under the fields' names, as strings, and Omni sends the command only once
2537
+ every `required` field has a value. The provider validates what arrives as it validates any
2538
+ command; a form is a declaration, not a contract for what the agent typed. Omni renders the
2539
+ control and invokes it with the shared custom task command:
2250
2540
 
2251
2541
  ```ts
2252
2542
  {
@@ -2654,6 +2944,7 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
2654
2944
  | --- | --- |
2655
2945
  | `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 **The login is the permission** below. |
2656
2946
  | `requests` | The members currently asking this lead to join a call, each with the task and the note. Required when the login declares `team.consultControl`, `[]` when nobody is asking; omitted when it does not. See **Consulting a lead**. |
2947
+ | `policies` | The team's policy per capability as it stands — the setting, who set it, and `lockedBy` where a tier above the team made it theirs to keep. Required when the login declares `team.policyControl`; omitted when it does not. See **Who decides what an agent may do**. |
2657
2948
 
2658
2949
  | `TeamMember` field | Contract |
2659
2950
  | --- | --- |
@@ -2863,7 +3154,8 @@ what failed, and reports. It never decides for the adapter what a missing microp
2863
3154
  | --- | --- |
2864
3155
  | `online` | Whether the host has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the host does — so `false` is a reason not to go ready and `true` is not a reason to. |
2865
3156
  | `audio` | Present on a voice connection, absent where there is no audio. |
2866
- | `audio.input` | `ready` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. `failure` carries the words Omni showed them. |
3157
+ | `audio.input` | `ready` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
3158
+ | `browsers.urlVisibility` | What Omni's own chrome shows of a task browser's URL: `hidden`, `domain`, or `full`. A statement about the chrome, not about what the page renders or a screenshot captures; task browsers only — the personal browser is the agent's own and nothing a provider sends appears in it. A provider reads it before deciding what URL it is willing to send: one that carries confidential data goes out under `hidden` and not under `full`. |
2867
3159
  | `audio.output` | `ready`, or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
2868
3160
 
2869
3161
  Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",