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.csis 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 (StreamStallTimeout60s, per-read, opt-out) — a half-open drop surfaces as aCompanionErrorinstead of hanging the reader forever. For an always-on subscription,EventStreamPump.RunAsyncowns the reconnect loop (immediate on the clean ~45s window close, backoff honouring Retry-After on errors);PouchyCompanionBehaviourrides it, so the drop-in Unity component stays live past its first stream window. - Dart / Flutter:
pouchy_companionon pub.dev (packages/flutter-sdk— pure Dart, nopackage:flutterimport, 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.gois 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 owesEndSession'sWithTranscript, which is the only path from anything said aloud into memory. Note the route-aware deadlines (LongWorkTimeout310s on/input,/tool-resultand/knowledge;ConfirmExecTimeout65s onConfirmAction): a flat client timeout under the server's 300s ceiling turns a billed turn into a client timeout whose retry races the first request.Eventscarries no deadline (a subscription is long-lived) but does bound byte-SILENCE atDefaultStreamStallTimeout60s, 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.mdfor 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:
- 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/sessionswith{ 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). - Personal access token (PAT). The user mints one via
POST /api/companion/keyswhile signed in to pouchy.ai (there is no in-app Wallet panel for this yet), choosing scopes — omittingscopesgrants the safe default pack. For personal/single-user integrations where the user brings their own companion. - 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.spend ⊇
wallet.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/reactwraps this SDK in a<CompanionProvider>+useCompanion/useMessages/useTyping/useCallhooks — the handshake, stream, and teardown are handled for you. Seepackages/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. Userememberwith 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
representscope, supplying avisitoris 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 underrepresent: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 holdsocial.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/meetpairing 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 dropped — accepted + 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 history — count: 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-05…2025-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 outcomeClass — success 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/sessionsinstances) — 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 grantedwallet.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 —403with codeforbidden(a deliberate denial, not a token problem: the SDK does not fireonAuthErroron 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 sendsAccess-Control-Allow-Origin: *, so a cross-origin renderer can fetch it directly. imageUrlis a flat 2D portrait (a snapshot of the VRM) for surfaces that just want an avatar image;nulluntil 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: truefor current state (scene, HP, score, hand) — coalesced pertype.- transient (no
retained) for moments; setsalience(0–1). voiceRelevant: trueonly 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:
- emits a
companion.confirm_requestevent on the stream ({ confirmId, scope, summary, summaryKey?, summaryVars? }), and pauses; - 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.
- first-party user tokens — on a Pouchy-controlled surface: open
- 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 optionalcodeis a stable, machine-readable tag the SDK surfaces asCompanionError.code— switch on it when present, fall back to the HTTP status when absent.errorId(anerr_…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 asCompanionError.errorId(JS 0.56.0) and it is the thing to quote to support. Never on a 4xx:code status meaning missing_token401 no Authorization: Bearerheaderinvalid_token401 token unknown, revoked, or expired — re-mint and retry ( onAuthErrorautomates this)missing_scope403 token lacks a required grant — fix the key/template scopes, don't retry invalid_request400 malformed JSON / missing or invalid fields session_not_found404 sessionId expired or never started turn_pending409 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_tools409 tool result posted but nothing is pending unknown_call404 tool result for a callId the turn didn't issue payload_too_large413 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 (32ktexton/input, 8MB per image, 2,000 chars ofcontenton/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_limited429 turn burst ceiling / demo daily budget spent, or the per-IP edge throttle — honor Retry-After(also onCompanionError.retryAfter, seconds, from SDK 0.27.0)forbidden403 authorization denial other than a missing scope unavailable503 backing store/provider not configured or down confirm_not_found404 confirmId unknown or not on this session (API 1.1) confirm_resolved409 confirm already approved/denied/expired — single-use (API 1.1) step_up_required401 approval needs a passkey assertion — fetch confirm/stepupoptions first (API 1.1)step_up_failed401 the passkey assertion didn't verify; retry the step-up (API 1.1) quota_exhausted402 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. Contrastrate_limited, which is. Only reachable on deployments withPLATFORM_CREDIT_ENFORCEMENTonnot_found404 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.mdfor the canonical, append-only vocabulary.The SDK additionally synthesizes client-side codes outside this HTTP vocabulary (
status: 0unless noted):reply_timeout(awaitReply's event-stream fallback gave up),needs_event_stream(awaitReply on a tool-pausing turn withoutstart(), SDK 0.28.0),not_connected/missing_option(client-side misuse guards),not_representative(pairVisitorwithout a representative session),aborted(a caller-suppliedAbortSignalcancelled the request, SDK 0.29.0),queued_offline(asendTextwithawaitReply: truewas queued while offline and will send on reconnect — opt-inqueueOffline, SDK 0.34.0),request_timeout(therequestTimeoutMsheaders 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,sendToolResultand the knowledge ingests 310 s,confirmAction65 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 missingevents.subscribescope), and — SDK 0.26.0 — the voice connect-step codes onconnectCall/startCallrejections: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) andcall_dependency_missing(@elevenlabs/clientnot installed). NativegetUserMediarejections (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
fetchtherefore rejects rather than resolving, and the SDK surfaces it as aCompanionErrorwithstatus: 0and nocode— i.e. indistinguishable from the network being down. WithqueueOffline: trueit 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, andtext+turnIdride along.Resuming the stream: reconnect
GET …/stream?cursor=Nwith the last seen sequence; the SDK does this automatically.One session per surface.
surfacekeys a resumable session — reuse it across reconnects.Idempotency. Inbound effectful messages carry a client-generated
id; resending the sameidis 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 asignored_fields: [...]on the 201 so a misplaced field can't fail silently. The classic trap:toolsdoes 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_idMUST 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 bysha256(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_idunder 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
scopesarray may only narrow them. Sensitive scopes are not mintable via sessions. On top of the narrowed base, the agent template grants capabilities:genui: trueon the agent addsui.render(Instant UI) to every session it mints, andinstructions: trueaddschat.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_inclamps to 300–86400 s. No CORS — server-to-server only.Plan gate: a new live user beyond the plan's monthly-active-user cap →
402monthly active user limit reached (N/limit). Existing actives keep working mid-month. Test-key users meter into a separatemauTestbucket — never gated, never billed. If the platform cannot read the account's billing state at mint time, a gated mint answers a retryable503(plan limits temporarily unavailable) instead of quoting limits it never read — retry; the402above 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): audio → companion.audio (a TTS mp3 of the reply, when the session negotiated voice and the token holds the voice scope; voiceId picks the voice), expression → companion.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
/inputthat ends in a reply charges once, a turn that ends intool_callscharges on the/tool-resulthop 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
/callmints the window (25 credits), and/endthen 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/endtherefore 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
actionId→ 409 — 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.