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 yet served: the client acceptsstream: 'websocket'and falls back to SSE automatically, but the server exposes no WS endpoint today — read every "WebSocket" mention below as the planned upgrade of the same envelope/event contract, which SSE carries unchanged in the meantime.
0. Three planes
| Plane | Transport | Carries |
|---|---|---|
| Control + data | REST + SSE (POST …/input, GET …/stream; Bearer auth). Planned upgrade: WebSocket wss://pouchy.ai/api/companion/session/{id}/ws (the URL the shipped stream: 'websocket' client already derives; it degrades to SSE until the endpoint lands), same envelope |
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). Today that plane is REST + SSE; the WebSocket upgrade will carry the identical envelope when it lands (§7).
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 hostmemory.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 } |
a user turn (text modality) |
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? } |
the assistant's reply text; 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 ~10 min 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 } |
TTS clip (non-call modality) (reserved — not emitted yet) |
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 (reserved — not emitted yet) |
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, 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. |
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. |
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 |
control.error |
{ code, message } |
typed error — see the code table below |
control.usage |
{ chatTurns, voiceSeconds, memoryOps } |
metering echo (per token) (reserved — not emitted yet) |
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[].)
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, seq, text, envelope } | { ok:false, error, status, errorId? } |
the authoritative result. envelope is the SAME companion.message that also lands on the event log |
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.
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. - 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. - 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. - 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),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.