Reference

The protocol.
Every rule, in one place.

The guide and every exported name, generated from the contract itself so it says what the code says. Where it disagrees with an adapter, the adapter is wrong. Also available as markdown.

@xema/omni-protocol API contract

This document is the normative contract between Omni Agent and a provider adapter. It describes observable behavior in addition to TypeScript shapes. When an example and a type declaration appear to disagree, the exported TypeScript declaration is authoritative.

All protocol definitions and code examples are written as valid TypeScript. Samples should name their contract type or use satisfies so the relationship between the example and the contract is visible and compiler-checkable. Use another language only when the artifact itself is not TypeScript.

Terms

Define a vocabulary; do not merely list it applies to this document's own prose. These words are used precisely throughout and mean nothing looser here.

Word What it means
Omni The desktop application an agent works in. It composes several providers into one agent-facing experience and owns everything outside a provider's own system.
Host Omni, named that way where the contrast with a provider is the point — the host carries the audio, host-side input.
Provider One independently connected external system: a voice platform, a chat platform, a mail platform.
Adapter The package implementing this contract for one provider. One adapter is one provider, so the words are often interchangeable; provider names the system, adapter the code speaking for it.
Agent The person signed in and taking work. On onCall, agent is a person on the call by user id; a transfer never names one, since a contact goes back to a queue.
Lead An agent whose login declares capabilities.team. The provider publishes a TeamRoster to them and to nobody else: the login is the permission.
Provisioning Omni-side policy about this agent, configured outside the protocol and never sent to a provider. It gates whether an offer may be rejected, whether the agent goes ready on login, and whether tasks are auto-accepted. Where a capability and provisioning disagree, the stricter wins.
Task One unit of assigned work — a call, a chat, a mail.
Channel The kind of work a provider carries: voice, chat, or email. Fixed per provider by its manifest.
Task type The provider's own name for a category of work — a queue, a mailbox folder, a chat source. Free-form, and finer-grained than a channel.
Capability A provider's declaration that a control exists for a task or a session. It says offer this; the provider performs it, and the host only offers it — see Where a command executes.
Login One authenticated sign-in to one provider, identified by loginId. A transport reconnect keeps it; signing in again replaces it, and nothing tied to the old loginId survives.
Transport The adapter's connection to its platform: a WebSocket or SignalR connection, required to be persistent and ordered. Which one and how it reconnects are the adapter's business; losing it does not end a login.
Connection The Connection object Omni holds for one login: the methods it can call and the events it receives.
Concurrent capacity How many tasks this provider may have allocated to the agent at once — an absolute ceiling, stated as AgentCapacity.count and standing until Omni restates it. The provider counts its own outstanding tasks against it.
Snapshot The provider's complete state at one moment. It replaces what Omni holds; it is never a patch.
Event One completed transaction reported after a snapshot established the baseline.
Break A reported, supervised state in which the agent is not working — one with a reason, a decision behind it and a return. It covers what a platform may call not-ready, including equipment trouble. An agent who is merely at capacity is not on a break.
Workspace What Omni shows the agent. The task workspace holds the selected task, its controls and its browsers; the idle workspace holds what a provider contributes when no task is selected — dialpad, contacts, calendar, roster.
Dial One outbound call the host asks a provider to place — from the idle dialpad, a cold or warm transfer, a conference add, or a connect-back. Identified by the host's dialId, accepted as dialling, and ended by exactly one dial-outcome. See Every dial has an outcome.
Monitor A lead listening to a member's call unasked, from the roster: monitor in silence, whisper heard by the agent alone, barge heard by everyone. Nothing of it reaches the member's task, and there is no take-over in it. See Monitoring a call.
On the call Who a voice task's audio joins, or is bringing in, as the provider states it on Task.onCall: the party, the agents, and anyone consulted or conferenced in from the moment their dial is placed.

Six words describe what state a thing is in, and they are not interchangeable: each belongs to one thing, so a bare "status" in conversation is always the authentication session's, and a transport, a task, a break and a call each have a word of their own:

Word Belongs to Values
phase A task pending, confirmed, preview, in-progress, paused, completing
media A task's audio started, ended
transport A connection connecting, active, error
status An authentication session signed-out, authenticating, authenticated, refreshing, expired
approval A break request not-requested, awaiting-decision, granted, starting-after-task, in-effect
availability A roster member ready, on-task, on-break, signed-out

Versioning

OMNI_PROTOCOL_VERSION

The exact protocol version implemented by this package. The current value is 1.

Manifest.supportedProtocolVersions

An adapter declares every version it can speak, not just the one it was compiled against:

supportedProtocolVersions: [1]        // v1 only
supportedProtocolVersions: [1, 2]     // can serve either host

A single pinned version would make migration impossible: recompiling against a newer package would silently move an adapter to the new version with no window in which both sides interoperate. Declaring a set lets an adapter support the old and new host at once, so the two can be deployed independently.

negotiateProtocolVersion(adapterVersions, hostVersions?)

Returns the highest version both sides support, or undefined when they share none. Omni must refuse to connect on undefined; silently attempting partial compatibility is not permitted.

negotiateProtocolVersion([1, 2], [2, 3]);   // 2
negotiateProtocolVersion([1], [2]);         // undefined — refuse to connect

Omni negotiates the version before it creates an authentication session. The selected protocolVersion is included in both AuthenticationContext and ConnectContext and remains fixed for that login, including transport reconnects. An adapter that advertises several versions must use this value to select the corresponding contract behavior.

Semantic types

This section is the registry for protocol-wide semantic aliases. Add an alias here when two values share a primitive wire representation but have different domain meaning or validation rules. Use the semantic name in every contract field rather than repeating the primitive type.

type IsoTimestamp = string;
type UserId = string;
type TaskId = string;
type DialId = string;
type DurationSeconds = number;
Semantic type Wire type Meaning and constraints
IsoTimestamp string An RFC-3339 timestamp with Z or an explicit numeric offset. Timezone-less values are invalid. It must pass the shared runtime validator. A JavaScript Date never crosses the protocol boundary.
UserId string A non-empty, opaque, stable identifier for a person, issued by the provider and drawn from the same directory as AuthenticationState.identity.id. It names agents and managers alike; the role is established by where the value appears, not by its type. Compare it exactly and only within one provider; do not parse it or infer meaning from its format.
TaskId string A non-empty, opaque task identifier unique within one provider. Omni scopes it with the provider ID.
DurationSeconds number A non-negative integer duration measured in seconds.

There is no Omni-wide user identity

Every person named in this protocol is named by the provider that reported them. Omni holds no identifier of its own for an agent or a manager, and none crosses this boundary — not the operating-system account, not a directory identity, not a licence.

So a UserId means nothing outside the provider that issued it. Provider A's handlingHistory[].by and provider B's roster memberId are unrelated strings that will eventually collide, and one person on several providers has several identities that nothing here pairs. Scope every user identifier with its provider ID before storing or comparing it, exactly as taskKey() already does for tasks — see userKey() under Utilities.

Shapes

Every data shape and published constant this contract names, declared once. The sections that follow explain what each field means and when to send it; this is where a reader checks a name an example uses.

Three method surfaces are not here — Adapter, Connection and AuthenticationSession. They are defined by what they do rather than what they hold, and each has its own table: Declaring an adapter, Live connection, and Authenticating with a provider.

Channel and identity

type Channel = "voice" | "chat" | "email";

type TimeZone = string;

type User = {
  id: UserId;
  displayName: string;
  timeZone: TimeZone;
};

type Attribute = { key: string; value: string };

Attribute is the same key/value detail on a Contact and a ScheduledActivity. A task's attributes are a different, typed shape — see TaskAttribute.

Manifest

type AuthenticationMethod = "browser-sso" | "credentials";

type BrowserAccess = {
  mode: "allow-all" | "block-all";
  allowList?: string[];
  blockList?: string[];
};

type PersonalBrowserCapability = {
  access: BrowserAccess;
  accessAppliesTo?: "initial-url" | "all-navigation";
};

type DialDestinations = "contacts-only" | "any-number";

type DialCapability = { destinations: DialDestinations };

type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";

type Phone = "softphone" | "deskPhone";

type IdleCapabilities<C extends Channel = Channel> = {
  personalBrowser?: PersonalBrowserCapability;
  calendar?: true;
  contacts?: true;
} & (C extends "voice" ? { dial?: DialCapability } : { dial?: never });

type Manifest<C extends Channel = Channel> = {
  id: string;
  displayName: string;
  channel: C;
  supportedProtocolVersions: number[];
  authenticationMethods: AuthenticationMethod[];
  idleCapabilities?: IdleCapabilities<C>;
  phaseLabels?: TaskPhaseLabels;
  taskTypePresentation?: Record<string, TaskTypePresentation>;
  orgLevels?: LevelDeclaration[];
  dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
  phones?: C extends "voice" ? Phone[] : never;
  runningStepReports?: true;
};

Closed sets are string-literal unions, never enum. An enum is the one TypeScript construct that is not type-only — it emits runtime code, which no other declaration here does — and it does not narrow inside a union as cleanly. Where a wire value is also the name you would want to type, as "contacts-only" is, the union alone is enough.

A named constant is added only where the wire value is not something to type at a call site. BROWSER_ISOLATION_SCHEMES is the one case: its values are structured strings, easy to mistype and unreadable as an argument, so the symbolic name earns its keep — and the constant-plus-derived-union pattern is the same one TASK_COMMAND_NAMES and TaskCommandName already use.

These serialized strings are stable protocol values and must not be renamed or reused.

Presentation

type TaskPhaseLabels = Readonly<Partial<Record<TaskPhase, string>>>;

type TaskTypePresentation = {
  singular: string;
  plural: string;
  referenceLabel?: string;
};

Authentication and connection

type SecretStore = {
  get(key: string): Promise<string | undefined>;
  set(key: string, value: string): Promise<void>;
  delete(key: string): Promise<void>;
};

type AuthenticationContext = {
  protocolVersion: number;
  loginId: string;
  timeZone: TimeZone;
  phone?: Phone;
  secrets: SecretStore;
  signal?: AbortSignal;
  log?: (entry: unknown) => void;
};

type MonitorMode = "monitor" | "whisper" | "barge";

type TeamCapabilities = {
  breakControl?: true;
  leadAssistControl?: true;
  monitorControl?: MonitorMode[];
  policyControl?: true;
};

type UserCapabilities = {
  breaks?: true;
  preferences?: AgentPreference[];
  team?: TeamCapabilities;
};

type AuthenticationState =
  | { status: "signed-out" }
  | { status: "authenticating" }
  | { status: "authenticated"; identity: User; capabilities: UserCapabilities; expiresAt?: IsoTimestamp }
  | { status: "refreshing"; identity: User; capabilities: UserCapabilities }
  | { status: "expired"; identity?: User; failure?: AuthenticationFailure };

type AuthenticationFailure = {
  code: string;
  message: string;
  retryable: boolean;
  retryAfterMs?: number;
  field?: string;
};

type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
type HostOutputUnavailableReason = "no-device" | "lost";

type HostAudioInput =
  | { status: "available"; localAudio: MediaStream; flowing: boolean }
  | { status: "unavailable"; reason: HostAudioUnavailableReason; failure: ProtocolFailure };

type HostAudioOutput =
  | { status: "available" }
  | { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };

type UrlVisibility = "full" | "domain" | "hidden";

type HostReport = {
  online: boolean;
  audio?: {
    input: HostAudioInput;
    output: HostAudioOutput;
  };
};

type HostGuarantees = {
  browserUrlVisibility?: true;
  personConsent?: true;
};

type Host = {
  guarantees: HostGuarantees;
  report(): HostReport;
  subscribe(listener: (report: HostReport) => void): Unsubscribe;
};

type ConnectContext = {
  protocolVersion: number;
  loginId: string;
  autoAcceptTasks?: boolean;
  timeZone: TimeZone;
  phone?: Phone;
  host: Host;
  signal?: AbortSignal;
  log?: (entry: unknown) => void;
};

type TransportStatus = "connecting" | "active" | "error";

type CredentialField = {
  name: string;
  label: string;
  type: "text" | "password";
  required?: boolean;
  autocomplete?: string;
};

type AuthenticationChallenge =
  | { flowId: string; method: "browser-sso"; authorizationUrl: string; browser: "system" | "omni" }
  | { flowId: string; method: "credentials"; fields: CredentialField[] };

type StartAuthenticationRequest =
  | { requestId: string; method: "browser-sso"; callbackUrl: string }
  | { requestId: string; method: "credentials" };

type StartAuthenticationResult =
  | { status: "interaction-required"; challenge: AuthenticationChallenge }
  | { status: "rejected"; failure: AuthenticationFailure };

type CompleteAuthenticationRequest =
  | { flowId: string; method: "browser-sso"; callbackUrl: string }
  | { flowId: string; method: "credentials"; values: Readonly<Record<string, string>> };

type CompleteAuthenticationResult =
  | { status: "authenticated"; identity: User; capabilities: UserCapabilities; expiresAt?: IsoTimestamp }
  | { status: "rejected"; failure: AuthenticationFailure };

type AuthenticationActionResult =
  | { status: "applied" }
  | { status: "failed"; failure: AuthenticationFailure };

type Unsubscribe = () => void;

type AuthenticationSession = {
  state(): AuthenticationState | Promise<AuthenticationState>;
  subscribe(listener: (state: AuthenticationState) => void): Unsubscribe;
  start(request: StartAuthenticationRequest): Promise<StartAuthenticationResult>;
  complete(request: CompleteAuthenticationRequest): Promise<CompleteAuthenticationResult>;
  cancelAuthentication(flowId: string): Promise<AuthenticationActionResult>;
  signOut(): Promise<AuthenticationActionResult>;
  close(): Promise<void>;
};

Provider state

type PreferenceId = "hold" | "mute" | `skill:${string}`;

type SetBy = Level | "provider";

type Resolved = {
  setBy: SetBy;
  lockedBy?: Level;
  reason?: string;
};

type AgentPreference = Resolved & {
  id: PreferenceId;
  label: string;
  enabled: boolean;
};

type SetPreferenceRequest =
  | { id: PreferenceId; enabled: boolean }
  | { id: PreferenceId; inherit: true };

type HandlingReport = { taskId: TaskId; step: HandlingStep; at: IsoTimestamp } & (
  | { ended: true; seconds: DurationSeconds }
  | { ended?: never; seconds?: DurationSeconds }
);

type HandlingReportResult =
  | { status: "recorded" }
  | { status: "failed"; failure: ProtocolFailure };

type PreferenceResult =
  | { status: "applied" }
  | { status: "failed"; failure: ProtocolFailure };

type Snapshot = {
  transport: TransportStatus;
  loginId: string;
  break: BreakState;
  tasks: Task[];
  taskCount: number;
  contacts?: Contact[];
  scheduledActivities?: ScheduledActivity[];
  team?: TeamRoster;
};

type AgentCapacity = {
  count: number; // absolute ceiling, at least 1
};

type CapacityResult =
  | { status: "applied" }
  | { status: "failed"; failure: ProtocolFailure };

Idle contributions

type Contact = {
  name?: string;
  number?: Lockable<string>;
  email?: Lockable<string>;
  attributes?: Attribute[];
};

type ScheduledActivity = {
  id: string;
  title: string;
  startsAt: IsoTimestamp;
  endsAt?: IsoTimestamp;
  party?: Contact;
  attributes?: Attribute[];
};

type DialRequest = {
  dialId: DialId;
  destination: string;
};

type DialResult =
  | { status: "dialling"; dialId: DialId }
  | { status: "failed"; failure: ProtocolFailure };

Task capabilities

type DispositionCode = { id: string; label: string; group?: string };

type DispositionRules = {
  required?: boolean;
  notes?: "required" | "optional" | "none";
  codes?: DispositionCode[];
};

type Destination = {
  id: string;
  label: string;
};

type DestinationDirectory = {
  destinations: Destination[];
};

type CustomCapability = {
  id: string;
  ui: {
    control: "button" | "toggle" | "menu-item";
    label: string;
    placement: "primary" | "secondary" | "overflow";
    render?: "inline" | "page";
  };
  prompt?: { fields: CredentialField[] };
};

type SharedTaskCapabilities = {
  browsers?: true;
  dispositions?: true | DispositionRules;
  custom?: CustomCapability[];
};

type TaskCapabilities<C extends Channel = Channel> =
  C extends "voice"
    ? SharedTaskCapabilities & {
        decline?: Lockable<true>;
        mute?: Lockable<true>;
        hold?: Lockable<true>;
        endCall?: Lockable<true>;
        connectBack?: Lockable<true>;
        coldTransfer?: Lockable<DestinationDirectory>;
        warmTransfer?: Lockable<DestinationDirectory>;
        leadAssist?: Lockable<true>;
        conference?: Lockable<DestinationDirectory>;
        recording?: Lockable<true>;
      }
    : C extends "chat"
      ? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
      : SharedTaskCapabilities & { decline?: Lockable<true> };

The channel arms are why Task<"email"> rejects hold at compile time rather than at runtime.

Task workspace

const BROWSER_ISOLATION_SCHEMES = {
  PROVIDER_NAME__TASK_ID__TAB_NAME: "ProviderName.TaskId.TabName",
  TAB_NAME: "TabName",
  PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME: "ProviderName.TaskTypeName.TabName",
  PROVIDER_NAME__TAB_NAME: "ProviderName.TabName",
  PROVIDER_NAME__TASK_TYPE_NAME: "ProviderName.TaskTypeName",
  TASK_TYPE_NAME__TAB_NAME: "TaskTypeName.TabName",
} as const;

type BrowserIsolationScheme =
  (typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];

type Browser = {
  id: string;
  name: string;
  url: string;
};

type TaskBrowser = Browser & {
  purpose: string;
  urlVisibility?: UrlVisibility;
} & (
  | { sharedSession: false; isolationScheme?: never }
  | { sharedSession: true; isolationScheme: BrowserIsolationScheme }
);

type PersonalBrowser = Browser;

type BrowserSessionKeyInput = {
  providerId: string;
  taskId: TaskId;
  taskType: string;
  browser: TaskBrowser;
};

That union is what makes a reusing browser with no scheme fail to compile rather than inherit a default — see Choosing an isolation scheme.

Task

type TaskPhase =
  | "pending"
  | "confirmed"
  | "preview"
  | "in-progress"
  | "paused"
  | "completing";

type CompletionMode = "agent-command" | "provider-automatic";

type TaskAttributeBase = {
  key: string;
  label?: string;
};

type TaskAttribute = TaskAttributeBase & (
  | { type: "text"; value: string }
  | { type: "contact"; party: Contact }
  | { type: "timestamp"; at: IsoTimestamp }
);

type HandlingStep =
  | "queued"
  | "offered"
  | "answered"
  | "held"
  | "muted"
  | "transferred"
  | "conferenced"
  | "unanswered";

type TaskHandlingHistory = {
  steps: TaskHandlingStep[];
  handleSeconds?: DurationSeconds;
  holdSeconds?: DurationSeconds;
  queueSeconds?: DurationSeconds;
  transfers?: number;
};

type TaskHandlingStep = {
  step: HandlingStep;
  at: IsoTimestamp;
  dialId?: DialId;
  destinationId?: string;
  seconds?: DurationSeconds;
  by?: UserId;
};

type TaskCompletion =
  | { completionMode: "agent-command"; wrapAllowance?: DurationSeconds }
  | { completionMode: "provider-automatic"; wrapAllowance: DurationSeconds };

type OnCallRole = "party" | "agent" | "consulted" | "conferenced";

type OnCallStage = "ringing" | "joined";

type OnCall = { since: IsoTimestamp; held?: true } & (
  | { role: "party" }
  | { role: "agent"; userId: UserId }
  | { role: "consulted" | "conferenced"; destinationId: string; stage: OnCallStage; dialId?: DialId; label?: string }
);

type TaskLeadAssist = {
  stage: "requested" | "joined";
  leadId?: UserId;
  note?: string;
  since: IsoTimestamp;
};

type TaskAssisting = {
  memberId: UserId;
  note?: string;
  since: IsoTimestamp;
};

type TaskMonitoring = {
  memberId: UserId;
  taskId: TaskId;
  mode: MonitorMode;
  since: IsoTimestamp;
};

type Level = string;

type LevelDeclaration = {
  id: Level;
  label: string;
};

type Locked = {
  lockedBy: Level;
  reason?: string;
};

type Lockable<T> = T | Locked;

type TaskMediaState = "started" | "ended";

type Task<C extends Channel = Channel> = {
  id: TaskId;
  title: string;
  channel: C;
  taskType: string;
  capabilities: TaskCapabilities<C>;
  browsers: TaskBrowser[];
  party?: Contact;
  phase: TaskPhase;
  acceptance?: AcceptanceMode;
  previewEndsAt?: IsoTimestamp;
  atDeadline?: PreviewDeadline;
  reference?: string;
  attributes?: TaskAttribute[];
  handlingHistory?: TaskHandlingHistory;
} & TaskCompletion & (
  C extends "voice"
    ? { onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
    : { onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
);

type PreviewDeadline = "calls" | "expires";

type AcceptanceMode =
  | "no-preference"
  | "consent"
  | "automatic";

type TaskOutcome =
  | { type: "completed"; by: "agent" | "provider" }
  | { type: "transferred"; destinationId?: string }
  | { type: "cancelled"; reason?: string }
  | { type: "expired"; phase: "pending" | "confirmed" | "preview" }
  | { type: "left" }
  | { type: "failed"; failure: ProtocolFailure };

Task commands

const TASK_COMMAND_NAMES = {
  voice: [
    "answer",
    "decline",
    "call",
    "mute",
    "hold",
    "resume",
    "end-call",
    "connect-back",
    "transfer",
    "lead-assist",
    "conference",
    "recording",
    "complete",
  ],
  chat: ["accept", "decline", "pause", "resume", "complete"],
  email: ["accept", "decline", "complete"],
} as const;

type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> =
  (typeof TASK_COMMAND_NAMES)[C][number];

type DispositionPayload = { disposition?: string; notes?: string };

type VoiceTaskCommand =
  | { type: "answer" }
  | { type: "decline" }
  | { type: "call"; dialId: DialId }
  | { type: "mute"; muted: boolean }
  | { type: "hold" }
  | { type: "resume" }
  | { type: "end-call" }
  | { type: "connect-back"; dialId: DialId }
  | { type: "transfer"; action: "cold"; dialId: DialId; destinationId: string }
  | { type: "transfer"; action: "warm"; dialId: DialId; destinationId: string }
  | { type: "transfer"; action: "complete" }
  | { type: "transfer"; action: "cancel" }
  | { type: "lead-assist"; action: "request"; note?: string }
  | { type: "lead-assist"; action: "cancel" }
  | { type: "lead-assist"; action: "take-over" }
  | { type: "lead-assist"; action: "leave" }
  | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
  | { type: "conference"; action: "remove"; destinationId: string; party?: never }
  | { type: "conference"; action: "remove"; party: true; destinationId?: never }
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
  | ({ type: "complete" } & DispositionPayload);

type ChatTaskCommand =
  | { type: "accept" }
  | { type: "decline" }
  | { type: "pause" }
  | { type: "resume" }
  | ({ type: "complete" } & DispositionPayload);

type EmailTaskCommand =
  | { type: "accept" }
  | { type: "decline" }
  | ({ type: "complete" } & DispositionPayload);

type CustomTaskCommand = { type: "custom"; name: string; [key: string]: unknown };

type TaskCommand<C extends Channel = Channel> =
  | (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand)
  | CustomTaskCommand;

type TaskCommandRequest<C extends Channel = Channel> = {
  taskId: TaskId;
  command: TaskCommand<C>;
};

type TaskCommandResult =
  | { status: "applied" }
  | { status: "dialling"; dialId: DialId }
  | { status: "failed"; failure: ProtocolFailure };

Who decides what an agent may do

An agent desk has two managers, not one. The queue — a process, a work type — is owned by a process manager and allows a set of capabilities: hold, mute, connect back, new call, conference, whether the number is visible, the actions it offers, the skills it needs. The people are managed through the organisation's structure — a team, a location, the organisation itself, in whatever combination the structure defines for a person — and decide per capability within what the queue allows: on for everyone, off for everyone, or left to the person. What you do to your team is a policy; what you do to yourself is a preference. A person belongs to one team and many queues, so a policy applies across every queue the person works.

The provider resolves; the protocol carries the result and who decided. The structure's levels are the provider's ladder: each level states only what it sets, an enforcing policy at a level above the person settles the value for everyone below it, and where nothing enforces the most specific level that says anything wins. The protocol names a level by the id the manifest declares for it and never describes the chain between them: which levels a person passes through is the structure's to know. A typical organisation has four, and they are the defaults — DEFAULT_LEVELS: org, site, team, person, each with the label a desk shows. A structure that differs states its whole ladder in Manifest.orgLevels, person included: what the list carries is in force, and what it leaves out does not exist — a structure with no site level declares org, team, person, and site is refused on its wire. A declared level is one the provider's own store actually resolves at: a label with no policy behind it decides nothing. A manifest that declares none has exactly the four. lockedBy is any level in force except person, who never locks their own value; setBy is any level in force, or provider, the protocol's word for "no level has said anything and the provider's own configuration supplied the value". A host renders "who decided" from the declared labels and needs no others, and validates every republished authenticated state against them, not only the sign-in. What the wire carries is the resolution:

  • lockedBy — a level above the person made this value theirs to keep. A person never locks their own value, and the queue is not a level: what the queue does not allow at all is absent.
  • setBy — who stated the value as it stands: a level, the person themself, or provider where no level has said anything. Provenance, not a lock: a value that came from a broad level as a default is still the person's to change.

On a task, a control the queue could allow may stand locked in its place. Task.capabilities is the effective set. What the queue does not allow is absent and nothing is shown. What the queue allows and a level above the person locked is present as { lockedBy, reason? } where the control's value would be — mute: { lockedBy: "team", reason: "Nobody on this team mutes" } — and Omni renders that control disabled, saying who decided, so an agent who cannot press Mute knows whether to ask their lead or their site. lockedBy is the discriminant: a value that carries it is the lock, so nothing that can be locked — a directory, a number — may carry that key itself. A contact's number and email are the same, since each identifies a person: where the queue says the agent may not see it, the provider sends { lockedBy } in its place — the last digits or nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour. A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom controls — is content, and is never locked.

A lead sets the team's policy from their roster. A login that declares capabilities.team.policyControl may executeTeamPolicy({ type: "set", capability, setting }) with on, off, or person, for any task control, dial, or a skill — and only hold, mute and skills may be person; connect back and new call are the team's, on or off, within what the queue allows. The roster carries policies for such a login: every policy as it stands, who set it, and lockedBy where a level above the team made it theirs to keep, which the lead sees and cannot change — executeTeamPolicy on it answers failed with omni.capability-not-enabled.

What the team left to the person is the person's, and the provider keeps it. The login's capabilities.preferences lists every preference the person may hold — hold, mute, a skill — with where it stands and who set it: setBy: "team" while they inherit the team's default, "person" once they have set their own, "provider" where no level has said anything. Nothing is hidden for want of a row, and a preference a level above has since locked is listed with lockedBy. setPreference is the person's act — { id, enabled } to set their own, { id, inherit: true } to give it up and inherit again — answered applied and republished as a new authenticated state when something changed, a state and not a flicker, as every republish of authenticated is; and it is durable: the person's across sessions. A lead may also set a person's preference from their own screen, which arrives the same way. A preference is keyed by the capability's own name because it is the same capability at another level: effective in Task.capabilities, set for the team in policies, left to the person in preferences. The command mute acts on one call; the preference mute says whether the person wants the control at all, and a host renders it in its settings, never as the button on a call.

Breaks

type BreakApproval =
  | "not-requested"
  | "awaiting-decision"
  | "granted"
  | "starting-after-task"
  | "in-effect";

type BreakReason = {
  id: string;
  label: string;
  group?: string;
  kind?: BreakKind;
  alwaysAvailable?: true;
};

type BreakRequest = {
  reason?: string;
  reasonId?: string;
};

type ImposedBreak =
  | { by: UserId; endsAutomatically: true; endsAt: IsoTimestamp }
  | { by: UserId; endsAutomatically: false; endsAt?: never };

type BreakState = {
  approval: BreakApproval;
  mayAsk: boolean;
  refusedReason?: string;
  decisionReason?: string;
  retryAfterMs?: number;
  reasons?: BreakReason[];
  activeReasonId?: string;
  imposed?: ImposedBreak;
};

type BreakRequestResult =
  | { status: "requested" }
  | { status: "failed"; failure: ProtocolFailure };

type BreakCommitResult =
  | { status: "committed" }
  | { status: "failed"; failure: ProtocolFailure };

type BreakCancelResult =
  | { status: "cancelled" }
  | { status: "failed"; failure: ProtocolFailure };

type BreakEndResult =
  | { status: "ended" }
  | { status: "failed"; failure: ProtocolFailure };

Team

type TeamMemberAvailability = "ready" | "on-task" | "on-break" | "signed-out";

type TeamMember = {
  id: UserId;
  availability: TeamMemberAvailability;
  since?: IsoTimestamp;
  break?: BreakApproval;
};

type LeadRequest = {
  id: string;
  memberId: UserId;
  taskId: TaskId;
  note?: string;
  since: IsoTimestamp;
};

type TeamRoster = {
  members: TeamMember[];
  requests?: LeadRequest[];
  policies?: TeamPolicies;
};

type PolicyKey =
  | Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities>
  | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities>
  | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities>
  | "dial"
  | `skill:${string}`;

type TeamPolicySetting = "on" | "off" | "person";

type TeamPolicy = Resolved & {
  setting: TeamPolicySetting;
};

type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;

type TeamPolicyCommand = { type: "set"; capability: PolicyKey; setting: TeamPolicySetting };

type TeamPolicyCommandRequest = {
  command: TeamPolicyCommand;
};

type TeamLeadAssistCommand =
  | { type: "join"; requestId: string }
  | { type: "decline"; requestId: string; reason?: string };

type TeamBreakCommand =
  | { type: "decide"; memberId: UserId; decision: "granted" | "denied"; reason?: string }
  | { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
  | { type: "place"; memberId: UserId; reason?: string }
  | { type: "release"; memberId: UserId };

type TeamBreakCommandRequest = {
  command: TeamBreakCommand;
};

type TeamLeadAssistCommandRequest = {
  command: TeamLeadAssistCommand;
};

type TeamMonitorCommand =
  | { type: "monitor"; memberId: UserId }
  | { type: "whisper" }
  | { type: "barge" }
  | { type: "leave" };

type TeamMonitorCommandRequest = {
  command: TeamMonitorCommand;
};

type TeamCommandResult =
  | { status: "applied" }
  | { status: "failed"; failure: ProtocolFailure };

Media

type VoiceMediaSession = {
  remoteAudio: MediaStream;
  setMuted(muted: boolean): void;
  close(): void;
};

type OpenMediaResult =
  | { status: "opened"; session: VoiceMediaSession }
  | { status: "unavailable"; failure: ProtocolFailure };

type OpenMediaRequest = {
  taskId: TaskId;
  localAudio?: MediaStream;
};

Events

type SummaryMetric = { id: string; label: string; value: string };

type QueueSummary = {
  title: string;
  subtitle?: string;
  waitingCount: number;
  updatedAt: IsoTimestamp;
  metrics?: SummaryMetric[];
};

type TransportRecovery = "reconnect" | "reauthenticate";

type ProviderEvent =
  | { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
  | { type: "transport-status"; status: "connecting" | "active"; message?: string }
  | { type: "transport-status"; status: "error"; recovery: TransportRecovery; message?: string }
  | { type: "break-state"; break: BreakState }
  | {
      type: "task-offered";
      task: Task;
      allocationExpiresAt?: IsoTimestamp;
    }
  | { type: "task-updated"; task: Task }
  | { type: "task-media-started"; taskId: TaskId }
  | { type: "task-media-ended"; taskId: TaskId }
  | { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
  | { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; destinationId?: string; reason?: string }
  | { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
  | { type: "queue-summary"; summary: QueueSummary }
  | { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId }
  | { type: "team-updated"; team: TeamRoster }
  | { type: "contacts-updated"; contacts: Contact[] }
  | { type: "calendar-updated"; scheduledActivities: ScheduledActivity[] };

type ProviderEventEnvelope = {
  id: string;
  loginId: string;
  occurredAt: IsoTimestamp;
  event: ProviderEvent;
};

ProviderEvent and its envelope keep a Provider prefix where nothing else does, for a mechanical reason rather than a naming one: Event is a DOM global, and a bare one would shadow it for every adapter compiled against the browser lib.

Adapter and connection

type Connection<C extends Channel = Channel> = {
  snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
  subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
  setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
  execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
  disconnect(): Promise<void>;

  describeUsers?(ids: UserId[]): Promise<User[]>;
  dial?(request: DialRequest): Promise<DialResult>;

  requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
  commitBreak?(): Promise<BreakCommitResult>;
  cancelBreak?(): Promise<BreakCancelResult>;
  endBreak?(): Promise<BreakEndResult>;

  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
  executeTeamLeadAssist?(request: TeamLeadAssistCommandRequest): Promise<TeamCommandResult>;
  executeTeamMonitor?(request: TeamMonitorCommandRequest): Promise<TeamCommandResult>;
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
  setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
  recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
  executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
};

type Adapter<C extends Channel = Channel> = {
  manifest: Manifest<C>;
  createAuthenticationSession(context: AuthenticationContext): Promise<AuthenticationSession> | AuthenticationSession;
  connect(context: ConnectContext): Promise<Connection<C>>;
};

Published constants

const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;

const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
type IdleCapability = (typeof IDLE_CAPABILITIES)[number];

const DEFAULT_LEVELS = [
  { id: "org", label: "Your organisation" },
  { id: "site", label: "Your site" },
  { id: "team", label: "Your team" },
  { id: "person", label: "You" },
] as const satisfies readonly LevelDeclaration[];

const IDLE_CAPABILITY_UI = {
  dial: "Dialpad",
  personalBrowser: "Browser",
  calendar: "Calendar",
  contacts: "Contacts",
} as const;

const BREAK_KINDS = [
  "short-break",
  "meal",
  "rest",
  "training",
  "coaching",
  "meeting",
  "administrative",
  "technical",
  "personal",
  "other",
] as const;

type BreakKind = (typeof BREAK_KINDS)[number];

const HANDLING_STEPS_WITH_A_PERSON = [
  "offered",
  "answered",
  "held",
  "muted",
  "transferred",
  "conferenced",
  "unanswered",
] as const;

const OMNI_FAILURE_CODES = [
  "omni.not-authenticated",
  "omni.capability-not-enabled",
  "omni.task-not-found",
  "omni.destination-not-permitted",
  "omni.phone-not-permitted",
  "omni.rate-limited",
  "omni.unavailable",
  "omni.break-already-committed",
] as const;
type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];

HANDLING_STEPS_WITH_A_PERSON is every HandlingStep except queued, which is the one nobody takes part in. handlingStepExpectsAPerson() tests membership.

Failure and validation

type ProtocolFailure = {
  code: string;
  message: string;
  retryable: boolean;
  retryAfterMs?: number;
};

interface ProtocolViolation {
  rule: string;
  path: string;
  message: string;
}

Presentation labels

Adapters may supply static display labels for canonical protocol values. Labels affect presentation only and cannot vary by connection, task, snapshot, or event.

Renaming a phase

manifest.phaseLabels renames the canonical TaskPhase values for the agent. It never adds a phase or removes one.

TaskPhaseLabels is Partial so that renaming one phase keeps the default wording for the rest — an adapter changing "On Call" does not restate the other five.

A task is labelled by the provider that owns it, from that provider's manifest merged over its channel defaults. Two adapters may word the same phase differently and each is right about its own tasks: a call reads as the voice platform names it while a chat beside it reads as its own does. How Omni words a view spanning several providers is its own presentation problem, not a provider's.

DEFAULT_TASK_PHASE_LABELS

What Omni shows when an adapter overrides nothing.

const DEFAULT_TASK_PHASE_LABELS = {
  voice: {
    pending: "Offered",
    confirmed: "Accepted",
    preview: "Preview",
    "in-progress": "On Call",
    paused: "On Hold",
    completing: "After Call Work",
  },
  chat: {
    pending: "Incoming Chat",
    confirmed: "Accepted",
    preview: "Preview",
    "in-progress": "In Chat",
    paused: "Paused",
    completing: "Wrap-up",
  },
  email: {
    pending: "Assigned",
    confirmed: "Accepted",
    preview: "Preview",
    "in-progress": "Working",
    paused: "Paused",
    completing: "Completing",
  },
} as const satisfies Readonly<
  Record<"voice" | "chat" | "email", Readonly<Record<TaskPhase, string>>>
>;

Naming a kind of work

TaskTypePresentation says what one kind of work is called. Unlike phaseLabels, an entry replaces the channel default outright rather than merging. singular and plural are required together because pluralisation is not mechanical, and a half-supplied entry would leave Omni pairing one provider's noun with another's plural.

DEFAULT_TASK_TYPE_PRESENTATION

The per-channel fallback, used for any taskType the adapter does not name.

const DEFAULT_TASK_TYPE_PRESENTATION = {
  voice: {
    singular: "Call",
    plural: "Calls",
    referenceLabel: "Call ID",
  },
  chat: {
    singular: "Chat",
    plural: "Chats",
    referenceLabel: "Chat ID",
  },
  email: {
    singular: "Email",
    plural: "Emails",
    referenceLabel: "Email ID",
  },
} as const satisfies Readonly<
  Record<"voice" | "chat" | "email", TaskTypePresentation>
>;

Naming task types

channel is too coarse for agent-facing names. A WhatsApp call and a PSTN call are both voice, but the provider may name them differently through static manifest metadata:

const taskTypePresentation = {
  WhatsApp: {
    singular: "Conversation",
    plural: "Conversations",
    referenceLabel: "Conversation ID",
  },
} satisfies Record<string, TaskTypePresentation>;

The map key is the exact Task.taskType. When no entry exists, Omni uses the provider channel's entry in DEFAULT_TASK_TYPE_PRESENTATION. A mixed-channel list falls back to the words "Task" and "Tasks" when its items do not share one label.

referenceLabel labels Task.reference; it does not label the protocol id. Omni shows a reference only when both values are present.

So supply it whenever the task type has references. Because an entry replaces the channel default outright, naming a task type and omitting referenceLabel removes the reference from the agent's view — the case or call number simply stops appearing, with nothing to indicate it was dropped. An adapter that wanted only a better noun loses a field it never meant to touch.

Protocol contract rules

Nine rules govern protocol data and behavior.

1. The protocol is authoritative

Adapters conform to the protocol. The protocol does not conform to adapters.

2. Never report a value you cannot observe

Report only values the provider can observe. Omit an unknown optional value; if a required value is unknown, do not publish the structure that requires it. Never substitute a default, placeholder, or inference.

Omni cannot distinguish an asserted zero from an unknown, and will present it to the agent as fact.

3. Omitted and empty are different claims

Omitting a field says I cannot see this. An empty value says I looked, and there is nothing.

Send whichever is true. A host renders them differently and cannot recover the distinction once it is lost.

The same distinction applies to nested fields. Omit a nested field only when its value is unknown; use an explicit empty value when the provider knows it contains nothing.

4. Presence is the permission

A capability authorizes a control the provider chooses to offer. Omni may offer or issue one only where the corresponding capability is declared; an absent capability means unavailable, and there is no separate permission flag.

The commands every task has are authorized by other fields the provider declared — a task that was offered can be accepted, one in preview can be called, one whose completionMode is agent-command can be completed. Nothing is issuable that the provider did not publish; only which field says so varies. See Which commands need a capability.

5. Snapshots establish state; events report transactions

Snapshots establish and replace provider state when an agent signs in, reconnects, or resynchronises.

A snapshot is the provider's answer, and an adapter that got no answer publishes nothing. A state read that answers unknown — a session not yet associated, a backend mid-failover — is not an empty state: the adapter keeps what it holds, stays connecting, and publishes a snapshot only once the provider has actually answered for this login, exactly as connect() may not resolve before it can provide a meaningful one. And emptiness is stated, never inferred: every snapshot carries taskCount, the provider's own count reconciled against tasks.length, so a snapshot with no work says taskCount: 0 in so many words and a blank or half-built state — which lacks the count — can never pass as a confirmed empty. Absence of knowledge is never evidence of absence, and every place "empty" is allowed to carry both meanings will eventually clear somebody's live call.

Events report completed transactions after that baseline. Nothing is missed while the connection holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event is applied.

6. An unsettled result is unknown, and unknown is not retried

A settled result is a fact. A promise that rejects with no result means unknown: the provider may have done it or not, and neither Omni nor an adapter on the agent's PC can find out in time to make a repeat safe. Omni does not retry; if the agent acts again it is a new command.

On a persistent ordered transport there is only one way a command goes unsettled: the connection went away underneath it. An adapter that cannot settle a command has lost its transport, and says so — transport-status connecting, reconnect, snapshot — whichever channel the command actually travelled on. An unsettled promise is therefore always followed by a snapshot, and that snapshot is the answer; Omni waits for it rather than calling snapshot() itself. While the transport is up, a result says the provider accepted the command, and the event that follows — task-updated, break-state — says what it did.

Classify on the rejection, never on a connection status published separately from it. The status is a report about the wire and races the rejection; the rejection is the event. A rejection is the provider's answer only when it carries the provider's answer — a failure the provider named. Every other rejection is transport loss, whatever the last transport-status said.

A command therefore carries no key. The provider names its own records — a task, a lead request, a member — and Omni refers to them by those names; Omni never asks a provider to remember a name Omni made up.

A command whose second execution changes nothing needs no protection: committing a break that is already committed stops an agent who is already stopped. A command whose second execution has a cost — a dial places a second call — is not made safe by anyone, which is why it is never repeated without a person deciding to.

An agent dials. The provider places the call and the dialling answer is lost. Omni shows the dial as unknown, not failed. Within a moment the provider offers the resulting call through task-offered and reports the dial's dial-outcome under the dialId Omni sent, and the agent is on it; had nothing been placed, nothing arrives and the agent dials again. What Omni must not do is dial again on the agent's behalf — the one outcome worse than a lost answer is two phones ringing at the customer.

An agent presses Hold while the provider is reconnecting. Nothing is queued at either end: the adapter answers failed with omni.unavailable, Omni shows the refusal, and the agent presses again once the provider is active — against the state as it is then, rather than a held press fired into a state that has moved on.

7. Work is pulled, never pushed

Allocate only within the concurrent capacity Omni has stated for this agent.

An allocation beyond that capacity, or with none currently stated, is invalid and Omni rejects it as a protocol violation. Work the agent was already handling when the connection came back is reported in the snapshot; it is not an allocation.

8. State is authoritative at the provider

Each provider is authoritative for the state it owns. Omni composes that state with Omni-owned policy and user actions. When provider-owned state diverges, Omni obtains a fresh snapshot and replaces its local provider view; it does not overwrite the provider.

9. Define a vocabulary; do not merely list it

Every member of a closed set — break kinds, handling steps, destination kinds — must have a normative definition; matching names alone do not establish shared meaning.

Provider adapter requirements

1. One adapter is one provider

An adapter represents one independently connected system: one voice platform, one chat platform, one mail platform. It owns its own authentication, transport, reconnection and internal state.

Omni composes providers into one agent. No adapter needs to know another exists.

2. The transport is a persistent ordered connection

An adapter reaches its platform over a WebSocket or a SignalR connection. Which of the two, which library, which framing and how it reconnects are the adapter's business and reach nothing in this contract — but that it is one long-lived, ordered, bidirectional connection is not optional, because the rest of this document rests on it.

Two properties are what everything else assumes. Events arrive in the order the provider observed them, and loss shows up as the connection dropping rather than as a message quietly going missing. That is why there is no sequence number to reconcile and no event log to replay: while the connection is up nothing has been lost, and when it comes back the adapter sends a snapshot.

Request/response polling does not have those properties and is not a transport for this contract.

Package entry points

Import Purpose
@xema/omni-protocol Provider adapter contract and shared domain types
@xema/omni-protocol/testing Adapter conformance helpers
@xema/omni-protocol/validation Runtime validators Omni and adapters both use to reject malformed data

Declaring an adapter

defineAdapter(adapter)

Compile-time helper that preserves the adapter's inferred concrete type while checking that it implements Adapter. It performs no connection and has no runtime side effects.

import { defineAdapter, OMNI_PROTOCOL_VERSION } from "@xema/omni-protocol";

export default defineAdapter({
  manifest: {
    id: "acme-voice",
    displayName: "Acme Voice",
    channel: "voice",
    supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
    authenticationMethods: ["browser-sso"],
    idleCapabilities: {
      dial: { destinations: "any-number" },
    },
    dialOutcomes: ["answered", "no-answer"],
  },
  createAuthenticationSession: context => createAcmeAuthentication(context),
  connect: context => createConnection(context),
});

dialOutcomes is a compile error off voice, as dial is. An adapter written once over C extends Channel therefore sets it the way it already sets idleCapabilities, with as Pick<Manifest<C>, "dialOutcomes">; an adapter written for one channel needs no cast.

Adapter.manifest

Static metadata that Omni can inspect before connecting. Manifest<C> is discriminated by channel, so a voice manifest may declare voice idle capabilities while Manifest<"chat"> and Manifest<"email"> reject dial at compile time.

Field Contract
id Required, stable and installation-wide unique. It must not change between launches. Omni refuses to load an adapter whose id a loaded adapter already claims — see below.
displayName Human-readable provider label.
channel Protocol-v1 value voice, chat, or email. New channels require a later protocol version.
supportedProtocolVersions Required non-empty list of versions this adapter can speak. Must share at least one with Omni.
authenticationMethods Required non-empty list of supported login methods.
idleCapabilities Declares actions Omni may offer while the agent has no active task, such as voice dialing. Task controls do not belong here.
phaseLabels Optional static adapter-defined display names for canonical TaskPhase values. They cannot vary at runtime.
taskTypePresentation Optional static adapter-defined presentation keyed by exact taskType. It names the item and its optional agent-facing reference.
orgLevels The organisation's whole ladder as the provider calls it, each level with the label a desk shows for "who decided". Stated outright, person included: what it leaves out does not exist. Omitted for the typical four, DEFAULT_LEVELS. See Who decides what an agent may do.
phones Voice only, and required there: the phones this platform can put an agent on, softphone (the call's audio lands in the host) and/or deskPhone (a handset the platform rings; the host shows the call and opens nothing). The host picks one per login. See How the agent hears the call.
dialOutcomes Voice only. How a dial can end on this platform, as it distinguishes them: answered and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that transfer, conference or call back — and a dial-outcome carries only a declared member. See Every dial has an outcome.
runningStepReports The provider takes running reports of a host-performed step — recordStep with seconds so far and no ended. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See The host records what it performs.

Authentication methods

A provider declares one or both protocol-v1 login methods:

Value Contract
browser-sso OAuth or OpenID Connect through the system browser or an Omni-managed browser.
credentials Provider-specific username and password collected through an Omni-hosted form.
authenticationMethods: [
  "browser-sso",
  "credentials",
]

The list must not be empty or contain duplicates. It declares available login methods only; credentials and tokens never belong in the manifest.

manifest.id partitions the SecretStore, so an id is first-claimed: an adapter whose id another loaded adapter already holds fails to load and is reported. It is never renamed to make room — that would move its secrets to a partition it has never used.

Idle capabilities

Idle capabilities are contributed by a provider but appear in Omni's idle workspace. They describe work the agent may initiate when no task is active; they never grant controls over an assigned task.

Capability Channel Omni UI Contract
dial Voice Dialpad Contributes an idle-dashboard dialpad and declares whether destinations are restricted to known contacts.
personalBrowser Voice, chat, email Browser Contributes Omni's managed personal browser and its allowed URL patterns.
calendar Voice, chat, email Calendar Contributes an idle-dashboard calendar of callbacks and other scheduled activities from this provider.
contacts Voice, chat, email Contacts Contributes an idle-dashboard contact list populated by this provider.

They are published through IDLE_CAPABILITIES, with UI metadata in IDLE_CAPABILITY_UI. Omni combines capabilities contributed by its active providers and removes a provider's contribution when that provider becomes inactive.

There is no enabled flag. Presence is the permission, here as everywhere: a capability the provider names is offered, and one it omits is not. A boolean on top of an optional field would be a second way to say what absence already says, and Omitted and empty are different claims only holds while each says something different.

Dialpad (dial)

dial is available to voice providers. It contributes a dialpad to the idle dashboard and requires a destination policy:

idleCapabilities: {
  dial: { destinations: "any-number" }
}

destinations is required and accepts one DialDestinations:

Value Contract
contacts-only The destination must be selected from this provider's own contributed contacts, and only entries carrying a number can be selected. Manual entry cannot bypass the restriction.
any-number The agent may enter any destination or select one from the contact list.

A contact restriction is scoped to the provider that declared it. Omni's idle contact list is aggregated from every provider, but a contacts-only dialpad offers only the entries this provider contributed. Any other reading lets a second provider put destinations into a directory the first restricted precisely so it could control what was dialled.

A declared dial capability requires Connection.dial() and a dialOutcomes declaration, since a dialpad dials. Omni sends the destination as the agent selected or typed it, under a dialId of its own minting, and the provider holds it to the declared destinations.

Personal Browser (personalBrowser)

personalBrowser is available to voice, chat, and email providers. It contributes Omni's managed personal browser to the idle dashboard. Each enabling provider supplies a complete URL access policy:

idleCapabilities: {
  personalBrowser: {
    access: {
      mode: "block-all",
      allowList: [
        "https://help.example.com/*",
        "https://*.microsoft.com/*"
      ],
      blockList: ["https://help.example.com/private/*"]
    }
  }
}
Field Contract
access.mode allow-all permits unmatched URLs; block-all denies unmatched URLs.
access.allowList URL-pattern exceptions permitted when the mode is block-all.
access.blockList Explicit denials. A match takes precedence over the same policy's allow list and mode.
accessAppliesTo all-navigation by default: every redirect and navigation is checked. initial-url checks the starting URL alone.

Patterns use the standard URLPattern syntax. Omni owns browser navigation, and the browser is hidden when no active provider contributes one.

accessAppliesTo defaults to all-navigation: every redirect and subsequent navigation is validated against the current combined policy, not only the starting URL. A provider may set initial-url to check the first hop alone, but that has to be asked for. A block-all policy enforced only on the initial URL stops nothing — one redirect leaves it — so the permissive reading is not something to inherit from a default.

Combining policies

The personal browser is one browser shared by every provider that contributes to it, so their policies have to be combined. Omni does it in the order that fails closed:

  1. An explicit blockList match denies the URL, whoever wrote it. A block is a deliberate statement about one address, and it is honoured across the whole combined policy — not only within the policy that declared it.
  2. Otherwise a URL is available when any contributing provider allows it, by its allowList or by an allow-all mode.
  3. Omni's own local policy denies on top of both.

Allow-lists are contributions and blocks are vetoes. Without step 1 a single provider declaring mode: "allow-all" would silently undo every block every other provider had written, and nothing in the agent's view would show that it had happened.

Calendar (calendar)

calendar is available to voice, chat, and email providers. It contributes a calendar to the idle dashboard for callbacks and other scheduled activities associated with that provider. Calendar is read-only in protocol v1: Omni can display activities but cannot create, reschedule, cancel, or complete them.

idleCapabilities: { calendar: true }

Omit calendar to make no calendar contribution. Omni combines calendar contributions from active providers into one agent-facing calendar while retaining the source provider identity for each activity.

When declared, the provider publishes its authoritative list through Snapshot.scheduledActivities and replaces it with a calendar-updated event when it changes.

Field Contract
id Required stable provider-local activity identity. It is what a replacement list is reconciled against.
title Required agent-facing activity title.
startsAt Required RFC-3339 start time with an explicit timezone.
endsAt Optional RFC-3339 end time with an explicit timezone.
party The person the activity reaches — a callback's customer — as a Contact. Optional.
attributes Optional ordered Attribute entries. Keys must be non-empty.

There is no type field, for the reason there is none on Contact: an open category is matched by nobody and defined by nobody, and calendars merge across providers exactly as contacts do, so one provider's Callback and another's Call back fragment a list that looks organised. Send the category as an attribute and Omni displays it.

Nothing is lost by that here, because there is nothing for a category to drive. A read-only calendar has no action to vary by kind, and what an activity is already shows in what it carries: a callback has a contact, a training does not.

Contacts (contacts)

contacts is available to voice, chat, and email providers. It contributes contacts to the idle dashboard and can supply destinations to a contact-restricted dialpad.

idleCapabilities: { contacts: true }

Omit contacts to make no contact contribution. When declared, the provider publishes its authoritative list through Snapshot.contacts and replaces it with a contacts-updated event when it changes. Every field is optional:

Note: Omni derives normalized number and email keys for indexing and deduplication while preserving the original values for display. A number supplied by multiple providers appears only once, with source icons indicating every provider that contributed it. Sources come from each Manifest; they are not repeated on Contact. If those providers supply different names, Omni keeps one as the display name and adds each distinct alternative to the merged attributes, labelled with its source provider.

Field Contract
name Optional agent-facing display name.
number Optional original dialable address, preserved for display.
email Optional original email address, preserved for display.
attributes Optional ordered Attribute entries. Keys must be non-empty.

Nothing is required, because every field is genuinely unknown somewhere. A call from an unrecognised number has an address and no name. A directory seeded from a mailbox has a name and an email and no number. A task's party on a withheld caller ID may have none of them. Requiring any one field would force exactly what Never report a value you cannot observe forbids — a fabricated "Unknown caller" that Omni cannot tell from a real one.

So Contact is deliberately broad, and the same shape serves a directory entry the agent reaches out to and the party already on a task. Send what you can see. Omni shows the name where there is one and falls back to the number or email where there is not.

normalizeContactNumber() and normalizeContactEmail() produce the internal comparison keys. Adapters may use these exported helpers when they need identical indexing behavior, but must keep the original contact values for display.

normalizeContactNumber() applies NFKC, strips whitespace, brackets, slashes, periods, and every Unicode dash, and rewrites a leading 00 to +. So +1 (415) 555-0100, +1.415.555.0100, and 0014155550100 all merge.

Cross-provider merging is reliable only for E.164 input. A national-format number such as 4155550100 carries no country context, and nothing in this protocol supplies one, so it will not merge with +14155550100 from another provider. A provider that wants its contacts merged with another provider's must publish +-prefixed numbers.

There is no type field, and Omni does not group the directory. A category is an attribute like any other: send it as one and it is displayed with the rest. A closed set of categories would have to be defined member by member to mean anything across providers — see Define a vocabulary; do not merely list it — and an open one is worse than none, because two providers publishing Lead and Prospect for the same person produce a directory that looks organised and is not.

That is the line attributes stays on the right side of. Omni renders keys and values and does not compute on them, so a provider writing Dept where another writes Department costs nothing; grouping on those keys would fragment the directory exactly as a free-form type did. Search across an agent's own contacts does the work a category was reaching for.

Merging follows the rule already stated for names. Where providers disagree on an attribute for the same contact, Omni keeps one value and adds each distinct alternative to the merged attributes, labelled with its source provider.

{
  name: "Asha Rao",
  number: "+919876543210",
  email: "asha@example.com",
  attributes: [
    { key: "Category", value: "Lead" },
    { key: "Priority", value: "High" }
  ]
}

Authenticating with a provider

After negotiating a protocol version, Omni creates an authentication session before it calls connect(). The authentication session is UI-facing and contains no task or provider transport state.

Adapter.createAuthenticationSession(context)

Creates the provider-scoped authentication session.

AuthenticationContext field Contract
protocolVersion The negotiated version, fixed for this login.
loginId Omni-generated identity for this login. The same value Omni later passes as ConnectContext.loginId, and how an adapter ties a connection back to the session that authenticated it.
timeZone The zone the agent's day is reckoned in, as an IANA name from the host's clock, stated before any identity exists so the first authenticated state already carries it. The same value Omni later passes as ConnectContext.timeZone. See The agent's day.
phone How this login hears its calls, chosen by the host from the manifest's phones: required for a voice provider, absent for any other. The same value Omni later passes as ConnectContext.phone. See How the agent hears the call.
secrets Omni-provided SecretStore, scoped to this provider's manifest id.
signal Optional cancellation signal.
log Optional structured logging callback. Never include credentials, tokens, or sensitive contact data.

It carries no identity: who the agent is on this provider is the outcome of authentication, not an input to it.

Closing this session releases observers and temporary flow state; it does not sign the agent out. Omni keeps the session open while the provider connection is active so refresh, expiry, and capability changes remain observable.

Authentication state

AuthenticationSession.state() returns the current authoritative state. subscribe() reports later changes.

Status Contract
signed-out No usable provider session exists.
authenticating An interactive browser-sso or credentials flow is active.
authenticated A usable session exists. Includes the provider identity, the login's capabilities, and optional token expiry time.
refreshing The adapter is refreshing its session. Existing provider identity and capabilities remain available, unchanged: a change to either is published as authenticated.
expired The session cannot currently be used. It may include an identity and typed failure.

Omni calls connect() only after authentication reaches authenticated. Token refresh remains adapter-owned; the adapter publishes refreshing, followed by authenticated or expired. refreshing asks nothing of the agent and Omni shows nothing for it. If authentication expires during active work, Omni preserves the task workspace and shows reauthentication for that provider: the transport is still up and the tasks still on it, so this is a login problem with a login fix, rendered apart from a provider Omni cannot reach. Commands meanwhile answer omni.not-authenticated.

Re-authentication restores the login; it does not replace it. It runs on the session Omni kept, under the same loginId — the old flowId died with the expiry, so the adapter issues a new challenge — and when the state returns to authenticated the connection and everything on it carry on: Omni does not call connect() again, since a second connection would be a second session for one agent. What "signing in again replaces the login" describes is a new AuthenticationSession under a new loginId, after signed-out.

What the login may do

capabilities declares provider actions available to this login rather than to one task: whether the agent may ask for a break, and whether they lead a team — and if so, whether they decide its breaks and whether they may join a member's call. It is declared by presence, like every capability in this contract, and it travels with the identity because it is part of who the agent is on this provider: the provider knows the roles, and says so at sign-in rather than leaving Omni to infer them from what arrives later.

Field Contract
breaks This login may request a break. Requires the four break methods on the connection.
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.
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.
team.leadAssistControl This lead may join a member's call on request. Requires executeTeamLeadAssist.
team.monitorControl This lead may listen to a member's call unasked, in the listed modes and no others: monitor, whisper, barge. The list always includes monitor, since the other two begin from one. Requires executeTeamMonitor. See Monitoring a call.
team.policyControl This lead sets the team's policy per capability — on, off, or the person's — within what the queue allows. Requires executeTeamPolicy; the roster carries policies.
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.

A session action is available only when both the capability and Omni provisioning permit it.

Capabilities are current, not fixed. They describe the login as of its latest authenticated state. A provider that reads roles live — a lead demoted mid-shift — republishes authenticated with the new set through subscribe() on the authentication session, which Omni keeps open for the life of the connection for exactly this reason, and the next snapshot agrees with it. Omni provisions what the capabilities call for at sign-in — a team panel for a lead, empty until the roster arrives, and nothing for anybody else — and withdraws it on the next render when the capability goes. A command that arrives after its capability was withdrawn is answered failed with omni.capability-not-enabled: the provider names it, so Omni never has to infer from a capability change it may not have rendered yet that "you are no longer a lead" is the message rather than "that did not work". One trap for adapter authors: the natural guard on a republish compares the identity, and a demotion does not touch it — compare the capabilities, field by field, which is what sameCapabilities() does. The thing that changed is not the thing you are comparing, and assertCapabilityWithdrawal is the test that catches a guard which never fires.

Starting authentication

AuthenticationSession.start(request) starts one advertised authentication method. Every request has a stable requestId. A successful start returns interaction-required with a short-lived, opaque flowId; rejection returns AuthenticationFailure.

Browser SSO

For browser-sso, Omni allocates a one-time callback URL and passes it to start():

{
  requestId: "auth-42",
  method: "browser-sso",
  callbackUrl: "omni-agent://auth/acme-voice/auth-42"
}

The adapter creates the OAuth/OIDC request, including PKCE, state, and OIDC nonce, and returns:

{
  status: "interaction-required",
  challenge: {
    flowId: "flow-42",
    method: "browser-sso",
    authorizationUrl: "https://identity.example.com/authorize?...",
    browser: "system"
  }
}

Omni opens the requested system or omni browser. After redirect, it passes the complete callback URL to complete(). The adapter validates the flow, exchanges the authorization code, and returns the authenticated provider identity. Provider tokens never enter Omni UI or general protocol state. An Omni-hosted SSO browser uses a dedicated temporary authentication session and never shares cookies or storage with task or personal browsers.

Credentials

For credentials, start() returns the form fields Omni must render:

{
  status: "interaction-required",
  challenge: {
    flowId: "flow-43",
    method: "credentials",
    fields: [
      { name: "username", label: "Username", type: "text", required: true, autocomplete: "username" },
      { name: "password", label: "Password", type: "password", required: true, autocomplete: "current-password" }
    ]
  }
}

Omni submits a short-lived values record to complete() and does not retain it after the promise settles. The adapter must not persist raw credentials. Field-specific failures may set AuthenticationFailure.field to a declared field name.

Cancelling authentication

cancelAuthentication(flowId) cancels an abandoned Browser SSO window or credentials form and releases its temporary state. It does not sign out an already authenticated session. It answers applied; a repeat, or a flow that already ended, is nothing to act on and answers applied too, by the rule that a command asking for a state answers success when that state holds.

Completion and failures

complete() returns either the authenticated state — identity and capabilities — or a typed failure:

{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao", timeZone: "Asia/Kolkata" }, capabilities: { breaks: true } }

The User it carries is the root of this provider's user namespace. Every other person this provider names — a roster member, the manager on an imposed break, the agent on a handling step — is identified from the same directory and carries the same UserId type.

Field Contract
id Required UserId. Unique within this provider and stable across logins: it is what Omni scopes and stores, so a value that changes between sessions breaks every reference to this person.
displayName Required agent-facing name. Presentation only — never an identifier, never compared.

AuthenticationFailure contains a stable code, safe agent-facing message, retryable flag, optional retryAfterMs, and optional credential field. It must never contain credentials, authorization codes, tokens, or provider responses containing secrets.

Sign-out

signOut() revokes or invalidates the provider session where supported, deletes stored session secrets, and moves state to signed-out. close() stops authentication-state observation but does not sign the agent out.

Secure-storage boundary

Omni provides an OS-backed SecretStore scoped to the provider manifest ID. It exposes only get, set, and delete; adapters cannot enumerate another provider's secrets. Adapters may store refresh tokens or equivalent session material, but never raw submitted credentials. Secrets must not appear in manifests, logs, events, snapshots, task attributes, errors, or browser storage.

Connecting to a provider

Adapter.connect(context)

Creates one live provider connection for the signed-in agent.

  • Called by Omni after validating the manifest.
  • Must resolve only when the connection can provide a meaningful snapshot.
  • May reject for authentication, configuration, or startup failure.
  • Must not create a second agent session merely because the underlying transport reconnects.
  • The returned connection owns reconnect until Omni calls disconnect() or aborts context.signal.
  • May be called again on the same login after that: once per Connection, not once per login. Omni disposes a connection whose error named recovery: "reconnect" with disconnect() and calls connect() afresh — see transport-status.

ConnectContext

Field Contract
protocolVersion Version negotiated before authentication. Fixed for this login.
loginId Omni-generated identity for this login. It is the same value passed as AuthenticationContext.loginId, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login.
autoAcceptTasks Agent provisioning policy relayed to the provider at login. Treated as true when omitted. When true, a pending task states its acceptance; when false, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits acceptance by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh connect().
timeZone The same value passed as AuthenticationContext.timeZone. The provider stores it on the agent and carries it on the identity. See The agent's day.
phone The same value passed as AuthenticationContext.phone: how this login hears its calls.
host The host's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See The host reports, the adapter decides.
signal Optional cancellation signal. Stop startup promptly when aborted and do not begin new work.
log Optional structured logging callback. Never include credentials, tokens, or sensitive contact data.

The agent's day

Every instant on this wire carries an explicit offset, so a moment is unambiguous everywhere and a desk renders it in the viewer's clock without help. A day is different: hours toward target, an answer streak, a per-queue count for today -- anything bucketed by day -- is bucketed by somebody's day, and a platform that was never told whose uses its own. An agent in Chennai then finds their day rolling at 05:30, and a night shift in Chicago lands in two buckets.

So the host says whose day it is, and it says so first. AuthenticationContext.timeZone is the agent's zone as an IANA name -- Asia/Kolkata, America/Chicago -- never an offset, since an offset cannot survive a daylight-saving boundary and a day boundary is exactly where that bites (context.timeZone). It is stated before any identity exists, and ConnectContext.timeZone is the same value again. The provider stores it on the agent and carries it as identity.timeZone on every authenticated state and on every User it returns from describeUsers(), so a lead reading a colleague's yesterday sees the colleague's yesterday and a summary is bucketed by the right day after a session has ended. A roaming agent corrects it by signing in from where they are.

The zone is never absent. Time zone awareness is a first-party property of this wire, not a field to fill in later: an identity without one is refused (authentication.identity.timeZone), and so is a zone that is not an IANA name, wherever it appears. Nothing is ever assumed in its place -- not the viewer's browser, which gives a different answer per reader for the same record, and not the provider's clock. The harness holds a provider to the round trip: the identity carries the zone the host stated (authentication.identity.timeZone.republished), judged by what the name denotes: Asia/Kolkata and Asia/Calcutta are one zone, and a provider that keeps the canonical name has kept the zone. The round trip is not the store. An adapter that echoes the stated zone back onto the identity passes that check with nothing kept, and a lead reading a colleague's day would still get the wrong one. What proves the store is a zone the run never sent: a colleague's User from describeUsers() carrying theirs. A provider's own tests are where that is shown, with a second agent whose zone arrived through another session.

Who the agent is

ConnectContext names no agent. The adapter already knows who is connected: it authenticated them, and AuthenticationState.identity holds the result. Omni has nothing to add — it holds no identifier of its own, as There is no Omni-wide user identity sets out.

Omni may still prefill a username into a credentials form from the operating-system account, because Omni renders that form itself. That is a local convenience and never reaches an adapter.

Provider state

Snapshot

What the login may do is declared on its AuthenticationState — see What the login may do — not here. The snapshot carries what the provider holds for the agent now, and where that depends on a capability it agrees with the login: a lead's snapshot carries team, nobody else's does.

Field Contract
transport Current TransportStatus — whether this provider's transport can serve the login. Defined under transport-status.
loginId Identity of this login. It must match the connection context.
break Complete break state, including approval, whether the agent may ask, reasons, retry details, and any imposed break.
tasks Complete set of tasks currently offered to or owned by this agent.
taskCount The provider's own count of those tasks, stated rather than inferred, and it must equal tasks.length. A snapshot with no work says taskCount: 0 in so many words — a blank or unanswered state lacks the count and cannot pass as a confirmed empty.
contacts Required complete contact contribution when the manifest declares contacts; [] clears it. Omitted only when it does not.
scheduledActivities Required complete calendar contribution when the manifest declares calendar; [] clears it. Omitted only when it does not.
team Required TeamRoster when the login declares capabilities.team, [] when nobody is in it. Forbidden otherwise — the login is the permission.

Live connection

Connection is what connect() returns. Its methods are documented in the sections that follow and under Breaks, Team leads, Real-time media and Task commands; this is the whole surface in one place, and what obliges an adapter to implement each one.

Method Implement it when
snapshot() Always.
subscribe(listener) Always.
disconnect() Always.
setCapacity(capacity) Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it.
execute(request) Always. Every channel has commands no capability gates — see Which commands need a capability.
describeUsers(ids) The adapter publishes any UserId: on ImposedBreak.by, a roster, or handlingHistory[].by. Each User carries its timeZone where the provider knows it.
dial(request) The manifest declares idleCapabilities.dial, and with it dialOutcomes.
requestBreak(request) The login declares capabilities.breaks.
commitBreak() The login declares capabilities.breaks. Commit and cancel are not optional halves of it.
cancelBreak() The login declares capabilities.breaks.
endBreak() The login declares capabilities.breaks.
executeTeamBreak(command) The login declares capabilities.team.breakControl.
executeTeamLeadAssist(command) The login declares capabilities.team.leadAssistControl.
executeTeamMonitor(command) The login declares capabilities.team.monitorControl.
setPreference(request) The login declares capabilities.preferences: the person's choice has to have somewhere to go.
recordStep(report) A task declares mute: the host performs that leg, and the provider's record has to have somewhere to take it. See The host records what it performs.
executeTeamPolicy(command) The login declares capabilities.team.policyControl.
openMedia(request) The manifest lists softphone among its phones. On a softphone the call's audio lands in Omni, so the adapter has to open it; a platform of desk phones alone never does.

The four break methods stand or fall together. Declaring capabilities.breaks at login and then implementing requestBreak without commitBreak leaves an agent granted a break that can never start, and the two-phase coordination in Coordinating a multi-provider break has no way to report that: granted is a promise to honour a later commit.

Connection.snapshot()

Returns the provider's complete authoritative state at one point in time.

  • Omni registers subscribe() before awaiting the initial snapshot and discards anything delivered while the snapshot is read, because the snapshot accounts for it. Events after it are applied in order.
  • tasks must contain every task currently owned by this agent for this provider.
  • A snapshot replaces Omni's state for this provider; it is not a partial patch.
  • The adapter may return synchronously when it already holds current live values, or asynchronously when it must obtain state.

Connection.subscribe(listener)

Registers a listener for provider changes and returns an idempotent unsubscribe function.

  • Every delivery is a ProviderEventEnvelope.
  • Delivery order must match the order in which the provider observes changes.
  • Never replay an event. Recovery is a snapshot, and a re-sent event would apply state the snapshot has already superseded.
  • On reconnect, the adapter must reactivate provider-side subscriptions and emit a snapshot event containing the complete refreshed state. This reconciles assignments or endings missed while disconnected without requiring a durable event log.
  • After unsubscribe, the listener must receive no further events.

Connection.describeUsers(ids)

Turns UserId values into something an agent can read.

describeUsers(ids: UserId[]): Promise<User[]>

Required of any adapter that publishes a UserId — on ImposedBreak.by, a team roster, or handlingHistory[].by. Publishing an identifier Omni cannot resolve puts a name on screen that reads as a database key.

  • Omit an id you cannot resolve; do not invent a name for it. A missing entry says I do not know this person, which Omni renders as such. Ordering is not significant and the response may be shorter than the request.
  • Take the whole list in one call. Omni resolves a roster or a handling history as a batch, and a per-id round trip multiplies that by its length.
  • Omni caches a result for one hour, then resolves it again. The identifier is stable across logins but the name behind it is not, so the cache expires on a clock rather than living for the session. One hour is a starting figure and may be tuned; an adapter must not depend on any particular value, or on Omni asking again at any particular moment.

This is the only place a name comes from. Task data carries identifiers alone — handlingHistory[].by is an id and nothing more — so a name is never copied into a task, never duplicated across tasks, and never stale.

Connection.disconnect()

Stops the connection and releases adapter-owned resources.

  • Must be safe after partial startup and safe to call once during normal shutdown.
  • Must stop automatic reconnect.
  • Must remove event handlers and release media resources owned by the adapter.
  • Does not imply that active tasks were completed or removed.

Task allocation lifecycle

The task-allocation lifecycle has five ordered stages:

1. The agent signs in. Omni initially places the agent in not-ready. The provisioning file's readyOnLogin flag determines whether Omni transitions them to ready immediately and defaults to true. A provider must allocate nothing while the agent remains not-ready.

2. The agent becomes ready. With readyOnLogin: true, Omni makes the transition automatically. Otherwise, the agent explicitly signals that they are ready to take work. A successful connection, a healthy provider, or the absence of a break does not imply readiness.

3. Omni states their concurrent capacity. Only now does Omni ask providers for work, saying how much the agent can take at once. It is a standing declaration rather than a poll: Omni restates the capacity every time it changes, in either direction, and does not ask again while that value holds. Silence keeps the last one in force, so a provider that waits to be asked a second time will never deliver again once it has gone quiet. Hold the capacity and deliver when work arrives.

4. A provider offers a task. The provider emits task-offered within the stated capacity.

5. Omni decides how the task is accepted. When autoAcceptTasks is false, every task requires agent acceptance. When it is true, the pending task's acceptance states the provider's intent.

Acceptance modes

During login, Omni sends the agent's autoAcceptTasks value to the provider. When it is true, the provider states acceptance on each pending task — on the task rather than the offer, so a reconnect snapshot says it too and an offer the host never received is not accepted on the person's behalf for want of a word:

Directive Contract
no-preference The provider leaves acceptance to Omni; with autoAcceptTasks: true, Omni accepts automatically.
consent The provider requires the person's explicit consent: Omni presents Accept and waits, whatever its own policy would have done. A host that declares guarantees.personConsent promises exactly this; a provider checks it before offering work only a person may take.
automatic Omni accepts immediately without agent interaction.

When Omni sent autoAcceptTasks: false, the provider omits acceptance and every task requires agent acceptance. The two are never confused on the wire: consent is always the provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own no-auto-accept policy puts no word on the wire at all — the field is absent, and the Accept press is Omni's doing, not the provider's.

An absent value means true, as readyOnLogin does, because an agent who has signed in and gone ready is telling the deployment they are working. Requiring a press before every contact is the exception a provisioning file asks for, not the state it falls into when a flag is missing.

Nothing is given away by that default. acceptance is the provider's own control and outranks it: consent puts the decision back in the agent's hands for any task where it belongs, whatever the host was configured with.

An automatically accepted task still arrives through task-offered.

Agent-initiated work arrives through task-offered with the task's acceptance: "automatic".

Pending

A task in the pending phase has been offered to the agent and not yet accepted. Omni applies autoAcceptTasks and the task's acceptance to decide whether acceptance is automatic or requires the agent. A provider that requires automatic acceptance still emits task-offered; it does not introduce new work as in-progress.

declare const task: Task;

const allocation = {
  type: "task-offered",
  task: { ...task, phase: "pending", acceptance: "consent" },
  allocationExpiresAt: "2026-08-25T10:41:07.000Z",
} satisfies Extract<ProviderEvent, { type: "task-offered" }>;

The rule the phase exists to express: nothing is acquired on the agent's behalf while a task is pending. A host that carries media must not open the microphone until the task is accepted. Omni does not open the task's browsers either — a task that rings out costs nothing.

When consent is required, Omni offers the agent an Accept control. The call is the medium the task arrives on, not a separate decision.

Once a task is accepted, the call that comes with it is answered. Omni has no discretion there and the provider is not consulted twice: one decision about the work, and the medium follows it. Where the audio lands was settled at sign-in — in Omni on a softphone, on the handset on a desk phone — and is not in question per call. See How the agent hears the call.

Automatic acceptance still begins with task-offered.

allocationExpiresAt is the deadline after which the offer lapses. Where present, Omni counts down and stops offering Accept once it passes. Omit it unless the provider can observe it. A provider that reports only elapsed ring time after the fact cannot say when an offer is due to end, and a computed value would have Omni withdraw Accept from a task still pending.

A preview's deadline is not on the offer. It travels on the task, as previewEndsAt with atDeadline, so a snapshot carries it too -- see Preview: the agent presses Call.

A provider may withdraw a pending task by emitting task-ended with a cancelled outcome.

Tasks already in progress

The provider does not introduce new work as already in-progress.

A task appears already in-progress only in a snapshot taken after a reconnect or a resync, reporting work that began earlier in this login and never stopped. A fresh login has none to report: nothing has been allocated yet, and work the agent was handling elsewhere is not carried into a new session.

Tasks

Task

Task is the provider-owned description of one task presented to Omni.

task-offered introduces a new task and does not imply acceptance.

Task<C> is channel-discriminated. For example, Task<"email"> accepts browsers and dispositions, but rejects voice-only controls such as mute and hold at compile time. Runtime conformance checks also require the task channel to match its provider manifest.

Field Contract
id Required TaskId, unique within the provider. Omni scopes it with the provider ID.
title Agent-facing task title.
channel Channel handling this task. It must equal the source provider's manifest channel.
taskType Required provider-defined source or category of work, such as a voice Queue Name, Mailbox Folder, Chat Source, Support, Billing, or Returns.
capabilities Controls and workspace features available for this specific task.
browsers Named browser definitions for the task workspace: at least one when the task declares the browsers capability, empty when it does not.
party The person or entity on the other end of this task, as a Contact: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is with; contacts is the directory.
phase Current canonical task phase: pending, confirmed, preview, in-progress, paused, or completing. preview is voice only.
media 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-media-started.
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.
previewEndsAt Voice only, in preview: when the system stops waiting for the agent to press Call. Absent, the agent has as long as they need. Always with atDeadline. See Preview: the agent presses Call.
atDeadline Voice only, in preview, with previewEndsAt: what the system does at the deadline -- calls places the call itself, expires takes the record back and the task ends expired.
reference Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol id.
completionMode agent-command waits for the channel's complete command; provider-automatic completes without one.
wrapAllowance Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after task-media-ended. Required under provider-automatic, where the provider acts on it. Optional under agent-command: omitted says the provider imposes no deadline, and Omni counts nothing down.
attributes Optional ordered, typed TaskAttribute entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members.
handlingHistory The call record: steps — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, handleSeconds, holdSeconds, queueSeconds, transfers, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See How a task has been handled.
onCall Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: party is the customer, agent a person by user id, consulted and conferenced somebody a dial is bringing in, listed from the moment the dial is placed -- with the destinationId dialled, the dialId where a host placed it, the stage reached (ringing until answered, joined after), and held: true on anyone joined and parked. A consulted entry is what makes transfer complete and cancel issuable. label names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See Every dial has an outcome.
leadAssist Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: requested while nobody has joined, joined with the lead's leadId once somebody has. See Lead assist.
assisting Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes lead-assist take-over and leave issuable. See Lead assist.
monitoring Voice only, on the lead's own task while they listen to a member's call: whose call, which call, and the mode they are heard in, restated on every change. Never on the member's task, and never together with assisting. See Monitoring a call.

TaskAttribute entries carry typed detail alongside the task:

const attributes: TaskAttribute[] = [
  {
    key: "related-contact",
    label: "Related contact",
    type: "contact",
    party: { name: "Asha Rao", number: "+919876543210" },
  },
  {
    key: "answered",
    label: "Answered",
    type: "timestamp",
    at: "2026-08-27T09:30:00.000Z",
  },
];

key is the stable machine identifier and must be unique within the task's attributes array. label is the optional agent-facing name.

The canonical task transitions are:

From Decision or event To
No task Provider allocates a task pending
pending Task is accepted confirmed
confirmed The customer's record is put in front of the agent before any call goes out (voice) preview
confirmed Work begins in-progress
preview Agent presses Call (call) and the customer answers, or the deadline calls and they answer in-progress
preview The call goes out and nobody answers completing
preview The deadline expires Removed by task-ended with expired outcome
pending Provider withdraws the allocation Removed by task-ended with cancelled outcome
No task Snapshot reports work already underway in-progress
in-progress Provider or agent pauses the task paused
in-progress Agent starts a warm transfer (transfer warm); the customer is parked paused
paused Provider or agent resumes the task in-progress
paused Agent cancels a warm transfer (transfer cancel) in-progress
in-progress or paused Contact handling ends and follow-up work remains completing
completing Agent connects back to the party (connect-back) in-progress
Any phase Provider emits task-ended Removed

Allocation, acceptance, and progress are distinct. Acceptance follows autoAcceptTasks and the task's acceptance, moving the task from pending to confirmed. The provider reports subsequent transitions to preview or in-progress; Omni does not infer them from the acceptance command.

Preview: the agent presses Call

An outbound campaign that lets the agent see who they are about to call is a preview: the record arrives as a task, the task sits in preview with the party on it, and no call has gone out. The agent reads and presses Call. That is the whole phase, and it exists on voice alone -- chat and email have no call to place, and their reading is ordinary work in in-progress.

const previewed = {
  channel: "voice",
  capabilities: {},
  phase: "preview",
  party: { name: "Maya Rao", number: "+919876543210" },
  previewEndsAt: "2026-08-25T10:40:37.000Z",
  atDeadline: "calls",
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "party" | "previewEndsAt" | "atDeadline">;

// The agent presses Call. It is a dial like any other.
const pressed: TaskCommand<"voice"> = { type: "call", dialId: "dial-7f2" };

Call is a dial. It carries the host's dialId, is answered dialling, and ends in exactly one dial-outcome; the phase is its gate and there is no capability, since a record put in front of an agent is there to be called. On answered the task is in-progress and its media follows on task-media-started. On any other outcome the task goes to completing, so the agent records the no-answer as the disposition it is -- in a campaign that is the commonest outcome there is, and it is work, not a cancellation. A preview task therefore needs a manifest that says how a dial ends (task.preview.dialOutcomes.required).

The deadline says what the system will do. A provider that will not wait forever states previewEndsAt, and with it atDeadline: calls means the system places the call itself when the time runs out, which is progressive dialling; expires means the record is taken back and the task ends with an expired outcome naming preview. The two travel together (task.preview.atDeadline.required, task.preview.previewEndsAt.required), only while the task is in preview (task.preview.deadline.unexpected), and Omni's countdown reads "calling in 12s" or "expires in 12s" from the provider's word. With neither, the agent has as long as they need. Reaching the instant is not itself a transition: the provider reports what it did, as an event.

Drop both fields when the phase moves. A provider that builds the in-progress task by spreading the preview one carries previewEndsAt and atDeadline with it, and the host refuses the update (task.preview.deadline.unexpected) while the provider's own state looks right: the desk hears nothing and the provider sees nothing wrong. The task past preview has no deadline to wait for, so it carries neither field.

A task is never its audio. A voice task is the allocation: the call is offered when it is routed to the agent and accepted as its acceptance dictates, and its presence and phase follow the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a consult, a conference leg joining or leaving, a transfer, a connect-back — the media follows separately, arriving on task-media-started, attaching through openMedia and ending with task-media-ended. Omni does not ring, bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave are the adapter's and the platform's, transient, and decide neither when a task exists nor what phase it is in.

The line runs between the provider's word and Omni's own senses. task-media-ended is the provider's report that primary handling ended — a fact about the work, which is why the completion allowance starts on it and the Connect back control appears on it — and Omni follows that report as it follows any other. What Omni never does is derive a task's state from its own media session: a stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an endpoint re-registering change nothing about the task until the provider says so. Structurally: task-media-started and task-media-ended alternate on a task whose work has begun, media ends only where it arrived, what follows the media ending is completing or task-ended, and every task is introduced once — exerciseAdapter holds the stream to that from the connect snapshot on, and assertMediaFollowsTheTask holds any sequence.

Completion timing

completionMode determines how completion is triggered. With agent-command, the provider keeps the task open until Omni sends the channel's complete command. With provider-automatic, the provider may complete the task without receiving that command.

wrapAllowance is independent of that decision. It is fixed, and when it starts depends on whether the channel carries real-time media:

Channel Wrap allowance starts at
Voice and any channel with real-time media The task-media-ended event
Chat When the conversation ends and the task enters completing
Email After the message is sent and the task enters completing
Other non-media channels The moment the task enters completing
const emailCompletion = {
  completionMode: "agent-command",
  wrapAllowance: 120,
} satisfies Pick<Task<"email">, "completionMode" | "wrapAllowance">;

In this example, the agent has two minutes after sending the email to add notes, select a disposition, and complete the task.

0 means completion may happen immediately. With provider-automatic, the provider may complete without waiting for a command; with agent-command, it still waits for complete.

There is no value meaning "unlimited", because a number that is not a duration would be read as one. A provider that imposes no deadline says so by omitting wrapAllowance, which only agent-command permits: the provider will not complete the task itself, so there is nothing for a deadline to trigger, and Omni counts nothing down. Omitted and empty are different claims applies -- omitted says there is no deadline to see, where 0 says the deadline is now. Under provider-automatic the field is required, because the provider is going to act on it.

const untimedWrap = {
  completionMode: "agent-command",
} satisfies Pick<Task<"voice">, "completionMode" | "wrapAllowance">;

Here the customer has hung up, task-media-ended has been sent on time, the task is completing, and the agent takes as long as the work needs. Moving the task to completing late to avoid a deadline is not an alternative on a media channel: the clock starts at a real event, and delaying that event would falsify the phase and everything timed from it.

Connecting back during completion

A task that declares connectBack lets the agent connect back to the party while the task is completing -- to finish what the call left unfinished, on the same task rather than a new one, whoever placed the call in the first place. Most such calls came in, so this is not a redial: the agent rings a party who rang them. Omni issues { type: "connect-back", dialId }; it is issuable only in completing, and only where the capability is declared. The provider knows who the party is; the command carries no destination, and it is a dial like any other, so it carries the host's dialId, is answered dialling, and ends in one dial-outcome.

On dialling the provider is placing the call and the task returns to in-progress: the agent is working again, and the wrap allowance is discarded, not paused. From there the call is reported as any call is -- paused, in-progress, and when its media ends, task-media-ended again, which starts a fresh allowance from that instant. A party who does not answer is a dial whose outcome says so and a call whose media ended: the task returns to completing through the same event and the clock starts again from there. At no point is an agent dialling against a deadline.

The control exists only while there is a window to use it in. Under agent-command the task stays completing until the agent completes it, so the window is open for as long as they need. Under provider-automatic the window is the allowance -- and with wrapAllowance: 0 there is none: the provider disposes the task at provider end, and Omni does not offer Connect back, whatever the task declares. A capability names a control that can be used; on a task with no completing window it cannot, and declaring it there changes nothing.

const connectBackCapable = {
  channel: "voice",
  capabilities: { hold: true, connectBack: true, dispositions: true },
  phase: "completing",
  completionMode: "provider-automatic",
  wrapAllowance: 30,
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "completionMode" | "wrapAllowance">;

With ten seconds of the thirty left, the agent presses Connect back: execute({ command: { type: "connect-back", dialId } }) returns dialling, the task is in-progress, and the thirty seconds are gone. The second call ends: task-media-ended, the task is completing, and a new thirty seconds runs from that instant.

const immediateProviderCompletion = {
  completionMode: "provider-automatic",
  wrapAllowance: 0,
} satisfies Pick<Task, "completionMode" | "wrapAllowance">;

How a task has been handled

Task.handlingHistory is the call record: the steps that brought the task to the agent, oldest first, and what they add up to before this agent:

handlingHistory: {
  steps: [
    { step: "queued",      at: "2026-08-21T00:59:00Z", seconds: 41 },
    { step: "answered",    at: "2026-08-21T00:59:41Z", seconds: 312, by: "a-17" },
    { step: "held",        at: "2026-08-21T01:02:10Z", seconds: 35, by: "a-17" },
    { step: "transferred", at: "2026-08-21T01:04:53Z", by: "a-17" },
    { step: "answered",    at: "2026-08-21T01:05:02Z", by: "a-23" },
  ],
  handleSeconds: 312,   // handled by others before this agent
  holdSeconds: 35,      // holds others put the caller on
  queueSeconds: 41,     // waiting before anyone answered
  transfers: 1,         // hands the call has changed
}

This is not an archive. It is live data about a task that is still open: it travels with the task to whoever holds it next and ends when the task does. Nothing stores it, nothing queries it, and there is no archive behind it. A provider that keeps a record of completed contacts is describing something else, which will arrive under its own name and must not be folded in here.

It rides in the snapshot and is replaced whole like everything else there.

Steps are queued, offered, answered, held, muted, transferred, conferenced, unanswered, and each is defined on TaskHandlingStep.

The record is one entry per occurrence, oldest first. Each hold is its own held entry — at when it began, seconds once it ended and omitted while it runs — and a second hold is a second entry after the first, never a revision of it. The same goes for every step: two mutes are two muted entries. Order is enforced: an entry earlier than the one before it is refused (task.handlingHistory.order). Handle time is anchored, not restarted. It runs from the answered step's at — from the task's first in-progress where the provider reports no history — until the task's media ends, and a hold neither pauses nor resets it: the hold's own duration is the held entry's seconds, and a desk that restarts its counter on resume is counting the wrong thing.

muted is there because Omni performs the mute rather than the provider — see Where a command executes — so without a step the provider, which alone keeps the task's record, would have no account of a period the agent could not be heard.

A step that dialled says which dial and where. transferred, conferenced and unanswered each end a dial, so the entry carries the destinationId it went to and, where a host placed it, the dialId the host minted; no other step dialled, and either field on one is refused (task.handlingHistory.dialId.unexpected). This is how a dial made before a transfer is placeable by whoever holds the task now: a dial-outcome naming a dialId the host never minted is looked up in the record, not discarded.

Four rules a provider has to keep:

  • Report seconds; never expect Omni to derive it. Omni does not subtract one timestamp from the next. An entry can be written while its leg is still running, so the arithmetic has no second operand, and a provider holding the authoritative number should not have it recomputed from instants that may be rounded or clock-skewed.
  • Omit seconds while it is unknown. Never send 0. A leg still talking is not a zero-second conversation, and on live data that is the ordinary case rather than an edge. A zero is rejected.
  • by is a bare UserId, and not necessarily an agent. A lead or a manager takes part in handling too — a transfer accepted, a call conferenced in — so the field names whoever it was, the same way ImposedBreak.by does. It comes from this provider's own directory, the same namespace as AuthenticationState.identity.id and the team roster, so entries pair within a provider and never across one.
  • A task carries no names. Omni resolves what to display with describeUsers(). Two people called Arun on one site is ordinary, and anything pairing entries on a display name pairs them wrongly; carrying the name here would also copy it into every task and leave it to go stale.

An absent by means different things on different steps, and both are legitimate. On queued nobody takes part, so there is nothing to name. On every other step somebody did — see HANDLING_STEPS_WITH_A_PERSON and handlingStepExpectsAPerson() — so an absent by there says this was handled and the provider cannot say by whom.

That case is ordinary rather than theoretical: a leg answered on a shared phone, a manager's handset, or a device the provider cannot resolve to a person. Report the step without by rather than dropping it. A list missing a real handler looks complete and is wrong, which is worse than one saying plainly it could not attribute a leg — and far better than publishing nothing because a single leg could not be named.

A host must render the two differently. Showing an unattributed answered the same way as queued tells the agent nobody was involved, which is not what was said. Omni renders it as "not recorded" in the place the name would go.

Omit handlingHistory entirely when the provider cannot observe the steps. Empty steps is a different claim — it says the task has had none.

What the record adds up to is stated, not summed. A call that has changed hands arrives carrying what others already used of it, and the agent reads that before they say hello: the totals are the provider's own — never a desk summing instants that may be rounded or skewed, for the same reason seconds is reported and not derived — and each is present when the provider knows it and absent when it does not, never a plausible nought. A fresh call from the queue has steps with no prior answered and no totals to state, and a desk shows nothing rather than zeros. The record is restated with the task on every task-updated and snapshot, so an entry that arrives late corrects the sums. These four are what every platform reports; more will be added here as a need is shown, not invented ahead of one.

The host records what it performs

muted is the one leg Omni performs rather than the provider, and a record kept by the provider would have a hole exactly there. So the host reports it, through recordStep, and the provider writes it into its record as it writes every other leg:

declare const connection: Connection<"voice">;
declare const taskId: TaskId;
declare const at: IsoTimestamp;

void connection.recordStep?.({ taskId, step: "muted", at });                          // the moment the agent mutes
void connection.recordStep?.({ taskId, step: "muted", at, seconds: 15 });             // still running, if the host chooses to say
void connection.recordStep?.({ taskId, step: "muted", at, seconds: 42, ended: true }); // the moment they unmute

The entry is keyed by step and at, so every report about one leg names the same instant. The host is the authority for the legs it performs, so it may say how long so far — seconds is elapsed while the leg runs and final once it has ended — and the end is stated, never inferred: ended: true marks the last report, and it carries the final duration. What a provider never asked for never crosses. The running report is sent only to a provider whose manifest declares runningStepReports; every other provider receives exactly two reports per leg, when it began and when it ended, and validateHandlingReport(report, path, manifest) refuses a running one it was never asked for (handlingReport.running.unexpected). What a provider that did ask for them forwards upstream, and how often, is its own business. The step appears in handlingHistory when the provider publishes it: Omni never writes the record itself. recordStep is required of a connection whose tasks declare mute, and answers recorded.

A host-performed leg still open when the task's media ends, or the task ends, is ended by the host at that instant — ended: true, seconds to the end, the same at — since a provider left to close it would be guessing at a host-performed duration. Both ends are read off one clock, the host's, and a leg shorter than a second is reported as the second it was, never as the nought the record refuses.

Browser capability

browsers is available to voice, chat, and email tasks. When declared, Omni renders the task's TaskBrowser entries as named browsers in the task workspace. A task that supplies one or more browser definitions must declare:

capabilities: { browsers: true }

Tasks without browser definitions omit the capability and provide an empty browsers array.

A browser is one tab, and each workspace keeps its own. Browser is what a tab is — an id, a name, a URL. The task workspace shows the task's TaskBrowser entries, one tab each, fixed at the task's definition: their count and their details, urlVisibility included, arrive with the task, and nothing adds a tab to a task later. The personal workspace is the agent's: as many PersonalBrowser tabs as they open, never on the wire, and no provider says anything about what they may see there.

TaskBrowser and isolation

Each TaskBrowser defines one named browser in the task workspace.

Field Contract
id Stable internal selection and update identity within the task.
name Agent-facing tab label, unique within the task, and an input to schemes containing TAB_NAME.
purpose Human-readable explanation of the browser's role.
url Initial URL. Must use http: or https:; see below. Later navigation comes from Chromium.
sharedSession Required. false creates a task-specific browser session.
isolationScheme Required when sharedSession is true, and rejected when it is false. There is no default: see below.
urlVisibility What the agent sees of this tab's URL in Omni's chrome: hidden, domain, or full. Omitted, the URL shows as any browser's does; a provider says hidden where the URL carries what the agent may not read — a caller's number, a CRM token. Per browser, on the provider's word; a host that declares guarantees.browserUrlVisibility honours it tab by tab, and a provider checks that guarantee before it sends such a URL at all.
Choosing an isolation scheme

Every scheme is supported and the provider picks the one its deployment needs. There is no default, and a sharedSession: true browser that declares none is invalid — the type will not compile it and validateSnapshot reports task.browser.isolationScheme.required.

That is deliberate. Sharing a signed-in session decides who else may see those credentials, and it is not a decision to inherit from whichever value happened to be the default. TAB_NAME keys on the tab label alone, so two providers that each publish a browser named "CRM" share one signed-in session — legitimate where a deployment wants exactly that, and a silent credential leak where it does not. It remains available; it has to be asked for.

browserSessionKey fails closed: given a reusing browser with no scheme it returns undefined and the browser is isolated. The safe reading of an invalid declaration is "do not share", never "share with everyone named the same".

Permitted URL schemes

TaskBrowser.url is provider-supplied and is loaded inside Omni's managed browser, so it is restricted to the schemes in ALLOWED_BROWSER_URL_SCHEMES — currently http: and https:. file:, chrome:, javascript:, and every other scheme are rejected. Omni substitutes a blank page rather than following a disallowed URL, and isAllowedBrowserUrl() is the shared predicate.

Reuse and isolation

With sharedSession: true, definitions producing the same isolation key share one storage profile: cookies, local storage, session storage, permissions, and cached credentials. Different keys are isolated from one another.

Sharing a profile is not sharing a window. Two browsers in the same task keep their own tab, their own visible label, and their own navigation state and history even when their keys match — a scheme that omits TAB_NAME, such as PROVIDER_NAME__TASK_TYPE_NAME, deliberately places every named browser of that task type in one signed-in profile without merging them into one page.

browserSessionKey() derives the key. Every part is escaped before the . separator is applied, because encodeURIComponent leaves . untouched and a raw join would let one value forge another key: provider Acme.Voice with task type Support would otherwise produce the same key as provider Acme with task type Voice.Support, silently placing two providers in one cookie jar. Hosts that must flatten the key further — for a native window label or a partition name with a restricted charset — must keep the mapping injective, for example by appending a fingerprint of the exact key, since lowercasing or replacing punctuation reintroduces exactly this collision.

browsers: [
  {
    id: "crm",
    name: "CRM",
    purpose: "Contact record",
    url: "https://crm.example.com/contact/42",
    sharedSession: true,
    isolationScheme: BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME,
  }
]

The supported BrowserIsolationScheme values, declared under Shapes, key as follows:

Enum member Example session key
PROVIDER_NAME__TASK_ID__TAB_NAME mailflow.EMAIL-829102.CRM
TAB_NAME CRM
PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME mailflow.Support.CRM
PROVIDER_NAME__TAB_NAME mailflow.CRM
PROVIDER_NAME__TASK_TYPE_NAME mailflow.Support
TASK_TYPE_NAME__TAB_NAME Support.CRM

TASK_TYPE_NAME refers to the mandatory Task.taskType. The isolation scheme never changes the browser tab label. Serialized values are stable protocol values and must not be renamed or reused.

PROVIDER_NAME is manifest.id, never manifest.displayName. Only the id is required unique across an installation; two providers may legitimately share a display label, and keying a cookie jar on one would put them in the same signed-in session. The id is also stable across launches, where a display name may be re-worded — and a changed key silently signs the agent out of every browser that used it.

Task capabilities

Task capabilities belong to each Task. If hold is omitted on one task, Omni must not show or issue hold for that task even if another task from the same provider supports it.

const taskCapabilities = {
  channel: "voice",
  capabilities: {
    hold: true,
    dispositions: true,
  },
  browsers: [],
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "browsers">;

Voice capabilities

Capability Omni UI Contract
decline Pending-task button: Decline The provider can decline a pending voice offer. Omni shows it only when provisioning also permits declining.
mute Primary toggle: Mute Omni may mute and unmute the agent's outbound audio.
hold Primary toggle: Hold Omni may issue voice-task hold and resume commands.
endCall Primary button: End call Omni may end the whole call: everyone leaves, the media ends, and the task stays for its wrap-up. See Ending a call, and removing one person from it.
connectBack Completing-task button: Connect back Omni may have the provider connect the agent back to the task's party while the task is completing, returning it to in-progress on the same task. Not offered where there is no completing window: provider-automatic with a zero allowance disposes at provider end. See Connecting back during completion.
coldTransfer Secondary menu item: Cold transfer Omni may hand the customer straight to a destination, with nobody spoken to first.
warmTransfer Secondary menu item: Warm transfer Omni may park the customer and call a destination first, then hand the customer over or cancel back. See Warm transfer.
leadAssist Secondary menu item: Lead assist Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on Task.leadAssist. See Lead assist.
conference Secondary button: Conference Omni may dial a destination into the active call, and remove one person from it -- a conferenced entry, one still ringing included, which calls the dial off, or the party, leaving the agent with the colleague. See Ending a call, and removing one person from it.
recording Overflow menu item: Recording Omni may expose start, pause, resume, and stop recording controls.
dispositions Primary button: Complete Omni may request task disposal with a provider disposition and notes.

Publishing codes and destinations

Four capabilities accept an object instead of true when the provider wants Omni to render real choices. true remains valid and means "offer the control with nothing published".

dispositions

capabilities: {
  dispositions: {
    required: true,
    notes: "optional",
    codes: [
      { id: "resolved", label: "Resolved" },
      { id: "callback", label: "Callback needed", group: "Follow-up" },
    ],
  },
}
Field Contract
required When true, Omni must collect a code before issuing complete. A required policy must publish at least one code.
notes required, optional, or none; controls the free-text field beside the code.
codes Codes Omni offers. id values are non-empty and unique; Omni sends the chosen id as TaskCommand.complete.disposition.

With dispositions: true Omni shows a Complete control and sends complete with no code, because the provider published none.

coldTransfer, warmTransfer and conference

capabilities: {
  coldTransfer: {
    destinations: [
      { id: "tier2", label: "Tier 2 support" },
      { id: "main-menu", label: "Back to the main menu" },
    ],
  },
}
Field Contract
destinations The items the control offers, at least one, with unique id values. Each is a button or a menu item on the desk.
id What Omni sends as destinationId on a transfer or conference command, and what the provider executes.
label What the agent reads.

The protocol does not say what an item does. A queue, an IVR menu, an outside line, a skill group -- that is the queue's configuration and the provider's business. The agent sees a list of labels, picks one, and Omni sends its id; the provider executes whatever the item is. No kind travels, nothing is typed, and a control with an empty directory has nothing to offer and is refused (task.destinations.offer). A control declared as bare true is refused for the same reason (task.destinations.shape): once nothing is typed, the directory is the control.

An agent never transfers to a named agent, warm or cold. A contact that needs another pair of hands goes back to a queue or a menu the queue configured, and who takes it next is the queue's decision: routing by skill, availability and fairness is what the queue is for, and a hand-off to a chosen colleague bypasses all three. The desk shows no agent picker and no dial pad on a transfer. The one way a named person ends up with an agent's call is a lead taking it over through lead assist, which is the lead's act, not a transfer the agent chose.

Ending a call, and removing one person from it

A call has everyone on Task.onCall, and two commands take people off it, both performed by the provider.

{ type: "end-call" }                                             // everyone leaves; the media ends; the task stays for its wrap-up
{ type: "conference", action: "remove", party: true }            // the customer leaves; the agent stays with the colleague
{ type: "conference", action: "remove", destinationId: "tier2" } // the conferenced person leaves; ringing, this calls the dial off

end-call ends the whole conversation for everyone on it, and is gated by endCall. The task does not end with it: task-media-ended follows, any wrap allowance runs, and the agent dispositions the call. conference remove takes one person off, named as the room names them, and the call goes on for the rest; it is gated by conference, since it only means something with a third person on the line. Removing the party leaves the agent with the colleague, a warm hand-over in reverse. A remove that would leave the agent alone is not a remove but an end-call, and a provider answers it failed. The consulted destination of a warm transfer is not removed this way: transfer cancel is its own step, which returns the agent to the parked customer, and a remove never promises that.

Warm transfer

A warm transfer parks the customer, calls the destination so the agent can speak to it first, and then either hands the customer over or returns to them. Cold and warm are the words agents use; telephony says blind and consult. It is its own capability, distinct from coldTransfer (a hand-over with nobody spoken to first) and from conference (everybody on one call): a queue may offer any of the three without the others, and each is declared on its own.

// 1. Warm. A dial: the provider parks the customer and calls the destination, answers
//    `dialling`, and the task reports `paused` with the destination `consulted` on `onCall`
//    while the call to it stands.
{ type: "transfer", action: "warm", dialId: "dial-7f2", destinationId: "tier2" }

// 2a. Hand the customer to the consulted destination and leave.
{ type: "transfer", action: "complete" }

// 2b. Or drop the destination and return to the customer.
{ type: "transfer", action: "cancel" }

warm is gated by the warmTransfer capability and names a directory item exactly as a cold transfer does, from the same kind of directory. It is a dial, so it carries the host's dialId and is answered dialling, and its dial-outcome says whether the destination was reached. While the destination is on the line the task carries a consulted entry on onCall, and that presence is what makes complete and cancel issuable -- they name no destination because there is exactly one they could mean, which is why a task carries at most one consulted entry (task.onCall.consulted.single). A consultation that could be started but not finished would strand the customer and the destination both, which is why all three are commands and a provider that offers warmTransfer implements all three.

applied on complete says the provider is bridging the customer to the destination and dropping the agent's leg. What follows is what follows any transfer: the agent's media ends and the provider reports task-media-ended, any wrap allowance runs, and the task ends with a transferred outcome naming the destination. applied on cancel says the destination is dropped; the task returns to in-progress with the consulted entry gone. Omni waits for the provider's report of both, as it does for every command.

A destination that does not answer is a dial that ended: the provider reports its dial-outcome, returns the task to in-progress with no consulted entry, and the agent is back with the customer.

Chat capabilities

Capability Omni UI Contract
decline Pending-task button: Decline The provider can decline a pending chat offer. Omni shows it only when provisioning also permits declining.
hold Primary toggle: Hold Omni may pause and resume agent handling of the chat.
dispositions Primary button: Complete Omni may request task disposal with a provider disposition and notes.

Email capabilities

Capability Omni UI Contract
decline Pending-task button: Decline The provider can decline a pending email offer. Omni shows it only when provisioning also permits declining.
dispositions Primary button: Complete Omni may request task disposal with a provider disposition and notes.

Custom capabilities

Every task may publish additional provider-specific controls in capabilities.custom:

capabilities: {
  hold: true,
  custom: [
    { id: "request-supervisor", ui: { control: "button", label: "Request supervisor", placement: "secondary" } },
    { id: "mark-vip", ui: { control: "toggle", label: "Mark as VIP", placement: "overflow" } },
  ],
}

Custom capability IDs must be non-empty and unique within the task. ui.control is button, toggle, or menu-item; ui.placement is primary, secondary, or overflow. ui.render says where the control's work appears: inline, in the workspace beside the task, or page, as a page of its own — a tab in the same work area as the task's browsers, beside them, and alone on a task that has none; inline when absent. prompt.fields are what the agent supplies before the action runs — a destination number, a reference — as CredentialFields Omni renders as a form; the values travel on the custom command under the fields' names, as strings, and Omni sends the command only once every required field has a value. The provider validates what arrives as it validates any command; a form is a declaration, not a contract for what the agent typed. Omni renders the control and invokes it with the shared custom task command:

{
  type: "custom",
  name: "request-supervisor",
}

name is the id of the custom capability the agent used. There is no taskId here: like every other command it travels on the TaskCommandRequest around it. A toggle carries the state it wants — { type: "custom", name: "mark-vip", on: true } — never a flip, for the reason under Task commands.

Custom capabilities must not redefine the meaning of a standard channel capability.

Idle actions

dial(request)

Starts one outbound call from the idle dialpad. It is present only when the voice provider declares dial.

  • dialId is the host's identity for this dial, minted before the request leaves.
  • destination is the original number selected or entered by the agent.
  • The provider holds destination to its declared destinations: under contacts-only, a number that is not one of its contacts answers failed with omni.destination-not-permitted.
  • dialling says the request was accepted and the call is being placed, and restates the dialId. It says nothing about whether anyone will answer.
  • failed contains a ProtocolFailure and confirms no call was placed.

The resulting call is offered through the normal task-offered event, and how the dial ended arrives on dial-outcome under the same dialId. A dialling result does not manufacture a task inside Omni.

Every dial has an outcome

Four commands place a call: dial from the idle dialpad, a blind or consult transfer, a conference add, and connect-back. Each is accepted or refused at once, and each then ends later and apart from its answer -- the destination picks up, is busy, or never does -- and a dial placed late in a call routinely outlives the call. Nothing in between is reported: the wire says dialling, then says how it ended, once.

The host mints the identity. Every command that dials carries a dialId the host made, unique across the installation, and it travels four ways so no leg is a lookup:

declare const connection: Connection<"voice">;
declare const taskId: TaskId;

// 1. Out, minted by the host.
const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });

// 2. Back on the result, restated, so the host compares and confirms.
expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });

// 3. On the outcome, however late, against the task it belonged to.
const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };

// 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };

The identity is a correlation handle between host and provider and is never shown to the agent. A host holding several tasks places an outcome by the dialId it minted; one it did not mint -- a dial made by the previous agent before a transfer -- it finds on the task's onCall or in its handlingHistory, which is why the steps that dial carry it. The dialId is not a retry key: Omni never repeats a dial, and a command still carries no key for that purpose.

dialling is the answer to a dial, and applied is not. A command that dials answers { status: "dialling", dialId }, restating the host's identity (result.dialId, result.dialId.mismatch); applied from such a command, or dialling from one that dialled nothing, is refused (result.status). validateResult(result, method, path, dialId) takes the dialId the host sent for that reason.

The outcome is stated, once, either way. A dial-outcome names the dialId, the task where there was one, the directory item it went to where there was one, and how the dial ended -- from a closed set: answered, busy, no-answer, unreachable, rejected, cancelled, unexplained. answered is stated rather than read off somebody appearing on the call, because a host that infers success cannot tell "reached" from "still ringing". cancelled is the dialler calling the dial off before anyone answered -- the agent hanging up while a consult still rings -- which is not a no-answer the destination never gave. unexplained is the switch dropping the call with no cause among those named here: a statement about the switch, sent instead of the nearest cause it did not give. reason is the switch's own words, optional, and shown to the agent attributed to the switch rather than to Omni. The two are different things and may travel together: a switch can say "could not create dialog" in words and still name no cause a code would carry, so unexplained with a reason is a dial the switch described but did not classify, and a desk renders the words without inventing the class.

taskId is the task the dial was placed on, resolved from the dial's own identity and never from whatever task the agent is looking at when the outcome arrives. That task may already have ended, and the harness places the outcome all the same (TaskStream, stream.dialOutcome.unknown, stream.dialOutcome.duplicate); an agent who moved on still learns nobody was reached, against the call it belonged to and never against the next one.

The stream knows a dial from three places: TaskStream.dialled(dialId), which a host calls when it places one; an entry on a task's onCall; and a step in its handlingHistory. Since a dialled entry is listed from placement, dialled() is load-bearing only for a dial that never has an entry -- a cold transfer, a connect-back, an idle dialpad call -- or whose entry has already left the room; an outcome for a dial nobody placed and no task mentions is what stream.dialOutcome.unknown refuses.

answered says what happened to the dial; onCall says who is in the room. They are two claims, and both are true at once when a leg answers and never joins. A desk shows answered as the dial's conclusion and says "on the call" only when the person appears on Task.onCall; the obvious reading of answered -- that they are on the line -- is the one it does not make.

A provider says which outcomes it can tell apart. No switch distinguishes all five, and a provider that cannot tell busy from unreachable must not pick one. The manifest declares dialOutcomes, and a dial-outcome carries only a declared member (event.dialOutcome.undeclared). The list includes answered, or the provider could never state a success (manifest.dialOutcomes.answered), and at least one other member, or it could never state a failure (manifest.dialOutcomes.failure). unexplained is declarable only beside a cause the provider does report -- busy, no-answer, unreachable, rejected or cancelled (manifest.dialOutcomes.unexplained.alone): a provider that reports causes may honestly say the switch gave none, while one that reports none would be sending the generic failure this set exists to refuse. A provider that dials at all declares it: an idle dialpad requires it (manifest.dialOutcomes.required), and a task that declares connectBack, coldTransfer, warmTransfer or conference under a manifest without it is refused (task.capability.dialOutcomes.required). Off voice nothing dials, and the field is a compile error there.

Who is on the call is stated on the task. Task.onCall lists everyone the task's audio joins right now, replaced whole with the task like every other field, and present when the provider knows the room:

const onCall: OnCall[] = [
  { role: "party", since: "2026-08-21T09:12:00Z" },
  { role: "agent", userId: "A-12", since: "2026-08-21T09:12:04Z" },
  { role: "conferenced", destinationId: "tier2", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
  { role: "conferenced", destinationId: "main-menu", dialId: "dial-3c9", stage: "ringing", since: "2026-08-21T09:16:02Z" },
];

party is the customer and agent a person by user id; neither was dialled from anywhere, so neither carries a destination. consulted and conferenced were, so both carry the destinationId of the directory item and, where a host's dial brought them, its dialId; a person the platform added itself carries none. held: true is presence as claim. A snapshot establishes state, so a host that attaches after a transfer reads the room from here rather than inferring it from a sequence of outcomes it never saw.

onCall is who is on the call now, and a task outlives its call. Wrap-up is not an ending: the task is alive, the agent is working, and nothing arrives to say the call ended until they dispose. So the room must not be the last word about a call that has ended. Once the call is over -- the task completing, or its media ended -- onCall is empty, or absent only where the provider never publishes the room at all, and a task carrying people on a call that has ended is refused (task.onCall.ended). Empty says nobody is on the call; absent says the provider does not say who is, which is a different claim, so a provider that publishes the room publishes one last change: the empty room.

A field that describes the present is cleared by the transition that ends it. onCall, previewEndsAt and atDeadline are three instances of one shape, and there will be more: each describes a state that is true now, and each is carried past the moment it stops being true by the most natural implementation there is, building the next task by spreading the last one. The provider's own state looks right, the claim on the wire is stale, and only the host sees it. An adapter that builds each transition from what is true after it, rather than from what was true before, needs none of these rules named. Two agents were once shown on calls for the better part of an hour while callers queued, because the last message describing the room live was never followed by one saying it had emptied, and every consumer that derived anything from it was wrong in a way that looked like its own bug.

A dialled entry is listed from the moment the dial is placed, not from the answer. The entry is what makes transfer cancel issuable and what conference remove names, and a destination the agent cannot call off while it rings is a parked customer with no way back -- which is the very case cancelled exists for. Consult and conference behave alike here: the consulted or conferenced entry appears with its dialId when the dial is placed, and a conference remove naming a still-ringing destinationId calls that dial off, with cancelled as its outcome.

The entry says where it stands. A dialled entry carries stage: ringing until its dial is answered, joined after. The dial-outcome is the transition and the stage is the state, the same pairing as task-media-started and media: an answered outcome and the task restated with the entry joined say one thing twice, and any other outcome removes the entry. The outcome comes first: a task-updated that itself moves an entry from ringing to joined before an answered outcome for its dial is refused (stream.taskUpdated.stage), as an update that moves media is. The rule keys on the entry's dialId, so an entry the platform added itself, with none, is one whose move the stream cannot hold to an outcome: a clean stream proves nothing about ordering there. It is stated rather than left to whoever placed the dial because that knowledge lives in one process: after a reload, on a second session, or on a supervisor's screen the snapshot is the only source, and a room that cannot tell ringing from joined says it contains someone who may never arrive. Nobody ringing is held (task.onCall.held.ringing). A consult destination that never answers is a dial that ended: the provider returns the task to in-progress with no consulted entry, as Consult transfer states, so the customer is never left parked by a conforming provider.

The desk shows a dial as placed on dialling, never as reached; shows the person in the room when they appear on onCall; and shows the outcome, with the switch's reason, against the call it belonged to -- with no retry, since a wrong number is not worth redialling and the decision is the agent's.

Breaks

Everything about an agent's breaks on one provider arrives as one object, Snapshot.break, replaced whole by a single break-state event. These facts are only meaningful together — an approval says nothing without knowing whether the agent chose the break, and a list of reasons says nothing while none are being accepted — so they are not published separately.

Field Contract
approval Where the agent's current request stands. See the states below.
mayAsk Whether the agent may ask at all. Distinct from approval.
refusedReason Display-ready reason shown when mayAsk is false — a standing gate that applies to everyone.
decisionReason The words whoever decided attached, from decide.reason. About one request and one decision, not a standing gate.
retryAfterMs How long until the agent may retry, when the provider can say.
reasons Not-ready codes this provider offers. Omitted when it defines none; an empty list is refused, being a second spelling of the same fact.
activeReasonId The BreakReason.id the current break is on. Omitted when there is no break, or when the provider cannot say.
imposed Set when the break was placed on the agent rather than requested.

A request can be waiting for two unrelated things, and they are separate values because rendering one as the other tells an agent to wait for somebody who is never coming:

approval Meaning
not-requested No request outstanding.
awaiting-decision A person has to decide. The agent is waiting on somebody.
granted A person decided yes. Omni may now tell this provider to stop the agent; until it does, work continues normally, and this says nothing about why Omni has not.
starting-after-task Omni has told the provider to stop; the break begins when the current task ends. No new work arrives meanwhile, and nobody needs to act.
in-effect The agent is on the break now. It holds no task: a break begins when the work ends, so a snapshot reporting in-effect beside a task is refused as break.in-effect.tasks. The one exception is a lead's monitoring task during a coaching, administrative or training break -- see Monitoring a call.

A denial is a decision, not a standing approval state. The provider transitions the request directly to not-requested; Omni returns the agent to idle and never asks again on their behalf. They saw the answer and ask again when they want to. decisionReason may carry the words attached to that decision, but approval does not remain denied.

A provider reports starting-after-task only after Omni commits a granted request while work is still active. Omni does not send the request again, because asking again would not move it; it sends the commit again only from a reconnect snapshot that shows the grant still standing.

mayAsk: false is what lets Omni withdraw the control rather than let an agent ask and be refused. A BreakReason marked alwaysAvailable survives it: a mandatory rest period is not something a busy hour can cancel, and Omni keeps offering those while the rest are withdrawn.

Imposed breaks

ImposedBreak says who placed the break, whether automatic ending is enabled, and, when enabled, when the provider will end it. A break the agent did not choose is not manually resumable by them.

Every imposed break has a person behind it. A lead or a manager placed it; there is no such thing as a break the platform imposed on its own. Where a platform applies one automatically, it is executing a preference somebody configured, and that person is the owner of the action — by names them, not the machinery that carried it out.

Omni resolves the name to show with describeUsers(), so a provider sends the identifier and never a display name.

For example:

imposed: {
  by: "manager-1042",
  endsAutomatically: true,
  endsAt: "2026-08-21T10:00:00.000Z"
}

The presence of imposed means the agent cannot end the break manually, so Omni withdraws its Resume control from that agent. With endsAutomatically: true, the provider ends the break at endsAt; with endsAutomatically: false, it does not end the break on a timer. An authorized lead may end either form with Resume, not only whoever placed it. Omni shows Stopped by , resolving the name with describeUsers(), and shows when the break will end where automatic ending is enabled.

A break applies to the agent, not to one provider. When a provider imposes one, Omni immediately requests a break on every other connected provider, or they would keep routing work to somebody who is not there. Providers should expect that follow-on request.

Capacity and break actions

Each method answers in its own words. A result is read by a person far more often than it is branched on by code — in a log, a support ticket, a conformance failure — so it says what happened rather than that something happened. failed is shared, because failing is the same act everywhere; success is not.

Method Succeeded
setCapacity applied
recordStep recorded
requestBreak requested
commitBreak committed
cancelBreak cancelled
endBreak ended

failed carries a typed ProtocolFailure and means the provider did not take the action, whether it would not or could not.

Succeeding is not the outcome. requested says the provider holds the request, not that a break was granted; ended says the provider has the instruction, not that the agent is working again. Every break method reports its real result through break-state; setCapacity reports none at all, because capacity is a statement rather than a request.

execute keeps applied rather than a verb per command, because the command is in the request: execute({ command: { type: "hold" } }) returning applied already says the hold applied. A held result would repeat the discriminant that travelled with it.

setCapacity(capacity)

States how many tasks this provider may have allocated to the agent at once.

count is an absolute ceiling, not an increment and never less than 1. An agent's capacity is a property of the agent, not of the moment: it is stated when the agent is set up and restated only when it genuinely changes, which is a provisioning change rather than a task starting or ending.

The provider counts its own outstanding tasks against it. Allocate while you hold fewer than count tasks for this agent, and stop when you hold that many; when one of yours ends you have room again and need no new signal to know it. Omni does not re-state capacity as tasks come and go, and a provider that waits for it will stall.

Your own tasks are the only ones you count. What the agent holds at other providers is not your concern — Omni set count knowing it.

Capacity supersedes rather than accumulates: the latest value is the ceiling.

Capacity gates what the provider allocates, not what the agent starts. A call placed from the idle dialpad arrives through task-offered like any other task, and a full agent does not forbid it: the ceiling binds allocation, not the agent's own hand.

requestBreak(request)

Requests permission to stop the agent later; it does not itself stop work. The provider continues offering work and reports awaiting-decision or granted through break-state events. If the request is denied, the provider reports not-requested directly, with decisionReason when one was supplied.

An agent asks from anywhere — idle or on a task — and Omni offers the request in the task workspace as it does on the idle dashboard. Asked on a task, the break is decided and committed like any other and begins when the work ends: that is starting-after-task, and assertBreakBeginsAfterTask is the scenario that holds a provider to it.

Break reasons

A provider that defines not-ready reason codes publishes them on Snapshot.break.reasons, and Omni returns the agent's choice as BreakRequest.reasonId:

break: {
  reasons: [
    { id: "lunch", label: "Lunch" },
    { id: "training", label: "Training", group: "Scheduled" },
  ]
}

Reason ids are non-empty and unique within the provider. They live on the snapshot rather than the manifest because a provider may change the codes it offers during a shift; a snapshot event replaces the list. BreakRequest.reason remains available for free text and is never a substitute for reasonId when the provider publishes codes. A provider that defines no codes omits the field.

One break across several providers

An agent connected to several providers takes one break, not one per platform. Omni gathers what every connected provider offers, shows the distinct breaks, and on a choice sends one request per provider: the same reason, and each provider's own reasonId.

To do that, Omni has to know when two providers mean the same break. Say so with kind:

break: {
  reasons: [
    { id: "lunch", label: "Lunch", kind: "meal" },
    { id: "rest",  label: "Mandatory rest period", kind: "rest", alwaysAvailable: true },
  ]
}

A provider decides which breaks it offers and what it calls them. BREAK_KINDS is the list Omni supports mapping them onto, and every member means something specific:

Kind What it means
short-break A brief rest between contacts — the comfort break a shift plan allows for.
meal A meal: lunch, dinner, whatever the shift calls it.
rest A rest period the agent is entitled to and a busy hour cannot cancel. Usually the reason also marked alwaysAvailable, though the two are separate: this says what the break is, that says whether policy can withdraw it.
training Learning something the agent is expected to know afterwards: a course, e-learning, a product walkthrough. However it is delivered, and whoever attends.
coaching Reviewing this agent's own work with somebody accountable for it: a call listened back, quality feedback, a one-to-one about their handling. Even where the outcome is that they learn something.
meeting A scheduled gathering that is neither — a team huddle, a project call, a town hall. The agent attends and contributes; nobody is assessing their work and there is nothing they must know by the end.
administrative Paperwork and follow-up not attached to a particular contact.
technical Equipment or system trouble stopping the agent taking work: a dead headset, a phone that never registered, a tool that will not load. The one member that is not an activity — it says why the agent cannot work rather than what they are doing, which makes it the right home for a not-ready state raised about the agent's equipment.
personal Personal time the deployment does not classify further.
other None of the above. Matches nothing, including another provider's other.

The definitions are the point, not decoration. Ten undefined strings would be the label problem one level up: two providers could both declare technical, one meaning a dead headset and the other scheduled maintenance, match on it, and nothing could tell the difference.

training, coaching and meeting can all fit one session, so take them in that order of specificity: about this agent's own work is coaching, else something they must know afterwards is training, else meeting.

A break somebody placed on the agent is not automatically one of these. Where the agent was stopped rather than choosing to stop — see Imposed breaks — set BreakState.imposed and prefer omitting kind to reaching for other. None of the ten describes "something was done to this agent", and other claims a classification that was never made.

Omni matches in this order:

  1. kind, where both providers declare one. This wins over the label, so { id: "MEAL", label: "Meal", kind: "meal" } lines up with { id: "lunch", label: "Lunch", kind: "meal" } and nobody has to word their codes the same way.
  2. The label, folded for case and surrounding spacing, where a kind is missing. "other" counts as missing: two providers saying "other" have only said their break is not on the list, which is no evidence they mean the same thing.

Nothing else is matched. A break Omni cannot pair stays on its own, the agent is told how many platforms their break will actually reach, and they can pair the odd one by hand — Omni remembers that for next time.

Three rules for a provider:

  • Declare kind where you can, and leave it out where you cannot. Leaving it out costs a little precision. Getting it wrong sends an agent to lunch on one platform and to training on another, and nothing can detect that.
  • You may publish several reasons of one kind — two meal slots, say. Omni will not choose between them for the agent; saying they are the same kind is not saying they are interchangeable.
  • Do not read reason as your own label. It is the agent's single choice, in whichever provider's wording they picked it from, sent unchanged to every provider in the break.

A provider that publishes no reason codes still receives the request, with reason set and reasonId omitted.

Coordinating a multi-provider break

Sending the requests is a two-phase operation, not independent best effort. A provider first grants permission for the agent to stop, then Omni either commits or cancels that permission.

granted is provider-visible state. It means only that this provider has granted the active request and is ready to stop the agent when Omni asks. It does not reveal that other providers exist or why Omni has not committed yet. While reporting granted, the provider remains available, continues to honour Omni's current capacity, and continues offering work normally.

Omni separately tracks the aggregate host states working, requesting-break, committing-break, cancelling-break, and on-break. While requesting-break, Omni shows which providers remain outstanding and offers Cancel break request, but does not tell the agent that their break has begun.

Omni offers the aggregate Break control only when every provider currently holding capacity declares capabilities.breaks at login. If one cannot be stopped, offering a global break would knowingly permit partial availability.

Omni coordinates one break attempt as follows:

  1. Freeze the providers the attempt asks: every connected provider from which the agent can currently receive work. A provider joining during the attempt is given no capacity until it finishes. A provider whose authentication is expired is not one the agent can receive work from and is not asked: nothing is asked of it, the break proceeds without it, and when the login is restored it is reconciled from its snapshot as a set-aside provider is, with no capacity until then. refreshing keeps a provider in — its identity and capabilities remain available and work continues. assertBreakAttemptProviders holds a host to this set.
  2. Enter requesting-break. Keep the agent's normal capacity in place throughout this phase.
  3. Send one requestBreak to every asked provider. A provider reports awaiting-decision or granted; neither state stops work. A denial transitions directly to not-requested and causes Omni to take the cancel path.
  4. If every asked provider reports granted, durably choose commit, enter committing-break, and send commitBreak() to each of them. A provider then stops offering new work and reports starting-after-task or in-effect. The commit bound — ten seconds from the decision, tunable per deployment — decides who is kept: a provider that has not applied the commit by then, still granted or unreachable, is set aside as unreconciled and the break begins without it. in-effect decides on-break: Omni enters it once every kept provider reports in-effect. A kept provider reporting starting-after-task has applied the commit and is finishing a task; the bound is on delivery, not on that task. Omni shows the break as settled and beginning when the task ends, and offers no cancel, because the commit is durable.
  5. If any asked provider fails or denies the request, cannot be reconciled within the bounded decision timeout, or the agent cancels before commit, durably choose cancel and enter cancelling-break. Send cancelBreak() to every provider still reporting awaiting-decision or granted. Work continues during cancellation because no stop was committed. Return to working only after no provider retains either state.

Commit and cancel are mutually exclusive decisions for one break attempt. Once Omni chooses commit it never rolls that attempt back: it is reconciled by snapshot until every asked provider is stopped. A provider that reports granted must therefore preserve the request across reconnects and must honour a later commit or cancel. This durable promise prevents a provider from failing the commit after another provider has already stopped the agent.

Why the commit phase is bounded and the decision is not

Waiting for unanimity forever is the one way this algorithm can strand an agent. The commit is durable and cannot be rolled back, so a provider that crashes, has its authentication revoked, or is uninstalled between granting and committing would hold Omni in committing-break with no exit: the providers that did commit have already stopped the agent, and the agent is neither working nor on a break.

Unanimity is required for a reason that survives the bound. It exists so the agent is not stopped on one platform while another keeps routing work to them — and a provider Omni cannot reach is routing nothing. Setting it aside therefore costs none of the property it was protecting. Waiting for it costs the agent their break.

Setting a provider aside is not a rollback and not a cancel. The commit stands and the obligation stands: until that provider is stopped it has not stopped. When it returns it emits a snapshot before anything else, and the snapshot decides:

  • in-effect or starting-after-task — the commit arrived after all. Nothing to send.
  • still granted — the commit was lost. Omni sends commitBreak() now. If the original turns up late behind it, the provider stops an agent who is already stopped; nothing happens, because a commit is a state to be in, not an act to be done.
  • not-requested — the grant did not survive. Omni makes a new request for that provider alone, against an agent who is already on break elsewhere. A new login is this case too: the grant belonged to the old session.

The provider must not offer work in the meantime, and the commit is what stops it; Omni gives it no capacity until it is reconciled.

If the agent has already ended the break elsewhere, the break attempt is over and the returning provider is reconciled to that instead: still granted gets cancelBreak(), because committing would stop an agent who is working again; starting-after-task or in-effect gets endBreak(). Never rolling back is about a break that is still on, not one the agent has finished.

Omni may tell the agent which platforms the break has not yet reached, as it already does when a break cannot be paired across every provider.

The decision phase needs no such bound, because nothing has stopped. Work continues throughout requesting-break, so a provider that never answers costs the agent a wait rather than their availability, and the existing decision timeout resolves it by cancelling — which is safe precisely because no stop was ever committed.

If cancel races with a late approval, the provider remains on the cancel path. If commit has already won, cancel returns omni.break-already-committed, and Omni resumes commit recovery rather than returning the agent to working.

An imposed break is not rolled back by this algorithm. If one appears during either the request or cancellation, Omni follows the imposed-break rule on the other providers and commits each grant as soon as it reaches granted; it does not wait for unanimity because the agent has already stopped elsewhere.

This is two-phase coordination across vendor systems: the approval phase keeps the agent working; the durable commit decision and snapshot reconciliation provide convergence after partial delivery.

Reporting the break the agent is on

BreakState.activeReasonId is the BreakReason.id the current break is on. Omni remembers what it asked for, but only until the session ends — after a reload or reconnect, or where the provider put the agent on the break itself, the provider is the only one who knows.

Omit it when you cannot say, and when there is no break: reporting a reason alongside approval: "not-requested" describes a break that is not happening, and is rejected.

cancelBreak()

Cancels the active pre-commit request while its approval is awaiting-decision or granted. Cancellation releases the request but does not restore work because work never stopped. If commit already won, the provider returns omni.break-already-committed. The resulting state is reported through break-state.

commitBreak()

Commits the granted request. Once the provider has reported granted, it cannot fail for a business reason. On commit the provider stops offering new work and reports starting-after-task while existing work finishes, or in-effect when the break is in effect. Committing a break that is already in effect changes nothing and answers committed.

endBreak()

Tells a provider that an agent already on a break wants to become available again. The provider reports the resulting state through break-state and provider status events.

It ends the break, which is the thing that started. Nothing about the connection was ever paused, so there is nothing on it to resume.

Team leads

A lead who also takes calls sees their team on the idle dashboard. Snapshot.team carries a TeamRoster, replaced whole by team-updated.

Field Contract
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.
requests The members currently asking this lead to join a call, each with the task and the note. Required when the login declares team.leadAssistControl, [] when nobody is asking; omitted when it does not. See Lead assist.
policies The team's policy per capability as it stands — the setting, who set it, and lockedBy where a level 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.
TeamMember field Contract
id Required UserId. A task carries no names and neither does a roster: Omni resolves what to display with describeUsers().
availability Required. What the member is doing now.
since Optional. When the current availability began — not when they signed in, and not when the roster was read.
break Present only while the member has an outstanding break request. See A member waiting for a break.

Each availability value means one thing:

Value Meaning
ready Signed in, able to take work, none assigned.
on-task Handling at least one task. It says nothing about how many, and nothing about whether more will fit.
on-break Stopped and not taking work, whether they asked or somebody stopped them. The reason lives on their own BreakState, not here.
signed-out Known to this team but not signed in to this provider.

Always publish the complete roster, never a change to it. Team presence typically reaches an adapter over a best-effort channel with no ordering and no delivery guarantee, so a stream of deltas cannot be trusted to reconstruct the truth. The adapter reconciles against its own authoritative read and publishes the result.

Omit since rather than inventing one. Omni renders it as a duration, so a timestamp synthesised from the adapter's own clock at seed time reads as "on task for 0 seconds" for everybody — worse than showing nothing, because it looks like data. Send it only when the provider knows when the state actually began. It times the current availability, so it moves every time that value does.

The login is the permission. A roster goes to a login that declares capabilities.team, on every snapshot, and to nobody else. Omni never decides who leads a team: the provider said so at sign-in, and the roster agrees with it — present, [] included, for a lead; absent for everybody else, which is the correct rendering for an agent who leads nobody. What the lead may do with the roster is on the login too, team.breakControl and team.leadAssistControl, never on the roster.

The roster never carries the agent it is published to — not in members, and not in requests. A lead does not report to themself: their own break request and their own ask for a lead go up to whoever leads them and appear on that person's roster, while the requester sees only their own BreakState and their task's leadAssist move. An adapter whose platform lists the lead among their own members filters the signed-in identity out before publishing. Being a lead is a role the provider knows, never inferred from who is listed: it is declared at sign-in, a lead with nobody in their team publishes [], an agent with no such role publishes nothing, and no member count can tell those two apart.

Lead commands

One method, executeTeamBreak, taking a discriminated command exactly as execute takes a TaskCommand:

Command Effect
{ type: "decide", memberId: UserId, 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.
{ type: "policy", policy } ask, auto-approve, or suspended.
{ type: "place", memberId: UserId, reason? } Puts a member on a break they did not ask for.
{ type: "release", memberId: UserId } Ends an imposed break on that member, whoever placed it.

memberId is this provider's own identifier for the member, as published on its roster. It is never an identifier from another provider, and Omni does not translate between them; names come from describeUsers().

suspended means requests are rejected outright rather than left pending — nobody is coming to approve them. A provider that suspends breaks must also publish mayAsk: false to the team's agents so they see it before asking. A place must likewise reach that member as an imposed break on their own BreakState, or they are stopped from working with no way to see why.

What happens when no lead is online — auto-approving, for instance — is the provider's decision and is never expressed here.

Lead assist

An agent on a call may ask a lead to join it -- a dispute that needs approval, a customer who asks for a manager, a moment the agent wants a second pair of ears. A call centre calls this assistance or escalation, and the name says who assists: the capability is leadAssist on the task; the lead's side is the roster, which is already the lead's view of the team, and a second lead method beside executeTeamBreak:

executeTeamLeadAssist({ command: TeamLeadAssistCommand }): Promise<TeamCommandResult>

Required when the login declares capabilities.team.leadAssistControl, and gated by it exactly as executeTeamBreak is by team.breakControl. The flow, in order:

// 1. The agent asks, with a small note. Their task carries `leadAssist` from here on.
execute({ taskId: "call-42", command: { type: "lead-assist", action: "request", note: "Refund dispute, needs approval" } })
//    task.leadAssist = { status: "requested", note: "Refund dispute, needs approval", since }

// 2. Every lead entitled to it sees the request on their roster.
//    team-updated: requests: [{ id: "req-7", memberId: "A-1", taskId: "call-42", note, since }]

// 3. A lead joins, or declines.
executeTeamLeadAssist({ command: { type: "join", requestId: "req-7" } })
executeTeamLeadAssist({ command: { type: "decline", requestId: "req-7", reason: "In a call" } })

On join the provider bridges three parties and the lead is on a task of their own, on the same task id, arriving on the lead's connection as task-offered with automatic -- the way a call an agent placed themselves arrives -- and carrying assisting. The agent's task moves to leadAssist: { stage: "joined", leadId }. A join is the lead's own act, so capacity does not trigger it; but from then on it is an outstanding task the provider counts against the lead's stated ceiling like any other, nothing more is allocated to the lead while it stands, and a provider whose lead is already at the ceiling answers the join failed.

A lead assists one call at a time. A join from a lead already on a call -- their own or one they joined -- is answered failed, whatever their ceiling; the request stands for another lead, or until it is withdrawn or declined.

A lead on a break does not join. A break is a reported state in which the agent is not working, and a join is work. Omni offers Join to a lead only while their own BreakState.approval is neither starting-after-task nor in-effect -- a committed break waiting for the lead's current work to finish is not given more -- and a provider answers a join from a lead on such a break failed. The request stands for another lead, as it does when this one is already on a call.

On decline, or a request the agent withdraws with { type: "lead-assist", action: "cancel" }, the provider clears leadAssist from the agent's task and drops the request from every roster. Nothing else changes; the agent is still on the call.

The lead then has two commands on their copy, gated by assisting being present, and a third choice that is no command at all:

The lead The agent's task The lead's task
{ type: "lead-assist", action: "take-over" } task-ended with { type: "transferred", destination: leadId }, straight from in-progress: no completing window, the agent is idle at once Continues alone, and ends as any call does
{ type: "lead-assist", action: "leave" } Continues; leadAssist is cleared task-ended with { type: "left" } -- the call goes on without them
Stays until the customer hangs up task-media-ended, completing, its own disposition The same, independently: both have the disposal window

left is the one outcome that ends a task without ending the call: this agent left a call that continues without them. It reads as neither a completion nor a cancellation, because it is neither.

const leadAssistCapable = {
  channel: "voice",
  capabilities: { hold: true, leadAssist: true, dispositions: true },
  phase: "in-progress",
  leadAssist: { stage: "joined", leadId: "L-9", note: "Refund dispute, needs approval", since: "2026-08-21T09:04:00Z" },
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "leadAssist">;

Lead and member alike are UserIds of this provider, so an adapter publishing them implements describeUsers(); names never travel on a task or a roster.

Monitoring a call

A lead may listen to a member's call without being asked -- to coach, to check quality, to step in when it goes wrong. This is not lead assist: nobody requested it, the member's task says nothing about it, and there is no take-over in it. A lead who wants the call itself uses lead assist. The three modes are the call centre's own words:

Mode Who hears the lead
monitor Nobody. The lead hears both sides in silence.
whisper The agent alone. The customer hears nothing.
barge Everyone on the call.

The login says which modes this lead has, as a list on team.monitorControl, and it always includes monitor, because whisper and barge begin from one (authentication.capability.team.monitorControl.monitor). A centre that lets every lead listen but reserves barging declares ["monitor", "whisper"], and Omni offers no Barge to that lead. The declaration requires executeTeamMonitor, gated exactly as executeTeamLeadAssist is by team.leadAssistControl.

// 1. The lead picks a member from the roster and starts silent.
executeTeamMonitor({ command: { type: "monitor", memberId: "A-1" } })

// 2. The lead's own task arrives -- task-offered with `automatic`, as a joined call does -- and
//    carries `monitoring`; the task id is the lead's, the member's call is named inside it.
//    monitoring: { memberId: "A-1", taskId: "call-42", mode: "monitor", since }

// 3. The lead changes how they are heard; the provider restates the task with the new mode.
executeTeamMonitor({ command: { type: "whisper" } })
executeTeamMonitor({ command: { type: "barge" } })

// 4. The lead leaves; their task ends with `{ type: "left" }`. The member's call goes on.
executeTeamMonitor({ command: { type: "leave" } })

The lead's task is what gives them audio: Omni opens media on it as on any voice task, and the provider bridges the lead's leg into the member's call in the mode stated. monitoring.mode is the state, restated on every change, and a whisper or barge the login's list does not include is answered failed. A lead listens to one call at a time (snapshot.monitoring.single), and a task is a joined call or a monitored one, never both (task.monitoring.assisting). The member's call ending ends the lead's task as it ends the member's, with left, since the lead was never handling it.

A lead listens only while holding no work of their own. A monitoring task is the lead's only task, and a snapshot carrying one beside any other is refused (snapshot.monitoring.alone); Omni offers Monitor to a lead with no task. That includes a lead on a break -- unlike joining, which a break forbids -- but only a break that is work of another sort: coaching, administrative or training, the kinds in MONITORING_BREAK_KINDS, and never a meal or a rest. A break in effect otherwise holds no task; the monitoring task is its one exception, and a snapshot carrying one during a break whose active reason is of any other kind, or of no stated kind, is refused (snapshot.monitoring.break). The kind decides, not the reason's name: a centre that wants leads listening during a break declares that break coaching, administrative or training.

Nothing reaches the member. The member's task carries no trace of a monitoring lead in any mode: not on onCall, not in the record. Whether a whisper is announced to the agent is the platform's business and travels on the audio, not on this wire.

const monitoringLead = {
  channel: "voice",
  capabilities: {},
  phase: "in-progress",
  monitoring: { memberId: "A-1", taskId: "call-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "monitoring">;

A member waiting for a break

A member who has asked for a break keeps working until Omni commits it, so asking is not an availability of its own — it rides alongside one on break. That a request exists says nothing about whether anybody has to act on it, and the difference is a lead's entire action list:

break Means
awaiting-decision Somebody has to decide. This is the lead's queue.
granted Decided yes, but Omni has not told the provider to stop yet. Work continues and nobody needs to decide.
starting-after-task Already granted; it begins when their current task ends. Nobody needs to act.

Those three are the only values that appear here. not-requested is absence — omit break instead. in-effect is availability: "on-break", and a denial transitions to not-requested, so neither survives to be reported. It is otherwise the same BreakApproval the member's own break state uses, rather than a parallel vocabulary for the lead's view, so the two cannot drift apart.

Omni offers Approve and Deny only while a member is awaiting-decision, shows granted as agreed but not started, and shows starting-after-task as settled.

Live status, not a record. The provider derives it from what is true now — not stored, not historical, carrying no decision made earlier. Like the roster it belongs to, it is published whole and replaced whole, and a provider that cannot say omits it.

An agent is not waiting on one person. Authority is held by several, everyone who holds it sees the request on their own console, and any one of them settles it. Omni offers the decision to every login that declares team.breakControl — which is how the provider already says who may decide — and does not try to work out whose turn it is.

A request needing more than one approval is not something this contract describes. There is no partial state to report and no progress to display: a request is either still owed a decision or it is not.

Real-time media

Every voice provider has media. There is nothing to announce, no capability to declare and no endpoint to choose: channel: "voice" says audio exists, and Omni is the device it lands on.

Other endpoints exist in a deployment — desk phones, the provider's own hardware, whatever the platform already rings — and none of them is the agent's. Omni does not enumerate them, map the agent onto one, or follow a change made to one. There is no device list, no device selection and no device on the snapshot, because there is no choice to record: audio for this agent arrives in Omni, and Omni registers the endpoint for it.

That removes a whole class of state the provider would otherwise own and Omni would have to track, and it removes the branch that came with it: no command has to ask where the audio went before deciding who performs it. Nor does the audio ever stand in for the task: a task's presence and phase follow the provider's reports about the work, and the media — attaching, moving through a hold, a consult, a conference or a transfer, and ending — is transient beside it. See A task is never its audio under Task allocation lifecycle.

The host reports, the adapter decides

The adapter runs inside Omni and sees nothing of the station for itself: the devices, the permissions, the audio element and the network are the host's, and only the host can tell. So the host reports, and the adapter asks. An adapter consults ConnectContext.host before it declares the agent ready to its platform, and again whenever the report changes, and it decides what any of it means and what to relay upstream — go not-ready, refuse calls, carry on because the platform's audio lands elsewhere. Omni facilitates and does not take responsibility: it captures the microphone once as the voice connection opens, so the permission prompt lands while the agent is signing in rather than over a contact, prompts, retries on the agent's request, tells the agent what failed, and reports. It never decides for the adapter what a missing microphone means.

The host also guarantees, and a promise the provider cannot see is not one it can rely on. Two of this contract's obligations fall on the host rather than the provider — honouring a task browser's urlVisibility, and taking a consent offer only on the person's own press — and more than one desk speaks this contract. So ConnectContext.host.guarantees says which promises the connected host makes, declared once per connection, presence being the guarantee exactly as it is the permission everywhere else:

Guarantee Contract
browserUrlVisibility Every task browser's urlVisibility is honoured in this host's chrome, tab by tab. A provider that would send a caller's number in a URL checks this first and tokenises where the promise is absent.
personConsent A consent offer is accepted only by the person's own explicit act, never on their behalf. A provider whose work may only be taken by a human checks this first and does not offer it where the promise is absent.

A guarantee the host does not make is an absent key, never false — validateHostGuarantees refuses a false one, as it refuses a name this contract does not list.

Field Contract
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.
audio Present on a voice connection, absent where there is no audio.
audio.input available 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.
audio.output available, 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.

Omni republishes the report whenever it changes — a permission granted late, a headset unplugged, a network gone — and it publishes a state, not a flicker: a change that resolves within moments is not reported, so an adapter may act on what it reads without debouncing it again. Nothing in the report is a task fact or a capacity fact; it is what the station can do, and the adapter's platform is the one that knows whether an agent without a microphone, or without a speaker, can work.

The host's own obligations here — asking at connect, never publishing not-asked when it does, publishing a state and not a flicker — are Omni's tests' to hold. exerciseAdapter holds the other side: it validates the shape of whatever host a test hands the adapter, requires audio on a softphone login and none elsewhere -- not on a desk phone, not off voice (context.host.audio.required / .unexpected) -- and refuses a voice adapter that never asked the host anything (connection.host.consulted).

Capacity around setup

Connecting is not the same as being able to take a call. A provider that treats a live connection as reachability opens a window where it believes the agent is available and the agent is not yet set up — the adapter's own registration incomplete, its credentials not yet renewed.

Nothing closes that window, because nothing opens it: Omni states no capacity until the connection is established, and Work is pulled, never pushed makes an allocation with none stated a violation. Capacity does not wait on the microphone: whether an agent without host audio can take calls is the platform's question, answered by the adapter from the host's report, not by Omni withholding capacity for every platform alike.

Capacity follows automatically once the connection is established; the agent does not press anything to become available.

Situation What Omni sends
Connecting Nothing. No capacity has been stated, so nothing may be allocated.
Connected and idle setCapacity({ count: n }), with the host's report already available to consult.
A task starts or ends Nothing. The provider counts its own against the ceiling.
The agent's provisioned capacity changes setCapacity({ count: n })
Agent asks for a break requestBreak. Capacity is unchanged and work continues.
Omni commits a break commitBreak. The break stops allocation, not the ceiling.
Agent returns from break endBreak

Stopping is a break, not a capacity of zero. Capacity says how much this agent can carry at once; a break says they are not working. Collapsing the two would leave a provider unable to tell an agent at their limit from an agent who has gone to lunch, and only one of those needs a reason, a decision and a return.

How the agent hears the call

A voice platform can put an agent on a softphone, where the call's audio lands in the host and Omni owns the microphone and the playback, or on a desk phone, a handset on the platform's switch that the platform rings, where the host owns no audio and only shows the call. Which of these a platform can do is a fact about the platform, and which one a login is on is a choice the host makes for that agent -- so the manifest declares and the host chooses.

// Manifest: what the platform can do
phones: ["softphone", "deskPhone"]
// The host, at authentication and again at connect: which one this login is on
{ phone: "softphone" }

A voice manifest lists its phones and any other channel lists none (manifest.phones.required, manifest.phones.channel). The host's phone is required on a voice login, absent on any other, and one the manifest listed (context.phone.required, .unexpected, .unsupported). Everything about audio then follows the phone rather than the channel: on a softphone the host reports its audio, the adapter implements openMedia, and task-media-started is the word to open it; on a desk phone the host reports no audio, opens nothing, and task-media-started still marks the moment the call is live so the desk shows it, with the sound on the handset. A platform that lists deskPhone alone never implements openMedia; one that lists softphone always does.

The mode comes from the host; the status comes from the provider. The host knows which kind of station the agent signed in at, because the person chose it, and nothing else can know that. The provider knows whether that station can carry a call right now -- whether a handset is registered -- which a host in a browser cannot see. So the host's phone selects the branch, and the provider's own knowledge of the device decides readiness within it. A provider never overrides the host's declaration from its device record: treating a desk-phone login as a softphone because the handset is momentarily unregistered, and demanding a microphone of it, is the reading this sentence exists to refuse. What the provider does when the handset is not registered is what it does for any station that cannot take a call -- hold the agent not-ready and say why.

On a desk-phone login the host never calls openMedia. There is no stream to hand over and no audio to attach; a host that calls it anyway is in error, and an adapter that receives the call answers unavailable with a non-retryable failure, since waiting changes nothing about a station that is a telephone. The harness requires no openMedia of such an adapter and never calls it.

Which handset a desk-phone login rings is the platform's configuration for that agent, and this wire never asks the agent for it: a host declares phone and nothing more. What a platform asks on surfaces of its own is its own decision.

A declared phone the platform does not permit for this agent is a login that cannot be established. The manifest says what the platform can do; the agent's record, kept by an administrator, says what this agent is configured for, and the two can disagree with the host's declaration -- a softphone declared for an agent whose record says desk phone. Honouring that declaration on a switch that allows one registration per endpoint would evict the handset: the agent's phone stops ringing and their calls land in a tab, and nobody is told. So the provider refuses the login at authentication, with omni.phone-not-permitted and a message the host shows -- "this agent is configured for a desk phone" -- and refusing is correct, not an override of the host. It is a refusal, never a negotiation afterwards and never a quiet substitution, and it is never retryable: trying again does not reconfigure the agent, and a host validating the answer (validateAuthenticationResult) refuses a phone refusal marked otherwise (authentication.failure.phone.retryable). And a provider never makes a declared phone true by changing the platform's configuration: the record is the administrator's, and a login is not a request to reconfigure an agent.

Opening the audio

openMedia hands Omni the remote audio for one task. Every adapter whose manifest lists softphone implements it, because on a softphone the call's audio lands in Omni:

openMedia({ taskId, localAudio }): Promise<OpenMediaResult>
// { status: "opened", session } | { status: "unavailable", failure }

The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor SDK, plain WebRTC — and none of that appears in this contract. Registration, signalling, credential renewal and reconnect are the adapter's, exactly as its authentication and transport already are. Omni owns what belongs to the host: the microphone, the output element, mute, and when a session ends — owning the microphone meaning capturing it, prompting, retrying and reporting how it stands, never deciding for the adapter what a missing one means (see The host reports, the adapter decides).

Member Contract
remoteAudio MediaStream Omni plays.
setMuted(muted) Mutes the agent's microphone on this session.
close() Releases the session. Omni calls it when the task ends.

localAudio is the agent's microphone as Omni captured it, the same stream the host's report carries as audio.input.localAudio, and absent while that input is unavailable. A provider that bridges audio without a host-side input may ignore it; one that needs it and finds it absent answers unavailable with a failure Omni shows the agent.

When to ask is the provider's word, not Omni's guess. Omni opens media on task-media-started, and on a task arriving with media: "started" on a snapshot; it closes on task-media-ended and when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a track that ends — moves the task or its audio.

A task-scoped session does not oblige one call per task. A platform holding a nailed-up leg for a whole shift may return the same session for every task and release the underlying path only when the connection closes. A platform placing a call per contact returns a new one each time. Omni asks when it needs audio and closes when it is done; how that maps to the platform is the adapter's business.

Task commands

Command names follow the channel's operational vocabulary, and each channel's command is a union discriminated by type — the same discriminant executeTeamBreak and custom already use. The unions are declared under Shapes.

taskId is not repeated on the command. It travels on the TaskCommandRequest around it.

A toggle carries the state it wants, not a flip. Inverting whatever is found cannot converge with a stale view: a flip against a state the provider has already changed turns something on and then off again. mute therefore carries muted, and a custom toggle control carries its own boolean. hold and resume, pause and resume need no flag, being pairs rather than toggles.

complete sends a disposition only where one was published. disposition is a DispositionCode.id from the task's own dispositions capability, and notes obeys that capability's notes setting. A task publishing no codes still receives complete, with neither.

Where a command executes

Every command reaches the provider through execute, with no branch at the call site. What differs is what the provider is being asked for: to perform the command, or to record that Omni already did.

Command The provider's part
mute Record it, and keep the history. The microphone is the host's, so Omni has already stopped the audio through VoiceMediaSession.setMuted() — no adapter can do that on the host's behalf. The command still arrives because the provider owns the task's record: it holds the current state for supervision, and each change as a muted handling step, exactly as it does for held. A platform that never hears about it shows a supervisor an agent who sounds absent for no reason, and reports a call with a silence it cannot explain.
Every other command Perform it. hold, transfer, conference, end-call, recording and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them.

The provider performs every action; the host only offers it. A control drawn on the host is an affordance, never the enforcement: the host asks, and the provider does or declines. A provider answers failed for a command whose capability it did not publish, and that is the provider honouring its own declaration, not the host deciding policy -- a host that refuses a command on its own reading of a queue's flag has decided something that was never its to decide. What a command means on the platform is the provider's to work out: end-call ends the conversation, and which legs on which bridge that touches is a fact about the switch, never a choice the host makes. The one leg the host performs physically is the microphone, and even there the provider owns the record.

A failed mute does not unmute the agent. The agent asked, Omni holds the microphone, and it is already done; a failure means only that the provider did not record it, leaving its view stale until the next snapshot. That is the safe direction to fail in, and it is the one place where failed does not mean nothing happened — everywhere else it does.

mute carries muted rather than flipping, so a stale view converges on the stated state instead of flipping it back — see Task commands.

Which commands need a capability

Presence is the permission gates the controls a provider chooses to offer. Four commands are not among them, because every task has them; each is authorized by a different field the provider declared:

Command What makes it available
answer, accept Nothing. A task that was offered can be accepted, or offering it meant nothing.
end-call The endCall capability.
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.
decline The decline capability on any channel, and Omni provisioning permitting it. One word for refusing an offer, whatever the channel.
call 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.
complete completionMode: "agent-command". The dispositions capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends.
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.
transfer with action: "warm" The warmTransfer capability. action: "cold" is gated by coldTransfer; the two are declared and offered separately.
transfer with action: "complete" or "cancel" A consultation in progress -- a consulted entry on Task.onCall. Without one there is nothing to complete or cancel, and a provider that receives either answers failed.
lead-assist with action: "request" or "cancel" The leadAssist capability. cancel needs a request standing -- Task.leadAssist with status requested.
lead-assist with action: "take-over" or "leave" The lead's own task, on a call they joined -- Task.assisting present. An agent's task never has it, and a provider that receives either without it answers failed.
Everything else Its own named capability.

validateTaskCommand(command, task) holds a command to this table at runtime, both ways: the capability it needs, the phase it belongs to, and the state that has to stand. The task it wants is the one the provider published, not a host's own mapping of it: a host that keeps only its mapped shape has nothing honest to pass, and then checks shape alone, which is still worth doing -- it names a command the wire never had -- but is not the table. Keeping the published task beside the mapped one is what the full check costs a host; an adapter has it for free. The validator holds a command only to a task that stands: a task handed in that is not one the wire published is named (command.task) rather than checked against.

Declining or rejecting a pending offer ends it without accepting or completing it. The provider confirms the end with task-ended and a cancelled outcome.

execute(request)

Applies a TaskCommandRequest to one provider-local task.

  • Omni serializes commands per task and sends the next only after the previous settled or was given up as unknown; it stops sending when task-ended arrives.
  • applied confirms the side effect completed. failed confirms it did not, with a typed ProtocolFailure; a provider that will not and one that cannot report the same shape, and code says which.
  • A command sent while transport-status is not active answers failed with omni.unavailable. Neither Omni nor the adapter queues it.
  • A command that asks for a state answers applied when that state holds, whoever brought it about; a command that acts answers failed when it cannot act. Declining a lead request already gone is applied; joining one already gone is failed, since nobody joined. A lead deciding a member's break that another lead has already decided is applied when the decisions agree and failed, saying so in message, when they differ. commitBreak() on a break already in effect is committed for the same reason.
  • A result is untrusted for the same reason a snapshot is. It comes from an adapter that may be compiled against another version, and Omni shows the agent what it says. Omni validates it at the boundary with validateResult(result, "execute") — a status the method does not answer, a failed without its failure, a success carrying one, or an omni. code this contract lacks is refused, and the command is treated as unsettled.
  • A settled result is a fact; an unsettled promise is not. Transport uncertainty may reject the promise with no result at all, and that means unknown, not failed, and a snapshot follows — see An unsettled result is unknown. failed must never be returned for something the provider is unsure of, because Omni will show the agent it did not happen.

ProtocolFailure

Field Contract
code Required stable machine-readable value. See the reserved codes below.
message Required, and safe for logs or agent display.
retryable Whether repeating the action can succeed at all.
retryAfterMs Optional minimum suggested delay before a retry. A suggestion, not a guarantee.

The omni. prefix is reserved. Adapters must not invent codes under it; every other value is provider-private and Omni treats it as opaque. Using a reserved code where it applies lets Omni react rather than only display the message:

Code Meaning
omni.not-authenticated The provider session is no longer usable. The adapter has published expired at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal.
omni.capability-not-enabled The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose capabilities no longer carry it.
omni.task-not-found The provider-local task id is unknown, typically after the task already ended.
omni.phone-not-permitted The host declared a phone the platform does not permit for this agent -- a softphone for an agent configured for a desk phone, or the reverse. The login is refused at authentication, and the provider never reconfigures the agent to make the declaration true. See How the agent hears the call.
omni.destination-not-permitted The dialled number, or the destinationId named, is not one the provider offers this agent.
omni.rate-limited The action was throttled. Pair with retryAfterMs.
omni.unavailable The provider is temporarily unable to serve the action, including any command sent while transport-status is not active.
omni.break-already-committed Cancellation lost the commit/cancel race; Omni must finish commit recovery.

They are published as OMNI_FAILURE_CODES.

Event delivery

ProviderEventEnvelope

Field Contract
id Required identifier for this event, unique within the login. Omni does not act on it; it exists so a host log line and an adapter log line can be matched when something has to be traced.
loginId Login session that produced the event. Omni rejects any other value, which only reaches it if an adapter kept an old connection emitting after a re-login.
occurredAt Valid RFC-3339 timestamp with an explicit timezone, representing provider observation time.
event Typed ProviderEvent payload.

Provider instants are read against a provider clock

Every deadline in this contract is a provider instant that Omni counts down: allocationExpiresAt, previewEndsAt, and the wrap deadline of task-media-ended plus wrapAllowance where one is stated. Comparing those against the host clock is wrong by whatever the two machines disagree by, and the damaging direction is early — Accept withdrawn from an offer still ringing, a wrap timer expiring before the agent has finished.

occurredAt is what fixes it. Omni notes the host time at which each envelope arrives, keeps the running offset against the occurredAt inside it, and translates provider instants through that offset before counting down. What remains is network delay, which biases every deadline later — the direction that costs a second rather than an action.

Report seconds; never expect Omni to derive it is the same hazard from the provider's side: neither party recomputes a duration across a clock it does not own.

Nothing is lost until the connection drops

There is no sequence number and no gap to detect. The transport delivers in order and does not silently lose a message, so while the connection is up Omni has seen everything the provider sent.

Loss has exactly one shape: the connection went away. The adapter reports connecting or error, reconnects, and emits a snapshot event carrying complete state. That snapshot is the repair — whatever was missed while the connection was down is in it, and Omni replaces its provider view rather than reasoning about what it did not receive. A repair is an answer like any other: a platform that has not yet answered for this login after a reconnect — a state read served empty by a backend that does not know the session yet — yields no snapshot, and the adapter stays connecting holding what it holds. See Snapshots establish state; events report transactions.

A snapshot must account for everything the adapter has emitted before it resolves, not merely everything emitted when it was requested. Omni discards events buffered during the read on that promise; an adapter that serves a stale snapshot and then lets an earlier event through will have Omni apply state the snapshot already superseded.

Liveness

transport-status is the only signal Omni has that a transport died. An adapter must emit transport-status with connecting or error as soon as it loses its transport, rather than leaving a stale active in place while it retries internally; Omni cannot distinguish a quiet healthy provider from a dead one. And what Omni is told, the agent is shown — see The status is seen, not merely known under transport-status.

Requesting a resync

Omni calls snapshot() at connect; after that, snapshots come to it — on reconnect, and when the provider asks. It may still call snapshot() at any time, but never to learn a command's fate: the reconnect snapshot already carries it. reason: "provider-requested" covers the opposite direction — the provider asking Omni to reconcile — and neither replaces the other.

snapshot

Carries a complete Snapshot after reconnect or when the provider explicitly requests reconciliation. reason is reconnected or provider-requested. Omni replaces the provider's current status, break state, tasks, contacts, scheduled activities and team roster with this snapshot. It carries what the login's capabilities call for — a roster for a lead, on every snapshot — and nothing they do not; a capability is withdrawn by a republished authenticated, never by an omission from a snapshot.

transport-status

Updates TransportStatus, and carries an optional message that may explain an error but must be safe for the agent to see.

Value Contract
connecting No usable transport right now, and the adapter expects to recover on its own. Nobody needs to act. Startup and every reconnect pass through this value.
active The transport is up and the provider is serving this session. It is the only value under which work arrives.
error The adapter cannot serve the session and is not simply mid-reconnect. Say why in message, and say what revives it in recovery — required here, forbidden on any other status. It is not terminal: an adapter that recovers on its own still reports connecting and then active, and one that cannot is revived as recovery says.

An error names its recovery, and the host acts on that word. The adapter knows why its session died; the host knows how to run a login. recovery joins the two:

  • reconnect — the login is good and this connection is not: a backend restart, a session the platform no longer recognises. Omni calls disconnect() on the dead connection and then connect() again on the same login — same loginId — and the fresh connect snapshot re-establishes state exactly as a reconnect snapshot does. connect() is once per Connection, not once per login.
  • reauthenticate — the session under the login died: a token rejected, a remote logout. Omni runs the authentication flow first; the authentication session decides whether stored material refreshes it silently or the agent must act, exactly as at sign-in.

Patience is the host's. An adapter in connecting retries for as long as it takes and never has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may call disconnect() and either connect() afresh or surface the failure — so neither side waits for the other to blink.

transport is about the transport, nothing else. It does not say whether the agent is available, whether they are on a break, or how much work they can take: capacity travels on setCapacity, availability on BreakState. Nor does it carry authentication — a session that expired reports expired on AuthenticationState and fails actions with omni.not-authenticated, while the transport underneath may be perfectly active. Provider login identity likewise belongs to authentication state, not here.

Only active means work can arrive. Omni stops expecting allocations in any other value, so an adapter that leaves a stale active in place is telling Omni to keep waiting for work that cannot come — see Liveness.

The status is seen, not merely known. A host renders the transport's status where the agent works — connecting from the moment it is reported, error with what revives it — and never a healthy workspace over a transport that cannot serve it: an agent talking on a desk whose transport died was an incident, and it happened because the desk knew and did not say. The same obligation covers a diagnostic: shown where the agent works, and counted.

break-state

Replaces this provider's complete break object. Its approval uses the canonical not-requested, awaiting-decision, granted, starting-after-task and in-effect states defined under Breaks; the event also carries the corresponding may-ask state, reasons, retry details, and any imposed break.

Each state is also held to the one before it. A commit's states, starting-after-task and in-effect, follow a grant — the one arrival in a committed state nobody asked for is a placed break, which says so with imposed: in effect at once, or starting-after-task while the member finishes the call they are on (stream.breakState.commitBeforeGrant); and a break never moves backwards — from in-effect or starting-after-task to a grant or a request, or from granted to awaiting-decision — a new request passes through not-requested (stream.breakState.backwards). exerciseAdapter holds the stream to that from the connect snapshot on; assertBreakFollowsItsRequests holds any sequence.

For a multi-provider break attempt, "every provider" is the set of providers frozen when the attempt entered requesting-break. Omni commits only after every asked provider reports granted — that one is unconditional, because nothing has stopped yet and waiting costs only time. It enters on-break once every kept provider reports in-effect; the commit bound decides who is kept, setting aside a provider that has not applied the commit rather than holding a break that has already begun elsewhere. Otherwise it follows the two-phase rules under Coordinating a multi-provider break.

task-offered

Offers a task to Omni without a separate offer acknowledgement. An offer does not accept the task: when its phase is pending, Omni applies autoAcceptTasks and the task's acceptance. task-offered must not introduce a task as in-progress; only a reconnect or resync snapshot may report work already in progress. The provider should include the task in later snapshots until it ends.

task-updated

Replaces the current representation of one provider-local task. It is a full task value, not a partial patch.

task-media-started

The provider's word that the task's audio should now attach. Omni calls openMedia on it — and on a task carried with media: "started", which is how a reconnect snapshot reattaches audio an earlier event brought — and renders the call as live from that word, never from its own senses. It precedes openMedia and is never a reply to it: a provider whose media state comes from the platform, a station going in use the moment a call is answered, sends it then, before any host has opened anything, and openMedia has its own answer for what the host did. It names a task whose work has begun, and it alternates with task-media-ended: media that never started cannot end, so a live call whose provider says nothing about its audio is a provider in breach, not a state a desk fills in from its own devices.

The event is the transition and the task's media field is the state. A task-updated re-states the media its task already holds — republishing started on a hold is a statement, not a second arrival — but it does not move it: an update that itself flips the field is refused (stream.taskUpdated.media), and the pairing at the moment audio arrives is the phase change without the field, then the event. Releasing ended is the one move an update may make, since wrapped audio has nothing left to end.

task-media-ended

Signals that a task's real-time media ended. For voice and similar channels, this starts the fixed completion timer. It does not remove the task, and it ends only audio that task-media-started — or a task carried with media: "started" — attached.

task-ended

Every outcome ends the task for this agent. On task-ended, Omni:

  • removes the task from its current provider view;
  • clears the task workspace when it is selected;
  • stops task timers and media;
  • releases task-scoped resources; and
  • selects another task or returns to the idle workspace.

A left outcome ends the task for this agent alone: the call continues without them, as it does when a lead who joined it leaves -- see Lead assist.

A successful complete or transfer command does not clear the task. Omni waits for task-ended. The task-media-ended event and the completing phase are likewise non-terminal. A replacement snapshot that no longer contains the task also clears it. Repeated task-ended delivery with the same envelope ID is harmless.

dial-outcome

How a dial the host placed ended, once, either way, named by the dialId the host sent: answered, or one of the declared ways of not reaching the destination, with the switch's reason where it gave one. taskId names the task the dial was on, where there was one, and that task may already have ended. Omni shows it to the agent against the call it belonged to and does nothing else: no audio moves, since the provider bridges the leg on its side, and no dial is repeated. See Every dial has an outcome.

announcement

Publishes an agent-facing message. text is always required and is the accessible fallback. Optional HTML is sanitized by Omni. announcedAt and optional expiresAt are RFC-3339 times with explicit timezones.

diagnostic

The provider's report that something its platform answered broke a rule the adapter relies on — a state read that contradicted itself, a task-ended naming a task nobody held, a party arriving without a name. expected is the rule as a sentence, observed is what came instead, and taskId names the task where there is one. Informational, never behavioural: the provider has already done the safe thing before it speaks, and nothing in the task or break machinery reacts to a diagnostic. It exists so that the loudness reaches a person — a host renders each one where the agent works and keeps a count an operator can read, because a healthy workspace over a shouting console is the silence problem one layer up. One event per occurrence; the host counts, the provider does not batch. exerciseAdapter treats a diagnostic delivered during a run as a violation (diagnostic.raised): a conformance run against a platform that is breaking its rules fails loudly rather than passing with a note.

Field Contract
expected Required. The rule that was broken, as a sentence a person can read.
observed Required. What the platform answered instead.
taskId Optional. The task concerned, where there is one.

queue-summary

Publishes the provider's current dashboard contribution. Omni combines only the latest summary from each connected provider.

Field Contract
title Required agent-facing heading for this provider's contribution.
subtitle Optional second line.
waitingCount Required non-negative count of work waiting at this provider. 0 says the queue is empty; omit the summary entirely rather than guessing.
updatedAt Required time the provider observed these figures, not the time it sent them.
metrics Optional SummaryMetric entries the provider chooses to display. id values are stable and unique within that provider's summary.

team-updated

Replaces this provider's complete TeamRoster. It is emitted only for an agent the provider publishes a roster to, and it carries the whole team every time — never a change to it, for the reason set out under Team leads. A lead's snapshot always carries the roster; it goes only when a republished authenticated no longer declares capabilities.team.

contacts-updated

Replaces this provider's complete contact contribution. It is emitted only when the manifest declares the contacts idle capability.

calendar-updated

Replaces this provider's complete scheduled-activity contribution. It is emitted only when the manifest declares the calendar idle capability.

Utilities

taskKey(providerId, taskId)

Returns a collision-safe global task key by encoding and joining the provider-local identifiers. Use this key in Omni state; never assume task IDs are unique across providers.

userKey(providerId, userId)

The same treatment for a UserId, and needed for the same reason: user identifiers are issued by each provider independently, so two providers will eventually issue the same string for different people. Encode and join before storing or comparing.

Use it for every UserId — handlingHistory[].by, roster members, memberId on a lead command, ImposedBreak.by. A bare one is only ever compared against another from the same provider; anything wider goes through this key.

sameCapabilities(a, b)

Whether two logins declare the same capabilities, field by field — key order aside, and with team: {} distinct from team absent. It is the comparison an adapter makes before republishing authenticated, and the one exerciseAdapter holds refreshing to.

Runtime validation

Structural rules in this document are executable through the runtime validators Omni applies to adapter output. Behavioral rules are exercised through deterministic conformance scenarios. The same exported checks are used by Omni and adapter tests so their interpretations do not drift.

Function Validates
validateManifest(manifest) Identity, protocol-version interoperability, authentication methods, and idle-capability shapes.
validateTask(task, { channel }) Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers.
validateSnapshot(snapshot, manifest) Status, break state, break reasons, team roster, the stated taskCount reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, [] included.
validateEventEnvelope(envelope, manifest) Envelope identity, timestamp, and the payload for each event type.
validateContact(contact) Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing.
validateScheduledActivity(activity) Required activity fields and start/end ordering.
validateHostGuarantees(guarantees) What a host promises: only the guarantees this contract names, each declared by presence and never false. The harness validates the guarantees of whatever host a test hands the adapter.
validateHandlingReport(report, path?, manifest?) What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive seconds where stated, and an explicit ended that carries the final duration. Given the manifest, a running report is refused unless it declares runningStepReports.
validateHostReport(report) The host's own report as published to an adapter: online, and where there is audio, an input that is available with the microphone and flowing, or unavailable with a reason and the failure that says why, and an output that is available or unavailable with its failure. The harness validates whatever host a test hands the adapter; stillHost(report) builds one that never changes.
validateAuthenticationResult(result, method) What start() or complete() answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an omni. code the contract lists, and omni.phone-not-permitted never retryable, since the agent's station is configuration. validateAuthenticationFailure(failure) is the same check on a failure alone.
validateTaskCommand(command, task?) What a command needs to be issuable, against the task it names: its own shape -- a dial's dialId, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (command.capability.<name>, .locked), the phase it belongs to (command.phase.*), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (command.conference.remove.alone). A host validates before sending and an adapter before acting.
validateResult(result, method) What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an omni. code is one this contract names.
validateAuthenticationState(state) The identity each state must carry, the capabilities a usable login declares, and the expiry that only authenticated may. Omni applies it to every state a session publishes — the republished as much as the first.

Each returns ProtocolViolation[] rather than throwing, so a caller can report every problem at once. A violation carries a stable rule id such as task.browser.url.scheme, the path it was found at such as snapshot.tasks[0].browsers[1].url, and a message.

Some rules need to know who is reading. validateTeamRoster, validateSnapshot, and validateEventEnvelope take an optional final { self, capabilities } — the signed-in agent's AuthenticationState.identity.id and their login's capabilities. Given self, a roster that carries that agent reports team.member.self or team.request.self. Given capabilities, a lead's snapshot without a roster reports team.required, a roster published to a login that does not lead reports team.unentitled, requests on a roster whose login lacks team.leadAssistControl reports team.requests.capability, and a roster without them on a login that declares it reports team.requests.required. Without them those rules are not checked, because they cannot be. exerciseAdapter always passes both.

assertNoViolations(violations) throws ProtocolConformanceError — which carries the full violations array — when the list is non-empty.

Omni must validate at runtime, not only in tests. An adapter is loaded from a separate package and may be compiled against a different protocol version, so its output is untrusted input. Validating a snapshot before it replaces provider state is what stops a malformed task from reaching the workspace.

Conformance helpers

exerciseAdapter(adapter, context, options?)

Adapter conformance exercise from @xema/omni-protocol/testing.

It validates the manifest, opens an authenticated session, connects, checks required capability methods, subscribes, validates the snapshot, every delivered event, and every authentication state the session publishes during the run — each against the latest login, since capabilities are current, not fixed — states a capacity, then unsubscribes and disconnects. Provider packages should run it with a deterministic test transport and authentication state.

By default it throws ProtocolConformanceError listing every violation. Pass { collectOnly: true } to receive them on the result instead:

const result = await exerciseAdapter(adapter, context, { collectOnly: true });
expect(result.violations).toEqual([]);
expect(result.disconnectWasClean).toBe(true);

result.authenticationState is the state the session was restored with; result.login is the latest the session published during the run, which differs only when the adapter republished authenticated. A published state that fails validation is reported and not adopted, and a refreshing state must carry over the login it refreshes — a different identity is authentication.refreshing.identity, a changed capability set authentication.refreshing.capabilities. A capability granted by a later login requires its methods just as one declared at sign-in does.

result.notExercised lists what the run never reached — one subject per family of rules: each optional part of a task (task.browsers, task.handlingHistory, task.leadAssist, …), the break's reasons and imposed, the roster's members and requests, each declared contribution, and each event type (event.task-ended, …) — and so what a clean violations says nothing about. Nothing there is a violation: an adapter with no team has nothing to exercise. But a fixture with no tasks exercises no task rule, and a pass over it reads as coverage it is not.

A static run never answers a call. It states a capacity and disconnects, so everything downstream of answer -- the room, the stage, media, every phase past pending, every dial outcome -- stays in notExercised for every adapter, and a rule about a live call is enforced only in each adopter's own tests. { drive: true } closes that: the exercise takes the first task the provider offers through one ordinary lifecycle -- accept it, wait for its media and open it on a softphone, hold and resume where the task offers hold, end the call where it offers endCall, complete it with a disposition where the agent completes -- and holds every step to the rules a host holds a provider to. Each command is validated against the task as published (drive.command.*), each answer for its method, a refusal of a control the task offered is a violation (drive.command.failed), and an event the provider owes and never sends is one too (drive.timeout, after driveTimeoutMs, 5000 by default). A task the agent completes is completed from wherever it stands once the drive has nothing left to do on it -- from completing after an end-call, or from in-progress where there is no call to end, which is every chat and email and a voice task offering no endCall -- so a conversation reaches its end as a call does. The drive stops where the task offers no way on and says nothing about what it could not reach. A softphone adapter written for a browser needs its media APIs supplied by whatever runs the harness: the drive opens media outside a browser, and a provider that quietly stopped carrying audio where AudioContext was missing would be lying about the one thing the channel is for. It is off by default because it issues commands against whatever platform the adapter is connected to: turn it on against a test backend.

const driven = await exerciseAdapter(adapter, context, { collectOnly: true, drive: true });
expect(driven.violations).toEqual([]);
assertReached(driven, ["task.onCall", "task.media", "event.task-ended"]);

assertReached(result, subjects) is the paired assertion: it throws naming every subject the run never met, so a test that meant to check a roster cannot pass on a fixture that never produced one. It reads like a guarantee and is a claim the adopter keeps making: it catches an adapter that stopped reaching a subject, not a list that stopped asking, so a list can rot to nothing and stay green. Keep it honest with a control beside it -- one subject the run genuinely cannot reach, asserted to throw -- so the assertion is shown to be looking rather than agreeing.

Three properties of the harness matter to adapter authors:

  • Violations are collected, never thrown from inside the subscribe listener. Throwing there would unwind through the provider's own dispatch for a synchronous emitter, and would be swallowed as an unhandled rejection for an asynchronous one — letting a non-conforming async adapter pass.
  • Resources are released even when the adapter fails. Every unsubscribe, disconnect(), and close() run in a finally block, and a throw from any of them is reported as disconnectWasClean: false rather than being hidden.
  • The login is read, never captured. Everything is validated against the latest authenticated state, so a withdrawal published before a snapshot is held against that snapshot.

Contract scenarios

The testing entry point also exports deterministic, reusable checks for lifecycle behavior that cannot be established from TypeScript structure alone.

Helper Contract checked
assertCapabilityWithdrawal(states, snapshot, manifest) A capability withdrawn by a later authenticated state is gone from the next snapshot: no roster for a login that no longer leads, no requests for one that may no longer join. Every state is validated on the way, refreshing must carry the login over, and the sequence passes only through usable states.
assertCommandRefusedAfterWithdrawal(result) A command that arrives after its capability was withdrawn fails with omni.capability-not-enabled, named by the provider. The same assertion serves a command the provider never supported under a capability it declares.
assertReached(result, subjects) The exercise met every subject named; throws listing those it did not. Pair it with a clean exerciseAdapter result.
assertAuthenticationRestoreAndExpiry(states) A restored authenticated session can refresh and ends in expiry. Every state is validated.
assertReconnectWithMissedAssignments(before, reconnect, ids) A reconnect snapshot restores assignments received while offline.
stillHost(report?) A host that reports one thing and never changes, for a test context: { online: true } by default, a report with audio for a voice adapter.
TaskStream, BreakStream The cross-event models the harness applies after the connect snapshot, exported for a host that wants the same rules at its boundary: seed(snapshot), then apply(envelope) returns the violations.
assertBreakFollowsItsRequests(envelopes, snapshot?) A break follows its requests: a commit's states only after a grant, never backwards, and a placed break arriving in effect with imposed. The harness applies the same rules after the connect snapshot.
assertMediaFollowsTheTask(envelopes, snapshot?) The media follows the task and never decides it: every task is introduced once, task-media-started and task-media-ended alternate on work that has begun, media ends only where it arrived, and what follows the media ending is completing or task-ended. The harness applies the same rules to every event after the connect snapshot (stream.*). A sequence with no media satisfies it by never testing it — pair it with the assertion that the media end is present.
assertBreakAttemptProviders(candidates, asked) A break attempt asks every usable provider holding capacity, refreshing included, and nothing of a provider whose login is expired.
assertBreakBeginsAfterTask(steps) A break asked for on a task is committed as starting-after-task while work remains and reaches in-effect only once nothing is outstanding — never beside a task, never later than the step that has none.
assertDeniedAndRetriedBreak(states) A denial transitions directly to not-requested; a later request can still be granted.
assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?) The wrap deadline equals media 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.
assertBrowserSessionIsolation(left, right, expected) Browser reuse follows only the declared isolation scheme.
assertNoBrowserSessionKeyCollisions(scenarios) No two distinct scenarios derive the same session key. Feed it adversarial names.

Adapters should run the relevant scenarios against deterministic test state before publishing.

Assert both directions. Each helper above rejects a violating input as well as accepting a conforming one. A suite that only ever asserts "this conforming case does not throw" passes unchanged if the helper is gutted, so pair every positive case with the violating twin.

A provider does not style the workspace

How a deployment themes Omni is the host's concern and is specified with the host, not here. What belongs in this contract is the boundary. A provider says what a control is through its capabilities and what its work is called through phaseLabels and taskTypePresentation; how any of it is drawn is Omni's. A task cannot select a design language, inject a component, or override the agent's theme and font preferences.


Reference

Every name the protocol package exports, generated from the emitted declarations so that what is published is what the compiler produced. The guide above explains the rules; this says what exists.

@xema/omni-protocol

179 exported names.

AcceptanceMode

type

What the provider wants of Omni's acceptance policy for one offer. Present only where Omni was willing to accept for the agent (autoAcceptTasks: true): consent is therefore always the provider's requirement of an explicit acceptance, never Omni's own policy, which travels as an absent field.

export type AcceptanceMode = "no-preference" | "consent" | "automatic";

Adapter

interface

export interface Adapter<C extends Channel = Channel> {
    manifest: Manifest<C>;
    createAuthenticationSession(context: AuthenticationContext): Promise<AuthenticationSession> | AuthenticationSession;
    connect(context: ConnectContext): Promise<Connection<C>>;
}

AgentCapacity

interface

How many tasks this provider may have allocated to the agent at once.

An absolute ceiling, never less than 1, standing until Omni restates it. The provider counts its own outstanding tasks against it and needs no new signal when one ends. What the agent holds at other providers is not this provider's concern -- Omni set count knowing it.

export interface AgentCapacity {
    count: number;
}

AgentPreference

interface

One choice the team may leave to the person, with where it stands and who set it. The provider keeps it: it is the person's across sessions, written through setPreference. Listed whether or not anyone has stated it, and even when a level above has since locked it.

export interface AgentPreference extends Resolved {
    id: PreferenceId;
    label: string;
    enabled: boolean;
}

ALLOWED_BROWSER_URL_SCHEMES

const

export declare const ALLOWED_BROWSER_URL_SCHEMES: readonly ["http:", "https:"];

Attribute

interface

Key/value detail on a Contact or a ScheduledActivity. A task's attributes are typed.

export interface Attribute {
    key: string;
    value: string;
}

AuthenticationActionResult

type

export type AuthenticationActionResult = {
    status: "applied";
} | {
    status: "failed";
    failure: AuthenticationFailure;
};

AuthenticationChallenge

type

export type AuthenticationChallenge = {
    flowId: string;
    method: "browser-sso";
    authorizationUrl: string;
    browser: "system" | "omni";
} | {
    flowId: string;
    method: "credentials";
    fields: CredentialField[];
};

AuthenticationContext

interface

export interface AuthenticationContext {
    protocolVersion: number;
    /** Omni's identity for this login. The same value arrives later as `ConnectContext.loginId`. */
    loginId: string;
    /**
     * The zone the agent's day is reckoned in, as the host's clock has it, stated before any
     * identity exists so the first `authenticated` state already carries it. The same value arrives
     * later as `ConnectContext.timeZone`.
     */
    timeZone: TimeZone;
    /**
     * How this login hears its calls, chosen by the host from the manifest's `phones`. Required for
     * a voice provider and absent for any other. The same value arrives later as `ConnectContext.phone`.
     */
    phone?: Phone;
    /** Scoped to this provider's manifest id. */
    secrets: SecretStore;
    signal?: AbortSignal;
    /** Never include credentials, tokens, or contact data. */
    log?: (entry: unknown) => void;
}

AuthenticationFailure

interface

export interface AuthenticationFailure {
    code: string;
    message: string;
    retryable: boolean;
    retryAfterMs?: number;
    /** Names a declared credentials field when the failure belongs to one. */
    field?: string;
}

AuthenticationMethod

type

export type AuthenticationMethod = "browser-sso" | "credentials";

AuthenticationSession

interface

export interface AuthenticationSession {
    state(): AuthenticationState | Promise<AuthenticationState>;
    subscribe(listener: (state: AuthenticationState) => void): Unsubscribe;
    start(request: StartAuthenticationRequest): Promise<StartAuthenticationResult>;
    complete(request: CompleteAuthenticationRequest): Promise<CompleteAuthenticationResult>;
    /** Cancels an abandoned SSO window or credentials form. */
    cancelAuthentication(flowId: string): Promise<AuthenticationActionResult>;
    signOut(): Promise<AuthenticationActionResult>;
    close(): Promise<void>;
}

AuthenticationState

type

Only authenticated and refreshing know who the agent is and what they may do, and only authenticated has something to expire. A state carrying more than it knows is a state Omni would render as fact.

export type AuthenticationState = {
    status: "signed-out";
} | {
    status: "authenticating";
} | {
    status: "authenticated";
    identity: User;
    capabilities: UserCapabilities;
    expiresAt?: IsoTimestamp;
} | {
    status: "refreshing";
    identity: User;
    capabilities: UserCapabilities;
} | {
    status: "expired";
    identity?: User;
    failure?: AuthenticationFailure;
};

BREAK_KINDS

const

export declare const BREAK_KINDS: readonly ["short-break", "meal", "rest", "training", "coaching", "meeting", "administrative", "technical", "personal", "other"];

BreakApproval

type

export type BreakApproval = "not-requested"
/** Somebody has to decide. The agent is waiting on a person. */
 | "awaiting-decision"
/** Granted, and a promise to honour a later commit. */
 | "granted"
/** Granted and begins when the current task ends. Nobody needs to act. */
 | "starting-after-task" | "in-effect";

BreakCancelResult

type

export type BreakCancelResult = {
    status: "cancelled";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

BreakCommitResult

type

Committing a break already in effect changes nothing and answers committed.

export type BreakCommitResult = {
    status: "committed";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

BreakEndResult

type

export type BreakEndResult = {
    status: "ended";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

BreakKind

type

The breaks a contact centre runs, named once so providers can agree.

An agent takes one break, not one per platform, so Omni has to know when two providers mean the same thing -- and it cannot tell from the labels, which are each deployment's own words. Every member's meaning is defined in the guide; ten undefined strings would be the label problem one level up. other matches nothing, including another provider's other.

export type BreakKind = (typeof BREAK_KINDS)[number];

breakKindAllowsMonitoring

const

export declare const breakKindAllowsMonitoring: (kind: BreakKind | undefined) => boolean;

BreakReason

interface

export interface BreakReason {
    id: string;
    label: string;
    group?: string;
    kind?: BreakKind;
    /** Survives `mayAsk: false`: a mandatory rest is not something a busy hour can cancel. */
    alwaysAvailable?: true;
}

BreakRequest

interface

export interface BreakRequest {
    reason?: string;
    /** The chosen `BreakReason.id`, where the provider publishes codes. */
    reasonId?: string;
}

BreakRequestResult

type

Succeeding is not the outcome: requested says the provider holds it, not that it was granted.

export type BreakRequestResult = {
    status: "requested";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

BreakState

interface

export interface BreakState {
    approval: BreakApproval;
    /** Whether the agent may ask at all. Distinct from the fate of a request already made. */
    mayAsk: boolean;
    /** Shown when `mayAsk` is false, such as "Busy hours". */
    refusedReason?: string;
    decisionReason?: string;
    retryAfterMs?: number;
    /** Not-ready codes this provider offers. Omitted when it defines none. */
    reasons?: BreakReason[];
    /** Which reason the current break is on. Omitted when there is no break. */
    activeReasonId?: string;
    imposed?: ImposedBreak;
}

Browser

interface

One tab, in either workspace.

export interface Browser {
    id: string;
    name: string;
    /** `http:` or `https:` only. */
    url: string;
}

BROWSER_ISOLATION_SCHEMES

const

How a reusing browser's session is keyed.

Named constants rather than a bare union because the values are structured strings: easy to mistype and unreadable as an argument.

export declare const BROWSER_ISOLATION_SCHEMES: {
    readonly PROVIDER_NAME__TASK_ID__TAB_NAME: "ProviderName.TaskId.TabName";
    readonly TAB_NAME: "TabName";
    readonly PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME: "ProviderName.TaskTypeName.TabName";
    readonly PROVIDER_NAME__TAB_NAME: "ProviderName.TabName";
    readonly PROVIDER_NAME__TASK_TYPE_NAME: "ProviderName.TaskTypeName";
    readonly TASK_TYPE_NAME__TAB_NAME: "TaskTypeName.TabName";
};

BrowserAccess

interface

export interface BrowserAccess {
    /** The decision when no list entry matches. */
    mode: "allow-all" | "block-all";
    allowList?: string[];
    blockList?: string[];
}

BrowserIsolationScheme

type

export type BrowserIsolationScheme = (typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];

browserSessionKey

function

The storage-profile key a reusing browser shares, or undefined where it shares nothing.

Fails closed. A browser with sharedSession: false has no key; nor does a reusing one whose scheme is missing or unknown -- the type forbids that, but an adapter compiled against another version can still send it, and the safe reading is "do not share", never "share with everyone named the same". Every part is encoded, separator included, before joining, so a tab called a.b cannot collide with a provider called a and a tab called b.

export declare function browserSessionKey(input: BrowserSessionKeyInput): string | undefined;

BrowserSessionKeyInput

interface

export interface BrowserSessionKeyInput {
    /** `Manifest.id`, never `displayName`: only the id is unique across an installation and stable. */
    providerId: string;
    taskId: TaskId;
    /** `Task.taskType`. */
    taskType: string;
    browser: TaskBrowser;
}

CapacityResult

type

export type CapacityResult = {
    status: "applied";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

Channel

type

New channels require a later protocol version.

export type Channel = "voice" | "chat" | "email";

ChatTaskCommand

type

export type ChatTaskCommand = {
    type: "accept";
} | {
    type: "decline";
} | {
    type: "pause";
} | {
    type: "resume";
} | ({
    type: "complete";
} & DispositionPayload);

commandDialId

function

The dial a command places, by the host's identity for it; undefined for a command that dials nothing.

export declare function commandDialId(command: TaskCommand): DialId | undefined;

CompleteAuthenticationRequest

type

export type CompleteAuthenticationRequest = {
    flowId: string;
    method: "browser-sso";
    callbackUrl: string;
} | {
    flowId: string;
    method: "credentials";
    values: Readonly<Record<string, string>>;
};

CompleteAuthenticationResult

type

export type CompleteAuthenticationResult = {
    status: "authenticated";
    identity: User;
    capabilities: UserCapabilities;
    expiresAt?: IsoTimestamp;
} | {
    status: "rejected";
    failure: AuthenticationFailure;
};

CompletionMode

type

Who ends the task: the agent issuing complete, or the provider deciding it is over.

export type CompletionMode = "agent-command" | "provider-automatic";

ConnectContext

interface

export interface ConnectContext {
    protocolVersion: number;
    /** The session that authenticated this connection. */
    loginId: string;
    /** Omni-side policy: whether the agent's tasks are accepted without asking them. */
    autoAcceptTasks?: boolean;
    /**
     * The zone the agent's day is reckoned in, the same value passed as
     * `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the
     * identity; a roaming agent corrects it by signing in.
     */
    timeZone: TimeZone;
    /** The same value passed as `AuthenticationContext.phone`: how this login hears its calls. */
    phone?: Phone;
    /**
     * The host's report of the agent's station, to consult before declaring the agent ready to the
     * platform and whenever it changes. Omni reports; the adapter decides.
     */
    host: Host;
    signal?: AbortSignal;
    log?: (entry: unknown) => void;
}

Connection

interface

export interface Connection<C extends Channel = Channel> {
    snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
    /** Delivery order must match the order the provider observes changes. Never replay. */
    subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
    /** Nothing may be allocated until a capacity is stated, so every connection receives it. */
    setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
    execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
    disconnect(): Promise<void>;
    /** Required of any adapter publishing a `UserId` -- on an imposed break, a roster, or history. */
    describeUsers?(ids: UserId[]): Promise<User[]>;
    /** Required when the manifest declares `idleCapabilities.dial`. */
    dial?(request: DialRequest): Promise<DialResult>;
    /**
     * The four break methods stand or fall together. Declaring `capabilities.breaks` at login and
     * implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can
     * never start, and the two-phase coordination has no way to report it.
     */
    requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
    commitBreak?(): Promise<BreakCommitResult>;
    cancelBreak?(): Promise<BreakCancelResult>;
    endBreak?(): Promise<BreakEndResult>;
    /** Required when the login declares `capabilities.team.breakControl`. */
    executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
    /** Required when the login declares `capabilities.team.leadAssistControl`. */
    executeTeamLeadAssist?(request: TeamLeadAssistCommandRequest): Promise<TeamCommandResult>;
    /** Required when the login declares `capabilities.team.monitorControl`. */
    executeTeamMonitor?(request: TeamMonitorCommandRequest): Promise<TeamCommandResult>;
    /** Required when the login declares `capabilities.team.policyControl`. */
    executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
    /** Required of a voice adapter whose manifest lists `softphone`: on one, the call's audio lands in Omni. */
    openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
    /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
    setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
    /** Records a handling leg the host performed. Required of a connection whose tasks may declare `mute`. */
    recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
}

Contact

interface

Every field is optional: a provider sends what it knows and omits what it does not.

export interface Contact {
    name?: string;
    /** 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. */
    number?: Lockable<string>;
    /** The email, or `{ lockedBy }` where the agent may not see it; it identifies a person as a number does. */
    email?: Lockable<string>;
    attributes?: Attribute[];
}

CredentialField

interface

export interface CredentialField {
    name: string;
    label: string;
    type: "text" | "password";
    required?: boolean;
    autocomplete?: string;
}

CustomCapability

interface

export interface CustomCapability {
    id: string;
    ui: {
        control: "button" | "toggle" | "menu-item";
        label: string;
        placement: "primary" | "secondary" | "overflow";
        /** Where the control's work renders: inline in the workspace, or as a page of its own. Inline when absent. */
        render?: "inline" | "page";
    };
    /** What the agent supplies before the action runs; the values travel on the custom command. */
    prompt?: {
        fields: CredentialField[];
    };
}

CustomTaskCommand

interface

export interface CustomTaskCommand {
    type: "custom";
    name: string;
    [key: string]: unknown;
}

DEFAULT_LEVELS

const

The levels a typical organisation has: the ladder in force when a manifest declares no orgLevels. A manifest that declares any states its whole ladder outright.

export declare const DEFAULT_LEVELS: readonly [{
    readonly id: "org";
    readonly label: "Your organisation";
}, {
    readonly id: "site";
    readonly label: "Your site";
}, {
    readonly id: "team";
    readonly label: "Your team";
}, {
    readonly id: "person";
    readonly label: "You";
}];

DEFAULT_TASK_PHASE_LABELS

const

export declare const DEFAULT_TASK_PHASE_LABELS: {
    readonly voice: {
        readonly pending: "Offered";
        readonly confirmed: "Accepted";
        readonly preview: "Preview";
        readonly "in-progress": "On Call";
        readonly paused: "On Hold";
        readonly completing: "After Call Work";
    };
    readonly chat: {
        readonly pending: "Incoming Chat";
        readonly confirmed: "Accepted";
        readonly preview: "Preview";
        readonly "in-progress": "In Chat";
        readonly paused: "Paused";
        readonly completing: "Wrap-up";
    };
    readonly email: {
        readonly pending: "Assigned";
        readonly confirmed: "Accepted";
        readonly preview: "Preview";
        readonly "in-progress": "Working";
        readonly paused: "Paused";
        readonly completing: "Completing";
    };
};

DEFAULT_TASK_TYPE_PRESENTATION

const

export declare const DEFAULT_TASK_TYPE_PRESENTATION: {
    readonly voice: {
        readonly singular: "Call";
        readonly plural: "Calls";
        readonly referenceLabel: "Call ID";
    };
    readonly chat: {
        readonly singular: "Chat";
        readonly plural: "Chats";
        readonly referenceLabel: "Chat ID";
    };
    readonly email: {
        readonly singular: "Email";
        readonly plural: "Emails";
        readonly referenceLabel: "Email ID";
    };
};

defineAdapter

function

Preserves the adapter's inferred concrete type while checking it implements Adapter. No connection, no runtime side effects.

export declare function defineAdapter<C extends Channel, A extends Adapter<C>>(adapter: A): A;

Destination

interface

One item the queue configured for the agent to send a contact to: a button or a menu item. The protocol does not say what it does -- a queue, a menu, a line -- the provider executes it when a command names its id. Never a named agent: who takes a contact next is the queue's decision.

export interface Destination {
    id: string;
    label: string;
}

DestinationDirectory

interface

The items a transfer or conference control offers. Nothing is typed; the agent picks one.

export interface DestinationDirectory {
    destinations: Destination[];
}

DIAL_OUTCOMES

const

export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];

DialCapability

interface

export interface DialCapability {
    destinations: DialDestinations;
}

DialDestinations

type

export type DialDestinations = "contacts-only" | "any-number";

DialId

type

The host's identity for one dial, minted before the command leaves and unique across the installation. A dial is the one command whose outcome arrives later and apart, so the provider names this on the outcome and the host has already placed it. Never shown to the agent.

export type DialId = string;

DialOutcome

type

How a dial ended, as the switch distinguishes it. answered is the one success; the rest are the ways a destination was not reached -- cancelled because the dialler called it off before anyone answered, not because the destination failed to, and unexplained because the switch dropped the call and gave no cause, a statement about the switch rather than a shrug from the adapter. A manifest declares which of these its platform can tell apart (dialOutcomes), and a dial-outcome carries only a declared one.

export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";

DialRequest

interface

export interface DialRequest {
    dialId: DialId;
    destination: string;
}

DialResult

type

dialling says accepted and being placed, nothing more; the outcome arrives on dial-outcome.

export type DialResult = {
    status: "dialling";
    dialId: DialId;
} | {
    status: "failed";
    failure: ProtocolFailure;
};

DispositionCode

interface

export interface DispositionCode {
    id: string;
    label: string;
    group?: string;
}

DispositionPayload

interface

export interface DispositionPayload {
    disposition?: string;
    notes?: string;
}

DispositionRules

interface

export interface DispositionRules {
    required?: boolean;
    notes?: "required" | "optional" | "none";
    codes?: DispositionCode[];
}

DurationSeconds

type

A non-negative whole number of seconds.

export type DurationSeconds = number;

effectiveLevels

function

The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none.

export declare function effectiveLevels(declared: readonly LevelDeclaration[] | undefined): LevelDeclaration[];

EmailTaskCommand

type

export type EmailTaskCommand = {
    type: "accept";
} | {
    type: "decline";
} | ({
    type: "complete";
} & DispositionPayload);

HANDLING_STEPS_THAT_DIAL

const

The steps a dial writes, and so the only ones that carry a dialId and a destinationId.

export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];

HANDLING_STEPS_WITH_A_PERSON

const

Every handling step somebody takes part in. queued is the one nobody does.

export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];

HandlingReport

type

The host's report of a handling leg it performed itself -- a mute, which Omni does rather than the provider -- so the provider's record has an account of it. Keyed by step and at: the same entry is reported when it begins, as often as the host cares to while it runs, and once more with ended, when seconds is the final duration. The host is the authority for the legs it performs, so seconds may say how long so far at any time; the end is stated, never inferred from a number's presence. What the adapter forwards upstream, and how often, is its own.

export type HandlingReport = {
    taskId: TaskId;
    step: HandlingStep;
    at: IsoTimestamp;
} & ({
    ended: true;
    seconds: DurationSeconds;
} | {
    ended?: never;
    seconds?: DurationSeconds;
});

HandlingReportResult

type

export type HandlingReportResult = {
    status: "recorded";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

HandlingStep

type

export type HandlingStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";

handlingStepDials

const

export declare const handlingStepDials: (step: HandlingStep) => boolean;

handlingStepExpectsAPerson

function

Whether an absent by means "could not attribute" rather than "nobody was involved".

export declare function handlingStepExpectsAPerson(step: HandlingStep): boolean;

Host

interface

export interface Host {
    guarantees: HostGuarantees;
    report(): HostReport;
    subscribe(listener: (report: HostReport) => void): Unsubscribe;
}

HostAudioInput

type

How the host's own audio stands: the microphone Omni captures for the agent, and whether it has it. Omni facilitates the microphone -- captures it, prompts, retries, tells the agent -- and does not decide for the adapter what a missing one means. The adapter does what its platform needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.

export type HostAudioInput = 
/** Omni has the microphone. `flowing` is false while the hardware or OS says no audio moves through it. */
{
    status: "available";
    localAudio: MediaStream;
    flowing: boolean;
}
/** Omni does not, and `reason` says which fix the agent needs; `failure` is the words Omni showed them. */
 | {
    status: "unavailable";
    reason: HostAudioUnavailableReason;
    failure: ProtocolFailure;
};

HostAudioOutput

type

export type HostAudioOutput = {
    status: "available";
} | {
    status: "unavailable";
    reason: HostOutputUnavailableReason;
    failure: ProtocolFailure;
};

HostAudioUnavailableReason

type

Why the host has no microphone, each wanting a different fix from the agent: no device; permission refused; permission never asked for (a host that asks at connect never says this); a device present and permitted that another application holds; a capture that ended.

export type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";

HostGuarantees

interface

What the host promises the provider, declared once per connection. Presence is the guarantee: an absent key is a host that makes no such promise, and a provider that needs one checks before it acts -- tokenising a URL it would otherwise send in the clear, or declining to offer work that only a person may accept.

export interface HostGuarantees {
    /** Every task browser's `urlVisibility` is honoured in this host's chrome, tab by tab. */
    browserUrlVisibility?: true;
    /** A `consent` offer is accepted only by the person's own explicit act, never on their behalf. */
    personConsent?: true;
}

HostOutputUnavailableReason

type

Why the host has no speaker: no device, or one that was removed.

export type HostOutputUnavailableReason = "no-device" | "lost";

HostReport

interface

export interface HostReport {
    /** 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. */
    online: boolean;
    audio?: {
        input: HostAudioInput;
        output: HostAudioOutput;
    };
}

IDLE_CAPABILITIES

const

Every idle capability a provider may declare. Only voice may dial; the channel arm says so.

export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];

IDLE_CAPABILITY_UI

const

What Omni calls each idle capability.

export declare const IDLE_CAPABILITY_UI: {
    readonly dial: "Dialpad";
    readonly personalBrowser: "Browser";
    readonly calendar: "Calendar";
    readonly contacts: "Contacts";
};

IdleCapabilities

type

Actions Omni may offer while the agent has no active task.

The channel arm is what makes dial a compile error on chat and email rather than a runtime one. Presence is the declaration: calendar: true says offer it, and omitting it says do not.

export type IdleCapabilities<C extends Channel = Channel> = {
    personalBrowser?: PersonalBrowserCapability;
    calendar?: true;
    contacts?: true;
} & (C extends "voice" ? {
    dial?: DialCapability;
} : {
    dial?: never;
});

IdleCapability

type

export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];

ImposedBreak

type

A break placed on the agent rather than requested by them.

by is required in both arms. Who put somebody off the floor survives whether or not the break ends on a clock -- an imposed break with no origin is a state the agent cannot reason about, and one that ends on a condition is still somebody's decision.

export type ImposedBreak = {
    by: UserId;
    endsAutomatically: true;
    endsAt: IsoTimestamp;
} | {
    by: UserId;
    endsAutomatically: false;
    endsAt?: never;
};

isAllowedBrowserUrl

function

export declare function isAllowedBrowserUrl(url: string): boolean;

IsoTimestamp

type

An RFC-3339 timestamp with Z or an explicit numeric offset.

Timezone-less values are invalid: the same string read on two hosts would be two different instants. A JavaScript Date never crosses this boundary.

export type IsoTimestamp = string;

LeadRequest

interface

A member asking this lead to join their call.

export interface LeadRequest {
    id: string;
    memberId: UserId;
    taskId: TaskId;
    note?: string;
    since: IsoTimestamp;
}

Level

type

A level of the organisation's structure, by the id its manifest declares -- or one of the four a typical organisation has, DEFAULT_LEVELS, when the manifest declares no ladder. The protocol never describes the chain: which levels a person passes through is the structure's to know.

export type Level = string;

LevelDeclaration

interface

A level the structure has, with the label a desk shows for "who decided".

export interface LevelDeclaration {
    id: Level;
    label: string;
}

Lockable

type

A value, or { lockedBy } in its place: present without permission, saying whose. lockedBy is the discriminant: a value that carries it is the lock, so no T may carry that key.

export type Lockable<T> = T | Locked;

Locked

interface

Something the queue could allow, locked above the person: the level that made it unchangeable, and why if they said. person never locks their own value, and the queue is not a level -- what the queue does not allow at all is simply absent.

export interface Locked {
    lockedBy: Level;
    reason?: string;
}

Manifest

interface

export interface Manifest<C extends Channel = Channel> {
    /** Stable, installation-wide unique, and unchanged between launches. */
    id: string;
    displayName: string;
    channel: C;
    /** Every version this adapter can speak, not only the one it was compiled against. */
    supportedProtocolVersions: number[];
    authenticationMethods: AuthenticationMethod[];
    idleCapabilities?: IdleCapabilities<C>;
    phaseLabels?: TaskPhaseLabels;
    /** Keyed by `taskType`. An entry replaces the channel default outright rather than merging. */
    taskTypePresentation?: Record<string, TaskTypePresentation>;
    /** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
    orgLevels?: LevelDeclaration[];
    /**
     * The outcomes this platform distinguishes when a dial ends, `answered` and at least one way of
     * not reaching the destination. Required of a provider that dials at all -- an idle dialpad, or
     * a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
     */
    dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
    /**
     * The phones this platform can put an agent on. Required of every voice manifest, and forbidden
     * off voice, where there is no call to hear. The host picks one of these per login.
     */
    phones?: C extends "voice" ? Phone[] : never;
    /**
     * The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
     * so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
     * when it ended, and a running report is refused: what a provider never asked for never crosses.
     */
    runningStepReports?: true;
}

MONITOR_MODES

const

export declare const MONITOR_MODES: readonly ["monitor", "whisper", "barge"];

MONITORING_BREAK_KINDS

const

The breaks during which a lead may listen to a member's call: work of another sort, not rest. A monitoring task is the one task a break in effect may hold, and only on one of these.

export declare const MONITORING_BREAK_KINDS: readonly ["coaching", "administrative", "training"];

MonitorMode

type

How a lead listening to a member's call is heard: monitor hears both sides in silence, whisper is heard by the agent alone, barge by everyone on the call.

export type MonitorMode = "monitor" | "whisper" | "barge";

negotiateProtocolVersion

function

Highest version supported by both the adapter and the host, or undefined when they cannot interoperate. Omni must refuse to connect on undefined rather than attempting partial compatibility.

export declare function negotiateProtocolVersion(adapterVersions: readonly number[], hostVersions?: readonly number[]): number | undefined;

normalizeContactEmail

function

The comparison key for a contact email. Never for display.

export declare function normalizeContactEmail(email: string): string;

normalizeContactNumber

function

The comparison key for a contact number. Never for display: keep the original value for that.

Applies NFKC, strips whitespace, brackets, slashes, periods and every Unicode dash, and rewrites a leading 00 to +, so +1 (415) 555-0100, +1.415.555.0100 and 0014155550100 all merge. Cross-provider merging is reliable only for E.164 input: a national-format number carries no country context, nothing in this protocol supplies one, and so it does not merge with its +-prefixed twin.

export declare function normalizeContactNumber(number: string): string;

OMNI_FAILURE_CODES

const

export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];

OMNI_PROTOCOL_VERSION

const

The protocol version implemented by this package.

export declare const OMNI_PROTOCOL_VERSION: 1;

OMNI_SUPPORTED_PROTOCOL_VERSIONS

const

Every version this package can interoperate with.

export declare const OMNI_SUPPORTED_PROTOCOL_VERSIONS: readonly number[];

OmniFailureCode

type

export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];

ON_CALL_ROLES

const

export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];

OnCall

type

Somebody on the call, or being brought onto it, as the provider states it: who, since when, and whether they are held aside. party is the customer; agent a person, by user id; consulted and conferenced someone a dial is bringing in -- listed from the moment the dial is placed, so the agent can call it off while it rings -- carrying the dial when a host placed it, the address either way, and the stage it has reached: ringing until the dial is answered, joined after, stated so a snapshot says who is present without anyone having seen the outcome. Nobody ringing is held. label names a destination -- a person, a queue -- not a phrase. At most one party and one consulted: the consult commands name neither because there is exactly one.

export type OnCall = {
    since: IsoTimestamp;
    held?: true;
} & ({
    role: "party";
} | {
    role: "agent";
    userId: UserId;
} | {
    role: "consulted" | "conferenced";
    destinationId: string;
    stage: OnCallStage;
    dialId?: DialId;
    label?: string;
});

OnCallRole

type

export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";

OnCallStage

type

Where a dialled entry stands: ringing until its dial is answered, joined after.

export type OnCallStage = "ringing" | "joined";

OpenMediaRequest

interface

export interface OpenMediaRequest {
    taskId: TaskId;
    /** The agent's microphone as Omni captured it, `HostReport.audio.input.localAudio`; absent while that input is `unavailable`. */
    localAudio?: MediaStream;
}

OpenMediaResult

type

export type OpenMediaResult = {
    status: "opened";
    session: VoiceMediaSession;
} | {
    status: "unavailable";
    failure: ProtocolFailure;
};

PersonalBrowser

type

A tab the agent opened in the personal workspace: theirs, as many as they like, and never on the wire.

export type PersonalBrowser = Browser;

PersonalBrowserCapability

interface

export interface PersonalBrowserCapability {
    access: BrowserAccess;
    accessAppliesTo?: "initial-url" | "all-navigation";
}

Phone

type

How the agent hears the call: on a softphone, where the host owns the audio and opens it through openMedia, or on a deskPhone, a handset the platform rings, where the host opens nothing and only shows the call. A voice manifest lists the phones its platform supports, and the host picks one for the login at authentication.

export type Phone = "softphone" | "deskPhone";

PHONES

const

export declare const PHONES: readonly ["softphone", "deskPhone"];

PolicyKey

type

A capability a team policy can name: any task control, new call, or a skill by its provider id.

export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;

PreferenceId

type

What the team may leave to the person: a capability by its own name -- hold, mute -- or a skill by its provider id. The same key as in Task.capabilities, because it is the same capability seen at another level. Connecting back and a new call are never the person's; they are the team's, on or off, within what the queue allows.

export type PreferenceId = "hold" | "mute" | `skill:${string}`;

PreferenceResult

type

export type PreferenceResult = {
    status: "applied";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

PreviewDeadline

type

What the system does when a preview's deadline passes with no Call pressed.

export type PreviewDeadline = "calls" | "expires";

ProtocolFailure

interface

export interface ProtocolFailure {
    code: string;
    message: string;
    retryable: boolean;
    retryAfterMs?: number;
}

ProtocolViolation

interface

One broken rule. Validators return every violation they find rather than throwing.

export interface ProtocolViolation {
    /** Stable machine-readable rule id, such as `task.browser.url.scheme`. */
    rule: string;
    /** Dotted path to the offending value, such as `snapshot.tasks[0].browsers[1].url`. */
    path: string;
    message: string;
}

ProviderEvent

type

export type ProviderEvent<C extends Channel = Channel> = {
    type: "snapshot";
    reason: "reconnected" | "provider-requested";
    snapshot: Snapshot<C>;
} | {
    type: "transport-status";
    status: "connecting" | "active";
    message?: string;
} | {
    type: "transport-status";
    status: "error";
    recovery: TransportRecovery;
    message?: string;
} | {
    type: "break-state";
    break: BreakState;
} | {
    type: "task-offered";
    task: Task<C>;
    allocationExpiresAt?: IsoTimestamp;
} | {
    type: "task-updated";
    task: Task<C>;
} | {
    type: "task-media-started";
    taskId: TaskId;
} | {
    type: "task-media-ended";
    taskId: TaskId;
} | {
    type: "task-ended";
    taskId: TaskId;
    outcome: TaskOutcome;
}
/**
 * How a dial the host placed ended, once, either way -- `answered` is stated, never read off
 * somebody appearing on the call, and says what happened to the dial; who is on the call is
 * `Task.onCall`. `taskId` is the task the dial was placed on, resolved from the dial and never
 * from what the agent is looking at, and that task may already have ended: a dial placed late
 * routinely outlives its call. `reason` is the switch's own words, shown to the agent as such.
 */
 | {
    type: "dial-outcome";
    dialId: DialId;
    outcome: DialOutcome;
    taskId?: TaskId;
    destinationId?: string;
    reason?: string;
} | {
    type: "announcement";
    text: string;
    html?: string;
    announcedAt: IsoTimestamp;
    expiresAt?: IsoTimestamp;
} | {
    type: "queue-summary";
    summary: QueueSummary;
} | {
    type: "diagnostic";
    expected: string;
    observed: string;
    taskId?: TaskId;
} | {
    type: "team-updated";
    team: TeamRoster;
} | {
    type: "contacts-updated";
    contacts: Contact[];
} | {
    type: "calendar-updated";
    scheduledActivities: ScheduledActivity[];
};

ProviderEventEnvelope

interface

The Provider prefix survives here for a mechanical reason rather than a naming one: Event is a DOM global, and a bare one would shadow it for every adapter compiled against the browser lib.

export interface ProviderEventEnvelope<C extends Channel = Channel> {
    id: string;
    /** The login this belongs to. */
    loginId: string;
    occurredAt: IsoTimestamp;
    event: ProviderEvent<C>;
}

QueueSummary

interface

export interface QueueSummary {
    title: string;
    subtitle?: string;
    waitingCount: number;
    updatedAt: IsoTimestamp;
    metrics?: SummaryMetric[];
}

Resolved

interface

What every resolved value carries: who set it, and who locked it if anyone did.

export interface Resolved {
    setBy: SetBy;
    lockedBy?: Level;
    /** Given with `lockedBy`, where whoever locked it said why. */
    reason?: string;
}

sameCapabilities

function

Whether two logins declare the same capabilities, field by field. The comparison an adapter makes before republishing authenticated: capabilities are current, not fixed, and the natural guard -- comparing the identity -- never fires on a demotion, because the thing that changed is not the thing being compared. Key order does not matter, and team: {} is not team absent.

export declare function sameCapabilities(a: UserCapabilities, b: UserCapabilities): boolean;

ScheduledActivity

interface

export interface ScheduledActivity {
    id: string;
    title: string;
    startsAt: IsoTimestamp;
    endsAt?: IsoTimestamp;
    /** The person the activity reaches -- a callback's customer. */
    party?: Contact;
    attributes?: Attribute[];
}

SecretStore

interface

export interface SecretStore {
    get(key: string): Promise<string | undefined>;
    set(key: string, value: string): Promise<void>;
    delete(key: string): Promise<void>;
}

SetBy

type

Who stated a value as it stands: a level -- person among them -- or provisioning, the protocol's own word for "no level has said anything and the provider's default applies". Nothing is hidden for want of a row.

export type SetBy = Level | "provider";

SetPreferenceRequest

type

The person's act: set their own value, or give it up and inherit again.

export type SetPreferenceRequest = {
    id: PreferenceId;
    enabled: boolean;
} | {
    id: PreferenceId;
    inherit: true;
};

SharedTaskCapabilities

interface

export interface SharedTaskCapabilities {
    browsers?: true;
    dispositions?: true | DispositionRules;
    custom?: CustomCapability[];
}

Snapshot

interface

export interface Snapshot<C extends Channel = Channel> {
    transport: TransportStatus;
    loginId: string;
    break: BreakState;
    /** Every task currently owned by this agent for this provider. */
    tasks: Task<C>[];
    /** The provider's own count of those tasks, stated rather than inferred: it must equal `tasks.length`, so a blank or unanswered state can never pass as a confirmed empty. */
    taskCount: number;
    contacts?: Contact[];
    scheduledActivities?: ScheduledActivity[];
    team?: TeamRoster;
}

StartAuthenticationRequest

type

export type StartAuthenticationRequest = {
    requestId: string;
    method: "browser-sso";
    callbackUrl: string;
} | {
    requestId: string;
    method: "credentials";
};

StartAuthenticationResult

type

export type StartAuthenticationResult = {
    status: "interaction-required";
    challenge: AuthenticationChallenge;
} | {
    status: "rejected";
    failure: AuthenticationFailure;
};

SummaryMetric

interface

export interface SummaryMetric {
    id: string;
    label: string;
    value: string;
}

Task

type

export type Task<C extends Channel = Channel> = {
    id: TaskId;
    title: string;
    channel: C;
    /** The provider's own name for a category of work. Finer-grained than a channel. */
    taskType: string;
    capabilities: TaskCapabilities<C>;
    browsers: TaskBrowser[];
    /** The person or entity on the other end of this task. Who the task is with; `contacts` on the snapshot is the directory. */
    party?: Contact;
    phase: TaskPhase;
    /**
     * How this offer is accepted, stated on the pending task rather than the offer so a reconnect
     * snapshot says it too: an offer the host never received is not accepted on the person's behalf
     * for want of a word. Required while `pending` when Omni said it may auto-accept
     * (`autoAcceptTasks: true`), forbidden when it said not, and absent past `pending`.
     */
    acceptance?: AcceptanceMode;
    /**
     * In `preview` only, and together: when the system stops waiting for the agent to press Call,
     * and what it does then -- `calls` places the call itself, `expires` takes the record back and
     * the task ends `expired`. Absent, the agent has as long as they need.
     */
    previewEndsAt?: IsoTimestamp;
    atDeadline?: PreviewDeadline;
    /** The identifier an agent reads back to a customer, where the provider has one. */
    reference?: string;
    attributes?: TaskAttribute[];
    handlingHistory?: TaskHandlingHistory;
} & TaskCompletion & (C extends "voice" ? {
    onCall?: OnCall[];
    leadAssist?: TaskLeadAssist;
    assisting?: TaskAssisting;
    monitoring?: TaskMonitoring;
    media?: TaskMediaState;
} : {
    onCall?: never;
    leadAssist?: never;
    assisting?: never;
    monitoring?: never;
    media?: never;
});

TASK_COMMAND_NAMES

const

export declare const TASK_COMMAND_NAMES: {
    readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "end-call", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
    readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
    readonly email: readonly ["accept", "decline", "complete"];
};

TaskAssisting

interface

On the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes lead-assist take-over and leave issuable.

export interface TaskAssisting {
    memberId: UserId;
    note?: string;
    since: IsoTimestamp;
}

TaskAttribute

type

export type TaskAttribute = TaskAttributeBase & ({
    type: "text";
    value: string;
} | {
    type: "contact";
    party: Contact;
} | {
    type: "timestamp";
    at: IsoTimestamp;
});

TaskAttributeBase

interface

export interface TaskAttributeBase {
    key: string;
    label?: string;
}

TaskBrowser

type

A tab the task brought: fixed at the task's definition -- count and details -- with why it is there, whether its session is shared across tasks, and whether the agent may read its URL. The union is what makes a reusing browser with no scheme fail to compile rather than inherit a default -- which is how two tasks end up sharing a session nobody intended.

export type TaskBrowser = Browser & {
    purpose: string;
    /** Hide this tab's URL from the agent in Omni's chrome. Omitted, the URL shows as any browser's does; a provider says `hidden` where the URL carries what the agent may not read. */
    urlVisibility?: UrlVisibility;
} & ({
    sharedSession: false;
    isolationScheme?: never;
} | {
    sharedSession: true;
    isolationScheme: BrowserIsolationScheme;
});

TaskCapabilities

type

A capability says offer this control, and nothing about who carries it out.

The channel arms are why Task<"email"> rejects hold at compile time rather than at runtime.

export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
    decline?: Lockable<true>;
    mute?: Lockable<true>;
    hold?: Lockable<true>;
    /** The agent may end the whole call: everyone leaves and the media ends, the task stays for its wrap-up. */
    endCall?: Lockable<true>;
    /** Connect back to the party while `completing`, whoever placed the call; the task returns to `in-progress`. */
    connectBack?: Lockable<true>;
    coldTransfer?: Lockable<DestinationDirectory>;
    /** Park the customer and call a destination first; then `complete` or `cancel`. */
    warmTransfer?: Lockable<DestinationDirectory>;
    /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.leadAssist`. */
    leadAssist?: Lockable<true>;
    conference?: Lockable<DestinationDirectory>;
    recording?: Lockable<true>;
} : C extends "chat" ? SharedTaskCapabilities & {
    decline?: Lockable<true>;
    hold?: Lockable<true>;
} : SharedTaskCapabilities & {
    decline?: Lockable<true>;
};

TaskCommand

type

export type TaskCommand<C extends Channel = Channel> = (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand) | CustomTaskCommand;

TaskCommandName

type

export type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> = (typeof TASK_COMMAND_NAMES)[C][number];

TaskCommandRequest

interface

export interface TaskCommandRequest<C extends Channel = Channel> {
    taskId: TaskId;
    command: TaskCommand<C>;
}

TaskCommandResult

type

applied rather than a verb per command: the command travels in the request, so execute({ command: { type: "hold" } }) returning applied already says the hold applied. A command that dials answers dialling instead, restating the host's dialId: accepted, the call being placed, and the outcome still to come on dial-outcome.

A promise that rejects with no result means unknown, not failed. Omni does not retry -- no adapter on the agent's PC can make a repeat safe -- and the next snapshot shows what the provider did. A command therefore carries no retry key of Omni's making; a dialId is not one, it is how an outcome arriving later is placed.

export type TaskCommandResult = {
    status: "applied";
} | {
    status: "dialling";
    dialId: DialId;
} | {
    status: "failed";
    failure: ProtocolFailure;
};

TaskCompletion

type

How the task ends, and how long after handling the provider allows for it.

The allowance is coupled to the mode. Under provider-automatic the provider acts on it, so it is required. Under agent-command the provider will not complete the task itself, so it may omit the allowance to say it imposes no deadline -- and Omni then counts nothing down. Omitted and 0 are different claims: no deadline to see, and a deadline of now.

export type TaskCompletion = {
    completionMode: "agent-command";
    wrapAllowance?: DurationSeconds;
} | {
    completionMode: "provider-automatic";
    wrapAllowance: DurationSeconds;
};

TaskHandlingHistory

interface

The call record: the steps that brought the task here, one entry per occurrence and oldest first, and what they add up to before this agent -- stated by the provider from its own record, never summed by a desk from instants. Each total is present when the provider knows it and absent when it does not; a plausible nought is the fallback the no-fallbacks rule forbids. The record rides on the task and is replaced with it, so a late entry corrects the sums.

export interface TaskHandlingHistory {
    steps: TaskHandlingStep[];
    /** Seconds others spent handling it before this agent. */
    handleSeconds?: DurationSeconds;
    /** Seconds the caller spent on hold at others' hands. */
    holdSeconds?: DurationSeconds;
    /** Seconds waiting before anyone answered. */
    queueSeconds?: DurationSeconds;
    /** How many times the task changed hands. */
    transfers?: number;
}

TaskHandlingStep

interface

export interface TaskHandlingStep {
    step: HandlingStep;
    at: IsoTimestamp;
    /**
     * On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
     * that dial when a host placed it, and the directory item it went to. Neither belongs on any other step.
     */
    dialId?: DialId;
    destinationId?: string;
    /**
     * Reported, never derived. An entry may be written while its leg is still running, so there is
     * no end to subtract from -- and omitted is the honest report of that, where nought would
     * claim it took no time.
     */
    seconds?: DurationSeconds;
    /**
     * Who took part. Absent on `queued`, where nobody does; absent on any other step means the
     * provider could not attribute it, which is a different claim and a legitimate one.
     */
    by?: UserId;
}

TaskId

type

A non-empty, opaque task identifier unique within one provider. Scope it with taskKey().

export type TaskId = string;

taskKey

const

A collision-safe global task key.

Task ids are unique only within one provider, so two providers will eventually issue the same string. Encoding before joining is what stops a provider id containing a separator from forging another provider's key.

export declare const taskKey: (providerId: string, taskId: TaskId) => string;

TaskLeadAssist

interface

The agent's request for a lead, from asking until the lead leaves or the request ends. requested while nobody has joined; joined, with leadId, once somebody has.

export interface TaskLeadAssist {
    stage: "requested" | "joined";
    leadId?: UserId;
    note?: string;
    since: IsoTimestamp;
}

TaskMediaState

type

The task's real-time audio as the provider holds it: ready while audio should be attached, ended once primary handling's audio ended, and the field omitted while none should be. The provider's word -- a desk attaches and renders audio from it, never from its own senses.

export type TaskMediaState = "started" | "ended";

TaskMonitoring

interface

On the lead's own task while they listen to a member's call: whose call, which call, and how the lead is heard, restated on every change of mode. Nothing of it reaches the member's task. There is no take-over here; a lead who wants the call uses lead assist. It is the lead's only task: a lead listens while holding no work of their own, idle or on a break of a kind in MONITORING_BREAK_KINDS.

export interface TaskMonitoring {
    memberId: UserId;
    taskId: TaskId;
    mode: MonitorMode;
    since: IsoTimestamp;
}

TaskOutcome

type

export type TaskOutcome = {
    type: "completed";
    by: "agent" | "provider";
} | {
    type: "transferred";
    destinationId?: string;
} | {
    type: "cancelled";
    reason?: string;
}
/** Only the phases in which somebody is still being waited on can expire. */
 | {
    type: "expired";
    phase: "pending" | "confirmed" | "preview";
}
/** This agent left a call that continues without them: a lead who joined and dropped. */
 | {
    type: "left";
} | {
    type: "failed";
    failure: ProtocolFailure;
};

TaskPhase

type

export type TaskPhase = 
/** Allocated to this agent and not yet accepted. */
"pending"
/** Accepted, and not yet started. */
 | "confirmed"
/**
 * Voice only: the customer's record is on the agent's screen and no call has gone out. The agent
 * presses Call, or `previewEndsAt` arrives and `atDeadline` says what the system does instead.
 */
 | "preview" | "in-progress" | "paused"
/** The work is over and the agent is finishing up. */
 | "completing";

TaskPhaseLabels

type

export type TaskPhaseLabels = Readonly<Partial<Record<TaskPhase, string>>>;

TaskTypePresentation

interface

export interface TaskTypePresentation {
    singular: string;
    plural: string;
    referenceLabel?: string;
}

TeamBreakCommand

type

export type TeamBreakCommand = {
    type: "decide";
    memberId: UserId;
    decision: "granted" | "denied";
    reason?: string;
} | {
    type: "policy";
    policy: "ask" | "auto-approve" | "suspended";
} | {
    type: "place";
    memberId: UserId;
    reason?: string;
} | {
    type: "release";
    memberId: UserId;
};

TeamBreakCommandRequest

interface

export interface TeamBreakCommandRequest {
    command: TeamBreakCommand;
}

TeamCapabilities

interface

What a lead may do with their team. Declared by presence.

export interface TeamCapabilities {
    /**
     * 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`. Requires `executeTeamBreak`.
     */
    breakControl?: true;
    /** This lead may join a member's call on request. Requires `executeTeamLeadAssist`. */
    leadAssistControl?: true;
    /**
     * This lead may listen to a member's call unasked, in these modes and no others. Whisper and
     * barge begin from a monitor, so the list always includes `monitor`. Nothing of it reaches the
     * member's task. Requires `executeTeamMonitor`.
     */
    monitorControl?: MonitorMode[];
    /** This lead sets the team's policy per capability -- on, off, or the agent's -- within what the queue allows. Requires `executeTeamPolicy`. */
    policyControl?: true;
}

TeamCommandResult

type

export type TeamCommandResult = {
    status: "applied";
} | {
    status: "failed";
    failure: ProtocolFailure;
};

TeamLeadAssistCommand

type

export type TeamLeadAssistCommand = {
    type: "join";
    requestId: string;
} | {
    type: "decline";
    requestId: string;
    reason?: string;
};

TeamLeadAssistCommandRequest

interface

export interface TeamLeadAssistCommandRequest {
    command: TeamLeadAssistCommand;
}

TeamMember

interface

export interface TeamMember {
    id: UserId;
    availability: TeamMemberAvailability;
    /** Omitted rather than invented: Omni renders it as a duration. */
    since?: IsoTimestamp;
    break?: BreakApproval;
}

TeamMemberAvailability

type

export type TeamMemberAvailability = "ready" | "on-task" | "on-break" | "signed-out";

TeamMonitorCommand

type

A lead listening to a member's call. monitor starts one, silent, on a member from the roster; whisper and barge change how the standing one is heard; leave ends it. One member at a time, and only the modes the login's monitorControl lists.

export type TeamMonitorCommand = {
    type: "monitor";
    memberId: UserId;
} | {
    type: "whisper";
} | {
    type: "barge";
} | {
    type: "leave";
};

TeamMonitorCommandRequest

interface

export interface TeamMonitorCommandRequest {
    command: TeamMonitorCommand;
}

TeamPolicies

type

export type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;

TeamPolicy

interface

One policy as the lead sees it: the setting, who set it, and lockedBy when a level above the team made it theirs to keep.

export interface TeamPolicy extends Resolved {
    setting: TeamPolicySetting;
}

TeamPolicyCommand

type

export type TeamPolicyCommand = {
    type: "set";
    capability: PolicyKey;
    setting: TeamPolicySetting;
};

TeamPolicyCommandRequest

interface

export interface TeamPolicyCommandRequest {
    command: TeamPolicyCommand;
}

TeamPolicySetting

type

On for everyone, off for everyone, or the agent's own choice. Only hold, mute and skills may be agent.

export type TeamPolicySetting = "on" | "off" | "person";

TeamRoster

interface

The login is the permission: published to a login that declares capabilities.team, on every snapshot, and to nobody else. What the lead may do with it is on the login too, not here.

export interface TeamRoster {
    members: TeamMember[];
    /** Omitted when the login lacks `team.leadAssistControl`; `[]` when nobody is asking. */
    requests?: LeadRequest[];
    /** The team's policy per capability, as it stands. Present exactly when the login declares `team.policyControl`. */
    policies?: TeamPolicies;
}

TimeZone

type

An IANA time zone name, such as Asia/Kolkata -- never an offset, which cannot survive a daylight-saving boundary, and a day boundary is exactly where that bites.

export type TimeZone = string;

TransportRecovery

type

What revives a connection that reported error: reconnect -- the login is good, dispose this connection and call connect() again -- or reauthenticate -- run the authentication flow first. The adapter knows which; the host acts on its word.

export type TransportRecovery = "reconnect" | "reauthenticate";

TransportStatus

type

export type TransportStatus = "connecting" | "active" | "error";

Unsubscribe

type

export type Unsubscribe = () => void;

UrlVisibility

type

What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it.

export type UrlVisibility = "full" | "domain" | "hidden";

User

interface

export interface User {
    id: UserId;
    displayName: string;
    /**
     * The zone this person's day is reckoned in, as the provider keeps it: stated by the host at
     * authentication, stored on the agent, and carried on every identity and every described user
     * so a day-scoped figure -- theirs or a colleague's a lead is reading -- is bucketed by the
     * right day. Never absent: a person whose day nobody can name is not on this wire.
     */
    timeZone: TimeZone;
}

UserCapabilities

interface

What this login may do, beyond any one task. It travels with the identity because it is part of who the agent is on this provider: the provider knows the roles and says so at sign-in. Current as of the latest authenticated state, never fixed for the login.

export interface UserCapabilities {
    /** This login may request a break. Requires the four break methods. */
    breaks?: true;
    /** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
    preferences?: AgentPreference[];
    /** This login leads a team: a `TeamRoster` is published to it on every snapshot, `[]` included. */
    team?: TeamCapabilities;
}

UserId

type

A non-empty, opaque, stable identifier for a person, issued by the provider.

It names agents and managers alike; the role comes from where the value appears, not from its type. Compare it exactly and only within one provider -- there is no Omni-wide user identity, so two providers will eventually issue the same string for different people. Scope it with the provider id through userKey() before storing or comparing.

export type UserId = string;

userKey

const

The same treatment for a UserId, and needed for the same reason.

There is no Omni-wide user identity: one person on several providers has several identities and nothing here pairs them. A bare UserId is only ever compared against another from the same provider; anything wider goes through this.

export declare const userKey: (providerId: string, userId: UserId) => string;

VoiceMediaSession

interface

export interface VoiceMediaSession {
    remoteAudio: MediaStream;
    setMuted(muted: boolean): void;
    close(): void;
}

VoiceTaskCommand

type

export type VoiceTaskCommand = {
    type: "answer";
} | {
    type: "decline";
}
/** In `preview`: place the call to the party whose record the agent has read. A dial, gated by the phase alone. */
 | {
    type: "call";
    dialId: DialId;
} | {
    type: "mute";
    muted: boolean;
} | {
    type: "hold";
} | {
    type: "resume";
}
/** End the whole call: everyone leaves and the task's media ends; the task stays for its wrap-up. Gated by `endCall`. */
 | {
    type: "end-call";
}
/** Issuable only in `completing`, under the `connectBack` capability. Dials the party's own number, so it names none. */
 | {
    type: "connect-back";
    dialId: DialId;
}
/** Cold: hand the customer to the directory item `destinationId` with nobody spoken to first. Gated by `coldTransfer`. */
 | {
    type: "transfer";
    action: "cold";
    dialId: DialId;
    destinationId: string;
}
/** Warm: park the customer and call the directory item `destinationId` first. Gated by `warmTransfer`. */
 | {
    type: "transfer";
    action: "warm";
    dialId: DialId;
    destinationId: string;
}
/** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
 | {
    type: "transfer";
    action: "complete";
}
/** Drop the consulted destination and return to the customer. Needs a `consulted` entry on `Task.onCall`. */
 | {
    type: "transfer";
    action: "cancel";
}
/** Ask a lead to join, with a note. Gated by `leadAssist`. */
 | {
    type: "lead-assist";
    action: "request";
    note?: string;
}
/** Withdraw a standing request. Needs `Task.leadAssist` with status `requested`. */
 | {
    type: "lead-assist";
    action: "cancel";
}
/** The lead keeps the customer; the agent's task ends `transferred`. Needs `Task.assisting`. */
 | {
    type: "lead-assist";
    action: "take-over";
}
/** The lead drops; the agent continues. The lead's task ends `left`. Needs `Task.assisting`. */
 | {
    type: "lead-assist";
    action: "leave";
}
/** Dial the directory item `destinationId` into the call. Gated by `conference`. */
 | {
    type: "conference";
    action: "add";
    dialId: DialId;
    destinationId: string;
}
/**
 * Remove one person from the call, named as `onCall` names them, and the call goes on for the
 * rest: a `conferenced` entry by its `destinationId`, which while it still rings calls the dial
 * off, or the `party`, leaving the agent with the colleague. A remove that would leave the agent
 * alone is `end-call`, and a provider answers it `failed`.
 */
 | {
    type: "conference";
    action: "remove";
    destinationId: string;
    party?: never;
} | {
    type: "conference";
    action: "remove";
    party: true;
    destinationId?: never;
} | {
    type: "recording";
    action: "start" | "pause" | "resume" | "stop";
} | ({
    type: "complete";
} & DispositionPayload);

@xema/omni-protocol/validation

25 exported names.

assertNoViolations

function

Throws ProtocolConformanceError when any violation is present.

export declare function assertNoViolations(violations: readonly ProtocolViolation[], summary?: string): void;

isTimeZone

function

An IANA zone name the runtime knows: Asia/Kolkata is one, an offset or a made-up name is not.

export declare function isTimeZone(value: unknown): value is string;

LoginValidationContext

interface

What a login is validated against beyond its own shape.

export interface LoginValidationContext {
    /** The level ids in force, from the manifest. The defaults when absent. */
    levels?: readonly string[];
}

ProtocolConformanceError

class

export declare class ProtocolConformanceError extends Error {
    readonly violations: readonly ProtocolViolation[];
    constructor(violations: readonly ProtocolViolation[], summary?: string);
}

ReaderContext

interface

Who is reading what the adapter published, and what their login declares. The validators check structure without it; given it, they also hold what the adapter publishes to the login.

export interface ReaderContext {
    /**
     * The signed-in agent, `AuthenticationState.identity.id`. A lead does not report to themself:
     * a roster that lists them in `members`, or their own ask in `requests`, is a violation.
     */
    self?: UserId;
    /**
     * The login's `AuthenticationState.capabilities`. The login is the permission: a lead's
     * snapshot carries a roster, nobody else's does, and `requests` need `team.leadAssistControl`.
     */
    capabilities?: UserCapabilities;
    /** The level ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
    levels?: readonly string[];
    /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
    loginId?: string;
    /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
    autoAcceptTasks?: boolean;
}

ResultMethod

type

The connection methods whose results validateResult knows.

export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamLeadAssist" | "executeTeamMonitor" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";

sameTimeZone

function

Whether two zone names denote one zone: Asia/Kolkata and Asia/Calcutta do, and a name is judged by what it denotes.

export declare function sameTimeZone(a: unknown, b: unknown): boolean;

TaskValidationContext

interface

export interface TaskValidationContext {
    /** The provider's channel, from its manifest. A task must agree with it. */
    channel: string;
    /** The level ids in force, from the manifest. The defaults when absent. */
    levels?: readonly string[];
    /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
    autoAcceptTasks?: boolean;
    /** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
    dialOutcomesDeclared?: boolean;
}

validateAuthenticationFailure

function

export declare function validateAuthenticationFailure(failure: unknown, path?: string): ProtocolViolation[];

validateAuthenticationResult

function

What start() or complete() answered: a challenge or a rejection from start, a login or a rejection from complete. A rejection carries a failure the host can show, and a login is held to the same rules as any authenticated state.

export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];

validateAuthenticationState

function

export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];

validateContact

function

Every field is optional, so this checks what is present rather than what is missing.

export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];

validateEventEnvelope

function

export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];

validateHandlingReport

function

What the host reports of a leg it performed, as an adapter may validate it before forwarding: a task, a step, when it began, how long so far if the host says, and an explicit end that carries the final duration.

export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];

validateHostGuarantees

function

What a host promises. Presence is the guarantee, so a key declared false is refused: a promise withheld is an absent key, never a false one, exactly as a capability is.

export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];

validateHostReport

function

export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];

validateManifest

function

export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];

validatePhone

function

The host's choice of phone for a login, held to the manifest: one the platform listed, required on voice, absent elsewhere.

export declare function validatePhone(value: unknown, manifest: unknown, path?: string): ProtocolViolation[];

validateResult

function

Validates what a connection method answered. A result is untrusted for the same reason a snapshot is: it comes from an adapter that may be compiled against another version, and Omni shows the agent what it says. A status the method does not answer, a failure status without a failure, a success carrying one, or an omni. code the contract lacks are each refused.

export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];

validateScheduledActivity

function

export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];

validateSnapshot

function

export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];

validateTask

function

export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];

validateTaskCommand

function

What a command needs to be issuable, checked against the task it names: the capability the guide's table gates it on, the phase it belongs to, and the state that has to stand -- a consulted entry, a lead requested, somebody else on the call. Without a task only the command's own shape is checked. A host validates before sending, and an adapter before acting: a command for a control the task never offered is the host's error, and this names it.

export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string): ProtocolViolation[];

validateTeamRoster

function

export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];

validateTimeZone

function

The zone a host sends at connect: required, and an IANA name.

export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];

@xema/omni-protocol/testing

24 exported names.

AdapterContractResult

interface

export interface AdapterContractResult {
    events: ProviderEventEnvelope[];
    /** The state the session was restored with at sign-in. */
    authenticationState: AuthenticationState;
    /**
     * The latest login the session published during the run. Equal to `authenticationState` unless
     * the adapter republished `authenticated` -- capabilities are current, not fixed.
     */
    login: AuthenticationState;
    /** True only when every unsubscribe, `disconnect()`, and `close()` settled without throwing. */
    disconnectWasClean: boolean;
    /**
     * What the run never reached, and so what a clean `violations` says nothing about. Nothing here
     * is a violation -- an adapter with no team has nothing to exercise -- but a fixture with no
     * tasks exercises no task rule, and an adapter's own test asserts that the subjects it meant to
     * reach are absent from this list.
     */
    notExercised: readonly ContractSubject[];
    /** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
    violations: readonly ProtocolViolation[];
}

assertAuthenticationRestoreAndExpiry

function

Validates restored authentication followed by a refresh failure or expiry. Every state is validated, and a refreshing state must carry over the login it refreshes.

export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;

assertBreakAttemptProviders

function

A break attempt asks every connected provider from which the agent can currently receive work. A provider whose login is not usable -- expired above all -- is not asked, whatever else is true of it: nothing can be asked of it, and a host that waits on it stalls the break for everyone. A usable provider holding capacity is asked, and cannot be left out. refreshing is usable: identity and capabilities remain available and work continues.

export declare function assertBreakAttemptProviders(candidates: readonly BreakCandidate[], asked: readonly string[]): void;

assertBreakBeginsAfterTask

function

A break asked for on a task begins when the work ends. steps is the sequence the provider published, first to last: the request is made while work is outstanding, the commit is reported as starting-after-task while it remains, and in-effect arrives only once nothing is outstanding -- never beside a task, and never later than the step that has none.

export declare function assertBreakBeginsAfterTask(steps: readonly BreakOnTaskStep[]): void;

assertBreakFollowsItsRequests

function

A break follows its requests. Given a provider's stream -- optionally seeded with the snapshot it began from -- every break-state moves the way the guide describes: a commit's states only after a grant, never backwards, and a break placed on the agent arriving in effect with imposed.

export declare function assertBreakFollowsItsRequests(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;

assertBrowserSessionIsolation

function

Validates whether two task-browser definitions should share one browser session.

export declare function assertBrowserSessionIsolation(left: BrowserIsolationScenario, right: BrowserIsolationScenario, expectedReuse: boolean): void;

assertCapabilityWithdrawal

function

Capabilities are current, not fixed. A provider that withdraws one republishes authenticated with the new set, and the next snapshot agrees with it. states is what the authentication session published, first to last: beginning and ending authenticated for the same identity, passing only through usable states (expired or signed-out ends the login instead), with at least one capability gone by the end. snapshot is the first snapshot published after the last state, validated against that login -- so a roster still published to a login that no longer leads, or requests to one that may no longer join, is the failure.

export declare function assertCapabilityWithdrawal(states: readonly AuthenticationState[], snapshot: Snapshot, manifest: Manifest): void;

assertCommandRefusedAfterWithdrawal

function

A command that arrives after its capability was withdrawn is answered failed with omni.capability-not-enabled: the provider names it, so Omni never has to infer from a capability change it may not have rendered yet that "no longer a lead" is the message. Takes any command result -- a team command, a break method -- after such a withdrawal.

export declare function assertCommandRefusedAfterWithdrawal(result: {
    status: string;
    failure?: {
        code: string;
    };
}): void;

assertDeniedAndRetriedBreak

function

Validates a refused break followed by a later request that is granted.

There is no denied approval: a refusal returns the agent to not-requested, because a pending request nobody is coming to decide is worse than none. So the scenario is a request that goes back to not-requested, and a later one that is granted.

A request shows as awaiting-decision where a person decides, and as granted at once where the provider decides alone; either is the request being made, and the rule is the same for both.

export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;

assertDuplicateEventDelivery

function

Validates duplicate delivery and returns the event sequence Omni applies once per ID.

export declare function assertDuplicateEventDelivery<C extends Channel>(envelopes: readonly ProviderEventEnvelope<C>[]): ProviderEventEnvelope<C>[];

assertMediaFollowsTheTask

function

The media follows the task and never decides it. Given a provider's stream -- optionally seeded with the snapshot it began from -- every task is introduced once, task-media-started and task-media-ended alternate on work that has begun, media ends only where it arrived, and what follows the media ending is completing or task-ended.

export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;

assertNoBrowserSessionKeyCollisions

function

Asserts that no two distinct scenarios in scenarios derive the same session key. Feed it adversarial names — a provider called A.B against a task type called B, casing variants, separators — because a collision silently shares cookies, storage, and permissions between two backends.

export declare function assertNoBrowserSessionKeyCollisions(scenarios: readonly BrowserIsolationScenario[]): void;

assertReached

function

Throws unless the run reached every subject named: the paired assertion beside a clean result, so a fixture that never produced a roster cannot pass a test that meant to check one.

export declare function assertReached(result: AdapterContractResult, subjects: readonly ContractSubject[]): void;

assertReconnectWithMissedAssignments

function

Validates an authoritative reconnect snapshot containing assignments missed while offline.

export declare function assertReconnectWithMissedAssignments<C extends Channel>(before: Snapshot<C>, reconnect: ProviderEventEnvelope<C>, missedTaskIds: readonly string[]): void;

assertWrapTimeout

function

Validates the deadline derived from media end and the task's fixed wrap allowance.

A task with no allowance has no deadline, so observedDeadline must then be undefined: a host counting down what the provider left open is the violation, and so is a host counting nothing down when the provider set a clock. toleranceMs absorbs scheduler jitter in a real implementation; pass 0 to demand an exact match.

export declare function assertWrapTimeout(task: Pick<TaskCompletion, "completionMode" | "wrapAllowance">, mediaEndedAt: string, observedDeadline: string | undefined, toleranceMs?: number): void;

BreakCandidate

interface

One provider as the host sees it when freezing the providers a break attempt asks.

export interface BreakCandidate {
    id: string;
    authentication: AuthenticationState["status"];
    /** Whether the agent can currently receive work from it: connected, with a capacity stated. */
    holdsCapacity: boolean;
}

BreakOnTaskStep

interface

One published moment of a break asked for on a task: the approval, and how many tasks were outstanding.

export interface BreakOnTaskStep {
    approval: BreakApproval;
    outstanding: number;
}

BreakStream

class

What a stream has said about the agent's break, and the moves it may not make.

export declare class BreakStream {
    private approval;
    /** Takes the break state a snapshot carries as the point the stream continues from. */
    seed(snapshot: unknown): void;
    /** Applies one envelope and returns the moves it may not make given where the break stood. */
    apply(envelope: unknown, path?: string): ProtocolViolation[];
}

BrowserIsolationScenario

type

One browser in one task of one provider. providerId is Manifest.id, never displayName.

export type BrowserIsolationScenario = BrowserSessionKeyInput;

ContractSubject

type

export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;

exerciseAdapter

function

Adapter conformance exercise: validates the manifest, opens an authenticated session, connects, checks that every method the declarations require is implemented, subscribes, validates the snapshot, every delivered event, and every authentication state the session publishes during the run -- against the latest login, since capabilities are current, not fixed -- states a capacity, then unsubscribes and disconnects.

Violations are collected rather than thrown from inside the adapter's own dispatch path. Throwing from a subscribe listener unwinds through the provider for a synchronous emitter, and is swallowed as an unhandled rejection for an asynchronous one — which would let a non-conforming async adapter pass.

export declare function exerciseAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: ExerciseAdapterOptions): Promise<AdapterContractResult>;

ExerciseAdapterOptions

interface

export interface ExerciseAdapterOptions {
    /** Return violations in the result instead of throwing. Defaults to `false`. */
    collectOnly?: boolean;
    /**
     * Drive one ordinary lifecycle on the first task the provider offers -- accept it, open its
     * media on a softphone, hold and resume where offered, end the call where offered, complete it
     * where the agent completes -- so the rules about a live call are reached rather than listed
     * under `notExercised`. Off by default, since it issues commands against whatever platform the
     * adapter is connected to: turn it on against a test backend.
     */
    drive?: boolean;
    /** How long the drive waits for each thing the provider owes it. Defaults to 5000. */
    driveTimeoutMs?: number;
}

stillHost

function

A host that reports one thing and never changes: what most adapter tests hand exerciseAdapter.

export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host;

TaskStream

class

What a stream has said about the tasks it carries, and the rules across events.

export declare class TaskStream {
    private readonly tasks;
    private readonly dials;
    /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
    dialled(dialId: string): void;
    private noteDials;
    private static stated;
    /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
    seed(snapshot: unknown): void;
    /** Applies one envelope and returns what it may not say given what came before. */
    apply(envelope: unknown, path?: string): ProtocolViolation[];
}