Pouchy Companion — API Reference

The complete interface for embedding the Pouchy companion (chat, real-time voice, live world-state, tools, memory) in any app, game, website, or device.

  • Base URL: https://pouchy.ai
  • Transport: HTTPS REST + Server-Sent Events (SSE). Voice is a direct WebRTC session to the provider (Pouchy is out of the audio path).
  • Machine-readable spec: GET /api/companion/openapi.json (OpenAPI 3.1).
  • SDK (recommended): @pouchy_ai/companion-sdk (TypeScript, isomorphic). This reference is for the raw HTTP contract; most integrators should use the SDK and only consult this for details.
  • Python (server-side): pouchy-companion (packages/python-sdk — httpx/SSE
    • pydantic; the protocol vocabulary is GENERATED from the JS SDK's protocol.ts and drift-tested, so this reference applies verbatim). Aimed at game backends / bots; voice/WebRTC stays with the JS SDK.
  • Unity / C#: ai.pouchy.companion (packages/unity-sdk — HttpClient/SSE + Newtonsoft, UPM package; Protocol.cs is GENERATED from protocol.ts and drift-tested). Aimed at Unity games / .NET; voice/WebRTC out of scope. 0.1.0-preview — the wire protocol is drift-guarded, the C# compiles clean in CI under Unity 2021.3's profile (MonoBehaviour included), and the pure .NET client is behaviour-tested (dotnet test, no editor needed); runtime inside a real editor/player is not yet proven. Same stream-silence bound as the Go SDK (StreamStallTimeout 60s, per-read, opt-out) — a half-open drop surfaces as a CompanionError instead of hanging the reader forever. For an always-on subscription, EventStreamPump.RunAsync owns the reconnect loop (immediate on the clean ~45s window close, backoff honouring Retry-After on errors); PouchyCompanionBehaviour rides it, so the drop-in Unity component stays live past its first stream window.
  • Dart / Flutter: pouchy_companion on pub.dev (packages/flutter-sdk — pure Dart, no package:flutter import, so it runs in Flutter apps, Dart CLIs and server-side Dart alike; the protocol vocabulary is GENERATED from the JS SDK's protocol.ts and drift-tested). Voice/WebRTC is deliberately out of scope — mint calls server-side or use the JS SDK in a webview.
  • Go (server-side): github.com/oviswang/Pouchy/packages/go-sdk (packages/go-sdk — stdlib only, zero dependencies; protocol.go is GENERATED from protocol.ts and drift-tested, so this reference applies verbatim). Aimed at the BFF shape the other SDKs do not serve: a backend that mints the session token and proxies the companion plane for an app forbidden from reaching Pouchy directly. Voice/WebRTC out of scope — but a backend that drives a voice call still owes EndSession's WithTranscript, which is the only path from anything said aloud into memory. Note the route-aware deadlines (LongWorkTimeout 310s on /input, /tool-result and /knowledge; ConfirmExecTimeout 65s on ConfirmAction): a flat client timeout under the server's 300s ceiling turns a billed turn into a client timeout whose retry races the first request. Events carries no deadline (a subscription is long-lived) but does bound byte-SILENCE at DefaultStreamStallTimeout 60s, above the route's own 45s window — a half-open drop otherwise hangs the reader forever.
  • Native (Kotlin / Swift): the JS SDK can't be imported into native code — see companion-native-integration.md for reference clients (chat / voice / avatar) + a Matrix 代聊 bridge.
  • Bluesky / AT Protocol: bridge spec (DMs 代聊, 代读/代发, social-graph→pairing) in companion-bluesky-bridge.md.
  • Protocol design notes: docs/companion-sdk-protocol.md. Quickstart: docs/companion-quickstart.md. Voice play-along: docs/companion-voice-integration.md.
  • Zero-code drop-in widget (<iframe src="https://pouchy.ai/embed?token=…">, theme/accent params + postMessage control plane): companion-widget.md.

1. Authentication

Every data-plane call carries a bearer token:

Authorization: Bearer pchy_xxxxxxxx

Three ways to obtain that token — all yield an identical pchy_… bearer:

  1. Platform session token (the standard path for apps with their own users). Create a project in the dashboard, mint a Secret Key, and have your backend call POST /v1/sessions with { agent, external_user_id } — first-seen users are auto-provisioned and each gets their own agent instance (memory, relationship, wallet). See §"Platform API" below and the end-to-end quickstarts (quickstart-romance-companion.md, quickstart-commerce-guide.md).
  2. Personal access token (PAT). The user mints one via POST /api/companion/keys while signed in to pouchy.ai (there is no in-app Wallet panel for this yet), choosing scopes — omitting scopes grants the safe default pack. For personal/single-user integrations where the user brings their own companion.
  3. Login with Pouchy (OAuth 2.1, Authorization Code + PKCE). Register a client, run the code flow, exchange for a token. See §6.

A token is scoped (§2). Session tokens and OAuth access tokens are short-lived (1h) — re-mint per session; PATs do not expire.

Two auth realms. Data-plane endpoints (/api/companion/session/**, /mcp, /memory) take the companion token above. Owner/management endpoints (key management, skill mirroring, confirming sensitive actions) require the first-party user's Firebase ID token — these are called by Pouchy's own app, not by a third-party embed.


2. Scopes

Scope Grants
chat Text turns (input.text).
voice TTS/STT (voice modality).
call Real-time WebRTC voice session.
files Multimodal image input.
events.subscribe Receive the outbound event stream (SSE).
worldstate.write Push live world-state / context.
ui.render Render Instant UI panels on the host (display + forms; not sensitive).
data.activity Receive companion.data_activity metadata frames (Data-plane activity indicators; granted automatically when the agent's Data flag is on; not sensitive).
memory.read:app / memory.write:app Read/write this app's memory namespace.
memory.read:core / memory.write:core Read/write the user's core memory.
The project KNOWLEDGE BASE is not a memory scope and never was (#3393). An agent's knowledgeDocIds corpus is shared to every instance of the project by construction, so turn-assembly recall against it runs on any session bound to that agent — including one minted { scopes: ['chat'] }. What the memory.read:* scopes govern is the per-user fact store (remember() / GET /api/companion/memory / the recall_memory tool), which stays unreadable without them. Until #3393 a single guard answered for both, so a narrow-scope session silently recalled zero documents while the dashboard's debug instance — minted with the full defaults — answered from the same corpus perfectly.
wallet.read Read-only wallet: balance + own deposit address (receive-only; not sensitive).
wallet.spend Sensitive — pay from the user's wallet (confirm-gated).
social.message Sensitive — message the user's friends (confirm-gated).
skills.execute Sensitive — run the user's skills (confirm-gated).
represent Sensitive — open a representative (visitor-facing) session (§3.3).
expose:knowledge Sensitive — let a representative answer from the user's knowledge base.
expose:facts Sensitive — widen the facts a representative may share past the identity/preference floor.
represent:pair Sensitive — let a representative pair a visitor (also a Pouchy user) with the owner as a friend (§3.4).
represent:remember Sensitive — let a representative keep durable per-visitor notes across visits.

Sensitive scopes never act silently — see §7 (the confirm boundary).

The whole vocabulary is exported typed from @pouchy_ai/companion-sdk (0.19.0+): COMPANION_SCOPES, SENSITIVE_SCOPES, REPRESENT_SCOPES, DEFAULT_SCOPES, COMPANION_MODALITIES, plus hasScope(granted, required) / grantsScope(granted, required) (0.38.0+) / isSensitiveScope() / isRepresentScope() — check hello.ack.grantedScopes with compile-checked strings instead of copying them from this table. hasScope is exact-match; grantsScope additionally honours scope subsumption (wallet.spendwallet.read, matching GET /api/companion/wallet), so gate a read-only affordance on grantsScope to avoid hiding a feature the API would serve.


3. SDK quick reference (recommended path)

React? @pouchy_ai/react wraps this SDK in a <CompanionProvider> + useCompanion / useMessages / useTyping / useCall hooks — the handshake, stream, and teardown are handled for you. See packages/react-sdk/README.md.

import { createCompanion } from '@pouchy_ai/companion-sdk';

const c = createCompanion({ baseUrl: 'https://pouchy.ai', token, surface: 'my-app',
                            modalities: ['text', 'voice'], tools: [/* see §3.1 */] });
await c.connect();                       // handshake → { session, grantedScopes, … }
c.onMessage((text) => render(text));     // assistant replies
c.start();                               // open the SSE stream (auto-reconnects)
await c.sendText('hi');                  // a user turn
c.sendWorldState({ type: 'app.scene', data: '…', retained: true });   // live context
const call = await c.connectCall({ locale: 'zh' });                   // real-time voice
Method Purpose
connect() Handshake; returns { session, grantedScopes, modalities, resumeCursor, representative?, visitorPaired?, pendingToolCalls }. pendingToolCalls (SDK 0.37.0; always present, empty when none) = the outstanding tool calls of a turn paused before this handshake, so an embed reloaded mid-pause can complete the turn (§3.1). Since 0.54.2 a re-handshake that mints a NEW session while the event stream is open restarts the stream on the new id (a resume of the same session leaves it alone) — before, the stream stayed bound to the old session and every reply for the new one went undelivered.
start() / stop() Open / close the outbound SSE stream.
close() One-call teardown: stop() + endSession() — the text-session mirror of the call handle's close().
onMessage(fn) / on(type, fn) Subscribe to replies / any outbound event.
sendText(text, opts?) A user text (or multimodal) turn. Streams the reply token-by-token when an onDelta subscriber exists (or opts.stream: true). opts.awaitReply: true resolves with the completed turn's reply { seq, text, envelope } (SendTextReply) instead of { seq } — request/response hosts skip the onMessage wiring; works without start() on a plain streamed turn, but two cases DO need the event stream: the stream:false / old-server fallback, and a turn that pauses on app tool calls (the post-resume reply only arrives on the stream — without start() the SDK fails fast with code: 'needs_event_stream'). Otherwise client-side code: 'reply_timeout' after opts.replyTimeoutMs, default 60 s.
onDelta(fn) Token streaming: fn(chunk, {reset?}) per generated text chunk; onMessage still fires exactly once with the authoritative final text.
sendWorldState(event | events) Push live world-state (§5).
connectCall(opts?) / startCall(opts?) Open a live voice call / mint call creds only. connectCall({ bargeIn: true }) = opt-in full duplex: mic stays open during agent speech so the user can interrupt (provider-native VAD); default half-duplex protects speakerphone use from self-echo interruptions.
call.interrupt() Silent cut (0.36.0): stop the utterance currently playing WITHOUT generating new speech; idempotent. openai-realtime: guaranteed (response.cancel + output-buffer clear). elevenlabs-convai: best-effort (user_activity + guarded internal buffer cut; no public EL stop-playback API — worst case no-op). injectEvent(text, true) remains the guaranteed-but-speaking preemption on EL.
onToolCall(fn) / sendToolResult(callId, {ok, result}) Handle companion tool calls (§3.1). replayed: true on the call (SDK 0.37.0) = re-delivery of a pause-orphaned call after a reconnect — treat id as an idempotency key for side-effecting tools (the server's result apply is idempotent per callId). Opt out of the auto-replay with the replayPendingToolCalls: false client option.
recall({query?, limit?}) / remember(fact) / forget(id) Read / write / forget memory facts. query = semantic search over the token-visible facts (GET ?q=); omit for importance/recency ranking. forget(id) = DELETE /api/companion/memory/{id} (0.53.0): sets userForgotten on the fact — needs the namespace's write scope; a fact the token cannot see answers the same 404; idempotent. Recall rows carry the id it takes.
history({limit}) Read this session's recent conversation turns ({ user, assistant, ts }, oldest→newest) — restore the transcript after a reconnect. Own session only; distinct from recall (durable facts). limit default 20, max 50.
setModalities(modalities) Change the live session's I/O modalities (e.g. toggle voice). Intersected with the token's grant; returns the effective set. POST …/session/{id}/modalities.
ping() Keepalive — bump the session's last-seen time so a long-idle embed stays "live" within the TTL. POST …/session/{id}/ping.
setToken(token) Swap the bearer for subsequent requests + stream reconnects (session tokens expire — 1h default). The onAuthError client option automates it: any 401 calls the hook; a returned fresh token retries transparently.
ingestKnowledge({text, name, kind?, locale?}) Ingest a document (PDF body, transcript, notes) into the user's knowledge base — summary + embedded full-text chunks, shown in "My materials" + cited, recalled like a first-party upload (§3.2).
ingestFile({dataUrl, name, kind?, locale?}) Same, but hand over a raw file — Pouchy understands it server-side (PDF text / audio transcription / image vision caption) before ingesting (§3.2).
flushOutbox() / pendingOutbox() / onOutboxChange(fn) Offline send queue (SDK 0.34.0; opt-in via the queueOffline: true client option, custom persistence via outboxStore). A sendText whose fetch fails at the NETWORK level (or while navigator.onLine === false) resolves { seq: null, queued: true, turnId } instead of rejecting (with awaitReply: true it rejects code: 'queued_offline'; the item is still queued). Since SDK 0.35.0 a MID-STREAM connection drop queues too. HTTP 4xx/5xx are never queued. Queued sends replay FIFO after connect(), on each stream reconnect (honoring a 429's Retry-After since 0.35.0), or explicitly via flushOutbox(){ sent, remaining } — each item reusing its ORIGINAL turnId, which the server dedupes, so a replay can never double-run a turn. Since SDK 0.50.0 a queued send that keeps failing with NO readable status (fetch rejected — what a body over the host's request-body limit produces cross-origin; see “Request body size”) is dropped after OUTBOX_MAX_REPLAY_ATTEMPTS (5) online attempts once it is older than 30 min, so it cannot block the queue behind it forever. Since SDK 0.50.1 the persistence key carries the session's identity: pouchy-outbox:<surface>, or pouchy-outbox-v:<surface>:<visitorId> on a representative session — connect() resolves the session from visitor as well as surface, so a shared surface-only key let one visitor's queued send replay into another visitor's thread on the same browser.

Every HTTP helper is bounded by the requestTimeoutMs client option (SDK 0.35.0; default 30 s, 0 disables) — a headers deadline only, so streaming bodies and the SSE event stream are never cut; expiry rejects code: 'request_timeout'. Exception since SDK 0.40.0/0.40.1: the long-work POSTs (the server answers only when the work completes) default to that work's own ceiling instead of 30 s — buffered /input sends, sendToolResult (the server runs the full continuation before responding) and ingestKnowledge/ingestFile (synchronous summarize + per-chunk embedding) get 310 s; confirmAction (an approve executes the action first) gets 65 s. An explicit requestTimeoutMs still bounds every request at your value; endSession() deliberately keeps 30 s (only its diagnostic return is lost on a slow consolidation — the consolidation itself completes).

3.1 App tools

Declare at connect() the actions your app can perform; the companion calls them, you report results:

createCompanion({ …, tools: [{ name: 'give_item', description: 'Give the player an item.',
  parameters: { type: 'object', properties: { item: { type: 'string' } }, required: ['item'] } }] });
c.onToolCall(async ({ id, name, argsJson }) => {
  const result = doIt(argsJson);           // argsJson = args pre-parsed by the SDK
  await c.sendToolResult(id, { ok: true, result });
});

First-party tool names are reserved: a declared tool whose name matches a built-in the session exposes (remember, find_apps, run_skill, …) is dropped server-side — the built-in wins and your tool is never called. Pick distinct names.

Reload mid-pause? (SDK 0.37.0) While the companion waits for your results the turn is paused — and if the embed dies right then, connect() on the resumed session returns the outstanding calls as HelloAck.pendingToolCalls and (by default) re-emits each to onToolCall with replayed: true, so the handler above completes the paused turn instead of abandoning it via endSession(). Register onToolCall before connect(); treat id as an idempotency key in side-effecting tools (the app may have performed the call before crashing; /tool-result is idempotent per id, and pausedAt lets you judge staleness).

3.2 Materials → knowledge base (the entry point can live in YOUR app)

remember(fact) writes one short fact. To turn a whole document into recallable knowledge — a PDF, a meeting recording, pasted notes — use ingestKnowledge. You extract the text (your own parser, or Pouchy's /api/stt for audio — send your pchy_… bearer token; the relay is auth-gated and metered); Pouchy distils it into a headline summary plus full-text chunks, each embedded, and surfaces it in the user's "My materials" list with source attribution — exactly like a first-party upload, recalled by the companion everywhere.

await c.ingestKnowledge({
  text: extractedText,      // you extract; this is plain text / a transcript
  name: 'Q3-board-deck.pdf',
  kind: 'pdf'               // free label → display + materials icon
});
// → { ok: true, summary, chunks, truncated }
//   truncated: true = the source overran the 60,000-char / 120-chunk caps and
//   the remainder was NOT ingested — split it and ingest the rest.
  • Scope: writes the user's shared knowledge, so the token must hold memory.write:core (the user's consent). Without it → 403. Use remember with an app namespace for private, app-only notes.
  • Embeddings are computed during the ingest on deployments with server-side embeddings enabled (production): the route summarizes and embeds every chunk before responding — which is why the call can run minutes for a large document (the SDK allows it 310 s since 0.40.1) and the material is fully semantically recallable the moment the call returns. On deployments without a server embedding key, chunks are recallable by keyword/recency until the user's next first-party Pouchy open computes embeddings client-side.

Don't want to parse the file yourself? Pass the raw file to ingestFile and Pouchy understands it server-side first:

await c.ingestFile({
  dataUrl: 'data:application/pdf;base64,JVBERi0…', // or data:audio/…
  name: 'board-meeting.m4a',
  kind: 'audio'
});

Supported today: PDF (server-side text extraction, best-effort), audio/video (Whisper transcript), and image (server-side vision caption — an objective description becomes the material, so a headless/native integrator gets the same "attach a photo → My materials" behaviour as the app). Other types → 415: extract the text yourself and use ingestKnowledge.

3.3 Representative mode — the companion answers visitors on the owner's behalf

By default a session is owner-facing: the companion talks to the token's owner, with their full memory. Pass a visitor and the session flips to representative ("on-behalf-of" / 代聊): the companion fields that visitor's messages on the owner's behalf — customer-service style — using only screened owner context.

const c = createCompanion({
  baseUrl, token,                         // a PAT that holds the `represent` scope
  surface: 'support-widget',
  appContext: { name: 'AcmeShop', description: 'Order support' },
  visitor: { id: stableVisitorId, displayName: 'Sam' }   // YOU supply a stable per-end-user id
});
const ack = await c.connect();            // → { …, representative: true }
c.onMessage(render); c.start();
await c.sendText('do you ship to Canada?');
  • Visitor id is opaque to Pouchy and must be stable per end-user (8–64 url-safe chars). Each (token, visitorId) gets its own continuous thread — a returning visitor is recognised and recent threads are picked back up.
  • Exposure is scope-gated. The floor (always) is the same screening a public share link uses: persona voice + screened identity/preference facts, with private/intimate facts, raw PII, your system prompt and custom instructions never exposed. The token's scopes widen it:
    • expose:knowledge → the representative may answer from your knowledge base ("My materials").
    • expose:facts → widens shareable facts past the identity/preference floor (still content-screened, never intimate).
  • No first-party actions. Wallet / skills / social tools are withheld in representative mode (they act as the owner). Your app's own declared tools (§3.1) still work.
  • Visitor input is quarantined: a visitor's messages give continuity with that visitor but never enter the owner's long-term memory.
  • Per-visitor memory (opt-in). With represent:remember, the representative distils each exchange into durable notes about that visitor ("asked about bulk pricing", "is evaluating a competitor") — kept in an isolated per-visitor store (never the owner's facts), so a returning visitor is met with real memory. Without the scope, only the short raw recent-thread continuity applies.
  • Hard requirement: without the represent scope, supplying a visitor is rejected (403) — a representative session is never silently downgraded to an owner-facing one.
  • Voice works too. connectCall() on a representative session opens a live voice call in the owner's companion voice, with the SAME screened instructions and a visitor-facing opener ("here to help on their behalf"), not the owner-facing companion framing. The end-of-call transcript is quarantined identically — it never reaches the owner's memory, and distils into per-visitor notes only under represent:remember.

The owner enables this from Wallet → AI usage → "Let it represent you to visitors" when minting the key.

3.4 Visitor → friend pairing → unlock A2A

When a visitor in a representative session is also a Pouchy user, the two can pair — their companions become friends and the full A2A plane (messaging, gifts, visiting each other's agent) lights up between them, in their own first-party apps.

// `c` is the OWNER's representative client (token holds `represent:pair`).
// `visitorToken` is the VISITOR's own Pouchy PAT (must hold `social.message`).
const { pairId } = await c.pairVisitor(visitorToken);

// On the next connect(), the session reflects it:
const ack = await c.connect();   // ack.visitorPaired === true
  • Two-token consent. The owner opts in by granting represent:pair; the visitor opts in by providing their own PAT (which must hold social.message). Passing the visitor's token through the app is the visitor's consent + proof that they're a Pouchy user.
  • Canonical record. Writes a2a_pairs/{sorted-uids} — the same record the first-party /meet pairing writes — so both users' apps sync it into their friends list and every A2A surface (send_friend_message, gifts, visit-paired) works with no further setup.
  • Continuity. The pairing is remembered against the session's visitorId, so a returning paired visitor is greeted as an established friend (ack.visitorPaired, and the representative greets accordingly).
  • Pairing is the only friendship-creating action available to a representative session; it does not let the embed message the owner's other friends.

4. REST endpoints

All paths are relative to the base URL. Unless noted, request/response bodies are JSON. {sessionId} comes from the handshake.

4.1 Session & data plane — Authorization: Bearer pchy_…

Method Path Scope Body → Response
POST /api/companion/session { surface?, modalities?, tools?, appContext?, handles?, contextKinds?, visitor? }{ session, grantedScopes, modalities, resumeCursor, representative?, visitorPaired?, pendingToolCalls? }pendingToolCalls (SDK 0.37.0; present and non-empty only when this handshake resumed a session mid-pause) lists the outstanding tool calls of a turn paused on your declared tools: [{ id, name, args, turnId?, pausedAt? }]. Perform each and POST /tool-result per id to COMPLETE the paused turn after a reload/crash (idempotent per id; the SDK auto-replays them to onToolCall with replayed: true unless replayPendingToolCalls: false) — the alternative to abandoning it via /end. On a re-handshake that resumes a live session, omitting tools / handles / contextKinds keeps the previously declared set; an explicit [] clears it
POST /api/companion/session/{sessionId}/input chat { text, images?, instructions?, stream?, turnId? } (text ≤ 32,000 chars → 413 payload_too_large above; images = up to 4 data: URLs ≤ 8MB each — a remote URL or a 5th image is a 400 invalid_request. Downscale images client-side before sending — see “Request body size” below; the 8MB figure is this API's ceiling, not the reachable one; turnId ≤ 64 chars is echoed back as replyTo on the reply's companion.message, API 1.1 — and doubles as an idempotency key: re-POSTing a COMPLETED turnId within ~60 min (last 32 per session) returns { seq, duplicate: true } with the recorded reply instead of running a second billed turn) → { ok, kind: 'message', seq, usage } (seq = the reply turn's sequence number, for ordering; usage = the turn's token totals, below) or { ok, kind: 'tool_calls', toolCalls } when the turn ends in tool calls (seq is null; perform them and POST /tool-result). The reply TEXT still arrives on the stream; sendText() surfaces { seq }. With stream: true the POST response itself is text/event-stream and carries the reply as it is generated: delta frames ({ text } raw chunks), an optional reset (a hop that streamed text ended in tool calls — clear the partial render), then one done frame ({ ok, kind, seq, text, envelope, usage } — the same companion.message envelope that also lands on the event stream; dedupe by envelope id). The done frame additionally carries first-party diagnostic fields — timings (server phase breakdown) and promptCache on success, errorId on failure — additive telemetry for Pouchy's own probes, not contract; don't build on their shape. Separately, both the stream done and the non-stream ack carry a stable usage block for per-call cost accounting: { promptTokens, completionTokens, cachedTokens } (whole-turn sums across all model hops, equal to the dashboard trace's tokensIn/tokensOut), plus reasoningTokens (a subset of completionTokens, not additive) and model (the served model, fallback-aware) when known; omitted/null on failed or duplicate acks. The server carries the session's conversation history — the last ~12 exchanges verbatim, plus a rolling server-side summary of everything older (updated in the background every few exchanges), so long sessions retain their early context; do not resend prior messages. instructions (API 1.x, needs the chat.instructions scope — 403 missing_scope without it) carries developer-authored rules for THIS turn: joined to the prompt after the history and immediately before the user's message, and never scored as user speech by the inbound moderation gate. ≤ 32,000 chars (413, never a silent truncation), and 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). It is not granted by default deliberately: the field skips moderation, and the session token that reaches this route is routinely held by the end user's own client, so honouring it has to be a decision by whoever mints the key. Before it existed, an integrator with per-turn rules had to prepend them to text, where the classifier scored the developer's prose as the user's — issue #3379. A turn refused by inbound moderation now says so on both response branches: blocked: true plus blockedBy (`'classifier'
POST /api/companion/session/{sessionId}/context worldstate.write WorldStateEvent | { events: WorldStateEvent[] }{ accepted, dropped, injected?, reacted?, duplicates? }injected = a voiceRelevant moment was spoken into a live call; reacted = it fired a proactive text reaction. Per-event caps: type ≤ 64 chars, data ≤ 2KB JSON; batch cap 64 events per POST (oversize events AND events past the batch cap count into droppedaccepted + dropped always sums to what you sent). Ingest is idempotent on the envelope id within a ~60-min window: a retried batch reports the same accepted (replayed ids counted in the additive duplicates), is not re-folded into the context digest, and never re-fires injected/reacted
GET /api/companion/session/{sessionId}/stream events.subscribe SSE — text/event-stream of envelopes (§8). Query ?cursor=N to resume, where N is a SEQUENCE number read from an event frame's id: line or from the closing reconnect frame's { cursor } — never an envelope's ts. The window is bounded (~45s) and pings every ~1.5s, then closes with reconnect; re-subscribe with the advanced cursor. See the protocol doc §8 “Resume”.
GET /api/companion/session/{sessionId}/history chat ?limit=N{ ok, count, history: CompanionTurn[] } — the session's conversation log (SDK: history()). A storage outage is 503 unavailable (retry), never an empty historycount: 0 always means the session has no turns.
GET /api/companion/session/{sessionId}/recall-audit chat { ok, sessionId, auditAvailable, verdict, meaning, minted, invocations, mintGapsS, rows } — did the voice agent call the in-call recall tool during THIS session? Self-serve diagnostics for "the model said it doesn't remember": verdict states the diagnosis directly — no_session (no credential was ever minted for this session: not an ElevenLabs call, or no memory read scope), never_invoked (a credential was minted and no recall followed — TWO causes, since minting happens before any WebRTC step: use mintGapsS, where repeated mints ~8–12s apart are the client connect timers firing on calls that never connected), credential_failed, searched (invoked and authenticated, nothing matched — a write-side question), answered (facts returned; if the companion still denied remembering, the model ignored a tool result). Tenant-safe like /history: rows are read under the token's own user, so a foreign sessionId returns a 200 with no rows, never a 404. Rows carry metadata only (query_chars, never the query text); each has ts_iso + age_s beside raw ts.
POST /api/companion/session/{sessionId}/modalities { modalities }{ modalities } — change the active I/O modalities mid-session (SDK: setModalities()).
POST /api/companion/session/{sessionId}/ping {}{ ok } — keep-alive so an idle session isn't reaped (SDK: ping()).
POST /api/companion/session/{sessionId}/tool-result chat { callId, ok, result }{ ok, allDone: false } while other calls in the turn are still outstanding; once every pending call is reported the response carries the resume outcome — { ok, allDone: true, resumed: 'message', seq } (the reply text arrives on the SSE stream) or { ok, allDone: true, resumed: 'tool_calls', toolCalls } (the resumed turn ended in MORE tool calls — perform those too), or { ok, allDone: true, resumed: 'already' } when a concurrent post already claimed the resume. The apply is idempotent per callId (a double post — e.g. after replaying a pause-orphaned call rediscovered via the session response's pendingToolCalls — is safe)
POST /api/companion/session/{sessionId}/call call { voice?, locale? }CallCredentials (§5.2), incl. callGen — the minted call-active window's generation handle; echo it in /end's callGen at call end so the window clear is fenced to THIS call (SDK 0.41.0 does this automatically). This starts — and immediately CHARGES — the metered voice window (25 credits/minute with a 60-second minimum block, so the mint itself costs 25 credits; see plan.credits_threshold under Webhook events for the rate and why it dwarfs chat), and the clock runs from the moment this call returns credentials, not from the moment audio connects. A mint that succeeds and never connects still holds an open window until something closes it — and has already been billed its block — so tear down on EVERY connect failure — connectCall() does it for you
POST /api/companion/session/{sessionId}/end { transcript?, callGen? }{ ok, facts, extracted?, filtered?, merged?, revived?, writeError?, skipped? }filtered = proposed facts the identifier filter refused (a card number pasted into the transcript), its own count so extracted − facts is not read as a refused write; skipped: 'no_visitor_scope' = a representative session whose token lacks represent:remember (or has no visitorId): nothing was consolidated anywhere. callGen fences the call-active-window clear (§5.2): the string from /call clears exactly that call's window (a stale teardown after a re-mint no-ops), explicit null leaves any live window alone (a text-only teardown), omitted = legacy unconditional clear. Closing a live call window here is what MEASURES the call and settles its bill — the server measures min(now, windowExpiry) − mintedAt and charges 25 credits/minute for it, reconciling against the 60-second minimum block /call already took: it charges only the excess, so a call shorter than a minute adds nothing here and keeps the block (no refund). A duplicate end beacon charges nothing (the second clear finds no open window), a call left to expire on its own 20-minute window is still billed its minimum block from the mint — it just never gets measured, so you lose reconciliation rather than the charge — and a call that outlives that window is measured only up to it. Consolidate the session into long-term memory (§5.3). skipped: 'no_session' = unknown sessionId (make sure you pass the SESSION id from opening the session, not the instance id); 'throttled' = duplicate end beacon (harmless); 'no_content' = nothing to consolidate — for a voice call this means your transcript never arrived and the session left no memory. For a voice call this response is the ONLY signal you get about whether the call became memory, and facts alone collapses distinct repairs — extracted (facts the extraction PROPOSED, present whenever it ran) and writeError complete the ladder: extracted: 0 = the transcript arrived and nothing durable was found in it; extracted > 0 with facts: 0 = facts WERE found and the store refused them, with writeError saying why (e.g. a token whose scopes cannot write the resolved memory namespace refuses every fact). facts counts writes the store ACCEPTED, which is not the same as NEW: since server-side write-time dedup a re-mention folds into a memory already held, and a repeated endSession() re-reads the same recent turns — so a second call answered facts: 3 for three merges and nothing minted. merged (0.62.0) is how many folded, facts − merged is the newly stored count, and revived is the subset whose target decay had archived and the merge brought back. Both are absent when zero and from a server older than 0.62.0. Ending also abandons any turn paused on tool results/end unblocks the session's 409 turn_pending immediately. Since SDK 0.37.0 abandoning is the FALLBACK, not the only option: an embed that lost its tool-call state (reload mid-pause) can instead COMPLETE the paused turn via the session response's pendingToolCalls + /tool-result (see the POST /session row). SDK: endSession() (returns this { ok, facts, extracted?, filtered?, merged?, revived?, writeError?, skipped? } diagnostic, or null), or close() which stops the stream and calls it once.
POST /api/companion/mcp JSON-RPC 2.0 — Pouchy as an MCP provider (companion_chat, recall_memory, remember, notify_world_state). Dual-era: the legacy initialize handshake (now negotiating any revision 2024-11-052025-11-25) AND the stateless 2026-07-28 revision (per-request _meta version, server/discover, resultType-decorated results) — see companion-api-versioning.md §MCP protocol revisions.
POST /a2a/{handle}/rpc A2A bearer (see agent-social-a2a.md) Google A2A JSON-RPC 2.0 — the open-interop transport every claimed @handle serves (discovery: GET /a2a/{handle}/.well-known/agent-card.json). message/send → an A2A Message, or a Task when the turn parked an owner confirm or the caller sent configuration.blocking: false; tasks/get / tasks/cancel; message/stream + tasks/resubscribe → a one-shot SSE response of TaskStatusUpdateEvent / TaskArtifactUpdateEvent; tasks/pushNotificationConfig/set|get|list|delete → an https webhook per task (SSRF-guarded), X-A2A-Notification-Token echoed. The Agent Card says streaming: true / pushNotifications: true for exactly these (design #24). No SDK helper yet — call it as JSON-RPC.
GET/POST /api/companion/memory memory.* GET ?limit=N&q=<query>{ memories: [...] } (q = semantic search; a token holding neither memory.read:app nor memory.read:core is a 403 missing_scope, not an empty 200 — and for the same reason a storage outage is a 503 unavailable, not an empty 200 either: {count: 0} must mean the user has no memories, never that they could not be read, so an integrator polling through an outage sees a retryable error instead of an empty profile); POST { content, … }{ ok, cloudId, namespace } (content ≤ 2,000 chars → 413 payload_too_large above it: a fact is one atomic claim, and an over-cap write is REFUSED rather than stored truncated, so what you send is what recall returns — chunk long text into separate facts, or send it to /api/companion/knowledge, which is built for document-length input; cloudId = the stored fact's id; namespace = where it landed — the app namespace unless the token's core scope allowed the requested one; SDK: remember()) dedup on the POST response (M-05b, 2026-09-05): { matched, similarity?, revived?, threshold, skipped? }matched: true means the write merged into the existing row cloudId names (the user restated something the store holds; the access signal was bumped and, if decay had archived it, it is live again — revived); skipped names why no match was attempted (disabled, opted-out, no-embedding, index-unavailable) — or target-vanished: a match WAS found but its row was deleted before the merge landed (a concurrent forget / GC on the user's own device), so a fresh row was minted. A storage fault on the write itself is a 503 unavailable, never a 500 (retry it).
DELETE /api/companion/memory/{factId} memory.write:* Forget ONE fact by the id a GET row carries (0.53.0) — sets userForgotten, the user's authoritative "stop telling me this": it stops recalling and background re-derivation will not resurrect it; never a document delete. Needs the write scope for the fact's namespace — without it a 403 missing_scope, the same code the GET leg answers, so one scope-upgrade handler covers both. A fact the token cannot SEE — another app's namespace, the intimate tier, or nonexistence — answers the SAME 404. Idempotent: { ok, forgotten: true, alreadyForgotten } (SDK: forget(id))
POST /api/companion/knowledge memory.write:core { text, name, kind?, locale? }{ ok, summary, chunks, truncated } — ingest a document as knowledge (§3.2). truncated: true = the document was NOT fully ingested (text over the 60,000-char cap, or more chunks than the 120-chunk ceiling) — split it and ingest the rest.
POST /api/companion/knowledge/file memory.write:core { dataUrl, name, kind?, locale? }{ ok, summary, chunks, truncated } — raw PDF/audio/image, understood server-side (§3.2). truncated covers the server-side understanding step too: a PDF whose text layer overruns the extraction cap reports true.
GET /api/companion/avatar { ok, name, archetype, modelId, vrmUrl, imageUrl } — the user's current companion avatar (§4.3).
GET /api/companion/wallet wallet.read { balances, totalUsd, currency } — the companion wallet's read-only balances (SDK: getWallet()).
GET /api/companion/generated-image/{imageId} — (capability URL) Serve a companion-generated image (generate_image replies carry this URL). No bearer token by design — an <img src> can't send headers, so the unguessable 128-bit id IS the permission; 7-day retention, immutable-cached.
GET /api/companion/generated-file/{fileId} — (capability URL) Download a companion-generated file (create_spreadsheet / create_document replies carry this URL — and, SDK 0.54.0, the same link rides the reply's companion.message envelope structurally as payload.attachments[]; on channels with attachment support the same file is also sent as one). Same capability-id trust model as generated-image; served with Content-Disposition (the generated file name); 7-day retention, immutable-cached.
POST /api/companion/voice-tool voice-tool token Called by ElevenLabs' backend DURING a live ConvAI call — not by your app. The in-call memory recall webhook tool: authenticates the voiceToolToken minted by /call (sent under the dynamic-variable twins — see connectCall/startCall), runs a scope-narrowed read-only recall, and answers the model. Your only integration duty is passing the token through; every invocation (and every rejection) is visible in /recall-audit.
POST /api/companion/voice-echo — (no auth) {}{ result: "ok", server_ms } — a webhook tool that does nothing, for measuring ElevenLabs' own latency floor. Register it as an EL webhook tool and invoke it in a live call: what you time is EVERYTHING EXCEPT Pouchy (VAD, ASR finalisation, the LLM deciding to call, the network hop, composing, TTS, first audio byte). Hosted by us ON PURPOSE — the leg being characterised includes the EL → pouchy.ai network and TLS, so an echo on your own host measures a different path. Reads nothing, writes nothing, returns a constant two-character reply so TTS length stays constant; rate-limited per source (60/min, fails closed → 429). server_ms is our handler time (~0) — the number you want is the wall-clock the USER experiences, measured on the device.
POST /api/companion/pair represent:pair { visitorToken, visitorId }{ pairId? } — pair a representative session's visitor with the owner. The owner PAT needs represent:pair; the visitorToken in the body must hold social.message. SDK: pairVisitor() (requires a visitor session).

4.2 Confirm a sensitive action

Method Path Body → Response
GET /api/companion/session/{sessionId}/confirm { pending: [{ confirmId, scope, summary, summaryKey?, summaryVars?, createdAt, status, stepUp, execUnsettled? }] }summaryKey/summaryVars are the localizable form of summary (see the protocol doc); fall back to summary. status (0.61.0) is pending (never answered) or exec_failed (already approved, execution flaked) — this listing has always returned both and until 0.61.0 said nothing about which, so an already-authorised op arrived under the same "Approve?" affordance as a fresh one. Only the idempotent wallet pay tools reach exec_failed; approving again re-runs the SAME request (keyed on confirmId) rather than starting a new one. execUnsettled (0.61.0, exec_failed only) says 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. stepUp (0.61.0) is the same advisory the companion.confirm_request event carries, computed with the predicate the approve POST ENFORCES (the row's scope unioned with the executing tool's), so a card rebuilt from this snapshot announces the passkey gate the approval will demand instead of meeting it as a 401 step_up_required. Treat an absent or unrecognized status as pending (a server older than 0.61.0 sends none of the three).
GET /api/companion/session/{sessionId}/confirm/{confirmId}/mandate → the full user-signed approval for one confirm (design #27 Phase 2): `{ confirmId, status, intent, intentDigest, mandate, credential: { credentialId, publicKey (COSE, base64url), format: 'cose-base64url' }
GET /a2a/{handle}/mandate-keys — (public)
POST /api/companion/session/{sessionId}/confirm { confirmId, approve, assertion? }{ status: 'approved' | 'denied' | 'exec_failed', outcome, retryable?, outcomeClass }outcomeClass (success | error | unknown | rejected | denied | unrecognized) is the machine-readable verdict: status:'approved' alone only says the approval was accepted (it covers an action that ran, one that failed terminally, and one the server refused), so switch on outcomeClasssuccess is the only class that proves completion, unknown means it may or may not have gone through (do not repeat it), rejected is the app declining, denied the user declining (nothing ran), unrecognized ordinary prose that proves nothing either way. outcome is a short natural-language summary of what the action did (or the decline notice), returned to the approving surface directly; the same text also arrives on the stream as a companion.message. It is prose meant for a person — never the upstream/MCP payload, and not a stable machine-readable shape: the raw tool result is an internal implementation detail that stays server-side (it is summarized before it leaves the trust boundary, exactly as a non-confirmed tool call's result only ever reaches the model). Do not parse it; if you need structured data, have your app declare its own tool and read the arguments it receives. Retry: if an approved idempotent action (a wallet payment) flakes on a transient error, status is 'exec_failed' + retryable:true — you MAY re-POST the SAME confirmId to re-run it (the server dedupes a partial first attempt). Non-idempotent actions (message / skill) stay single-use (a settled confirm re-POST is 409). mandate? (design #27 Phase 1) — present only when the approval passed the passkey step-up: { intentDigest, credentialId, signedAt }. intentDigest is a sha256 over the confirm's canonical intent ({ v:1, confirmId, tool, args, scope, createdAt, action }, sorted keys), the step-up challenge is sha256("pouchy-stepup:" + confirmId + ":" + intentDigest), and the full WebAuthn assertion is kept on the confirm row — a user-signed statement of WHAT was approved, verifiable by whoever holds the credential's public key.

Biometric step-up (first-party money confirms). When the approve POST returns 401 { code: 'step_up_required' }, mint the passkey challenge and retry with the assertion:

Method Path Body → Response
POST /api/companion/session/{sessionId}/confirm/stepup { confirmId }{ ok, options } — WebAuthn PublicKeyCredentialRequestOptions for the pending confirm. Firebase-user auth ONLY (never a companion token). 404 unknown confirmId; 409 no passkey enrolled.

Pass the resulting AuthenticationResponseJSON from navigator.credentials.get() as assertion on the confirm POST; a failed verify is 401 { code: 'step_up_failed' } (retry the ceremony).

Two accepted identities, matching the two-token model (§7):

  • First-party (Firebase) auth — backs the Pouchy-hosted confirm page. A third-party app embedding a first-party user does not call these (it opens the hosted page).
  • Platform session tokens (/v1/sessions instances) — the session itself may list + resolve its confirms (client.pendingConfirms / client.confirmAction): the embedding app's end user is the instance's only human. Instances are never granted wallet.spend, so this path cannot approve money; it exists for confirm-gated custom skills. A first-party user's PAT/session token still cannot resolve here — 403 with code forbidden (a deliberate denial, not a token problem: the SDK does not fire onAuthError on it).

4.3 Avatar & branding — render the same virtual human

GET /api/companion/avatar (any valid token, no special scope) returns the user's current companion avatar so your surface can show the same virtual human Pouchy does:

{
  "ok": true,
  "name": "Luna",                 // companion display name (or null)
  "archetype": "girlfriend",      // girlfriend | boyfriend | pet
  "modelId": "default-gf-luna",   // active model id (built-in or custom)
  "vrmUrl": "https://pouchy.ai/models/gf-luna.vrm",  // the avatar — a VRM 3D model
  "imageUrl": "https://…/avatar-thumbnails/default-gf-luna.png"  // 2D portrait, or null
}
  • The avatar is a VRM 3D model (vrmUrl) — load it with a VRM/glTF renderer (e.g. @pixiv/three-vrm). The /models/* asset sends Access-Control-Allow-Origin: *, so a cross-origin renderer can fetch it directly.
  • imageUrl is a flat 2D portrait (a snapshot of the VRM) for surfaces that just want an avatar image; null until a portrait has been generated for that model.
  • Reflects the user's live choice (built-in or a custom upload); falls back to the archetype default model if unset. Custom uploads return their absolute Blob URL.
  • SDK: companion.getAvatar().

Pouchy brand icon (static, no token): https://pouchy.ai/brand-assets/icon/pouchy-icon-{256|512|1024}.png (also Access-Control-Allow-Origin: *). SDK: companion.brandIconUrl(size?) or pouchyBrandIconUrl(baseUrl, size?).

4.4 Owner / management — first-party (Firebase) auth

Called by the Pouchy app to manage the embed platform; listed for completeness.

Method Path Purpose
GET/POST /api/companion/keys, DELETE /api/companion/keys/{tokenId} Manage the user's companion access keys. Revoking an OAuth-minted key ("Login with Pouchy") also revokes the app's whole refresh-token family, so the app cannot silently re-mint access. DELETE returns real statuses: 200 {ok:true} on success, 404 unknown key, 502 when the family cascade failed (nothing was revoked — retry the DELETE), 503 storage unavailable.
POST /api/companion/skills-sync Mirror skill metadata for companion awareness.
GET/POST /api/companion/skill-defs Mirror executable skill definitions (for run_skill).
GET/POST /api/companion/skill-grant List / set grants (domain allowlist + free-HTTP) on server-side skill defs, so chat-installed skills are reachable like client-managed ones.
GET/POST/DELETE /api/companion/skill-credentials KMS-encrypted skill credential store.
GET/POST /api/companion/skill-oauth2 OAuth2 refresh-token store for skills.
GET/POST /api/companion/apps App directory, supply side — the integrating dev declares an app manifest (mints a global appId) / lists their own apps.
GET /api/companion/apps/directory Browse all registered apps (name/category-filterable, verified first) — powers the connect picker.
POST /api/companion/apps/recommend The agent's recommendation read path — rank the user's connected apps for a turn's intent, with reasons + requested scopes.
POST /api/companion/apps/{appId}/verify Domain verification via DNS TXT — flips domainVerified, making the app eligible for global discovery.
POST /api/companion/webauthn Passkey (WebAuthn) enrolment + status for biometric step-up — { action: 'status' | 'register_options' | 'register_verify' }. Approval itself lives on the session confirm/confirm/stepup routes.

5. World-state — the play-along channel

The single most important input for a good companion: stream what's happening so it reacts in context. Sent via POST …/context (or sendWorldState).

5.1 WorldStateEvent (CloudEvents-shaped)

interface WorldStateEvent<D = unknown> {
  type: string;        // namespaced kind, e.g. 'game.player.hp', 'poker.event.river'
  data: D;             // the payload (string or object)
  retained?: boolean;  // true = latest-value STATE, coalesced per `type`
  salience?: number;   // 0–1, for transient events (how notable)
  voiceRelevant?: boolean; // true = react OUT LOUD now if a call is active
  // CloudEvents plumbing — the SDK fills these; raw HTTP callers should send them:
  specversion?: '1.0'; id?: string; source?: string; time?: string; ttl?: number;
}

Guidance:

  • retained: true for current state (scene, HP, score, hand) — coalesced per type.
  • transient (no retained) for moments; set salience (0–1).
  • voiceRelevant: true only for beats worth speaking on (a win, a key card, your turn). Over-flagging makes the companion chatty.

5.2 Real-time voice (CallCredentials)

POST …/call (or connectCall) returns provider-discriminated credentials:

type CallCredentials =
  | { provider: 'elevenlabs-convai'; token; agentId; instructions; voice?; language?;
      firstMessage?; callGen?; voiceToolToken? }
  | { provider: 'openai-realtime'; clientSecret; model; voice; expiresAt: number | null };

voiceToolToken (SDK 0.46.0) is the call-scoped bearer for the webhook tool that recalls the session's memory MID-CALL. Pass it to ElevenLabs at session start as dynamicVariables: { [VOICE_TOOL_VARIABLE]: creds.voiceToolToken, [VOICE_TOOL_VARIABLE_PLAIN]: creds.voiceToolToken } (0.47.0 — ElevenLabs ignores a client-supplied secret__ variable, so the plain twin is the one that reaches the tool header) — EL substitutes it into that tool's Authorization header, and the secret__ class of variable is never sent to the LLM, so the credential rides a live conversation without ever entering a prompt. connectCall does this for you.

It is absent, not empty, when the session holds no memory grant — a client with no scopes then has nothing to pass rather than something that fails inside a live call. Two consequences worth planning for: omitting it raises no error anywhere (EL substitutes the agent's empty default, the tool call 401s, and the model just says it can't recall anything — a call that sounds entirely normal with memory switched off), and the recall depends on the agent having the tool registered at all, which is agent-side configuration, not per-session.

The openai-realtime branch returns an ephemeral clientSecret (ek_…) and its expiresAt (epoch ms, or null if the provider didn't stamp one) — mint a fresh credential before it lapses. It carries no instructions; the system prompt is applied server-side at mint.

The SDK's connectCall opens the WebRTC session for you (mic + speaker) and bridges server voiceRelevant moments into the live call automatically. ElevenLabs needs the optional peer dep @elevenlabs/client; OpenAI Realtime has no extra deps. Full recipe: docs/companion-voice-integration.md.

A successful …/call mint marks the session call-active for up to 20 minutes: while that window holds, voiceRelevant world-state routes into the live call as companion.voice_inject and the text play-along reaction loop is suppressed. The window ends early at POST …/end — which connectCall's close() (and its provider-teardown error path) already calls via endSession() — so hanging up restores text reactions immediately. Since SDK 0.40.1 a connect-phase FAILURE after a successful mint (mic denied, missing @elevenlabs/client, SDP failure) tears the window down the same way before rethrowing, so a call that never opened can't strand it. If you drive startCall yourself (your own WebRTC plumbing), call endSession() when your call ends — including when your connect fails after the mint — or the window only lapses on its own after ≤20 min.

The clear is generation-fenced (SDK 0.41.0): the /call mint returns callGen, and /end accepts it back — a matching gen clears exactly that call's window, a mismatched one no-ops (a stale handle's late teardown after a fresh re-mint can no longer silence the NEW call's injects), and an explicit callGen: null skips the clear entirely (how a 0.41+ text client's close() avoids killing a live call's window on the same session). An omitted field keeps the legacy unconditional clear, so every pre-0.41 SDK in the field retains the behavior above. Self-plumbed integrations: pass creds.callGen to endSession({ callGen }) (or POST it in /end's body).

5.3 Session memory (the companion remembers the play-along)

A companionship session — especially a real-time voice play-along — should leave a memory, so later (even in the first-party Pouchy app) the companion recalls "we played poker and you won a big pot." Because voice audio is provider-direct (Pouchy isn't in the audio path), the server can't hear the call; instead it consolidates the session at the end:

  • When a connectCall() handle is closed, the SDK posts the buffered voice transcript to …/end, which distills it — together with the streamed world-state (hands, wins, losses, key moments) — into durable memory facts.
  • For a non-voice session, call companion.endSession() yourself when the session is over (e.g. the player leaves the table).
  • Text turns also capture memory continuously (throttled), so a chat-only session is remembered too.

Facts land in the brain the first-party companion reads back, so the experience carries across surfaces. They're written to the token's own memory namespace by default; grant the token memory.write:core if you want them in the shared core brain that every surface (and other authorized apps) can see. endSession() is best-effort + idempotent — safe to call more than once.


6. Login with Pouchy (OAuth 2.1 + PKCE)

Method Path Purpose
POST /api/oauth/register Register a public PKCE client → { clientId }. (GET lists; DELETE /api/oauth/register/{clientId} removes a client the caller owns — first-party.)
GET/POST /api/oauth/authorize Validate the request / mint a one-time auth code on consent.
POST /api/oauth/token grant_type=authorization_code or refresh_token{ access_token, token_type, expires_in, refresh_token, scope }.
POST /api/oauth/revoke RFC 7009 — { token, client_id } revokes a refresh token and its whole rotation family (sign-out). Idempotent, always 200.

Standard Authorization-Code-with-PKCE flow; the resulting access_token is the same pchy_… bearer used everywhere above. Refresh tokens rotate on every use and are reuse-detected: replaying an already-consumed refresh token revokes the entire token family (a short grace window absorbs a benign concurrent double-submit).


7. Sensitive actions & the confirm boundary

When the companion attempts a sensitive action (wallet.spend, social.message, skills.execute), the runtime does not execute it on a third party's say-so. It:

  1. emits a companion.confirm_request event on the stream ({ confirmId, scope, summary, summaryKey?, summaryVars? }), and pauses;
  2. the user approves it:
    • first-party user tokens — on a Pouchy-controlled surface: open https://pouchy.ai/companion/confirm?session={sessionId}&confirm={confirmId} (a popup); the user approves with their own Pouchy login. The third-party page can observe the request but can never approve it.
    • platform session tokens — in YOUR app: show your own confirm card and call client.confirmAction(confirmId, approve) (§4.2). Your end user is the instance's only human; money scopes are never minted for instances, so the ceiling here is skill runs / messages.
  3. only then does the action run, and the outcome arrives as a companion.message.

Plain chat / voice / world-state need none of this.

7.1 The REST-only confirm flow (no SDK) — end to end

The field-reported gap (2026-07-14, DeJoy): integrators driving /input with plain HTTP saw the reply say "please confirm" and had no idea where the card lives. The card is session state, not part of the /input response — fetch it, render it in YOUR chat UI, resolve it:

# 1. the turn — reply says a confirmation is needed
curl -X POST "https://pouchy.ai/api/companion/session/{sid}/input" \
  -H "Authorization: Bearer $SESSION_TOKEN" -H "Content-Type: application/json" \
  -d '{ "text": "帮我点一杯燕麦拿铁" }'

# 2. fetch the pending card(s) — one extra call after any turn
curl "https://pouchy.ai/api/companion/session/{sid}/confirm" \
  -H "Authorization: Bearer $SESSION_TOKEN"
# → { "ok": true, "pending": [{ "confirmId": "cfm_…", "scope": "skills.execute",
#     "summary": "my-coffee/place_order …", "createdAt": …,
#     "status": "pending", "stepUp": false }] }
#   render summary + [approve] [decline] buttons in your chat thread;
#   status "exec_failed" means the user already approved it and the run flaked
#   (approving again retries the SAME request), and stepUp true means the
#   approval will demand a passkey / Face ID

# 3. the user taps — resolve it (platform session tokens may resolve directly)
curl -X POST "https://pouchy.ai/api/companion/session/{sid}/confirm" \
  -H "Authorization: Bearer $SESSION_TOKEN" -H "Content-Type: application/json" \
  -d '{ "confirmId": "cfm_…", "approve": true }'
# → { "ok": true, "status": "approved", "outcome": "Ordered — your latte will be ready at the Shenzhen Bay store in about 8 minutes." }

# 4. render `outcome` as the agent's next bubble (it also arrives on the
#    SSE stream as a companion.message — pick ONE channel to display)

Notes: confirms expire after 10 minutes and are single-use (a settled re-POST is 409 confirm_resolved — render "expired, just ask again"); GET /confirm's pending list is also how you rebuild cards after your UI reloads. Alternative with ZERO confirm code: connect through a channel connector (§ channels) — the channel confirm relay lets the user approve by replying 确认/confirm (or 取消/cancel) directly in the chat, in solo chats and group rooms alike (see the channels section for the group-mode rules).


8. Event catalog

Every message is an envelope: { v: 1, id, session?, ts, type, payload }.

Inbound (app → Pouchy)

hello · input.text · context.event · context.snapshot · tool.result · control.start_call · control.end_call · control.set_modalities · control.ping — most are issued for you by the SDK / REST endpoints above.

Outbound (Pouchy → app, over the SSE stream)

Type Payload
hello.ack { session, grantedScopes, modalities, resumeCursor, representative?, visitorPaired?, pendingToolCalls? }pendingToolCalls (SDK 0.37.0) = outstanding tool calls of a turn paused before the handshake, non-empty only when resuming mid-pause (see the POST /session row)
companion.message { text, replyTo?, attachments? } — an assistant reply; replyTo echoes the originating sendText's turnId (absent on proactive messages); attachments (SDK 0.54.0) = files the turn produced (create_spreadsheet / create_document), MessageAttachment[]: { fileId, fileName, mimeType, url } with url the 7-day capability download URL — read the field, don't parse the reply text. Subscribe with onMessage (token stream: onDelta)
companion.audio { url } — a TTS clip of the reply (non-call modality), 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 (server 0.58.0). Never on A2A / channel surfaces. Subscribe with onAudio
companion.tool_call { id, name, args, replayed? } — perform an app tool, then tool-result. Subscribe with onToolCall. replayed: true (SDK 0.37.0) = re-delivery of a still-outstanding call after a mid-pause reconnect — treat id as an idempotency key for side-effecting tools
companion.ui_action { interface } — render an Instant UI panel (platform-neutral genui schema; needs ui.render). Subscribe with onRender. See companion-instant-ui.md
companion.ui_update { update } — live { panelId?, updates:[{key,value}] } write into an already-rendered panel (no rebuild). Subscribe with onInterfaceUpdate
companion.expression { expression } — an avatar cue, a VRM 1.0 preset (happy / sad / angry / surprised / relaxed / neutral) read from the reply's tone, when the agent opted in (replyCues.expression) (server 0.58.0). viseme / gesture are declared in the shape and not produced. Subscribe with onExpression
companion.social_message { fromUid, fromName, content, createdAt } — an inbound A2A friend message, delivered cross-app to social-scoped embeds. Subscribe with onSocialMessage
companion.data_activity { kind, capability, outcome, ms, actionId? } — METADATA-ONLY Data-plane activity (a View read or an Action settling; needs data.activity, SDK ≥0.44.0). Never carries rows, intent values, endpoints or receipts; outcome: "unknown" is verbatim ambiguity — render "pending verification", never success/failure. Subscribe with on('companion.data_activity', …)
companion.voice_inject { text, speak } — push a line into the live call. Subscribe with onVoiceInject (connectCall bridges it automatically)
companion.typing { active } — activity indicator, true at turn start / false at reply or tool pause; spans the tool-loop/thinking phase before the first text delta. Subscribe with onTyping
companion.confirm_request { confirmId, scope, summary, stepUp? } — see §7. stepUp:true ⇒ the first-party surface should require a biometric/passkey before approving (money ops). Subscribe with onConfirmRequest
control.call_ready a call is ready (secret-free echo of start_call's accept — credentials ride the HTTP response)
control.error { code, message } — stream-plane codes: agent_error (a server-side turn failed after accept; no reply was produced, safe to re-send), call_mint_failed (start_call couldn't mint the voice-provider credential — the HTTP response errors too; retry later or fall back to text) and the SDK-synthesized stream_unauthorized (stream 401/403 — a 401 exhausted the onAuthError refresh retries; a 403 is immediately terminal, commonly a token missing the events.subscribe scope. Refresh/fix the token and start() again). A separate vocabulary from the HTTP code table in §9 — exported typed since SDK 0.30.0 as CONTROL_ERROR_CODES + the ControlErrorCodeValue union (drift-tested, append-only). Subscribe with onError
control.usage { chatTurns, promptTokens, completionTokens, cachedTokens, reasoningTokens?, model? } per-turn metering echo, emitted after an input turn that billed tokens (same totals as the input done frame's usage). Subscribe with onUsage. voiceSeconds / memoryOps remain reserved

9. Errors, limits, notes

  • Errors: non-2xx responses on the companion plane are { "ok": false, "error": "message", "code"?: string, "errorId"?: string } (OAuth uses { error, error_description }). The optional code is a stable, machine-readable tag the SDK surfaces as CompanionError.code — switch on it when present, fall back to the HTTP status when absent. errorId (an err_… reference) is present on a persisted server fault only — a real 5xx whose detail is kept server-side under that id; the SDKs surface it as CompanionError.errorId (JS 0.56.0) and it is the thing to quote to support. Never on a 4xx:

    code status meaning
    missing_token 401 no Authorization: Bearer header
    invalid_token 401 token unknown, revoked, or expired — re-mint and retry (onAuthError automates this)
    missing_scope 403 token lacks a required grant — fix the key/template scopes, don't retry
    invalid_request 400 malformed JSON / missing or invalid fields
    session_not_found 404 sessionId expired or never started
    turn_pending 409 a turn owns the session: paused awaiting tool results, or another fresh turn is still mid-flight (wait for its reply, then retry)
    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 — the per-endpoint request-body ceilings are 40MB for /input (multimodal images), 4MB for /knowledge, 512KB for every other companion endpoint, plus the field caps documented per route (32k text on /input, 8MB per image, 2,000 chars of content on /memory). These are the API's own caps and are NOT the effective limit — the hosting platform rejects any request body over ~4.5MB before this API sees it; see “Request body size”.
    rate_limited 429 turn burst ceiling / demo daily budget spent, or the per-IP edge throttle — honor Retry-After (also on CompanionError.retryAfter, seconds, from SDK 0.27.0)
    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 (API 1.1)
    confirm_resolved 409 confirm already approved/denied/expired — single-use (API 1.1)
    step_up_required 401 approval needs a passkey assertion — fetch confirm/stepup options first (API 1.1)
    step_up_failed 401 the passkey assertion didn't verify; retry the step-up (API 1.1)
    quota_exhausted 402 the account's monthly allowance is spent — credits, or realtime voice minutes (POST .../call). Not retryable: upgrade the plan, or wait for the 1st (UTC) when the allowance resets. Contrast rate_limited, which is. Only reachable on deployments with PLATFORM_CREDIT_ENFORCEMENT on
    not_found 404 a resource addressed by id does not exist, or is one this token cannot see — deliberately the same answer, so refusals cannot map the space. First user: DELETE /api/companion/memory/{factId} (0.53.0)

    See companion-api-versioning.md for the canonical, append-only vocabulary.

    The SDK additionally synthesizes client-side codes outside this HTTP vocabulary (status: 0 unless noted): reply_timeout (awaitReply's event-stream fallback gave up), needs_event_stream (awaitReply on a tool-pausing turn without start(), SDK 0.28.0), not_connected / missing_option (client-side misuse guards), not_representative (pairVisitor without a representative session), aborted (a caller-supplied AbortSignal cancelled the request, SDK 0.29.0), queued_offline (a sendText with awaitReply: true was queued while offline and will send on reconnect — opt-in queueOffline, SDK 0.34.0), request_timeout (the requestTimeoutMs headers deadline elapsed — default 30 s, SDK 0.35.0; since SDK 0.40.0/0.40.1 the long-work POSTs default to their route's own ceiling instead — buffered /input, sendToolResult and the knowledge ingests 310 s, confirmAction 65 s — because their headers only arrive when the server work completes), stream_unauthorized (stream 401/403 — a 401 exhausted the token-refresh retries; a 403 is immediately terminal, commonly a missing events.subscribe scope), and — SDK 0.26.0 — the voice connect-step codes on connectCall/startCall rejections: call_unsupported (no WebRTC/mic in this environment), call_connect_failed (mic-permission timeout or SDP exchange failure; carries the HTTP status on a non-2xx exchange) and call_dependency_missing (@elevenlabs/client not installed). Native getUserMedia rejections (e.g. NotAllowedError) propagate untouched.

  • Request body size — the effective limit is ~4.5MB, and it is NOT this API's cap. Pouchy runs on serverless functions whose host rejects any request body over roughly 4.5MB before the request reaches this API at all. Every per-endpoint and per-field cap documented above (40MB for /input, 8MB per image, …) is the ceiling this API enforces once it sees the body — the host's limit is lower and hits first, so the range between them is not usable.

    Consequence you must design for: that rejection is not a readable 413 in a browser. It is produced before any Pouchy code runs, so it carries no CORS headers; a cross-origin fetch therefore rejects rather than resolving, and the SDK surfaces it as a CompanionError with status: 0 and no code — i.e. indistinguishable from the network being down. With queueOffline: true it is treated as an offline send and queued for replay, where it will fail identically on every flush and hold up the sends behind it until the queue's 30-minute staleness window drops it. Same-origin callers do see the 413.

    So: downscale images client-side before sending. Pouchy's own clients do — canvas re-encode to a 1024–1280px long edge at JPEG q0.8, which turns a 3–10MB phone photo into a few hundred KB and is well past what a vision model resolves anyway. Budget the whole JSON body, not just the image: data: URLs are base64, so they inflate ~33% over the raw file, and text + turnId ride along.

  • Resuming the stream: reconnect GET …/stream?cursor=N with the last seen sequence; the SDK does this automatically.

  • One session per surface. surface keys a resumable session — reuse it across reconnects.

  • Idempotency. Inbound effectful messages carry a client-generated id; resending the same id is de-duplicated.

  • CORS / origin. Calls go through Pouchy's authenticated proxy; outbound skill HTTP is server-side (SSRF-guarded), so the browser never hits third-party APIs directly.


Platform API (/v1) — projects, sessions, Admin

The developer-platform surface behind pouchy.ai/dashboard. A project holds agent templates; each end user gets an instance (own memory, relationship, wallet) addressed by your external_user_id.

Key types (strictly separated)

Key Prefix Holder Can
Secret Key pchy_sk_… / pchy_sk_test_… your backend mint end-user sessions (/v1/sessions); emit Data Events (/v1/projects/{id}/events)
Admin Key pchy_admin_… your backend/CI manage the project (/v1/admin/*)
Session token pchy_… end user's client the companion protocol above

Cross-use is refused with 401 (secret key on /v1/admin/*, admin key on /v1/sessions). pchy_sk_test_ keys are unmetered and quota-exempt (test data, never billed).

POST /v1/sessions — mint a per-user session (Secret-Key auth)

POST /v1/sessions
Authorization: Bearer pchy_sk_…
{ "agent": "<agentId>", "external_user_id": "user_4211",
  "expires_in": 3600, "scopes": ["chat"] }        // both optional

201 → { "session_token": "pchy_…", "expires_in": 3600, "agent": "<agentId>",
        "instance": { "id": "…", "external_user_id": "user_4211", "created": true } }
  • Unknown body fields are ignored — and named. The mint body reads exactly agent, external_user_id, expires_in, scopes, referral_code; anything else is not an error (forward-compat) but is echoed back as ignored_fields: [...] on the 201 so a misplaced field can't fail silently. The classic trap: tools does NOT go here — declare tools on the handshake body (POST /api/companion/session), which has accepted them all along.

  • First-seen external_user_ids are auto-provisioned (persona applied from the agent template) — end users never configure anything.

  • ⚠️ external_user_id MUST be a stable id from YOUR account system — never a browser-generated / device-local id (localStorage device id, anonymous visitor id, session cookie). The instance — and ALL of its server-side memory, relationship state, and wallet — is addressed by sha256(projectId + agentId + external_user_id): if the id changes (user clears the browser, reinstalls, switches devices), the next mint creates a brand-new instance and the agent "forgets" the user. The old instance's memories remain intact under the old id, permanently orphaned. This is the single most common integration mistake; the dashboard's End-users page flags the fingerprint (mostly one-shot instances) when it sees it.

  • Instances are isolated PER AGENT — but MAU counts PEOPLE. The same external_user_id under two agents of one project gets two fully separate instances (memory, relationship, wallet) — what the user tells one agent, another never "remembers". Billing does NOT multiply: that member is ONE monthly-active user however many agents they talk to. Pairings created before 2026-07-15 keep their original instance ids (grandfathered; nothing moves).

  • Sessions get the non-sensitive default scopes; a scopes array may only narrow them. Sensitive scopes are not mintable via sessions. On top of the narrowed base, the agent template grants capabilities: genui: true on the agent adds ui.render (Instant UI) to every session it mints, and instructions: true adds chat.instructions (per-turn developer instructions). Those two are the pattern, not exceptions: a scope outside the default set is reachable ONLY through a template flag, never by asking for it in the mint body — a requested scope that is not in the default set is narrowed away silently, so ["chat","chat.instructions"] mints as ["chat"] until the template flag is on.

  • expires_in clamps to 300–86400 s. No CORS — server-to-server only.

  • Plan gate: a new live user beyond the plan's monthly-active-user cap → 402 monthly active user limit reached (N/limit). Existing actives keep working mid-month. Test-key users meter into a separate mauTest bucket — never gated, never billed. If the platform cannot read the account's billing state at mint time, a gated mint answers a retryable 503 (plan limits temporarily unavailable) instead of quoting limits it never read — retry; the 402 above is always computed from the account's real plan.

  • Moderation gates at mint: suspended end user → 403; draft agent with a live key → 403 (test keys still mint — build → test → publish); archived project → 403.

Organization members & roles

Vercel/Stripe-style org collaboration: the org is the founding user; email invites activate automatically when a VERIFIED account with that email signs in (no email round-trip). Roles gate every project endpoint below: owner (billing mutations, member management, hard delete) → admin (all day-to-day mutations) → member (read-only).

GET/POST  /v1/org/members                    # roster / invite { email, role }
PATCH/DELETE /v1/org/members/{memberId}      # change role / remove
GET/POST  /v1/org/roles                      # custom RBAC roles: POST { name,
                                             #  baseRole:'admin'|'member',
                                             #  capabilities?: string[] } — a named
                                             #  role inheriting a built-in rank (never
                                             #  owner, so it can't escalate). The
                                             #  capabilities list is advisory today:
                                             #  the rank gates access; capability
                                             #  narrowing lands as endpoints adopt it
DELETE    /v1/org/roles/{roleId}             # remove (members on it fall back per rank)
GET       /v1/org/audit                      # ORG-plane audit log, newest first.
                                             #  ?limit=1..200 (default 100) →
                                             #  { logs: [{ at, type, detail, actor? }] }
                                             #  Types: member.invited /
                                             #  member.role_changed /
                                             #  member.scope_changed /
                                             #  member.removed / role.created /
                                             #  role.deleted / sso.config /
                                             #  sso.scim_token_minted

Org events are a SEPARATE plane from the per-project audit log (GET /v1/projects/{projectId}/logs), because they name no project: an invite grants access to the org (or to a scope list spanning several of its projects), and deleting a custom role revokes across all of them. Both planes share the row shape, the 200-row page ceiling, and the one-year retention.

Enterprise SSO + SCIM (API view)

The operator activation walkthrough lives in enterprise-sso-runbook.md; these are the REST endpoints behind it (Firebase auth; the org is the caller's own — owner-only by construction):

GET/PUT   /v1/org/sso                        # SSO policy + SCIM state: { enabled,
                                             #  enforced, provider:'saml'|'oidc',
                                             #  domains[], metadataUrl,
                                             #  firebaseProviderId, scimEnabled,
                                             #  scimTokenSet } — the SCIM token hash
                                             #  never leaves the server. `enforced`
                                             #  blocks every non-SSO sign-in except
                                             #  the owner the moment firebaseProviderId
                                             #  is set (inert until then)
POST      /v1/org/sso/scim-token             # mint the SCIM bearer: plaintext returned
                                             #  ONCE, hash stored; minting enables SCIM
                                             #  and hard-rotates any prior token

SCIM 2.0 endpoint for the IdP (Okta / Azure AD / …): base URL https://pouchy.ai/scim/v2, Authorization: Bearer scim_…:

POST      /scim/v2/Users                     # provision: { userName: email,
                                             #  name?, active? } → member invited
                                             #  (idempotent per email)
GET       /scim/v2/Users?filter=…            # roster; supports
                                             #  userName eq "alice@acme.com"
GET/PATCH/PUT/DELETE /scim/v2/Users/{id}     # read / update (PATCH active:false
                                             #  deactivates) / replace / deprovision.
                                             #  Role writes are clamped to the built-in
                                             #  ranks — SCIM can never mint an owner

Project endpoints (Firebase-user auth; role-gated per the table above)

Machine-readable. GET /v1/projects/openapi serves the full OpenAPI 3.1 contract for this plane (public, CORS-open — the third public spec beside /v1/admin/openapi and /v1/world/openapi). It is composed rather than restated: the world routes are copied verbatim from the world spec, a route whose Admin-API twin shares the handler reuses the admin operation (its x-pouchy-admin-path names the twin), and only dashboard-only routes are described here. Every operation carries x-pouchy-auth — the exact mechanism set its handler enforces (owner / admin / member on the Firebase token, secret-key, admin-key, public; two entries mean either) — and a drift test re-derives that set from the route source, so the document cannot claim a role the handler does not check.

GET       /v1/projects/openapi               # OpenAPI 3.1 spec of this plane (PUBLIC)
GET/POST  /v1/projects                       # list / create (+ optional first agent + key)
GET/PATCH /v1/projects/{id}                  # read / { name? } rename, { archived? } soft delete
DELETE    /v1/projects/{id}                  # HARD delete — body { name } must equal the
                                             #  exact project name; removes the project tree
                                             #  + every end user's data; >300 users → 409
                                             #  (archive instead)
GET/POST  /v1/projects/{id}/agents           # agent templates
GET/PATCH/DELETE /v1/projects/{id}/agents/{aid}
                                             # persona/voice updates bump templateRev; status
                                             #  'draft'|'published' gates live minting (no bump);
                                             #  DELETE removes the template (instances keep data)
                                             # create/update: `skills` slugs are filtered to the
                                             #  registered allowlist; anything filtered is echoed
                                             #  as `droppedSkills` on the response (same contract
                                             #  as the Admin API mirror below)

Agent template fields (POST create / PATCH update, all optional):

Field Shape Semantics
name, archetype, systemPrompt strings persona — re-applied to every instance on its next session mint (rev bump). Clients that care about wording should always send systemPrompt (the dashboard fills a localized template client-side); when a CREATE omits it, the server seeds a default matching the request's Accept-Language — Chinese template for a leading zh-* tag, English otherwise
scenes [{ "title", "when", "content", "enabled"?, "trigger"? }] (≤12; [] clears; enabled: false keeps the draft but excludes it from the prompt; trigger: { "minSessions"?: 1..999, "stage"?: "<stage id>", "once"?: bool }) scripted story lines (剧本) — compiled into a pinned prompt section per instance turn. The prose when steers the model in-scene; the OPTIONAL trigger is evaluated SERVER-SIDE against the instance's evolved state (deterministic narrative): minSessions holds the scene back until the Nth consolidated session ("the confession fires on session 3", guaranteed), stage gates it to a relationship stage, once plays it in exactly one session and retires it (演过不再演; keyed by title — retitling re-arms it). No trigger = always in the prompt (rev bump). Industry templates ship presets
initialStage one of stranger acquaintance friend close_friend romantic_interest dating committed soulmate companion, '' clears relationship stage seeded into new instances only — existing users' evolved stage is never reset (no rev bump). Instances then EVOLVE server-side: the friendship band advances at 3/8/15 consolidated sessions (romance stages never move automatically), and each session's end-of-session consolidation blends one emotional observation into the instance's mood (12-emotion model; time decay applies between sessions — a long-absent user is greeted with a "missed you" tone). Both feed the system prompt automatically; no API surface to manage
voices { "<locale>": { "openAI"?: "<textVoice>", "elevenLabs"?: "<hdVoiceId>" } } (≤10 locales; {} clears; field names are historical — values are Pouchy voice-catalog ids) per-language voice defaults applied to all instances (rev bump). Voice calls prefer the HD voice id (exclusive/cloned voices provisioned for your account work); the text-voice preset covers text-to-speech + call fallback. The dashboard picker lists the platform voice catalog
social { "pair"?: bool, "crossProject"?: bool, "allowAgents"?: string[], "dailyLimit"?: 1..500, "blockedTopics"?: string[], "autoReply"?: bool } ({} clears; allowAgents ≤50 agent ids; blockedTopics ≤20 × ≤30 chars) instance A2A: pair lets this agent's instances pair with other companions and exchange TEXT messages (session mints gain social.message + represent:pair; policy enforced server-side at the pair chokepoint AND re-evaluated on every send, so flipping pair:false is a retroactive kill switch that severs existing pairs, not just blocks new ones). crossProject additionally allows pairs crossing the project boundary — required on every instance side; narrowing it also applies live to existing pairs. allowAgents is a per-agent whitelist: when non-empty, this agent's instances pair only with instances of the listed agent templates (both sides' non-empty lists must admit the other; first-party pairs are unaffected — crossProject is their gate); removing an agent from the list stops further messages on existing pairs too. Outbound content policy (enforced server-side at send time, so it also governs pairs that pre-date a policy edit): dailyLimit caps friend messages per instance per UTC day (absent = unlimited; a broadcast counts once per recipient; over-limit sends fail 429), blockedTopics refuses any outgoing message containing one of the keywords, case-insensitive (403 naming the matched topic). Money (wallet) is never granted here. autoReply makes instances answer inbound friend messages on their own (a bounded server-side reply grounded in persona + memory, delivered as a normal message row stamped auto: true); the reply passes the SAME chokepoints as any send (pair check, blockedTopics, dailyLimit, rate limit) and is strictly one level deep — an auto-reply never triggers another auto-reply, so two auto-reply agents cannot ping-pong. Read at receive time like the content policy (edits apply to the next message)
wallet { "enabled"?: bool } ({} clears) READ-ONLY instance wallet (C5 conservative): session mints gain wallet.read — check balance + own deposit address (lazy-provisioned at first read; USD stablecoins via OpenWeb3). Paired friends can send funds TO an instance; instances can never spend (wallet.spend is never granted). Per-instance balance readable at GET /v1/projects/{id}/users/{iid}/wallet (+ admin parity)
data { "enabled"?: bool, "actions"?: { "enabled"?: bool, "autoRun"?: string[], "autoRunDailyCap"?: 1..50 }, "events"?: { "enabled"?: bool, "subscriptions"?: string[] } } ({} clears; autoRun ≤5 action names, subscriptions ≤10 event-capability names — both lowercased + deduped) Agent Data — the project's published data world. enabled is the PARENT gate: instances get the read_data tool plus a pinned capability MENU naming the project's published view capabilities. actions.enabled (effective only with enabled) additionally exposes the run_action gateway over published action capabilities — every run is user-confirm-gated, executed against a frozen, runtime-normalized intent, and idempotent at your backend by a runtime-minted actionId. events.enabled (effective only with enabled) lets your backend POST published event capabilities at this agent's instances (POST /v1/projects/{id}/events). The agent selects capabilities BY NAME and never authors a query or names whose data to touch — the caller's identity is minted server-side and sent as signed actor context (X-Pouchy-Actor + X-Pouchy-Actor-Signature, audience-scoped). View results are curated to declared fields, bounded, and carry explicit complete / returnedCount; intimate-tier capabilities are reachable by nobody. The menu is PINNED per session, so publishing affects new sessions only; a hidden capability is indistinguishable from a nonexistent one. Rev bump; instances pick changes up next session. Live kill switches: each flag is re-read on every turn/acceptance — flipping one off stops that plane on the NEXT TURN (or next event) of sessions already running. Publishing a NEW capability version is NOT revocation; these flags are the revocation levers. events.subscriptions opts the agent into subscription routing: an event POSTed WITHOUT agentId fans out to every subscribed, events-enabled agent (fan-out cap 5, agentId-sorted, truncation reported) — live, no rev bump. actions.autoRun + autoRunDailyCap (default 10, per user per UTC day) are the operator half of the event-to-action automation two-key (§Data capabilities below): effective only for actions whose PUBLISHED declaration also says automation: "allowed"; both halves live — removing a name stops automation on the next event
skills string[] of built-in catalog slugs (dictionary wikipedia exchange-rates rest-countries public-holidays ip-lookup cat-fact dad-joke stoic-quote iss-position photo-search) plus the project's custom-skill slugs ([] clears) pre-installs the skills on every instance (rev bump) AND grants skills.execute on session mints. Built-ins and credential-free GET-only customs auto-run without the confirm gate; POST/credentialed customs run behind the end user's confirm card (SDK confirmAction), with API keys injected server-side from the project credential vault. MCP skills come later
nudge { "enabled"?: bool, "afterHours"?: 1..720, "campaigns"?: [{ "id": string ≤40, "afterHours": 1..720, "stage"?: "<stage id>", "label"?: string ≤80 }] } ({} clears; afterHours default 24h; campaigns ≤6, ids deduped) 主动性: a platform cron fires an agent.nudge webhook for live instances silent past the window (once per silence period) — your backend reaches the user (push/email) and can mint a comeback session. Lifecycle campaigns (campaigns) upgrade the single window into independent, escalating rules: each has a stable id, its own afterHours silence gate, an optional relationship-stage filter, and an optional human label. When present, campaigns REPLACE the single afterHours rule. Every campaign dedups on its own id (a day-1 and a day-3 nudge don't cancel each other); when an instance is overdue for several at once, the most-overdue fires that sweep and the rest follow on later sweeps (never a burst). The agent.nudge payload then carries campaign (the id), campaign_label (when set), and stage (when the stage was read) alongside inactive_hours
modelTier 'standard' | 'pro' (default 'standard') which LLM class serves this agent's chat turns — aligned with the pricing page's Standard + Pro model rates (rev bump; instances pick it up on their next session mint). 'pro' routes turns to the premium model when the platform has one configured (POUCHY_MODEL_PRO), else falls back to standard silently
genui boolean Instant UI — newly minted session tokens for this agent carry the ui.render scope (no rev bump; existing tokens gain it on re-mint)
instructions boolean (default false) 单轮指令 — newly minted session tokens for this agent carry the chat.instructions scope, which lets POST …/input carry an instructions field: developer-authored rules for THAT turn, joined to the prompt and never scored as user speech by inbound moderation (≤32,000 chars; not persisted). This is the ONLY way to obtain the scope — it is outside the default set on purpose, because the field skips moderation and a session token is routinely held by the end user's own client, so the grant has to be the agent owner's decision rather than the token holder's. Leave it off unless your backend assembles per-turn rules itself. No rev bump; existing tokens gain it on re-mint
imageGen boolean (default false) 图像生成 — instances get the generate_image tool: server-side image generation on the platform's image model, returned as a 7-day capability URL (/api/companion/generated-image/{id}) the reply carries. Budgeted per instance per day (POUCHY_IMAGE_DAILY_LIMIT, default 20). Rev bump; instances pick it up next session
spawnSubtasks boolean (default false) 子代理扇出 — the agent's turns offer the synthetic spawn_subtasks tool (concurrent headless workers under one persona; worker calls are metered on the turn's ledger). OFF by default since owner decision D-10 (2026-09-05): 5,000 fleet turns over 7 days never called it while its schema cost ~150 prompt tokens on every one. Persona-class (rev bump; instances pick it up on their next mint)
replyCues { "audio"?: bool, "expression"?: bool, "voiceId"?: string } ({} clears) 回复提示 — opt the agent's text turns into the two outbound cue events (design #25): audiocompanion.audio (a TTS mp3 of the reply, when the session negotiated voice and the token holds the voice scope; voiceId picks the voice), expressioncompanion.expression (a VRM 1.0 preset read from the reply's tone). Both ride behind the reply and never delay it; neither fires on A2A or channel surfaces. Persona-class (rev bump)
safety { "level": 'off' | 'standard' | 'strict', "message"?: string ≤300 } 内容安全 (P0-B): per-agent moderation on every end-user chat message (OpenAI omni-moderation, run in PARALLEL with assembly — no added latency; fails OPEN on outages). Absent = 'standard', which blocks only the never-acceptable categories (sexual/minors, self-harm intent/instructions, threats, graphic violence, violent-illicit) — a romance companion's normal range is untouched; 'strict' additionally blocks all sexual/hate/harassment/violence/self-harm/illicit content (family-friendly); 'off' is the explicit opt-out. Blocked turns reply with message (default: "I can't help with that. Let's talk about something else."), stamp meta.blocked on the transcript row, and write a moderation.blocked audit row (level + categories, never message content). The three facts this row states — the absent-means-standard default, the two category sets, and the fail-open posture — are also published as a VERSIONED, machine-readable contract at GET /v1/safety-defaults (public, no auth), carrying a version and a digest over those three facts and nothing else. An integrator whose own compliance statement cites them should diff that digest in CI rather than re-reading this prose: CI here reds if any of the three moves without the version and digest moving with it. What it deliberately does NOT pin is the CLASSIFIER — omni-moderation-latest's verdicts can drift with no constant in this repo changing, so a downstream disclosure must carry that distinction or it will overclaim. Rev bump
knowledgeDocIds string[] of project knowledge-document ids ([] clears) 知识范围 — restricts this agent's project-knowledge recall to the named documents (capped; unknown/malformed ids are dropped). Read live off the agent row every turn, so a change takes effect immediately with no rev bump
agentCardExtras { … } (present key replaces, absent keeps, empty clears) A2A AgentCard extras — merchant capability entries plus the x_pouchy block on this agent's published card (see docs/dejoy-integration.md)
status 'draft' | 'published' draft refuses live-key mints; test keys + debug still work
POST      /v1/projects/{id}/agents/{aid}/debug-session
                                             # owner debug instance (test env, no secret key)
GET/PUT   /v1/projects/{id}/agents/{aid}/evals
                                             #  golden-test suite (evals v1): PUT { cases:
                                             #  [{ name?, message, expect, turns?,
                                             #  expectTools?, forbidTools?, maxToolCalls? }]
                                             #  ≤8 } replaces; GET returns { cases, lastRun }.
                                             #  Dev tooling — never mirrored onto instances,
                                             #  no rev bump.
                                             #  TRAJECTORY ASSERTIONS (all optional; a case
                                             #  that sets none behaves exactly as before):
                                             #    expectTools[]  every name must have been
                                             #                   called at least once
                                             #    forbidTools[]  none of these may be called
                                             #    maxToolCalls   ceiling on TOTAL tool calls;
                                             #                   0 is meaningful ("answer from
                                             #                   context, touch nothing")
                                             #  Judged against the server-side turn log (the
                                             #  same `meta.tools` the transcript endpoint
                                             #  returns), NOT against companion.tool_call
                                             #  frames — those fire only for app-declared
                                             #  tools, so they are structurally empty for
                                             #  first-party tools. A case's stored `pass` is
                                             #  the AND of the judge verdict and the
                                             #  trajectory, so the CI gate below inherits it.
POST      /v1/projects/{id}/agents/{aid}/evals/session
                                             #  { runId } → session token for a THROWAWAY
                                             #  eval instance (__eval__{aid}__{runId}, test
                                             #  env — clean room per run; DELETE the returned
                                             #  instanceId via users/{iid} when done)
POST      /v1/projects/{id}/agents/{aid}/evals/judge
                                             #  { message, expect, reply } → { score 0-100,
                                             #  pass, reason } — LLM judge, one case per call
POST      /v1/projects/{id}/agents/{aid}/evals/last-run
                                             #  { results } → persists the run summary the
                                             #  dashboard Evals tab shows on load. Each row
                                             #  may carry tools[] (what the run actually
                                             #  called, in order) and trajectory[] (the
                                             #  failures: {kind:'missing'|'forbidden',tool} /
                                             #  {kind:'too_many',limit,actual} /
                                             #  {kind:'unobserved'}). A row with any
                                             #  trajectory failure is stored pass:false
                                             #  regardless of the judge's verdict
POST      /v1/projects/{id}/agents/{aid}/debug-moderate
                                             #  { text } → { level, flagged[], checked,
                                             #  wouldBlock }: classify a sample against THIS
                                             #  agent's safety profile with the same
                                             #  moderateText+safetyBlocks pair the turn gate
                                             #  runs — the gate's verdict, read out of band
                                             #  (nothing persisted; sample not logged).
                                             #  checked=false ⇒ moderation API unreachable
                                             #  (the live gate fails open in that state)
GET       /v1/projects/{id}/agents/{aid}/versions
                                             #  archived template revisions, newest first
                                             #  (a snapshot is cut when an edit supersedes
                                             #  a rev; the live template is on the agent)
GET       /v1/projects/{id}/agents/{aid}/versions/{rev}
                                             #  one archived revision, full template
POST      /v1/projects/{id}/agents/{aid}/versions/diff
                                             #  { from, to } → field-level diff
POST      /v1/projects/{id}/agents/{aid}/versions/rollback
                                             #  { version } — git-revert semantics: the
                                             #  snapshot re-applies as a NEW edit (rev
                                             #  moves forward; current state archived
                                             #  first). Admin auth + audited
GET/POST  /v1/projects/{id}/agents/{aid}/promote
                                             #  staging→prod (#5). GET → { stagingRev,
                                             #  prodRev, pending, diff,
                                             #  pinnedSnapshotMissing } — what a promotion
                                             #  would ship; pinnedSnapshotMissing=true means
                                             #  the pin's snapshot is gone and live runs the
                                             #  staging head until a re-promote.
                                             #  POST pins prodRev to the head
                                             #  WITHOUT a rev bump; live instances resolve
                                             #  the pinned version on their next mint, test
                                             #  instances always track the head. Idempotent
GET/PUT   /v1/projects/{id}/agents/{aid}/evals/config
                                             #  CI gate config: { gateThreshold } (default
                                             #  70) — the pass mark the gate judges against
GET       /v1/projects/{id}/agents/{aid}/evals/gate
                                             #  the CI PASS GATE — poll with the project
                                             #  ADMIN KEY (machine auth) and read `pass`
                                             #  (latest run passed every case AND avg ≥
                                             #  gateThreshold). Always HTTP 200: the JSON
                                             #  is the signal, not the status code
GET       /v1/projects/{id}/agents/{aid}/evals/runs
                                             #  rolling run history: summary + per-case
                                             #  results + versionTag (regression trend /
                                             #  A-B compare across template revisions)
POST      /v1/projects/{id}/agents/{aid}/sweep
                                             #  Prompt Playground (#6): { variants:
                                             #  [{ label, system, model?, maxTokens? }],
                                             #  prompts: [string] } → side-by-side
                                             #  comparison grid via one-shot completions.
                                             #  Admin auth (spends provider tokens);
                                             #  capped ≤4 variants × ≤6 prompts; audited
GET       /v1/projects/{id}/agents/{aid}/debug-state
                                             #  instance persona/relationship snapshot
GET       /v1/projects/{id}/agents/{aid}/debug-turns
                                             #  recent turn transcript (debug instance)
GET       /v1/projects/{id}/agents/{aid}/debug-inbox?sinceMs=
                                             #  a2a messages DELIVERED to the debug
                                             #  instance — ground truth for "did the
                                             #  social send really happen". Newest-first
                                             #  (100 rows) then filtered by sinceMs;
                                             #  `indexed: false` means the index was
                                             #  missing and an UNORDERED sample served
                                             #  the read — a missing row proves nothing
                                             #  in that state
GET       /v1/projects/{id}/agents/{aid}/debug-skill-audit?sinceMs=
                                             #  server-side skill-execution audit rows —
                                             #  ground truth for "did a skill really run"
GET       /v1/projects/{id}/agents/{aid}/debug-confirms?sinceMs=
                                             #  confirm-gated calls the debug instance
                                             #  recorded, WITH the args the model passed
                                             #  (redacted: credential-like values masked,
                                             #  long/deep/wide payloads bounded) — ground
                                             #  truth for "what did it actually send",
                                             #  which `summary` alone never answered
GET/POST  /v1/projects/{id}/knowledge        # SHARED knowledge corpus (C2): POST
                                             #  { text, name?, kind?, locale? } chunks +
                                             #  embeds ONCE at project level (≤60k chars,
                                             #  ≤120 chunks, ≤50 docs); every instance of the
                                             #  project's agents recalls it semantically
                                             #  alongside personal memory, cited by name
POST      /v1/projects/{id}/knowledge/file   # RAW file ingest (K-1): { dataUrl, name?,
                                             #  kind?, locale? } — PDF (pdfjs extract, 60k
                                             #  cap, p.N citations; scanned → vision OCR;
                                             #  where POUCHY_PDF_FIGURES=1, a text-layer PDF
                                             #  also gets pages the text layer could not read
                                             #  — charts, stamps, table images, and text that
                                             #  failed to extract — SENT TO THE VISION
                                             #  PROVIDER as page images and filed under their
                                             #  own p.N, prefixed "[figure]"; the response
                                             #  carries `figurePass` naming exactly which
                                             #  pages that was), audio/video (Whisper),
                                             #  image (vision caption). Same understanding
                                             #  module as the SDK's ingestFile; caps 20MB pdf
                                             #  / 25MB a-v / 8MB image → 413; other → 415
POST      /v1/projects/{id}/knowledge/url    # K-3 web-page ingest: { url, name?,
                                             #  replaceDocId? } — https only, SSRF-guarded
                                             #  fetch (public-IP re-check per redirect hop),
                                             #  html/plain only (415), 2MB cap (413), HTML
                                             #  reduced to readable text; <title> = default
                                             #  doc name, kind 'web'
GET       /v1/projects/{id}/knowledge/{docId}/chunks
                                             #  K-2 chunk preview: the doc's stored rows in
                                             #  document order (row 0 = headline summary,
                                             #  citation suffix included) — see how the
                                             #  corpus was actually split
                                             #  (POST bodies above also take replaceDocId:
                                             #  replace a doc IN PLACE — same docId, old
                                             #  chunks removed, doc cap not re-charged)
DELETE    /v1/projects/{id}/knowledge/{docId}# remove a doc + all its chunks from recall
POST      /v1/projects/{id}/knowledge/search # 检索测试: { query } → { mode, hits } — the
                                             #  exact chunks an instance turn would recall
                                             #  (ranked, cited). mode 'semantic' = vector
                                             #  search answered; 'lexical' = the keyword
                                             #  fallback did (vectors off/unavailable)
GET/PUT   /v1/projects/{id}/knowledge/config # per-project RAG tuning (#3): PUT
                                             #  { chunkChars, chunkOverlap, recallTopK,
                                             #  recallMinScore, embeddingModel? } (admin).
                                             #  Chunk settings apply to the NEXT ingest;
                                             #  recall settings on the next turn. Switching
                                             #  embeddingModel ('default'|'openai'|'google')
                                             #  with embedded docs present answers
                                             #  409 { code:'reembed_required' } — clear and
                                             #  re-ingest the knowledge base to switch
GET/POST  /v1/projects/{id}/custom-skills    # 自装技能: POST { md } (skill.md content),
                                             #  { url } (manifest fetched via the SSRF
                                             #  guard), { mcpUrl, slug? } (connect an MCP
                                             #  server — tools discovered server-side via
                                             #  tools/list), or { openapi | openapiUrl,
                                             #  slug? } (OpenAPI 3.x spec translated into a
                                             #  skill server-side, ≤50 operations; response
                                             #  carries `warnings` for skipped ops). Classes
                                             #  at install: credential-free GET-only HTTP ⇒
                                             #  platformSafe (instances auto-run); POST /
                                             #  credentialed HTTP and ALL MCP ⇒ installable
                                             #  but every run waits for the end user's
                                             #  confirm card (SDK confirmAction). MCP auth =
                                             #  a vault credential named mcp_auth (bearer)
                                             #  stored for the slug BEFORE connecting.
                                             #  EVERY install also passes the safety judge
                                             #  (static rules + fail-open LLM layer):
                                             #  manipulative manifests are rejected 422;
                                             #  warn-tier findings land on the record as
                                             #  safetyWarnRules. Execution enforces the
                                             #  domain allowlist either way. ≤50/project;
                                             #  slugs join the selectable `skills` set.
                                             #  Install (incl. RE-install) re-pushes the def
                                             #  to running instances — response carries
                                             #  { reprovisioned, truncated } like the PATCH
PATCH     /v1/projects/{id}/custom-skills/{slug}
                                             #  { ratePerMin: 1..120 | null } — per-skill
                                             #  per-minute call budget, HTTP and MCP alike
                                             #  (clamped; null restores the default 60), OR
                                             #  { maxCallsPerDay: 1..20000 | null } — opt-in
                                             #  daily call ceiling (runaway guard; null = no
                                             #  ceiling), OR
                                             #  { freeHttp, grantedDomains } — free-HTTP grant
                                             #  (arm a docs-only skill for http_request; effective
                                             #  allowlist = manifest ∪ granted), OR
                                             #  { autoRunTools: string[] } — per-tool confirm
                                             #  exemptions: the named tools run WITHOUT a confirm
                                             #  card on platform instances, everything else keeps
                                             #  the gate. The only route to no-confirm for an MCP
                                             #  skill, which can never be platformSafe. Names not
                                             #  declared by the skill are dropped and echoed in
                                             #  `dropped`; [] revokes all. Grant read-only tools
                                             #  only — the server's own readOnlyHint annotation is
                                             #  shown as advice, never trusted as the gate.
                                             #  EXACTLY ONE knob per call — a body naming two
                                             #  (e.g. { ratePerMin, maxCallsPerDay }) is refused
                                             #  400, never half-applied. { freeHttp,
                                             #  grantedDomains } is ONE knob, not two.
                                             #  EVERY branch re-pushes the def to running
                                             #  instances and returns { reprovisioned, truncated }.
                                             #  truncated:true = the sweep hit its bound, so the
                                             #  remaining instances keep the OLD def (including a
                                             #  revoked grant) until their agent's next template
                                             #  edit — re-issue after editing, or narrow the agent
POST      /v1/projects/{id}/custom-skills/{slug}/compile
                                             #  P2: compile a docs-only skill's prose into
                                             #  declared http tools (one-shot LLM; output
                                             #  re-validated: https-only, host must be in the
                                             #  skill's allowed domains, ≤16 tools) and
                                             #  reinstall through the full pipeline (safety
                                             #  judge + version archive — rollback-able).
                                             #  Returns { toolNames, warnings } — warnings
                                             #  carries BOTH the compiler's per-tool
                                             #  notices and the reinstall's own (e.g. a
                                             #  depth-trimmed schema)
DELETE    /v1/projects/{id}/custom-skills/{slug}
                                             #  uninstall + strip from every agent template
                                             #  (version history goes with it)
GET       /v1/projects/{id}/custom-skills/{slug}/versions
                                             #  S-5: archived install revisions, newest
                                             #  first (rolling window of 10) — metadata
                                             #  only ({ version, archivedAt, displayName,
                                             #  toolNames, kind }); the raw manifest never
                                             #  travels. Reinstalling the same slug bumps
                                             #  `version` and archives the previous row;
                                             #  the developer's ratePerMin survives an
                                             #  upgrade
POST      /v1/projects/{id}/custom-skills/{slug}/rollback
                                             #  { version } → re-install that archived
                                             #  revision (git-revert semantics: the old
                                             #  manifest goes back THROUGH the normal
                                             #  pipeline incl. the safety judge, and the
                                             #  version counter moves FORWARD). MCP rows
                                             #  have no manifest — reconnect instead.
                                             #  A skill tool may also declare
                                             #  `handler.response_pick: ["data.x", ...]`
                                             #  (S-6, ≤10 dot-paths): after a successful
                                             #  call only those fields travel to the
                                             #  model (fail-open — non-JSON bodies and
                                             #  unmatched picks keep the raw body)
POST      /v1/projects/{id}/skills/{slug}/test
                                             #  试运行: { tool, args? } → run ONE tool of an
                                             #  installed skill (built-in or custom) with
                                             #  concrete args; returns { ok, status?,
                                             #  durationMs, body?, error? } (body capped
                                             #  20KB). Runs through the production
                                             #  chokepoints (rate limit, domain allowlist,
                                             #  SSRF guard, vault credentials) and counts
                                             #  in skill-stats + the audit log
GET/POST  /v1/projects/{id}/credentials      # 凭据保险库 (write-only): POST { skill,
                                             #  credentials: [{ credentialName?, scheme:
                                             #  bearer|api_key|basic|hmac, value, header? }] }
                                             #  encrypts at rest; GET lists secret-free
                                             #  metadata only (slug, entry count, schemes) —
                                             #  values are never returned by any endpoint.
                                             #  Instance skill calls inject them server-side,
                                             #  scoped to the manifest's allowed domains
DELETE    /v1/projects/{id}/credentials/{skill}
                                             #  remove a skill's stored credentials
GET/POST  /v1/projects/{id}/keys             # secret keys ({ env: 'live'|'test' })
DELETE    /v1/projects/{id}/keys/{keyId}     # revoke (immediate)
POST      /v1/projects/{id}/keys/{keyId}/rotate
                                             #  mint a replacement key and grace the old one
                                             #  (body { graceHours?: 0..168 }, default 24) so
                                             #  running servers keep verifying during the swap;
                                             #  plaintext returned ONCE. 409 while the key is
                                             #  already in grace (revoke it or let it lapse)
GET/POST  /v1/projects/{id}/admin-keys       # admin keys (+ DELETE /{keyId} revoke)
GET       /v1/projects/{id}/users            # instances + per-instance usage. Filters:
                                             #  ?external_user_id= (exact) or
                                             #  ?external_user_prefix= (range) — era-agnostic;
                                             #  per-agent keying ⇒ one row per agent the
                                             #  member has met
POST      /v1/projects/{id}/users/import     # P-4 bulk pre-provision instances from your
                                             #  own user ids. Body { externalUserIds: string[]
                                             #  | newline/comma text, agentId?, env? } — binds
                                             #  to agentId (default first agent), env 'live'
                                             #  (default) | 'test'. Idempotent per external id
                                             #  (existing SKIPPED, never overwritten); ≤1000/
                                             #  call; persona hydrates on first session; MAU
                                             #  accrues only when a user is actually active
                                             #  (import never meters). 201 { requested, created,
                                             #  skipped, invalid, env }
PATCH     /v1/projects/{id}/users/{iid}      # { suspended: boolean } — refused at mint
GET       /v1/projects/{id}/users/{iid}/export  # GDPR portability: registry + character state + memories (≤500) + contacts, one JSON
DELETE    /v1/projects/{id}/users/{iid}      # GDPR erasure (registry + full data subtree)
GET       /v1/projects/{id}/users/{iid}/sessions
                                             #  the instance's sessions, most recently active
                                             #  first: { sessionId, surface, createdAt,
                                             #  lastSeenAt, turns }; ?limit=1..50 (default 20)
GET       /v1/projects/{id}/users/{iid}/sessions/{sid}/turns
                                             #  the session's turn log, oldest-first, with
                                             #  per-turn trace meta where recorded:
                                             #  { ms, tools[], in, out, cached } (latency /
                                             #  tool calls / token totals across the turn's
                                             #  hops); ?limit=1..50. Dashboard End Users →
                                             #  Sessions renders these
GET       /v1/projects/{id}/usage            # month { mau, mauTest, sessions, mauLimit,
                                             #         sessionsByDay, mauByDay }
GET       /v1/projects/{id}/usage/history    # month-over-month series, oldest first:
                                             #  { months:[{ month, mau, mauTest,
                                             #  sessions }], scope }; ?months=1..24
                                             #  (default 12). scope='account' is the pooled
                                             #  figure the plan cap compares (multi-project
                                             #  accounts differ from their per-project
                                             #  slice); quiet months zero-fill, never drop
GET       /v1/projects/{id}/retention        # P-1 survival retention + weekly cohorts:
                                             #  { overall:[{window,eligible,retained,
                                             #  rate}], cohorts:[{cohortStart,size,
                                             #  points[]}], instancesScanned, truncated }.
                                             #  survival = lastActiveAt−createdAt ≥ N days;
                                             #  a cohort too young for a window reads N/A
                                             #  (rate null), never a fake 0%. windows =
                                             #  D1/D7/D30. ?env=live|test|all (default
                                             #  live). Reads ≤5000 most-recent instances
GET       /v1/projects/{id}/health?sinceHours=
                                             #  project-wide production health (E2):
                                             #  overall latency P50/95/99, error rate,
                                             #  token cost + cache share, plus per-agent
                                             #  summaries with live alert-breach verdicts
                                             #  (same config the cron alert sweep fires
                                             #  on). Backs the dashboard Monitoring page
GET       /api/status                        # P-5 PUBLIC platform health (no auth,
                                             #  no-store): { ok, status:'operational'|
                                             #  'degraded', checks:{ firestore }, sha, at }.
                                             #  Actively probes Firestore; 200 healthy /
                                             #  503 degraded so a status-code-only uptime
                                             #  monitor is correct. (cf. /api/version, which
                                             #  only self-attests the deployed SHA)
GET/POST  /v1/projects/{id}/support          # P-5 support tickets (member). POST
                                             #  { category?, subject?, message } files a
                                             #  request → 201 { ticket:{ ref, category,
                                             #  subject, status, plan, createdAt } }; the
                                             #  audit row carries only { category, ref, plan }
                                             #  — never the message body. GET lists the
                                             #  project's recent tickets (newest first)
POST/DELETE /v1/projects/{id}/sample-instances
                                             #  P-3 sample-end-user generator (admin):
                                             #  POST { count?≤500, agentId? } seeds
                                             #  synthetic test-env instances (mauTest —
                                             #  never billed, marked sample:true) across
                                             #  cohorts so the retention surface populates
                                             #  before real users ship; DELETE sweeps them
GET/POST  /v1/projects/{id}/template-gallery  # P-2 模板画廊 (global gallery). GET
                                             #  (member) lists built-in industry
                                             #  templates + community-published ones
                                             #  (metadata + a systemPrompt PREVIEW, never
                                             #  the full prompt). POST (admin) { agent,
                                             #  slug? } publishes one of THIS project's
                                             #  agents as a community template (persona:
                                             #  prompt + scenes — NO user data/keys;
                                             #  first-publisher-wins, version-bump on
                                             #  republish, 409 for another project)
DELETE    /v1/projects/{id}/template-gallery/{slug}
                                             #  unlist your community template (built-ins
                                             #  can't be unlisted); agents others already
                                             #  created are unaffected
POST      /v1/projects/{id}/template-gallery/{slug}/apply
                                             #  spin up a NEW agent in THIS project from
                                             #  a gallery template (built-in slug
                                             #  builtin:<id>:<i> or a community slug);
                                             #  community applies bump the install counter
POST      /v1/projects/{id}/invites          # G-2 深链/分享卡 (admin): { agent,
                                             #  referrer_external_user_id?,
                                             #  deep_link_base?, ttl_hours? } →
                                             #  { code, url, referral_code? }. url =
                                             #  the hosted landing pouchy.ai/i/<code> —
                                             #  a branded, OG-rich share card that
                                             #  funnels to your deep_link_base (agent +
                                             #  ref appended). A referrer id also mints a
                                             #  referral code so the conversion attributes
                                             #  (G-1). Public landing: GET /i/<code>
GET/POST  /v1/projects/{id}/referrals        # G-1 推荐/邀请归因. POST (admin)
                                             #  { referrer_external_user_id, ttl_hours?,
                                             #  max_uses? } → { code, expires_at, max_uses }
                                             #  mints an invite code for a referrer end
                                             #  user; surface it as a link. A NEW user's
                                             #  first /v1/sessions carrying it
                                             #  (referral_code) attributes the conversion
                                             #  (one-time per invitee, self-referral
                                             #  rejected) and fires referral.converted.
                                             #  GET (member) → { totalConversions,
                                             #  uniqueReferrers, topReferrers[], truncated }
                                             #  — conversions feed the retention cohorts.
                                             #  /v1/sessions gains optional referral_code
GET       /v1/projects/{id}/logs             # audit log (management actions + mint
                                             #  denials + moderation.blocked rows — level
                                             #  and categories only, never message
                                             #  content), newest first, ?limit=1..200
GET       /v1/projects/{id}/safety-events    # Trust & Safety feed (#7): recent
                                             #  moderation.blocked + moderation.redacted
                                             #  events — category flags, redaction counts,
                                             #  timestamps, session/agent ids. Message
                                             #  content is NEVER stored or returned
                                             #  (compliance-safe by construction)
GET/POST  /v1/projects/{id}/marketplace      # S-7 跨项目技能市场 (global shelf; the
                                             #  projectId only anchors auth). GET lists
                                             #  listed skills, most-installed first —
                                             #  metadata only, the raw manifest never
                                             #  travels. POST { slug } publishes one of
                                             #  THIS project's manifest (http) skills:
                                             #  the manifest passes the SAME safety
                                             #  review as installs before it lists;
                                             #  republishing bumps the listing version
                                             #  (installs kept); same-slug by another
                                             #  project → 409. MCP connects have no
                                             #  manifest and can't be published
DELETE    /v1/projects/{id}/marketplace/{slug}
                                             #  unlist (publisher-only) — installed
                                             #  copies keep working
POST      /v1/projects/{id}/marketplace/{slug}/install
                                             #  copy a listed skill into THIS project:
                                             #  the raw manifest re-enters the FULL
                                             #  install pipeline (parse rules + safety
                                             #  gate + cap + version history), the row
                                             #  gets a provenance stamp (sourceListing
                                             #  {slug, version}) and the shelf's install
                                             #  counter bumps. Credentials never travel —
                                             #  wire your own vault for gated skills.
                                             #  Returns { skill, warnings? } — the
                                             #  installer's own notices (e.g. a schema
                                             #  the depth clamp trimmed, so argument
                                             #  validation stops short of what the
                                             #  manifest declares). A shelf manifest was
                                             #  authored by ANOTHER project, so this is
                                             #  the lane with the least prior knowledge
                                             #  of what it degraded. Absent when nothing
                                             #  was degraded
GET       /v1/projects/{id}/skill-stats      # aggregated skill-call health across ALL the
                                             #  project's instances (from the server-side
                                             #  skill-call audit): per-skill calls, success
                                             #  rate, p50/p95 ms, error mix; ?limit=1..5000
GET       /v1/projects/{id}/agents/{aid}/memory-health
                                             # read-only census over the agent's instance
                                             #  MEMORY stores (most recently active first):
                                             #  per store + in aggregate — total/live/dead
                                             #  facts, healthy vectors, rows awaiting the
                                             #  nightly embed repair, recalled-in-30d, age
                                             #  buckets, kind mix. Same census the nightly
                                             #  repair sweep runs (one definition of
                                             #  "healthy"); zero writes, zero embed spend.
                                             #  ?limit=1..20 stores/call, ?cursor= resumes;
                                             #  404 on an unknown agentId (an empty report
                                             #  about a typo'd id is worse than an error)
GET       /v1/projects/{id}/requests         # per-request API log (method/path/status/ms
                                             #  for every /v1/** call the project authenticates
                                             #  — the console plane AND your /v1/admin/**
                                             #  Admin-API traffic; query strings excluded;
                                             #  7-day retention), newest first
GET/POST  /v1/projects/{id}/webhooks         # push events to your backend; https only;
                                             #  signing secret returned ONCE
PATCH     /v1/projects/{id}/webhooks/{whid}  # edit url and/or events in place (keeps the
                                             #  secret + delivery history)
DELETE    /v1/projects/{id}/webhooks/{whid}  # remove the endpoint
POST      /v1/projects/{id}/webhooks/{whid}/rotate-secret
                                             # roll the signing secret — new whsec_… returned
                                             #  ONCE; the old secret stops verifying immediately
POST      /v1/projects/{id}/webhooks/{whid}/test
                                             # send a signed, marked sample event to THIS
                                             #  endpoint (ignores subscriptions) — returns
                                             #  the delivery outcome inline
POST      /v1/projects/{id}/webhooks/deliveries/{deliveryId}/redeliver
                                             # manually re-send a recorded delivery's ORIGINAL
                                             #  payload (re-signed, fresh timestamp, attempt+1)
GET/POST  /v1/projects/{id}/channels         # channel connectors (owner-auth twin of
                                             #  /v1/admin/channels): deploy the agent into
                                             #  any of the 82 transports (Telegram/Slack/
                                             #  Matrix/飞书/…) — per-provider credentials,
                                             #  webhook steps and caveats are enumerated
                                             #  in channel-setup.md; GROUP MODE
                                             #  (config.groupMode) in channel-group-mode.md.
                                             #  CONFIRM RELAY: confirm-gated skills work
                                             #  over channels by KEYWORD REPLY — the agent
                                             #  asks the user to reply 确认/confirm (or
                                             #  取消/cancel); an exact-keyword next message
                                             #  resolves the instance's oldest pending
                                             #  relayable confirmation (uid-wide across
                                             #  its sessions — a channel-delivered
                                             #  SCHEDULED turn's confirm is reachable
                                             #  too) through the same pipeline as POST
                                             #  /confirm and delivers the outcome as the
                                             #  reply. Addressing tokens
                                             #  (bot mention, wake word, @handles) are
                                             #  stripped before matching, and in group
                                             #  rooms a bare un-addressed exact keyword
                                             #  works too. Group rooms count only the
                                             #  deliberate keyword set (casual ok/yes/no
                                             #  excluded), and only the member whose turn
                                             #  recorded the confirm can resolve it. The
                                             #  relay's fixed replies + the cancel notice
                                             #  follow the keyword's language (zh keyword
                                             #  → zh reply). Non-keyword text runs a
                                             #  normal turn; step-up (money) confirms are
                                             #  refused over chat (instances never hold
                                             #  them) and no longer block a relayable
                                             #  confirm queued behind them.
                                             #  AUTO-REGISTRATION: for telegram/discord
                                             #  the create registers the connector with
                                             #  the provider (Telegram setWebhook, Discord
                                             #  slash command) using secret.token and
                                             #  reports it on `provision`
                                             #  { status, code, detail?, manualCommand? }.
                                             #  Best-effort — the connector is durable
                                             #  first, so a provider outage NEVER fails
                                             #  the create; a `failed` carries the manual
                                             #  curl (secrets as <PLACEHOLDER>). Branch on
                                             #  `code`, not `detail`. setWebhook REPLACES,
                                             #  so send autoProvision:false if you already
                                             #  manage your bot's webhook.
PATCH/DELETE /v1/projects/{id}/channels/{cid}# enable/disable, rotate inbound URL, remove.
                                             #  A rotate or a secret replacement RE-
                                             #  REGISTERS a telegram/discord connector
                                             #  (the rotate invalidates the old inbound
                                             #  URL — without this the provider keeps
                                             #  delivering to a dead endpoint while the
                                             #  connector reads healthy) and returns the
                                             #  same `provision` object; autoProvision:
                                             #  false suppresses it
GET/POST  /v1/projects/{id}/schedules        # scheduled proactive triggers (owner-auth
                                             #  twin of /v1/admin/schedules)
PATCH/DELETE /v1/projects/{id}/schedules/{sid}
GET       /v1/projects/{id}/traces/summary   # aggregate trace analytics (owner-auth twin
                                             #  of /v1/admin/traces/summary)
GET       /v1/projects/{id}/traces/recent    # project-wide recent runs (?agentId&sinceHours
                                             #  ≤720&errorsOnly&limit ≤200), newest first
GET       /v1/voice-catalog                  # enabled PLATFORM voices for the per-locale
                                             #  voice pickers — the ids the agent template's
                                             #  `voices` field accepts (any signed-in user)
GET       /v1/projects/{id}/billing          # plan in force + tiers + ledger tail
POST      /v1/projects/{id}/billing/checkout # { plan: 'pro'|'scale' } → Stripe hosted
                                             #  checkout URL (503 until Stripe env is set)
POST      /v1/projects/{id}/billing/portal   # Stripe billing portal URL
POST      /v1/projects/{id}/billing/grant    # MANUAL provisioning (USDT/design-partner):
                                             #  { plan, periodEnd?, mauLimit?, note? }

Billing model (M4): plan tiers ARE the MAU cap — free 10 forever, pro 1,000, scale 10,000, enterprise custom. Stripe keeps billing/state in sync via /api/stripe/webhook (signature-verified); expiry falls back to free without cutting off mid-month actives.

Webhook events (v1): user.created (first-seen (agent, external_user_id) pairing auto-provisioned — under per-agent keying the same external user meeting a SECOND agent fires again, once per new isolated instance; the payload's agent field says which), mau.limit_reached (a live mint hit the plan gate), plan.usage_80 (live MAU crossed 80% of the plan cap — once per month), plan.usage_threshold (live MAU crossed one of the OWNER-CONFIGURED percentages — set via PATCH …/billing { "alertThresholds": [50, 90, 95] }, 1..99, ≤4 values, [] clears; fires once per threshold per month with { threshold, mau, mau_limit, month }; the built-in 80% alert is independent and always on), plan.credits_threshold (the pooled SHADOW credit meter crossed a built-in threshold — { threshold: 80 | 100, credits, credit_limit, month }, once per threshold per month. Two things accrue credits against the plan's monthly allowance (credit_limit; developer 500 / pro 10,000 / scale 100,000, enterprise custom):

  • chat — standard 1 / Pro 5 per COMPLETED turn, whatever the turn's size. It is not per-token and not per-message: one /input that ends in a reply charges once, a turn that ends in tool_calls charges on the /tool-result hop that completes it, and a failed turn charges nothing.
  • realtime voice — 25 credits per measured MINUTE, prorated from a millisecond-precise duration and rounded to whole credits, with a 60-second minimum billable block per call. The block is charged the moment /call mints the window (25 credits), and /end then charges only the excess of the measured length over it — so a call is never cheaper than a minute, a call shorter than that pays the block and is not refunded the difference, and a window you open and never close still costs the block. Skipping /end therefore forfeits reconciliation, not the bill; it is strictly worse than calling it. Close every call.

Budget for voice deliberately, and note the ratio before you ship a voice-first embed: at these rates one voice minute costs the same as 25 standard chat turns, so twenty minutes of realtime audio is the whole developer-tier monthly allowance and 400 minutes is Pro's. A project whose traffic is mostly voice can therefore cross 80%/100% with almost no turn count behind it — this event is the only push signal for that, and GET /v1/admin/usage breaks the same month down into credits, creditsVoice, voiceMs and voiceCalls so you can attribute it. In the current shadow phase nothing is GATED on any of this — the event is the early-warning signal ahead of the enforcement wave), agent.nudge (主动性 — a live instance went silent past its agent's nudge window; payload carries external_user_id, agent, last_active_at, inactive_hours, plus campaign / campaign_label / stage when lifecycle campaigns are configured; at most once per silence period per campaign — your backend does the push/email and can mint a comeback session), agent.scheduled (the DEFAULT delivery channel of a schedule reply — a scheduled job that has no connector deliverTo fans its generated reply to the project's webhooks; payload { scheduleId, agentId, instanceId, reply }), agent.run_awaiting (a durable workflow run paused for a human decision — a run that hits a human_approval step drops out of the sweep's due window and releases its lease, so it advances again only when someone answers; payload { runId, agentId, instanceExternalUserId, goal, stepId, prompt, token, since }. POST the token back to /v1/projects/{projectId}/runs/{runId}/resume with { approved, note? }. Best-effort delivery: the same token is always readable from GET /v1/projects/{projectId}/runs/{runId}, so a dropped webhook costs a notification, never the ability to resume), agent.run_awaiting_event is NOT a webhook — an event wait is resumed by POSTing to /v1/projects/{projectId}/runs/{runId}/signal with { event, payload? }, where event must equal the key the run is waiting on (a mismatch answers 409 event_mismatch, so a retried webhook can tell "too late" from "rejected"). A wait may declare input.timeoutMs; on timeout the step runs again with no payload and fails unless it declared input.optional: true, agent.event_reply (an event-woken agent turn produced a reply — { capabilityId, eventId, receiptId, agentId, instanceId, subject, reply }; subscribing at least one endpoint to this event is what gives Event wakes a delivery surface — without one, accepted events defer and park as next-turn signals instead of waking), agent.cost_alert (a per-agent trace alert breached its configured threshold in the evaluation window — configure via the agent's traceAlert policy; payload { agentId, window: { hours, sinceMs }, reasons, summary: { count, p50Ms, p95Ms, p99Ms, avgTokensOut, okRate } }; de-duplicated so it fires on the breach edge, not every sweep), referral.converted (G-1 — a NEW user's first /v1/sessions mint carried a valid referral_code; payload { code, referrer_instance, referrer_external_user_id, invitee_instance, invitee_external_user_id, env }; one-time per invitee, self-referral rejected — mint codes via POST …/referrals, read the graph via GET …/referrals); subscribe with events: ["*"] for everything. Deliveries are signed Stripe-style: X-Pouchy-Signature: t=<unix>,v1=<hex HMAC-SHA256("<t>.<body>", secret)> (4 s timeout per attempt). Failed real deliveries are retried with backoff — 5 min → 30 min → 2 h, max 3 retries — re-signing the ORIGINAL body (the event id stays stable across retries, so consumers can dedupe). Test sends are never retried. Every attempt (success or failure) lands in the delivery history returned by GET …/webhooks (deliveries, latest 50); failures are also audited.

Rate limit: POST /v1/sessions is capped per key per minute (default 300; 429 with a retry hint beyond it) — an abuse guard, not a quota.

Data capabilities — publish, act, and emit events (/v1/projects/{id}/…)

Declare the project's data world as capabilities (also editable in the dashboard under Data Capabilities): view (read authoritative state), action (change external state safely), event (tell the agent the world changed). Published revisions are immutable — publishing identical content returns the existing revision; different content mints a new version and never rewrites an old one, because running sessions may have pinned it. Publishing is not revocation; the per-agent data flags above are the live levers. The whole declare→publish→test loop is also admin-key mirrored for headless CI (/v1/admin/capabilities, /v1/admin/capabilities/{name}/ test-read|test-action — project implied by the key, same harnesses, same consent gate), and so are the observability readers (/v1/admin/actions, /v1/admin/events — same rows as the owner-plane viewers, so CI can read back what actually ran); signing key management is deliberately NOT mirrored — the one-time pcsk_ reveal stays a human act on the owner plane.

GET  /v1/projects/{id}/capabilities            # heads (+ disabled bit) + signing status (member)
POST /v1/projects/{id}/capabilities            # publish next immutable version (admin)
PATCH /v1/projects/{id}/capabilities/{name}    # { disabled } — per-capability LIVE revoke (admin):
                                               #  existing sessions refuse at the next execution
                                               #  moment (read/action/event wake), new plans see
                                               #  honest absence; a reconciliation pinned to
                                               #  a revision keeps reconciling
GET  /v1/projects/{id}/capabilities/{name}/versions  # full history w/ declarations (admin);
                                               #  rollback = POST an old declaration → NEXT version
GET  /v1/projects/{id}/actions?limit=          # the durable Action journal, newest first (member;
                                               #  admin-key mirror: GET /v1/admin/actions)
GET  /v1/projects/{id}/events?limit=           # Event receipts + wake trail (member or Secret Key;
                                               #  admin-key mirror: GET /v1/admin/events)

# view   { kind, name, description, sensitivity?, audience, endpoint, fields[], filters[]? }
# action { kind, name, description, sensitivity?, audience, endpoint, args[]?,
#          idempotency: "action_id", reconcile?: { endpoint }, compensate?: { endpoint },
#          automation?: "allowed" }   # author-declared: safe for unattended runs (below)
#   compensate is declared + validated today but NOT yet dispatched by any
#   runtime path — declare it for forward-compat; nothing calls it yet
# event  { kind, name, description, sensitivity?, source, schemaVersion, subjectField,
#          signing?: "required" }   # require a POUCHY-SOURCE-V1 signature (below)

POST /v1/projects/{id}/capabilities/source-signing   # admin; per-source pesk_ keys
{ "action": "provision" | "rotate" | "retire_previous", "source": "game-backend" }
#   provision → one-time plaintext (409 if keys exist); rotate → new key ONCE,
#   old key keeps verifying until retire_previous. Masked status rides the
#   capabilities GET as `sourceSigning`. Owner-plane only (no admin mirror).

Capability signing — the trust contract. Two credentials, two directions, never reused: your Project API key (pchy_sk_… / pchy_admin_…) authenticates your backend → Pouchy; the capability signing secret (pcsk_…) is what you verify Pouchy → your backend with. Signing secrets are issued, never readable: provision or rotate returns the plaintext exactly once; every later read (dashboard or API) shows key IDs and rotation state only. Lost secret ⇒ rotate, not retrieve.

Provision BEFORE your first test call. If no keys exist yet, the platform mints them on the first capability call (test-read, test-action, or a live agent read) — and auto-minted plaintext is never revealed anywhere. So the correct order is: provision (record the secret) → implement the receiver → test. If provision answers 409 because a test already minted keys, don't hunt for the plaintext — it doesn't exist for you; rotate instead (fresh plaintext, once) and retire_previous when your receiver is switched over.

POST /v1/projects/{id}/capabilities/signing        # admin
{ "action": "provision" }        → { keyId, secret, oneTime: true }   # 409 if keys exist
{ "action": "rotate" }           → { keyId, secret, oneTime: true, previousKeyId, overlap: true }
{ "action": "retire_previous" }  → { signing: { activeKeyId, previousKeyId: null, … } }

Rotation keeps the outgoing key verifying (the request's kid selects which secret to check) until you retire_previous — so in-flight agent turns never break mid-rotation; after retirement the old kid must be treated as unknown.

Verification algorithm (POUCHY-ACTOR-V1) — exact enough to reimplement in any language; a deterministic golden vector plus adversarial variants ships inside the @pouchy_ai/backend-kit npm package (vectors/capability-signing-vectors.json, ≥0.3.0), regression-pinned against the production signer, so you can prove byte-compatibility before going live.

Headers: X-Pouchy-Actor (base64url of the claims JSON, exactly as sent — do NOT re-serialize it) and X-Pouchy-Actor-Signature = t=<unixSeconds>, kid=<keyId>,v1=<hex hmac>. Compute HMAC-SHA256 with the kid-selected secret over this newline-joined, 8-line canonical string:

POUCHY-ACTOR-V1                    # scheme/version literal
<t>                                # unix seconds, decimal, from the signature header
<callId>                           # the claims' `cid` (for an Action this IS the actionId)
<audience>                         # your hostname, lowercased
<METHOD>                           # HTTP method, uppercased
<path?query>                       # URL pathname verbatim (already %-encoded) + `?` +
                                   #   query pairs sorted by key then value, each
                                   #   encodeURIComponent(k)=encodeURIComponent(v),
                                   #   joined with `&`; NO `?` if the query is empty
<bodySha256 | ->                   # hex sha256 of the EXACT raw body bytes; the
                                   #   literal `-` when the body is absent/empty
<claimsB64>                        # the X-Pouchy-Actor header value, verbatim

Checks, in order: parse t/kid/v1 (v1 is 64 lowercase hex chars); reject unknown kid; reject when |now − t·1000| > 300 000 ms (±5 min skew); recompute and compare with a constant-time comparison; only then decode the claims and require aud to equal your (lowercased) hostname and cid to be present. On ANY failure respond with one uniform error (the reference receivers use a bare 404) — distinguishing failure reasons builds an oracle. Claims carry { kind, sub, projectId, agentId?, instanceUid, sessionId?, aud, cid }; sub is null for subjects that have none (e.g. system).

The trust boundary, stated bluntly: X-Pouchy-Actor is readable base64 — parsing it proves NOTHING. decode claims → trust sub is an unauthenticated, forgeable identity and is exactly the bug this contract exists to prevent. Correct order: verify the full signed request → only then use the verified claims' subject. Never act on claims from a request that failed any check above.

View integration journey: publish the View (dashboard → Data Capabilities) → provision the signing secret (once, BEFORE any test call — see above) → implement the receiver: the request is a signed GET with no body; declared filters arrive as query-string parameters (part of the signed canonical string, so verify the full URL you actually received) — verify as above, read the verified subject, return 200 with your business data as a flat JSON object or an array of objects (Pouchy curates to the DECLARED fields and bounds rows — extra fields never reach the agent) → prove it with POST /v1/projects/{id}/capabilities/{name}/test-read { externalUserId?, filters? } (project-admin; returns the curated rows the agent would see, the outcome, and the pinned revision — no agent conversation needed) → enable Data on the agent. The same verifier, unchanged, serves your Action receiver below.

Action receiver contract (your backend). A run_action the user approved arrives as ONE signed POST to the declared endpoint, content-type: application/json, with exactly this body (fixed field order; args is the runtime-normalized intent in canonical sorted-key form — hash these bytes for your intentHash):

{"actionId":"act_…","action":"<capability name>","args":{"item":"…","qty":"1"}}

Verify X-Pouchy-Actor-Signature over the request including the body hash, then treat (actionId, intent) as the idempotency identity:

  • first delivery → execute once, durably store intentHash + your result
  • repeat, same intent → do NOT re-execute; return the original result
  • repeat, different intent under the same actionId409 — never return the original result as though the new intent succeeded

Your HTTP answer IS the outcome claim. Any 2xx classifies committed — the response body (bounded) is stored as the durable receipt and replayed on duplicates; any 4xx classifies rejected (a proven refusal — the 409 intent-mismatch included); 5xx or transport death classifies unknown. So a handler that throws into a framework 500 reports AMBIGUITY, not refusal — refuse with a clean 4xx you chose.

The wire is at-least-once and a timeout is NOT a failure: Pouchy classifies each dispatch as committed (proven applied), rejected (proven refused — a clean 4xx), or unknown (transport died / 5xx — it may have applied). unknown is never retried blindly and never reported as success or failure; it is resolved only by the optional reconcile endpoint — a signed, read-only GET ?actionId=… your backend answers with { "status": "committed" | "rejected" | "not_found", … } (answer from your actionId store, never by re-reading current business state). Pouchy does not promise exactly-once network delivery; it promises a stable logical action identity and requires your backend to collapse duplicates on it.

Event-to-action automation (C⑤). An event-driven turn normally has no run_action (nobody is present to approve a card). It gains one under a TWO-KEY standing authorization: the action's published declaration says automation: "allowed" (the AUTHOR's pinned safety claim) AND the agent's live data.actions.autoRun list names it (the operator's revocable grant, ≤5, with autoRunDailyCap bounding runs per user per day, default 10; at most ONE automated action per event turn). Missing either key reads exactly like an unauthorized action. The execution path is the confirmed leg's own code — frozen intent, journal create-once, ONE dispatch, durable unknown + reconcile — with the journal recording authorizedBy: "automation" and confirmId: "automation" (no approval record is faked) plus an action.auto_executed audit row. Receiver note: these dispatches sign claims kind: "automation" with sub = the routed instance's external_user_id — machine-initiated FOR that user; treat it as a distinct trust tier from end_user (where a human approved) when your policy cares.

Client-side activity visibility (companion.data_activity, SDK ≥0.44.0). An embed host can render trust-building indicators for the Data plane: when the agent reads a View or an approved Action settles, sessions holding the data.activity scope (granted automatically from the agent's Data flag) receive a METADATA-ONLY stream frame { kind, capability, outcome, ms, actionId? } — never rows, intent values, endpoints or receipts. An action outcome of unknown is verbatim ambiguity: render "pending verification", never success or failure. Advisory by contract — the Action journal and the receipts remain the ground truth.

Backend Kit. @pouchy_ai/backend-kit (Node ≥18, zero deps, TypeScript declarations since 0.3.0) implements this wire contract so you don't hand-write it: verifyPouchyRequest (the verifier above, byte-pinned to the packaged test vectors), idempotentAction + reconcileFrom (the (actionId, intentHash) contract over YOUR store — throw ActionRejected to refuse with a stored, replayed 4xx; reconcile answers committed | rejected | not_found), verifyWebhookSignature (platform webhook deliveries, incl. the agent.event_reply subscriber Events need), PouchyClient.emitEvent (same-eventId-safe retries; omit agentId for subscription fan-out). It never declares capabilities — the dashboard/API stay the one source of truth — and raw REST remains fully supported; the kit is convenience, not a requirement.

Action integration test (project-admin). Verify your receiver against the REAL protocol — pinned revision, production signer, outbound guard chain, production outcome classifier — without platform access or an agent conversation:

POST /v1/projects/{id}/capabilities/{name}/test-action
{ "args": { … }, "externalUserId"?: "…", "confirmDuplicates": true }

⚠ This sends REAL requests to your configured endpoint and can create real side effects; phases B/C intentionally re-deliver the same actionId, so confirmDuplicates: true is required (use a scratch capability/subject). Four phases, reported separately: A signed connectivity, B same actionId + same intent (expect no second mutation, same durable result), C same actionId + different intent (expect the mismatch refusal, no mutation), D reconciliation by the same actionId. Each phase reports the remote outcome in production vocabulary (committed | rejected | unknown) SEPARATELY from the protocol verdict — "Protocol: PASS, outcome: unknown" is a valid result, and an ambiguous phase A is never retried (same actionId, reconciled in D). The harness mints actionId and the Principal; caller attempts to supply them are ignored/refused. This proves receiver protocol compliance only — confirmation UX, model planning and agent policy are covered by the runtime smokes.

Action developer journey: publish Action → provision signing secret (before any test call — see the provisioning note above) → implement receiver from the signing contract + test vectors → implement (actionId, intentHash) idempotency → implement reconcile → run the integration test above (connectivity → duplicate → mismatch → reconcile) → enable Actions on the agent → test real agent confirmation behavior separately.

Event ingress. Machine auth is the Secret Key (pchy_sk_…) — the same backend→Pouchy credential that mints sessions; a signed-in project member token also works (dashboard, probes). Admin keys and session tokens are refused with a pointed 401 (the cross-use rule above). A secret key only authenticates its OWN project — used against any other project id it reads exactly like an unknown project.

POST /v1/projects/{id}/events        # Secret-Key or member auth; 300/min/project
{ "name": "quest.completed", "agentId"?: "…", "eventId": "evt_123",
  "schemaVersion": 1, "occurredAt": 1712345678901,
  "data": { "playerId": "player_123", … } }
→ 202 { status: "accepted", duplicate: false, receiptId, revision, wake: "pending" }
#  agentId ABSENT → subscription routing: the event goes to every agent whose
#  template subscribes to this capability (data.events.subscriptions — the
#  agent-template field above, editable on the agent's Data tab in the
#  dashboard; live, max 10 per agent, fan-out capped at 5,
#  agentId-sorted, truncation reported). One receipt + wake trail PER routed
#  agent; subscribed agents the subject has no instance under are `skipped`
#  (reported, never silent); zero routable targets → 422. The response gains
#  routing: "subscription" + targets[] + skipped[] + truncated.

Semantics that do NOT collapse into one another: accepted means Pouchy durably holds the event (deduped on (project, capability, eventId) — a retry returns the SAME receipt and never wakes twice; a storage fault is a 503, retry with the same eventId). Accepted ≠ an agent turn ran; a turn running ≠ the user was notified — the receipt's wake trail records wake_scheduled / turn_started / turn_completed / delivery_succeeded / delivery_unavailable separately, and an admin flipping events.enabled off between acceptance and wake suppresses the wake (wake_suppressed_revoked) without erasing the receipt. The event is validated against the head revision resolved at acceptance and pinned — publishing v2 later cannot re-interpret it; a schemaVersion that doesn't match the published contract is refused, never interpreted "as latest".

An event is a signal, not a payload transport: data is bounded, mined for the declaration's subjectField (a TARGET reference resolved against an EXISTING instance — unknown subjects are refused 422 and never mint one), and discarded. The agent's prompt only ever sees "event X occurred at T — read your data capabilities for current facts"; no payload field can become agent context, an agent instruction, or a Principal (event-driven turns act as system, never as the subject, and get no run_action gateway). source is bound by the declaration — a payload source may confirm it, never decide it. With no agent.event_reply webhook subscribed, accepted events defer and surface as context on the subject's next conversation turn instead of waking.

Per-source signing (POUCHY-SOURCE-V1). A project credential proves "a caller with project access"; a capability published with signing: "required" additionally demands proof the event came from the DECLARED source. Your emitter attaches X-Pouchy-Source-Signature: t=<unixSeconds>,kid=<keyId>,v1=<hex> where v1 = HMAC-SHA256 with the source's pesk_ secret over this newline-joined, 5-line canonical string: POUCHY-SOURCE-V1 / <t> / <source> / <eventId> / <bodySha256 | -> (hex sha256 of the EXACT raw body bytes; - when empty). Sign at send time on every attempt (±5 min skew) — a retried eventId carries a fresh t, and duplicate suppression stays the eventId dedupe's job. Any failure — missing, stale, unknown kid, wrong bytes, or keys not yet provisioned — is ONE generic 403; the specific reason appears only in the project's audit trail. A deterministic golden vector plus adversarial variants ships inside @pouchy_ai/backend-kit (vectors/event-source-signing-vectors.json, ≥0.3.0), regression-pinned against the production verifier; the kit (≥0.2.0) also signs for you (sourceKeys + source on emitEvent).

Admin API (Admin-Key auth; project implied by the key)

Day-to-day dashboard parity — the core CRUD a project needs is manageable headlessly with a pchy_admin_… key; the dashboard is an optional UI. Agent version control (versions/diff/rollback/promote) and the full knowledge ingestion front door (file/url + the recall search probe) are admin-mirrored, so the whole build-and-serve loop is reachable from your backend. Deliberately excluded: admin-key management (escalation loop), billing grants/checkout (payment bypass; billing is read-only here), and HARD project delete (owner-only destruction). Still owner-auth-only (no admin mirror): evals (except the evals/gate CI poll, which takes the admin key), prompt sweep, knowledge config (RAG tuning), custom-skill versions/rollback/test, marketplace, template gallery, referrals, retention, safety-events, health, support, invites — use the owner surface above for those.

GET  /v1/admin/agents                        # list templates
POST /v1/admin/agents                        # create
GET/PATCH/DELETE /v1/admin/agents/{agentId}  # read / update / remove — same semantics
                                             #  as the owner endpoint (status included)
                                             #  create/update: `skills` slugs are filtered
                                             #  to the registered allowlist; anything
                                             #  filtered is echoed as `droppedSkills` on
                                             #  the response (a slug typo — e.g.
                                             #  client_action vs the registered
                                             #  client-action — is named, never silent)
GET  /v1/admin/agents/{agentId}/versions     # rolling revision history (+ /{rev} snapshot,
                                             #  /diff?from=&to= field-level diff; current=live)
POST /v1/admin/agents/{agentId}/versions/rollback  # { version } — git-revert to a revision
GET/POST /v1/admin/agents/{agentId}/promote  # GET: staging vs pinned prod + diff;
                                             #  POST: promote head → prod (no rev bump)
PATCH     /v1/admin/capabilities/{name}      # { disabled } per-capability live revoke
GET       /v1/admin/capabilities/{name}/versions  # history; rollback = republish old declaration
GET/POST  /v1/admin/capabilities             # Data capability heads + MASKED signing
                                             #  status / publish next immutable version
                                             #  (idempotent on content; audit actor is
                                             #  the key id). Signing-key management is
                                             #  NOT mirrored — pcsk_ provisioning stays
                                             #  an owner-plane human act
POST      /v1/admin/capabilities/{name}/test-read    # View integration test (curated
                                             #  rows, pinned revision, decoded claims)
POST      /v1/admin/capabilities/{name}/test-action  # Action protocol verifier — REAL
                                             #  deliveries incl. intentional duplicates;
                                             #  requires confirmDuplicates: true (428
                                             #  without), outcomes in production
                                             #  vocabulary separate from verdicts
GET/POST  /v1/admin/keys                     # secret keys ({ env }); plaintext ONCE
DELETE    /v1/admin/keys/{keyId}             # revoke
POST      /v1/admin/keys/{keyId}/rotate      # rotate: new key ONCE, old key keeps
                                             #  verifying for the grace window
                                             #  ({ graceHours: 0-168 }, default 24)
GET       /v1/admin/voice-catalog            # platform voices for programmatic
                                             #  provisioning (?gender=&age=&locale=);
                                             #  provider says which `voices` slot
                                             #  the providerVoiceId goes in
GET       /v1/admin/users                    # instances + per-instance usage
PATCH     /v1/admin/users/{iid}              # { suspended: boolean }
DELETE    /v1/admin/users/{iid}              # GDPR erasure
POST      /v1/admin/users/import             # batch import ({ externalUserIds,
                                             #  agentId?, env? }); idempotent per id;
                                             #  never meters MAU
GET       /v1/admin/users/{iid}/export       # GDPR data-portability document
GET       /v1/admin/users/{iid}/sessions     # session list (+ /{sid}/turns transcript)
GET       /v1/admin/users/{iid}/traces       # per-instance run traces
GET       /v1/admin/users/{iid}/wallet       # per-instance wallet ({ hasWallet, balance } —
                                             #  no address field; deposit addresses come from
                                             #  the instance-side get_deposit_address tool)
GET/POST  /v1/admin/knowledge                # shared corpus (+ DELETE /{docId})
POST      /v1/admin/knowledge/file           # ingest a PDF/audio/video/image data URL
                                             #  (server-side OCR/Whisper/vision → chunks)
POST      /v1/admin/knowledge/url            # ingest a web page by URL (https, SSRF-guarded)
POST      /v1/admin/knowledge/search         # recall probe ({ query }) — ranked chunks +
                                             #  mode (semantic|lexical); no LLM turn burned
POST      /v1/admin/utility/json             # structured JSON extraction OUTSIDE the companion:
                                             #  { schema, content, system?, strict?, model?,
                                             #  reasoningEffort? } → { data, raw, usage? }.
                                             #  reasoningEffort (minimal|low|medium|high, default
                                             #  low) is the thinking budget: the shared one-shot
                                             #  floor is `minimal`, and an A/B measured that floor
                                             #  labelling a multi-directive utterance by its
                                             #  LEADING clause ("以后别叫我宝贝,叫我老板" → boundary,
                                             #  not nickname) and merging three directives into
                                             #  two. Raise it when one input carries several
                                             #  directives; `minimal` restores the old floor.
                                             #  No persona, no memory, no
                                             #  tools, no session, NO blank-turn fallback — use
                                             #  this instead of driving an extraction agent
                                             #  through a companion turn, which answers in
                                             #  conversational prose by design. strict (default
                                             #  true) makes the provider ENFORCE the schema, which
                                             #  must then sit in its structured-output subset (root
                                             #  object, additionalProperties:false, EVERY property
                                             #  in required — model an optional field as a union
                                             #  with null). Typed failures, not prose:
                                             #  schema_invalid (400, fix it — strict calls only,
                                             #  since strict:false transmits no schema),
                                             #  request_invalid (400, unknown model / bad param /
                                             #  any provider rejection of a schema-less call —
                                             #  fix it), unavailable (5xx, retry), invalid_json
                                             #  (502, carries `raw`)
POST      /v1/admin/utility/embeddings       # text → embedding vectors OUTSIDE the companion (the
                                             #  vector sibling of utility/json): { input: string |
                                             #  string[], model?, dimensions?, encoding_format? } →
                                             #  { object:'list', model, dimensions, data:[{ object:
                                             #  'embedding', index, embedding }], usage? } —
                                             #  OpenAI-shaped, so an OpenAI embeddings client
                                             #  pointed here parses it unchanged. For a BFF that
                                             #  ranks its OWN inventory by cosine (album pick,
                                             #  intent-gate exemplars): retrieval stays on the
                                             #  caller; Pouchy only turns text into vectors, on one
                                             #  billing/key channel. `model` in the response is a
                                             #  STABLE id (openai-text-embedding-3-small-768
                                             #  grammar) — persist it on every stored vector,
                                             #  refuse mixed-model cosine, and PIN it in later
                                             #  requests: an id the deployment cannot serve
                                             #  EXACTLY is a 400, never a silent substitute, so a
                                             #  platform model swap surfaces as an explicit
                                             #  reindex signal. Batch ≤ 64 texts/call, each ≤ 8000
                                             #  chars — an over-cap text is a 400 naming its
                                             #  index; texts are NEVER silently truncated.
                                             #  dimensions: OpenAI models only (16..1536, default
                                             #  768; text-embedding-004 fixed at 768).
                                             #  encoding_format 'base64' → little-endian float32
                                             #  base64 (~3.5× smaller; bulk reindex). Typed
                                             #  failures: invalid_request (400), unavailable (503,
                                             #  retry w/ backoff), provider_error (502). Stateless:
                                             #  no session, no persona, no memory, no companion
                                             #  side effects. Prompt tokens roll into the project
                                             #  month bucket tagged `utility_embed`; no new credit
                                             #  rate. Shares the fail-closed per-IP utility rate
                                             #  limit (120/min, 3000/hr).
POST      /v1/admin/utility/tts              # text → a spoken-audio mp3 FILE OUTSIDE the companion
                                             #  and OUTSIDE a realtime call (the audio sibling of
                                             #  utility/json): { text, voice, format?, model? } →
                                             #  { data:{ audioBase64, mimeType, format, voiceId,
                                             #  provider, model }, usage:{ characterCount } }. voice
                                             #  is a catalog id (std_…/hd_…) or providerVoiceId —
                                             #  the SAME id used for /call.voice, restricted to
                                             #  enabled catalog rows + the built-in OpenAI roster.
                                             #  Stateless: no session, no persona, no memory. Use
                                             #  this (not /call, which is live ConvAI) when you need
                                             #  an audio FILE for a headless/Ops job (e.g. a
                                             #  reference clip for a video/lip-sync API). mp3 only
                                             #  in v1. Synthesis bills on the operator provider
                                             #  account like /api/tts — no platform credit rate;
                                             #  bounded by the input cap + the shared per-IP
                                             #  utility rate limit.
                                             #  Typed failures: voice_not_found (404), text_too_long
                                             #  (413), unsupported_format (400), unavailable (503),
                                             #  provider_error (502)
GET/POST  /v1/admin/skills                   # custom skills — install from
                                             #  { md | url | mcpUrl | openapi | openapiUrl }.
                                             #  Install (incl. RE-install) re-pushes the def to
                                             #  running instances and returns { reprovisioned,
                                             #  truncated } like the PATCH knobs below
PATCH     /v1/admin/skills/{slug}            # EXACTLY ONE knob per call (a body naming two is
                                             #  refused 400, never half-applied):
                                             #  { ratePerMin } call budget, OR
                                             #  { maxCallsPerDay } daily ceiling (runaway
                                             #  guard; null = no ceiling), OR
                                             #  { freeHttp, grantedDomains } — free-HTTP grant
                                             #  (arm a docs-only skill to run via http_request;
                                             #  effective allowlist = manifest ∪ granted). EVERY
                                             #  branch re-pushes the def to running instances and
                                             #  returns { reprovisioned, truncated }; truncated:true
                                             #  = the sweep hit its bound and the remainder keep the
                                             #  OLD def until their agent's next template edit
POST      /v1/admin/skills/{slug}/compile    # P2 compile (docs-only prose → declared http
                                             #  tools; same pipeline as the owner route)
POST      /v1/admin/skills/agent-plugin      # import an Agent Plugins 1.0.0 package
                                             #  (agent-plugins.org) as custom skills. Body
                                             #  { files:[{path,content}] } (explicit file map,
                                             #  no archive). skills/<dir>/SKILL.md → docs-only
                                             #  skill; mcp.json streamable-http → MCP connect;
                                             #  stdio/sse skipped per component (serverless);
                                             #  extensions["ai.pouchy"] round-trips a Pouchy
                                             #  export losslessly. Same parse + safety judge as
                                             #  native installs; declared mcp headers are NEVER
                                             #  forwarded (use the credentials vault). 201 when
                                             #  ≥1 component installed ({installed,skipped,
                                             #  errors}); 422 when nothing was installable.
                                             #  The installed set is re-pushed to running
                                             #  instances in ONE bounded sweep — the response
                                             #  carries { reprovisioned, truncated }
GET       /v1/admin/skills/{slug}/agent-plugin  # export one skill as a conformant Agent
                                             #  Plugins 1.0.0 package ({files}). Portable face:
                                             #  SKILL.md prose or an mcp.json reference;
                                             #  lossless face: the Pouchy manifest under
                                             #  extensions["ai.pouchy"]. Secrets never ride
DELETE    /v1/admin/skills/{slug}            # uninstall
GET/POST  /v1/admin/credentials              # skill credential vault (write-only;
                                             #  values never returned) + DELETE /{skill}
GET/POST  /v1/admin/webhooks                 # + PATCH/DELETE /{whid} (edit in place);
                                             #  POST /{whid}/test; POST /{whid}/rotate-secret;
                                             #  POST /deliveries/{did}/redeliver
GET       /v1/admin/logs                     # audit log (?limit=1..200)
GET       /v1/admin/usage                    # month usage incl. day buckets
GET       /v1/admin/usage/history            # month-over-month series, oldest first:
                                             #  { months:[{ month, mau, mauTest,
                                             #  sessions }], scope }; ?months=1..24
                                             #  (default 12)
GET/PATCH /v1/admin/project                  # read / rename / archive
GET       /v1/admin/billing                  # plan in force + ledger (read-only)
GET       /v1/admin/channels                 # channel connectors (+ POST, /{id} GET/PATCH/DELETE)
                                             #  config.groupMode: true → GROUP MODE: the whole
                                             #  room shares one instance (memory/history); turns
                                             #  carry "Name: " speaker labels; the agent replies
                                             #  only when @mentioned / replied-to / a
                                             #  config.wakeWords entry matches (set
                                             #  config.groupRequireMention: false to answer all)
GET       /v1/admin/schedules                # scheduled triggers (+ POST, /{id} GET/PATCH/DELETE)
GET       /v1/admin/traces/summary           # aggregate trace analytics (P50/95/99, tokens)
GET       /v1/admin/traces/recent            # recent run rows project-wide
                                             #  (?agentId=&sinceHours=&errorsOnly=&limit=)
GET       /v1/admin/openapi                  # OpenAPI 3.1 spec of this surface (PUBLIC)

Machine-readable + typed client. GET /v1/admin/openapi serves the full OpenAPI 3.1 contract (public, CORS-open) — import it into Postman / Swagger UI or generate a client in any language with openapi-generator. For TypeScript, the first-party client is @pouchy_ai/admin-sdk:

import { createAdminClient } from '@pouchy_ai/admin-sdk';
const admin = createAdminClient({ adminKey: process.env.POUCHY_ADMIN_KEY! });
const { agents } = await admin.listAgents();
const { key } = await admin.createKey({ label: 'prod', env: 'live' }); // `key` IS the plaintext, shown once

Key plaintexts are returned once at creation; storage is hash-only.

Give the long routes a long client deadline. POST /knowledge, POST /knowledge/file, POST /knowledge/url, DELETE /users/{instanceId} and POST /capabilities/{name}/test-action run under maxDuration: 300 and answer only when the ingest (chunk + summarize + embed, after OCR / Whisper / vision or an SSRF-guarded page fetch), the recursive GDPR erasure, or the Action protocol verifier's real deliveries to your endpoint have finished — response headers can legitimately be minutes away. A shorter client timeout reports a failure for an operation that is succeeding, and retrying then races the still-running first one (a re-run test-action repeats deliveries the aborted one already made). The typed client handles this for you (310s on exactly the routes in its exported LONG_WORK_REQUESTS, 30s elsewhere); a hand-rolled or generated client must set it.

Back off on a 429. Every non-GET request to /v1/* passes through one per-IP throttle (120 writes/minute), so a migration loop over agents, users or skills will reach it. The 429 names its own delay in seconds, in the body as retryAfter and in the standard Retry-After header — honour it instead of retrying on a guessed interval, which keeps the sliding window saturated. The typed client surfaces it as AdminApiError.retryAfter (from 0.7.0), undefined when the server named no delay.

Switch on code, not on the status, for a 409. Every machine-readable failure on this plane is a 409, so the status line separates none of them — the body's code is the only discriminator, and two of the durable-run routes answer 409 for two entirely different reasons each. The vocabulary is append-only:

code route recovery
schedule_limit_reached POST /schedules disable or delete a schedule, retry
channel_limit_reached POST /channels delete an unused connector, retry
webhook_limit_reached POST /webhooks delete an unused endpoint, retry
run_limit_reached POST /runs wait for a run to finish, or cancel one
reembed_required PUT /knowledge/config clear + re-ingest to switch embedding model
run_terminal DELETE /runs/{runId} the run already finished — nothing to cancel
run_not_parked POST /runs/{runId}/resume already decided; stop retrying
stale_token POST /runs/{runId}/resume re-read the run, answer its current awaiting.token
run_not_waiting POST /runs/{runId}/signal the wait already ended; stop retrying
event_mismatch POST /runs/{runId}/signal the run wants a different key — your event is early or wrong; safe to retry later
one_shot_spent PATCH /schedules/{id} this one-shot already fired — create a new schedule instead of re-enabling
ghost_row PATCH /schedules/{id} a legacy incomplete row that can never fire — delete it, create a new schedule

Most failures carry no code at all (400s, 404s, the 429), so always keep a default branch. The typed client surfaces it as AdminApiError.code from 0.10.0, with ADMIN_ERROR_CODES / AdminErrorCode exported for a typed switch; the type stays open to a code newer than the installed build.