Pouchy Companion Protocol (v1 draft)
The interface a third-party website / app / game / hardware uses to embed the Pouchy companion — chat, play, watch, spend, execute — with shared memory and bidirectional, real-time data (the app streams live context in; the companion streams actions out). Standards-first: a partner that already speaks MCP / AG-UI / CloudEvents / WebRTC / OAuth integrates with almost no Pouchy-specific learning.
Status: live in REST/SSE form. The auth layer (Personal Access Tokens + "Login with Pouchy" OAuth 2.1), session lifecycle, world-state ingest, the server agent loop that fills the outbound stream, and mid-call voice context injection are all shipped (
/api/companion/session…). The WebSocket plane is not served by pouchy.ai (a serverless runtime cannot hold a socket): the client acceptsstream: 'websocket'and falls back to SSE automatically against it. It IS served by an optional self-hosted WebSocket gateway worker (a small always-on process that ships with the platform source and proxies a session's SSE stream over one socket) — a host that can hold sockets runs it and points the SDK's stream at it; every frame still comes from the platform's SSE route with the same cursor, so read every "WebSocket" mention below as the same envelope/event contract SSE carries, over one socket.
0. Three planes
| Plane | Transport | Carries |
|---|---|---|
| Control + data | REST + SSE (POST …/input, GET …/stream; Bearer auth). Optional WebSocket for the receive half: wss://<gateway>/api/companion/session/{id}/ws, served by the self-hosted gateway worker (§7; streamBaseUrl names the gateway origin, SDK 0.59.0) — pouchy.ai serves no socket route, and against it the client degrades to SSE. Same envelope either way |
user input, inbound world-state, outbound companion events |
| Media (call mode) | WebRTC P2P to the voice provider after a token mint | live audio in/out — Pouchy stays out of the audio path |
| Stateless | REST (OpenAPI 3.x) | token mint, TTS/STT, vision/image, key management |
The control/data plane is also the signaling + bridge that feeds live world-state into the voice session (§5). That plane is REST + SSE; the WebSocket gateway (§7) carries the identical envelope over one socket for a host that runs it.
1. Authentication
Every connection carries a Personal Access Token the user generated via
POST /api/companion/keys (signed-in first-party user; no in-app Wallet
panel for this yet):
Authorization: Bearer pchy_<opaque-secret>
- The token represents one user; the same token = the same companion + the same memory on every surface. (Multi-user products use "Login with Pouchy" OAuth at P1 — same bearer shape downstream.)
- The token carries scopes and modalities chosen at creation. Sensitive
scopes (marked below) are off unless the user opted in. The full
vocabulary (source of truth:
src/lib/types/companion-scopes.ts, also exported typed from the SDK asCOMPANION_SCOPES/SENSITIVE_SCOPES/DEFAULT_SCOPES/hasScope(granted, required)since@pouchy_ai/companion-sdk0.19.0, drift-guarded against the server list):chat— text turnsvoice— TTS / STTcall— realtime WebRTC voice sessionfiles— multimodal file in/out (vision / image)events.subscribe— receive the outbound companion event streamworldstate.write— push inbound live world-state / context eventsui.render— let the companion render Instant UI surfaces on the hostdata.activity— receive metadata-only Data-plane activity frames (capability + outcome + timing; never rows or intent values). Granted automatically from the agent template's Data flag.chat.instructions— carry developer-authored per-turn instructions on a text turn (input.text/POST …/inputfieldinstructions). NOT granted by default, and the reason is the security of the field rather than caution:instructionsis joined to the prompt WITHOUT being scored by inbound moderation, and the session token that reaches/inputis routinely held by the end user's own client. Honouring it therefore has to be a deliberate act by whoever mints the key. How to obtain it: setinstructions: trueon the AGENT TEMPLATE (Dashboard → Agents → the agent, or the template PATCH) — the samegenui → ui.rendershape. Asking for it in the mint body does nothing:validateSessionRequestnarrows requested scopes to the default set, which this one is deliberately outside of.memory.read:app/memory.write:app— this app's own memory namespacememory.read:core/memory.write:core— the shared global brain (SENSITIVE)skills.execute— run the user's installed skills (SENSITIVE)wallet.read— read-only balance + own deposit address (receive-only)wallet.spend— spend from the Care Wallet (SENSITIVE)social.message— message friends via A2A (SENSITIVE)represent— open visitor-facing representative sessions (SENSITIVE)expose:knowledge— let the representative draw on the owner's knowledge base (SENSITIVE)expose:facts— widen exposed facts past the identity/preference floor (SENSITIVE)represent:pair— let a representative pair a visitor as a friend (SENSITIVE)represent:remember— durable per-visitor notes across visits (SENSITIVE)
- WS upgrade and every REST call verify the token via
requireAppToken(src/lib/server/app-token.ts). 401 = missing/invalid/revoked; 403 = missing a required scope.
2. Message envelope (control/data plane)
All control/data-plane messages (SSE today, WebSocket when it lands) share one JSON envelope (AG-UI-aligned event types):
{
"v": 1, // protocol version
"id": "msg_01H…", // sender-generated; idempotency key for effectful inbound
"session": "sess_01H…", // assigned by hello.ack
"ts": 1718600000123, // unix ms
"type": "context.event", // see §3
"payload": { /* per-type */ }
}
Conventions: text/JSON for web/app; protobuf for the same schema on bandwidth-
constrained hardware (§7). Unknown types are ignored (forward-compat).
3. Message types
Inbound (app → Pouchy)
type |
payload | notes |
|---|---|---|
hello |
{ modalities, handles[], contextKinds[], tools[] } |
capability handshake; first message. tools is CompanionToolDecl[] — { name, description?, parameters? } with JSON-Schema parameters — the actions the app offers for companion.tool_call. On a re-handshake that resumes a live session, omit tools / handles / contextKinds to keep the previously declared set; send an explicit [] to clear it |
input.text |
{ text, instructions? } |
a user turn (text modality). instructions (SDK 0.63.0, needs chat.instructions) is developer-authored guidance for THIS turn — joined to the prompt after the history and immediately before the user's message, and never scored as user speech by inbound moderation. 32,000 chars max; a longer one is 413 payload_too_large rather than a silent truncation. It is not persisted: no turn-log row, no memory distillation, no recall key, so it governs the turn it rides and nothing after it (it does survive a pause/resume within the same logical turn). Without it, an integrator with per-turn rules had to prepend them to text, where the classifier scored the developer's prose as the user's — see issue #3379. Not behaviour-neutral vs text — the platform derives reasoning effort, tool-forcing and memory recall from the USER message, and text is persisted into history while instructions is not; see the /input row in companion-api-reference.md for the four named differences (issue #3388) — including that prose in text is moderated as user speech even when its content is a refusal policy, which instructions is not. |
context.event |
CloudEvents envelope (§4) | live world-state — the play-along channel |
context.snapshot |
{ kind, payload } |
full replace of a retained kind (e.g. level loaded) |
tool.result |
{ callId, ok, result? } |
result of a companion.tool_call the app performed (REST: POST …/tool-result). A failure is ok: false (put any detail in result) — there is no separate error field; the server reads only these three keys |
control.start_call / control.end_call |
{} |
open/close the voice media plane |
control.set_modalities |
{ modalities } |
change active I/O mid-session |
control.ping |
{} |
heartbeat |
Outbound (Pouchy → app)
type |
payload | notes |
|---|---|---|
hello.ack |
{ session, grantedScopes, modalities, representative, visitorPaired, resumeCursor, pendingToolCalls? } |
handshake reply. pendingToolCalls (SDK 0.37.0; non-empty only when resuming a session mid-pause) lists the outstanding tool calls of a turn paused on the app's declared tools before this handshake — PendingToolCall[]: { id, name, args, turnId?, pausedAt? } — so an embed that lost its state (reload / crash while the companion waited for results) can COMPLETE the paused turn by posting each result via tool.result (idempotent per id) instead of abandoning it via /end. Rides the /session REST response AND the stream's greeting open frame. |
companion.message |
{ text, replyTo?, attachments? } |
the assistant's reply text; attachments (0.54.0) is present only when the turn PRODUCED files (create_spreadsheet / create_document) — MessageAttachment[]: { fileId, fileName, mimeType, url }, url a 7-day capability download URL (the same link the reply text contains, carried structurally so hosts never parse prose); replyTo echoes the input POST's turnId (API 1.1) so a client can correlate the reply to its turn — absent on proactive messages (world-state reactions, confirm outcomes). turnId also dedupes: re-POSTing a completed turnId within ~60 min (last 32 per session) returns the recorded reply ({ seq, duplicate: true }) instead of a second billed turn. Token deltas ride the input POST's SSE response, §3.1 — never this envelope. Pouchy strips its own internal state/memory objects from this text server-side, so it never carries a leaked {summary,keyTopics,emotionalArc} / {insight,importance} / {domain,key,value,label} / {content,kind,importance,valence} blob; JSON you explicitly ask the companion to produce is preserved. |
companion.audio |
{ url | ref } |
a TTS clip of the reply, non-call modality (design #25): url is a capability URL of an mp3. Emitted after a text turn when the agent opted in (replyCues.audio), the session negotiated voice and the token holds the voice scope; never on A2A / channel surfaces. ref stays unused. |
companion.tool_call |
{ id, name, args, replayed? } |
asks the app to perform a declared action (e.g. game.highlight). replayed: true (SDK 0.37.0) marks a client-side RE-delivery of a still-outstanding call after a mid-pause reconnect (see hello.ack.pendingToolCalls) — the app may already have performed it before crashing, so side-effecting tools should treat id as an idempotency key; absent on the original live delivery |
companion.ui_action |
{ interface } |
render an Instant UI panel — interface is the platform-neutral genui schema ({ title?, nodes, state?, computed?, reportChanges? }). Gated by the ui.render scope. See companion-instant-ui.md. |
companion.ui_update |
{ update } |
live update to an already-rendered panel — update is { panelId?, updates: [{key,value}] }, written into the panel's state bag (no rebuild). Also ui.render. |
companion.expression |
{ viseme? , expression?, gesture? } |
avatar cues for embeds rendering the VRM (design #25): expression is a VRM 1.0 preset (happy / sad / angry / surprised / relaxed / neutral) read from the reply's tone, emitted after a text turn when the agent opted in (replyCues.expression). viseme / gesture stay unused. |
companion.social_message |
{ fromUid, fromName, content, createdAt } |
an inbound A2A message from a paired friend, delivered cross-app to any live embed whose token holds social.message (plain messages only) |
companion.voice_inject |
{ text, speak } |
a voiceRelevant world-state line to say aloud during a live call — route text to your voice session when speak. Subscribe with onVoiceInject. |
companion.confirm_request |
{ confirmId, scope, summary, summaryKey?, summaryVars?, stepUp? } |
a sensitive op needs the user's approval on a trusted surface — stepUp:true is the host's cue to demand a biometric/passkey gesture (set for irreversible money ops). Platform session tokens resolve it with confirmAction; first-party user tokens are observe-only (approval stays first-party). Subscribe with onConfirmRequest. summaryKey/summaryVars carry the SAME sentence as data — an i18n key (confirmSummary.*) plus its placeholder values — so a host that knows the user's language renders the approval in it; summary stays the English fallback and is always present, so render summaryKey ? localize(summaryKey, summaryVars) : summary (absent on tools with no keyed summary and on rows recorded before 0.43.0). |
companion.typing |
{ active } |
activity indicator — active:true when a turn starts working, active:false when it emits its reply or pauses for a tool result. Spans the whole turn (incl. the tool-loop / thinking phase before the first text delta). Advisory; the terminal companion.message stays authoritative. Subscribe with onTyping. |
companion.data_activity |
{ kind, capability, outcome, ms, actionId? } |
a Data-plane operation the agent performed server-side: kind:'view' (outcome ok | unavailable | error) or kind:'action' (outcome committed | rejected | unknown — verbatim; render unknown as "pending verification", never as success or failure). METADATA ONLY by contract: never rows, intent values, endpoints or receipts. Requires the data.activity scope; advisory — the journal stays the ground truth. |
companion.skill_card |
{ skill, tool, markdown } |
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. The shape is bounded by the skill AUTHOR (a manifest field, parsed and safety-reviewed at install), substituted against that skill's own HTTP response — a delivery channel for a declared shape, never an arbitrary model-composed payload. markdown is TEXT, not HTML, deliberately: a host handed HTML tends to innerHTML it, and the non-web SDKs cannot use it. Unfilled {{placeholders}} are stripped. Requires the skills.execute scope; a handler that never fires means the skill ships no template, not that the call failed. |
control.call_ready |
{ provider, agentId?/model?, voice?, ... } |
a call is ready (stream echo of start_call's accept). Deliberately SECRET-FREE — the actual WebRTC credentials only ride the startCall HTTP response. CONDITIONAL, and the condition is operational rather than protocol-level: agentId is secret-free only while the provider agent REQUIRES AUTHENTICATION. On a provider agent that accepts anonymous conversations, the agent id alone is enough to open calls billed to the operator's account — measured live 2026-08-06, where an agent left public carried ~45% of a sampled window's voice spend from a client the operator did not mint tokens for. Operators MUST require auth on the provider agent; that is also what forces every call through the operator's own mint, which is the only place usage can be metered and attributed. |
control.error |
{ code, message } |
typed error — see the code table below |
control.usage |
{ chatTurns, promptTokens, completionTokens, cachedTokens, reasoningTokens?, model? } |
per-turn metering echo, emitted after an input turn that billed tokens — same whole-turn totals as the input done frame's usage block. Subscribe with onUsage. voiceSeconds / memoryOps remain reserved |
control.error codes (a separate vocabulary from the HTTP {ok:false, code}
tags in §9 — these arrive ON the stream, surfaced by the SDK's onError):
| code | emitted by | meaning |
|---|---|---|
agent_error |
server | a server-side turn failed after the input was accepted (model/provider fault) — the turn produced no reply; safe to re-send |
call_mint_failed |
server | start_call couldn't mint the voice-provider credential (provider outage / unconfigured) — the HTTP start_call response also errors; retry later or fall back to text |
stream_unauthorized |
SDK (synthesized) | the event stream got a permanent 401/403 and reconnect gave up — a 401 is retried through onAuthError token refresh (up to 3 attempts) before going permanent; a 403 is immediately terminal (commonly a token missing the events.subscribe scope). Refresh/fix the token (setToken / onAuthError) and start() again |
This table is exported typed since SDK 0.30.0: CONTROL_ERROR_CODES (the
server-emitted rows; runtime source of truth is
src/lib/types/companion-protocol.ts, mirrored into the SDK and drift-tested —
including a scan of every emit site's code literal) plus the
ControlErrorCodeValue union onError's err.code carries (server codes +
the SDK-synthesized stream_unauthorized + an open arm for codes newer than
the installed SDK build). The vocabulary is APPEND-ONLY.
companion.tool_call is offered only for tools the app declared in the
hello/session tools[] ∩ the token's scopes — the agent can't ask a game to
run a payment it didn't opt into. (handles[] is stored but never consumed
server-side today — treat it as a client-side capability hint; a tool the app
wants called MUST be declared in tools[].)
The name you get back is always the name you declared, byte for byte, so
onToolCall can switch on it directly. Providers cap a function name at 64
characters and the session handshake truncates there, but the truncation is a
provider-side detail: the relayed companion.tool_call, the /input response
and the hello.ack.pendingToolCalls replay all speak your name. (Two declared
names that differ only past character 64 still collapse to one — the provider
rejects a duplicate function name outright — so keep the first 64 distinct.)
3.1 Token streaming (the input POST response)
The reply can render as it is generated instead of arriving as one block.
Deltas do NOT travel on the outbound event log (that would mean a durable
write per chunk plus poll latency) — they ride the very request that runs
the turn: POST …/input with { stream: true } answers with
text/event-stream:
| frame | data | meaning |
|---|---|---|
delta |
{ text } |
a raw text chunk of the reply being generated |
reset |
{} |
the text streamed so far was tool-call deliberation, not the reply — clear the partial render |
done |
{ ok, kind: 'message', seq, text, envelope, usage } | { ok, kind: 'tool_calls', toolCalls } | { ok:false, error, status, errorId? } |
the authoritative result. envelope is the SAME companion.message that also lands on the event log. The tool_calls variant is how a REST/SSE turn pauses on your declared tools[] — there is no separate companion.tool_call frame on this plane: read toolCalls[{ callId, name, args }] off the done frame (args is a raw JSON string), execute, then POST …/tool-result with each callId to resume the turn. A parser that treats done as "terminal, discard payload" silently loses every tool call — a real integrator's first-round failure |
The success done frame also carries first-party diagnostic fields —
timings (server-side phase breakdown incl. server_first_delta_ms /
server_total_ms) and promptCache (the final hop's prefix-cache health) —
and the failure shape carries errorId (the sanitized server-error
reference). They are additive telemetry for Pouchy's own perf probes, not
contract: receivers ignore unknown fields by doctrine, and third-party clients
should not build on their shape.
The success done frame additionally carries a usage block for
per-turn cost accounting — { promptTokens, completionTokens, cachedTokens }
(whole-turn sums across every model hop, so they equal the dashboard trace's
tokensIn / tokensOut), plus reasoningTokens (a subset of
completionTokens, not additive — present only when the served model reports
it) and model (the served model, fallback-aware — present when known). The
same usage block is returned on the non-stream JSON ack
({ ok, kind: 'message', seq, usage }) so a host that does not open the SSE
stream still gets it. It is omitted / null on the failure and duplicate acks.
Unlike promptCache, this block is a stable additive field integrators
may persist; it stays out of the versioned envelope union
(PROTOCOL_VERSION unchanged) because it is a transport feature of the input
endpoint, not a wire event.
Contract: deltas are advisory; the final companion.message is
authoritative (the server strips its internal state block from the final
text only). The done frame carries the full envelope so a client can emit
it locally (zero poll latency) and drop the event-log replay by envelope
id — the SDK's onDelta + sendText do exactly this, and onMessage
still fires exactly once per turn. Errors after the stream opens arrive in
the done frame (the HTTP status line is already sent). No envelope-union /
PROTOCOL_VERSION change — this is a transport feature of the input
endpoint.
4. World-state stream — the play-along channel (the crux)
This is what makes 陪玩/陪看 good: the app streams what's happening; the companion
reacts. Use a CloudEvents-shaped envelope inside context.event.payload:
{
"specversion": "1.0",
"id": "evt_01H…",
"source": "game://my-rpg/session/42",
"type": "game.event.boss_spawned", // namespaced kind (see below)
"time": "2026-06-17T12:00:00Z",
"data": { "boss": "Dragon", "arena": "north" },
// Pouchy extensions:
"retained": false, // true = latest-value state; false = transient moment
"ttl": 5000, // ms the value stays relevant
"salience": 0.9, // 0..1 — how much this should grab the companion
"voiceRelevant": true // push into a live voice call (§5)
}
Kind taxonomy (namespaced, dotted): game.player.hp, game.player.location,
game.event.boss_spawned, game.event.level_up, media.playback.position,
media.scene.changed, shop.cart.updated, … Apps declare the kinds they emit in
hello.contextKinds.
Two flavors:
- Retained state (
retained:true): current values (hp, scene, location). The server keeps the latest per kind in a short-TTL per-session buffer; coalesced so a value changing every frame doesn't wake the agent. - Transient events (
retained:false): discrete moments. Carrysalienceand, if it should interrupt a conversation,voiceRelevant.
Ambient vs. trigger: a kind is either ambient (consulted at the next turn — "knows your hp when you ask") or a trigger (proactively prompts a companion reaction — "boss incoming, heal!"). Declared per kind at app registration.
Server handling: raw telemetry never enters the prompt. The server maintains a
rolling compact digest of world-state and injects only that (arbitrated by the
prompt's context budget). salience + per-token rate limits throttle the firehose
and protect the bill. Authenticated + replay-deduped with the same HMAC machinery
as webhooks (src/lib/server/webhook-verify.ts).
5. Realtime voice ↔ world-state (call mode)
In call mode the audio is WebRTC peer-to-peer to the voice provider (ElevenLabs Convai, or OpenAI Realtime as fallback) — Pouchy mints a short-lived token and steps out of the media path:
app → control.start_call
Pouchy → control.call_ready { provider: "elevenlabs-convai", agentId: "…", … } (secret-free; creds ride the HTTP response)
app → opens WebRTC to the provider with that token
The hard part — and the highest lever on quality — is feeding live game context into the in-call model even though Pouchy isn't in the audio loop:
- When a
context.eventarrives withvoiceRelevant: true, the server pushes a compact contextual update into the live voice session over the provider's data channel — ElevenLabs contextual updates, or OpenAI Realtimeconversation.item.create+response.create. So the companion can say "Boss incoming — heal up!" mid-call. This routing is gated on a call-active window the/callmint opens (≤20 min) andPOST /endcloses —connectCall().close()sends that automatically viaendSession(), and since SDK 0.40.1 a connect-phase failure after the mint tears it down the same way; self-plumbedstartCallintegrations should callendSession()at hang-up (and on a failed connect) so text play-along reactions resume immediately instead of after the window lapses. The clear is generation-fenced (SDK 0.41.0):/callreturnscallGen,/endechoes it — a mismatch (stale teardown after a re-mint) no-ops, an explicitcallGen: null(a text client's teardown) leaves a live window alone, and an omitted field keeps the pre-0.41 unconditional clear. - Updates are debounced/coalesced so we never spam the voice model.
- Today the persona/memory prompt is injected only at connect
(
src/lib/services/convai/connection.ts,…/realtime/connection.ts); mid-call injection is net-new and the key thing to validate first. - Fallback for tight coupling (rhythm games, etc.): route audio through a server agent (LiveKit) where Pouchy fully controls the loop — at latency/cost expense. P3 only.
6. Standards mapping
| Concern | Standard |
|---|---|
| Tools / context / Pouchy-as-provider, external agents calling in | MCP (Model Context Protocol) |
| Agent ↔ agent (companion ↔ external agents) | A2A (Agent2Agent) |
| Agent ↔ app event/UI stream | AG-UI event types |
| World-state event envelope | CloudEvents |
| Realtime voice | WebRTC + ElevenLabs Convai / OpenAI Realtime |
| Identity | Bearer/RFC 6750 PAT (v1) · OAuth 2.1 + OIDC + PKCE (P1) |
| REST + schemas | OpenAPI 3.x + JSON Schema |
| Avatar | VRM + standard visemes |
| Payments | x402 |
| Hardware / low-bandwidth | gRPC/protobuf, MCP over Streamable HTTP, SSE/long-poll |
Bespoke (no standard — carried inside the above): the world-state kind
taxonomy, the per-app memory scope/consent model, persona/relationship-stage.
7. Hardware / constrained clients
Same envelope and types, but:
- protobuf encoding option for the WS frames.
- SSE + REST long-poll fallback when WS/WebRTC aren't available.
- MCP over Streamable HTTP as the agent-interop variant (a device that is itself an agent).
- Server-driven TTS/STT (
/api/tts,/api/stt) when the device can't run a WebRTC voice session. Both are auth-gated and metered: send a companion token carrying thevoicescope (it covers TTS and STT — a token granted only chat/memory must not drive operator-billed transcription).
8. Cross-cutting
Handshake (
hello→hello.ack) negotiates modalities, declared action handlers, context kinds, granted scopes, and the protocol version.Resume:
hello.ack.resumeCursor; on reconnect the client replays missed outbound events by cursor. Heartbeats viacontrol.ping.The cursor is a sequence number, not a time. On the SSE transport the server writes it on each event frame's
id:line and repeats the window's final value in the closingreconnectframe's{ cursor }; a client resumes with?cursor=<n>(orLast-Event-ID), and the server answers with the events whose seq is strictly greater. Take it from those two places only — in particular never from an envelope'sts, which is a wall-clockDate.now()stamp: feeding one back as a cursor asks for a sequence past 1.7e12 and matches nothing, so the stream goes silent from the first reconnect onward with no error anywhere. (The Go SDK did exactly this through 0.1.1; see its 0.1.2 entry.)Client-restart recovery of a paused turn (SDK 0.37.0): a turn paused on the app's declared tools survives the app itself dying. Discovery:
hello.ack.pendingToolCallslists the still-outstanding calls when the handshake resumes a session mid-pause. Completion: perform each call and posttool.resultperid(idempotent — a double post is safe), and the turn resumes normally. The SDK automates this: unlessreplayPendingToolCalls: false, each pending call is re-emitted to the app'sonToolCallhandler withreplayed: trueone tick afterconnect()resolves (at most once per client instance), so the existing tool loop completes the turn. Abandon path (unchanged, SDK 0.36-era):/endclears the paused turn and unblocks the session's 409turn_pending— recovery now makes that the fallback, not the only option.Idempotency: effectful inbound (
tool.result, effectfulcontext.event) carries the envelopeid; the server dedupes so SDK retries don't double-apply. For/contextthe dedup window is 60 min and the response'sduplicatesfield reports how much ofacceptedwas replay dedup — stamp STABLE ids on host-level retries (an omitted id is minted fresh per POST and never dedups).Conversation continuity: the server keeps a rolling per-session turn log — each text turn reasons over the last ~12 exchanges (including approved confirm outcomes), and end-of-session memory consolidation falls back to this log when the client sends no transcript. Embedders do NOT need to resend history with each message.
Sensitive actions (spend / message a friend / run a skill) require a confirm, enforced server-side. WHO approves depends on the token: a first-party user's ops confirm only on a Pouchy-controlled surface (hosted widget or their app/phone — never trust the third-party DOM); a platform instance's ops (session tokens minted via
/v1/sessions) confirm via the session itself (client.confirmAction/POST /api/companion/session/{id}/confirm) — the embedding app's end user is that account's only human, and instances are never grantedwallet.spend, so this path can't move money. Pending list for rebuilds:client.pendingConfirms()(GET on the same endpoint).Errors: typed
control.error { code, message }; HTTP uses standard status +{ ok: false, error, code? }JSON —code(v0.21+) is a stable machine-readable tag (invalid_token,missing_scope,session_not_found,turn_pending, …; full table in companion-api-versioning.md) surfaced by the SDK asCompanionError.code. The SDK also synthesizes client-side codes outside that HTTP vocabulary:reply_timeout(awaitReply fallback),needs_event_stream(awaitReply on a tool-pausing turn withoutstart(), SDK 0.28.0),not_connected/missing_option(client-side misuse guards),not_representative(pairVisitoron a non-representative session),aborted(a caller-suppliedAbortSignalcancelled the request, SDK 0.29.0),queued_offline(queued while offline, SDK 0.34.0),request_timeout(therequestTimeoutMsheaders deadline elapsed, SDK 0.35.0; since SDK 0.40.0/0.40.1 the long-work POSTs default to their route's own ceiling — buffered/inputsends,sendToolResultand the knowledge ingests 310 s,confirmAction65 s — their headers only arrive when the server work completes),stream_unauthorized(stream 401 exhausted reconnects), and the voice connect-step codes (SDK 0.26.0)call_unsupported/call_connect_failed/call_dependency_missingonconnectCall/startCallrejections. From SDK 0.28.1 the full list is drift-tested (SDK_SYNTHESIZED_ERROR_CODES).
9. End-to-end example — a game that plays along
1. app → hello { modalities:{text,call},
tools:[{ name:"game.highlight", description:"highlight a UI element",
parameters:{ type:"object", properties:{ target:{ type:"string" } } } }],
contextKinds:["game.player","game.event.*"] }
(`tools[]` is what makes companion.tool_call possible — `handles[]` alone
does not register a tool server-side)
← hello.ack { session, grantedScopes, modalities, resumeCursor }
2. app → control.start_call
← control.call_ready { provider:"elevenlabs-convai", agentId:"…" } (secret-free)
app opens WebRTC to ElevenLabs.
3. during play:
app → context.event { type:"game.player.hp", retained:true, ttl:5000,
data:{ hp:80 } } // coalesced, ambient
app → context.event { type:"game.event.boss_spawned", salience:0.9,
voiceRelevant:true, data:{ boss:"Dragon" } }
4. server pushes a contextual update into the live voice session →
companion SAYS: "Boss incoming — heal up first!"
5. companion → companion.tool_call { id:"tc_1", name:"game.highlight",
args:"{\"target\":\"healPotion\"}" }
(`args` is the model's JSON **string** — parse it, or use the SDK's
pre-parsed `argsJson`)
app highlights the potion, then:
app → tool.result { callId:"tc_1", ok:true }
The tighter steps 3-4 are wired (salience, coalescing, voiceRelevant → injection), the better the companionship feels — which is exactly why this plane gets the most care.