// The wire contract, as the SDK sees it — a self-contained copy so the package // has ZERO repo-internal ($lib) imports and publishes / builds with plain tsc. // // This mirrors the server's source of truth (src/lib/types/companion-protocol.ts); // a drift test (protocol.drift.test.ts) keeps PROTOCOL_VERSION + the type unions // in sync. Adding an outbound type is non-breaking (receivers ignore unknowns). export const PROTOCOL_VERSION = 1 as const; /** App → Pouchy (control/data plane). */ export const INBOUND_TYPES = [ 'hello', 'input.text', 'context.event', 'context.snapshot', 'tool.result', 'control.start_call', 'control.end_call', 'control.set_modalities', 'control.ping' ] as const; export type InboundType = (typeof INBOUND_TYPES)[number]; /** Pouchy → app (control/data plane). * NOTE: every type here has a server producer since 0.58.0 (design #25): * `companion.audio` (a TTS clip of the reply, when the agent opted in and the * session negotiated `voice` on a token holding the `voice` scope) and * `companion.expression` (an avatar cue from the reply's tone, opt-in) were * the last two reserved ones. The set of reserved types — now empty — is * pinned against the server's producers by outbound-producers.census.test.ts, * so a marker here is only ever as true as that census says. */ export const OUTBOUND_TYPES = [ 'hello.ack', 'companion.message', 'companion.audio', // a TTS clip of the reply (design #25; opt-in per agent, voice modality + scope) 'companion.tool_call', 'companion.ui_action', 'companion.ui_update', 'companion.expression', // an avatar cue from the reply's tone (design #25; opt-in per agent) 'companion.voice_inject', 'companion.confirm_request', 'companion.social_message', 'companion.typing', 'companion.data_activity', // metadata-only Data-plane activity (requires the data.activity scope) 'companion.skill_card', // a skill's own card_template rendered against its response (requires the skills.execute scope) 'control.call_ready', 'control.error', 'control.usage' // per-turn metering echo, emitted after an input turn that billed tokens ] as const; export type OutboundType = (typeof OUTBOUND_TYPES)[number]; export type MessageType = InboundType | OutboundType; /** `control.error` STREAM-plane code vocabulary (0.30.0) — a separate, smaller * vocabulary from COMPANION_ERROR_CODES below (those tag HTTP `{ok:false}` * response bodies; these tag `control.error` envelopes on the event stream, * surfaced by `onError`). Self-contained mirror of the server list * (drift-tested); APPEND-ONLY. The SDK itself synthesizes one more, * `stream_unauthorized` (errors.ts) — the public union `ControlErrorCodeValue` * covers both plus a forward-compat string arm. */ export const CONTROL_ERROR_CODES = [ 'agent_error', // a server-side turn failed after the input was accepted — no reply was produced; safe to re-send 'call_mint_failed' // start_call couldn't mint the voice-provider credential — retry later or fall back to text ] as const; export type ControlErrorCode = (typeof CONTROL_ERROR_CODES)[number]; /** HTTP error `code` vocabulary — what `CompanionError.code` can hold. A * self-contained mirror of the server's api-error.ts list (drift-tested); * APPEND-ONLY: renaming or removing a code is a breaking SDK change. * Consumers can switch on these instead of string-matching error prose. */ export const COMPANION_ERROR_CODES = [ 'missing_token', // 401 — no Authorization: Bearer header 'invalid_token', // 401 — token unknown, revoked, or expired 'missing_scope', // 403 — token lacks a required grant 'invalid_request', // 400 — malformed JSON / missing or invalid fields 'session_not_found', // 404 — sessionId expired or never started 'turn_pending', // 409 — a turn is paused awaiting tool results 'no_pending_tools', // 409 — tool result posted but nothing is pending 'unknown_call', // 404 — tool result for a callId the turn didn't issue 'payload_too_large', // 413 — body/image over the documented cap 'rate_limited', // 429 — turn burst / demo daily budget spent; Retry-After + retryAfterSec say when 'forbidden', // 403 — authorization denial other than a missing scope 'unavailable', // 503 — backing store/provider not configured or down 'confirm_not_found', // 404 — confirmId unknown or not on this session 'confirm_resolved', // 409 — confirm already approved/denied/expired (single-use) 'step_up_required', // 401 — approval needs a passkey assertion (fetch confirm/stepup) 'step_up_failed', // 401 — the passkey assertion didn't verify; retry the step-up 'quota_exhausted', // 402 — the account's monthly allowance (credits, or realtime voice minutes) is spent; upgrade the plan or wait for the 1st (UTC) reset. NOT retryable — contrast rate_limited (429), which is 'not_found', // 404 — a resource addressed by id does not exist, or is one this token cannot see (deliberately the same answer: refusals must not map the space). First user: DELETE /memory/{factId} (0.53.0) 'mandate_absent' // 404 — getMandate() on an approval that carries no mandate (a deny, an approval that needed no step-up, a pre-feature row); getMandate() returns null ] as const; export type CompanionErrorCode = (typeof COMPANION_ERROR_CODES)[number]; /** The single envelope every control/data-plane message shares. */ export interface CompanionEnvelope { v: typeof PROTOCOL_VERSION; id: string; session?: string; ts: number; type: MessageType; payload: T; } /** Payload of a `companion.ui_action` event — an Instant UI surface for the host * to render. `interface` is the platform-neutral genui schema: a tree of UI * `nodes` (Text/Slider/Button/Chart/…), an optional `state` bag input atoms * read+write, and optional `computed` derived values. The SAME shape is drawn by * a web embed, a native iOS/Android renderer, or a CLI/TUI — only the renderer * differs per platform, never the contract. Kept structurally loose here (the * SDK package carries no $lib import); the authoritative schema lives in * src/lib/genui/schema.ts and the renderer-contract doc. When the panel sets * `reportChanges`, the user's edits stream back as a normal text turn. */ export interface RenderInterfacePayload { interface: { title?: string; nodes: Array<{ type: string; [k: string]: unknown }>; state?: Record; computed?: Record; reportChanges?: boolean; }; } /** Payload of a `companion.confirm_request` event — the companion is asking the * user to approve a sensitive op (a payment, a skill run, a friend message). * Who may approve depends on the token: a PLATFORM SESSION TOKEN (an end-user * instance minted via /v1/sessions) resolves it directly with * `client.confirmAction` — the embedding app's end user is that account's * human. A first-party user's token can only OBSERVE: approval of a real * Pouchy user's ops is authed as that user (their app / a hosted confirm * page), so a third-party DOM can never confirm a spend, and the first-party * host can gate approval behind a biometric (Face ID / passkey) — see * docs/companion-wallet-noncustodial.md. */ export interface ConfirmRequestPayload { /** Opaque id the first-party surface passes to the confirm endpoint. */ confirmId: string; /** The scope of the op needing approval (e.g. `wallet.spend`). */ scope: string; /** A human, user-facing summary of what approving will do. */ summary: string; /** The same sentence as DATA — an i18n key (`confirmSummary.*`) plus its * placeholder values — so a host that knows the user's language can render * the approval in it. `summary` stays the English fallback and is always * present; these two are absent for a tool that ships no keyed summary and * on confirmations recorded before this field existed, so render * `summaryKey ? localize(summaryKey, summaryVars) : summary`. The server * cannot do this for you: the turn endpoint carries no locale. */ summaryKey?: string; summaryVars?: Record; /** Advisory: when true, the first-party approval surface should require a * stronger user gesture — a biometric / passkey (Face ID, Touch ID) — before * approving, not just a tap. Set for irreversible money ops (wallet.spend). * Cryptographic enforcement is server-side follow-up; this is the host's cue. */ stepUp?: boolean; } /** One still-pending confirmation, as returned by `client.pendingConfirms()` — * display-safe (never the recorded tool call or its raw args). */ export interface PendingConfirm { confirmId: string; /** The scope of the op needing approval (e.g. `skills.execute`). */ scope: string; /** A human, user-facing summary of what approving will do. */ summary: string; /** The same sentence as DATA — an i18n key (`confirmSummary.*`) plus its * placeholder values — so a host that knows the user's language can render * the approval in it. `summary` stays the English fallback and is always * present; these two are absent for a tool that ships no keyed summary and * on confirmations recorded before this field existed, so render * `summaryKey ? localize(summaryKey, summaryVars) : summary`. The server * cannot do this for you: the turn endpoint carries no locale. */ summaryKey?: string; summaryVars?: Record; /** Epoch ms when the companion asked. */ createdAt: number; /** Which of the two still-actionable rows this is (0.61.0). `pending` is a * request the user has never answered. `exec_failed` is one they ALREADY * approved, whose execution then flaked — only the idempotent wallet pay * tools reach it, and approving again re-runs the SAME request (keyed on * `confirmId`) rather than starting a new one, which is the safe recovery. * Render the two differently: before this field existed they arrived under * one "Approve?" button and nothing said which. Absent from an older * server; treat an absent or unrecognized value as `pending`. */ status?: 'pending' | 'exec_failed' | (string & {}); /** `exec_failed` only (0.61.0): the flake was the execution DEADLINE, so the * callee was left RUNNING and may still complete on its own — the opposite * truth from a definite failure, and the reason `status` alone is not * enough to write the honest sentence. Absent otherwise. */ execUnsettled?: boolean; /** Advisory, same meaning as `ConfirmRequestPayload.stepUp` (0.61.0): the * first-party approval surface should require a passkey / Face ID before * approving this one. Computed with the predicate the approve endpoint * ENFORCES (the row's scope unioned with the executing tool's), so a card * rebuilt from this snapshot announces exactly what the approval will * demand — before this field existed a reload mid-`wallet.spend` rebuilt * the card as an ordinary tap and the user met an unannounced biometric * prompt. Absent from an older server. */ stepUp?: boolean; } /** Payload of a `companion.tool_call` event — the companion is asking the host * to run one of the app-declared tools it was given at `hello`. `args` is the * raw JSON string the model produced (parse it yourself); reply by posting the * result back with `client.sendToolResult(id, …)`. */ export interface ToolCallPayload { /** Correlation id — echo it back on the tool result. */ id: string; /** The declared tool name the model chose. */ name: string; /** The model's arguments as a raw JSON string. */ args: string; /** True when this delivery is a REPLAY of a still-outstanding call from a * turn that paused before a reconnect (see hello.ack `pendingToolCalls`) — * the app may have already performed it and crashed before posting the * result. Side-effecting tools should treat `id` as an idempotency key. * Absent on the original live delivery. */ replayed?: boolean; } /** One outstanding tool call from a turn that paused BEFORE this handshake — * carried on `hello.ack` (0.37.0) so an embed that lost its state (tab * reload / crash mid-pause) can rediscover what it owes and COMPLETE the * paused turn instead of abandoning it via `/end`. Post each result with * `sendToolResult(id, …)` exactly like a live `companion.tool_call`; the * server's apply is idempotent per id, so double-posting is safe. */ export interface PendingToolCall { /** Correlation id — echo it back on the tool result. */ id: string; /** The declared tool name the model chose. */ name: string; /** The model's arguments as a raw JSON string. */ args: string; /** Correlation id of the paused turn (when the pausing client minted one). */ turnId?: string; /** Epoch ms the turn paused / last progressed — judge staleness before * replaying a side-effecting call. */ pausedAt?: number; } /** Payload of a `companion.ui_update` event — a live change to an * already-rendered Instant UI panel: write each `{ key, value }` into the * panel's state bag (re-evaluating templates / progress / `when`), without * rebuilding. `panelId` targets one of several on-screen panels when present. */ export interface InterfaceUpdatePayload { update: { panelId?: string; updates: Array<{ key: string; value: string | number | boolean }>; }; } /** Payload of a `companion.audio` event — a TTS clip for the assistant's reply on * a non-call modality. Either a playable `url` or an opaque `ref` the host * resolves. */ export interface AudioClipPayload { url?: string; ref?: string; } /** Payload of a `companion.expression` event — avatar cues for an embed rendering * the companion's body (e.g. a VRM). What is PRODUCED today (server 0.58.0): * `expression`, a VRM 1.0 preset (happy / sad / angry / surprised / relaxed / * neutral) read from the reply's tone, when the agent opted in (reply cues). * `viseme` and `gesture` are declared for the shape and stay unused. A * pure-text/audio host can ignore it. */ export interface ExpressionPayload { viseme?: string; expression?: string; gesture?: string; } /** Payload of a `control.usage` event — a per-turn metering echo the host can * surface in a usage/billing view. Emitted after a companion input turn that * billed tokens, carrying the same whole-turn totals as the input done frame's * `usage` block; subscribe via `client.onUsage(...)`. All fields optional. */ export interface UsagePayload { chatTurns?: number; voiceSeconds?: number; memoryOps?: number; /** Whole-turn prompt tokens (sum across hops). */ promptTokens?: number; /** Whole-turn completion tokens (inclusive of `reasoningTokens`). */ completionTokens?: number; /** Whole-turn prefix-cache hits. */ cachedTokens?: number; /** Reasoning tokens — a subset of `completionTokens`, not additive; present * only when the served model reports it. */ reasoningTokens?: number; /** The served model of the final billed hop (fallback-aware), when known. */ model?: string; } /** Payload of a `companion.social_message` event — an inbound A2A message from * one of the user's paired friends, delivered cross-app to any live embed whose * token holds `social.message`. Lets a native/web host surface friend messages * in real time without the first-party Pouchy app. Only plain human messages are * delivered (Pouchy-internal structured envelopes are not). */ export interface SocialMessagePayload { /** The sender's Pouchy uid. */ fromUid: string; /** The sender's display name. */ fromName: string; /** The message text. */ content: string; /** ISO timestamp of delivery. */ createdAt: string; } /** Payload of a `companion.voice_inject` event — a `voiceRelevant` world-state * line the companion should say aloud DURING a live call (the app pushed the * moment via `sendWorldState`; the server decided it's worth voicing). `speak` * is the host's cue to route `text` to the active voice session. A text-only * host can ignore it — the moment still shaped the next turn's context. */ export interface VoiceInjectPayload { text: string; speak: boolean; } /** Payload of a `companion.typing` event — an activity indicator bracketing the * model work of a turn: `active:true` when the companion starts working, * `active:false` when it emits its reply or pauses for an app tool result. It * spans the whole turn — the tool-loop / thinking phase BEFORE the first text * delta, which `onDelta` can't observe — so a host can show a "typing…" state * uniformly across streaming and non-streaming replies. Advisory: a lost event * never affects the turn; the terminal `companion.message` is authoritative. */ export interface TypingPayload { active: boolean; } /** A file the turn PRODUCED (create_spreadsheet / create_document), carried * structurally on the reply (0.54.0). `url` is a 7-day capability URL — the * same link the reply text contains — so a host can render a download * affordance without parsing prose the model may rewrite or drop. */ export interface MessageAttachment { fileId: string; fileName: string; mimeType: string; url: string; } /** Payload of a `companion.message` event — the companion's text reply. When * the turn was started by this client with sendText, `replyTo` echoes the * request's turnId (0.28.0) so a reply can be correlated to its turn; * proactive messages (world-state reactions, confirm outcomes) carry none. * `attachments` is present only when the turn produced files (0.54.0). */ export interface MessagePayload { text: string; replyTo?: string; attachments?: MessageAttachment[]; } /** A live world-state event the app streams in. `retained:true` = latest-value * state (coalesced); `retained:false` = a transient moment carrying `salience` * and optional `voiceRelevant`. */ export interface WorldStateEvent { specversion: '1.0'; id: string; source: string; /** Namespaced dotted kind, e.g. `game.player.hp`, `game.event.boss_spawned`. */ type: string; time?: string; data: D; retained?: boolean; ttl?: number; salience?: number; voiceRelevant?: boolean; } /** Payload of a `control.error` event — a typed error on the event stream * (surfaced by `onError`). `code` is one of CONTROL_ERROR_CODES, the * SDK-synthesized `stream_unauthorized`, or a code newer than this SDK build * (the open arm keeps forward compat without losing autocomplete). */ export interface ControlErrorPayload { // eslint-disable-next-line @typescript-eslint/ban-types code: ControlErrorCode | (string & {}); message: string; } /** Payload of a `hello.ack` — the handshake reply. On the REST plane this is * the `/session` response body (what `connect()` normalizes into `HelloAck`); * the event stream's greeting `open` frame carries a partial echo (just * `{ resumeCursor, pendingToolCalls? }` — the latter only when reconnecting * mid-pause), so every field is optional at the wire level. */ export interface HelloAckPayload { session?: string; grantedScopes?: string[]; modalities?: string[]; resumeCursor?: number; representative?: boolean; visitorPaired?: boolean; /** Outstanding tool calls from a turn that paused before this handshake — * present (non-empty) only when resuming a session mid-pause. See * PendingToolCall; the SDK re-emits them to onToolCall with * `replayed: true` unless `replayPendingToolCalls: false`. */ pendingToolCalls?: PendingToolCall[]; } /** Payload of a `control.call_ready` event — the stream echo of `start_call`'s * accept. Deliberately SECRET-FREE (`provider` plus non-secret metadata such * as `agentId` / `model` / `voice`); the actual WebRTC credentials only ride * the `startCall` HTTP response. */ export interface CallReadyPayload { provider: string; [k: string]: unknown; } /** Per-event payload types, keyed by outbound event name — what `client.on()` * narrows envelopes with (0.30.0). Type-only; extending the base Record keeps * indexing valid for every OutboundType, so an event added to the vocabulary * before it gets a dedicated payload type simply reads as `unknown`. */ export interface OutboundPayloadMap extends Record { 'hello.ack': HelloAckPayload; 'companion.message': MessagePayload; 'companion.audio': AudioClipPayload; 'companion.tool_call': ToolCallPayload; 'companion.ui_action': RenderInterfacePayload; 'companion.ui_update': InterfaceUpdatePayload; 'companion.expression': ExpressionPayload; 'companion.voice_inject': VoiceInjectPayload; 'companion.confirm_request': ConfirmRequestPayload; 'companion.social_message': SocialMessagePayload; 'companion.typing': TypingPayload; 'companion.data_activity': DataActivityPayload; 'companion.skill_card': SkillCardPayload; 'control.call_ready': CallReadyPayload; 'control.error': ControlErrorPayload; 'control.usage': UsagePayload; } /** `companion.data_activity` — a Data-plane operation the agent performed * server-side: a View read or a settled Action. METADATA ONLY by contract * (capability name + kind + outcome + timing; actions add their stable * actionId) — never rows, never intent values, never endpoints. `unknown` * is a real Action outcome, passed through verbatim: render it as "pending * verification", not as success or failure. Emitted only when the session * token carries the `data.activity` scope (granted from the agent template's * Data flag). */ /** Payload of a `companion.skill_card` event — the rendered `card_template` of * a skill whose tool the companion just ran server-side, so your app can show * the result as a CARD instead of only hearing it described in the reply. * * The shape is bounded by the skill AUTHOR, not by the model: `card_template` * is a manifest field written at authoring time, parsed and safety-reviewed at * install. This event carries that template substituted against the skill's own * HTTP response — it is a delivery channel for a declared shape, never an * arbitrary payload the model can compose. * * `markdown` is TEXT, deliberately not HTML: a host handed HTML tends to put it * in `innerHTML`, and the non-web SDKs cannot use it anyway. Render it with * whatever markdown path your app already has. Placeholders the response did * not fill are stripped, so a card never shows `{{price}}` to a user. * * Delivered only to sessions holding `skills.execute` — the scope that let the * call happen — and only for skills whose author shipped a template, so a * handler that never fires means "no template", not "no result". * * Ordering: like every outbound event it carries the session's monotonic `seq`, * so it is totally ordered against the `companion.message` that describes it. */ export interface SkillCardPayload { /** The installed skill's name, as the model named it. */ skill: string; /** The tool of that skill which produced the response. */ tool: string; /** The author's `card_template` with `{{placeholders}}` substituted from the * response body. Markdown; unfilled placeholders removed. */ markdown: string; } export interface DataActivityPayload { kind: 'view' | 'action'; capability: string; /** view: ok | unavailable | error · action: committed | rejected | unknown */ outcome: string; ms: number; actionId?: string; }