Pouchy Companion — API Reference

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

  • Base URL: https://pouchy.ai
  • Transport: HTTPS REST + Server-Sent Events (SSE). Voice is a direct WebRTC session to the provider (Pouchy is out of the audio path).
  • Machine-readable spec: GET /api/companion/openapi.json (OpenAPI 3.1).
  • SDK (recommended): @pouchy_ai/companion-sdk (TypeScript, isomorphic). This reference is for the raw HTTP contract; most integrators should use the SDK and only consult this for details.
  • Python (server-side): pouchy-companion (packages/python-sdk — httpx/SSE
    • pydantic; the protocol vocabulary is GENERATED from the JS SDK's protocol.ts and drift-tested, so this reference applies verbatim). Aimed at game backends / bots; voice/WebRTC stays with the JS SDK.
  • Unity / C#: ai.pouchy.companion (packages/unity-sdk — HttpClient/SSE + Newtonsoft, UPM package; Protocol.cs is GENERATED from protocol.ts and drift-tested). Aimed at Unity games / .NET; voice/WebRTC out of scope. 0.1.0-preview — the wire protocol is drift-guarded, the C# awaits its first Unity/CI compile pass.
  • Native (Kotlin / Swift): the JS SDK can't be imported into native code — see companion-native-integration.md for reference clients (chat / voice / avatar) + a Matrix 代聊 bridge.
  • Bluesky / AT Protocol: bridge spec (DMs 代聊, 代读/代发, social-graph→pairing) in companion-bluesky-bridge.md.
  • Protocol design notes: docs/companion-sdk-protocol.md. Quickstart: docs/companion-quickstart.md. Voice play-along: docs/companion-voice-integration.md.
  • Zero-code drop-in widget (<iframe src="https://pouchy.ai/embed?token=…">, theme/accent params + postMessage control plane): companion-widget.md.

1. Authentication

Every data-plane call carries a bearer token:

Authorization: Bearer pchy_xxxxxxxx

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

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

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

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


2. Scopes

Scope Grants
chat Text turns (input.text).
voice TTS/STT (voice modality).
call Real-time WebRTC voice session.
files Multimodal image input.
events.subscribe Receive the outbound event stream (SSE).
worldstate.write Push live world-state / context.
ui.render Render Instant UI panels on the host (display + forms; not sensitive).
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.
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) / isSensitiveScope() / isRepresentScope() — check hello.ack.grantedScopes with compile-checked strings instead of copying them from this table.


3. SDK quick reference (recommended path)

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

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

const c = createCompanion({ baseUrl: 'https://pouchy.ai', token, surface: 'my-app',
                            modalities: ['text', 'voice'], tools: [/* see §3.1 */] });
await c.connect();                       // handshake → { session, grantedScopes, … }
c.onMessage((text) => render(text));     // assistant replies
c.start();                               // open the SSE stream (auto-reconnects)
await c.sendText('hi');                  // a user turn
c.sendWorldState({ type: 'app.scene', data: '…', retained: true });   // live context
const call = await c.connectCall({ locale: 'zh' });                   // real-time voice
Method Purpose
connect() Handshake; returns { session, grantedScopes, modalities, resumeCursor, representative?, visitorPaired?, pendingToolCalls }. pendingToolCalls (SDK 0.37.0; always present, empty when none) = the outstanding tool calls of a turn paused before this handshake, so an embed reloaded mid-pause can complete the turn (§3.1).
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) Read / write memory facts. query = semantic search over the token-visible facts (GET ?q=); omit for importance/recency ranking.
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?}) 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.

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'.

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 }
  • Scope: writes the user's shared knowledge, so the token must hold memory.write:core (the user's consent). Without it → 403. Use remember with an app namespace for private, app-only notes.
  • Embeddings are eventually-consistent: computed on the user's next first-party Pouchy open (server writes carry none). Until then the chunks are recallable by keyword/recency; after, by full semantic similarity.

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

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

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

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

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

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

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

3.4 Visitor → friend pairing → unlock A2A

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

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

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

4. REST endpoints

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

4.1 Session & data plane — Authorization: Bearer pchy_…

Method Path Scope Body → Response
POST /api/companion/session { surface?, modalities?, tools?, appContext?, handles?, contextKinds?, visitor? }{ session, grantedScopes, modalities, resumeCursor, representative?, visitorPaired?, pendingToolCalls? }pendingToolCalls (SDK 0.37.0; present and non-empty only when this handshake resumed a session mid-pause) lists the outstanding tool calls of a turn paused on your declared tools: [{ id, name, args, turnId?, pausedAt? }]. Perform each and POST /tool-result per id to COMPLETE the paused turn after a reload/crash (idempotent per id; the SDK auto-replays them to onToolCall with replayed: true unless replayPendingToolCalls: false) — the alternative to abandoning it via /end. On a re-handshake that resumes a live session, omitting tools / handles / contextKinds keeps the previously declared set; an explicit [] clears it
POST /api/companion/session/{sessionId}/input chat { text, images?, 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; 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 ~10 min returns { seq, duplicate: true } with the recorded reply instead of running a second billed turn) → { ok, kind: 'message', seq } (seq = the reply turn's sequence number, for ordering) 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 } — 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. 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
POST /api/companion/session/{sessionId}/context worldstate.write WorldStateEvent | { events: WorldStateEvent[] }{ accepted, dropped, injected?, reacted? }injected = a voiceRelevant moment was spoken into a live call; reacted = it fired a proactive text reaction. Per-event caps: type ≤ 64 chars, data ≤ 2KB JSON; batch cap 64 events per POST (oversize events AND events past the batch cap count into droppedaccepted + dropped always sums to what you sent)
GET /api/companion/session/{sessionId}/stream events.subscribe SSE — text/event-stream of envelopes (§8). Query ?cursor=N to resume.
GET /api/companion/session/{sessionId}/history chat ?limit=N{ ok, count, history: CompanionTurn[] } — the session's conversation log (SDK: history()).
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 }allDone: true once every pending call is reported and the turn resumes. 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)
POST /api/companion/session/{sessionId}/end { transcript? }{ ok, facts, skipped? } — 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. 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?, 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).
GET/POST /api/companion/memory memory.* GET ?limit=N&q=<query>{ memories: [...] } (q = semantic search); POST { content, … }{ ok, cloudId, namespace } (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())
POST /api/companion/knowledge memory.write:core { text, name, kind?, locale? }{ ok, summary, chunks } — ingest a document as knowledge (§3.2).
POST /api/companion/knowledge/file memory.write:core { dataUrl, name, kind?, locale? }{ ok, summary, chunks } — raw PDF/audio/image, understood server-side (§3.2).
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.
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, createdAt }] }
POST /api/companion/session/{sessionId}/confirm { confirmId, approve, assertion? }{ status: 'approved' | 'denied' | 'exec_failed', outcome, retryable? }outcome is the executed action's result text (or the decline notice), returned to the approving surface directly; the same text also arrives on the stream as a companion.message. 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).

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

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

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

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

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

4.3 Avatar & branding — render the same virtual human

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

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

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

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

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

Method Path Purpose
GET/POST /api/companion/keys, DELETE /api/companion/keys/{tokenId} Manage the user's companion access keys.
POST /api/companion/skills-sync Mirror skill metadata for companion awareness.
GET/POST /api/companion/skill-defs Mirror executable skill definitions (for run_skill).
GET/POST /api/companion/skill-grant List / set grants (domain allowlist + free-HTTP) on server-side skill defs, so chat-installed skills are reachable like client-managed ones.
GET/POST/DELETE /api/companion/skill-credentials KMS-encrypted skill credential store.
GET/POST /api/companion/skill-oauth2 OAuth2 refresh-token store for skills.
GET/POST /api/companion/apps App directory, supply side — the integrating dev declares an app manifest (mints a global appId) / lists their own apps.
GET /api/companion/apps/directory Browse all registered apps (name/category-filterable, verified first) — powers the connect picker.
POST /api/companion/apps/recommend The agent's recommendation read path — rank the user's connected apps for a turn's intent, with reasons + requested scopes.
POST /api/companion/apps/{appId}/verify Domain verification via DNS TXT — flips domainVerified, making the app eligible for global discovery.
POST /api/companion/webauthn Passkey (WebAuthn) enrolment + status for biometric step-up — { action: 'status' | 'register_options' | 'register_verify' }. Approval itself lives on the session confirm/confirm/stepup routes.

5. World-state — the play-along channel

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

5.1 WorldStateEvent (CloudEvents-shaped)

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

Guidance:

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

5.2 Real-time voice (CallCredentials)

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

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

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.

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 /{clientId} removes — first-party.)
GET/POST /api/oauth/authorize Validate the request / mint a one-time auth code on consent.
POST /api/oauth/token grant_type=authorization_code or refresh_token{ access_token, token_type, expires_in, refresh_token, scope }.
POST /api/oauth/revoke RFC 7009 — { token, client_id } revokes a refresh token and its whole rotation family (sign-out). Idempotent, always 200.

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


7. Sensitive actions & the confirm boundary

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

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

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

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

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

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

# 2. fetch the pending card(s) — one extra call after any turn
curl "https://pouchy.ai/api/companion/session/{sid}/confirm" \
  -H "Authorization: Bearer $SESSION_TOKEN"
# → { "ok": true, "pending": [{ "confirmId": "cfm_…", "scope": "skills.execute",
#     "summary": "my-coffee/place_order …", "createdAt": … }] }
#   render summary + [approve] [decline] buttons in your chat thread

# 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": "my-coffee/place_order → HTTP 200 …" }

# 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? } — an assistant reply; replyTo echoes the originating sendText's turnId (absent on proactive messages). Subscribe with onMessage (token stream: onDelta)
companion.audio TTS clip reference (non-call modality) (reserved — not emitted yet). 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 avatar viseme/expression/gesture cue (reserved — not emitted yet). 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.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, voiceSeconds, memoryOps } metering echo (reserved — not emitted yet). Subscribe with onUsage

9. Errors, limits, notes

  • Errors: non-2xx responses on the companion plane are { "ok": false, "error": "message", "code"?: string } (OAuth uses { error, error_description }). The optional code is a stable, machine-readable tag the SDK surfaces as CompanionError.code — switch on it when present, fall back to the HTTP status when absent:

    code status meaning
    missing_token 401 no Authorization: Bearer header
    invalid_token 401 token unknown, revoked, or expired — re-mint and retry (onAuthError automates this)
    missing_scope 403 token lacks a required grant — fix the key/template scopes, don't retry
    invalid_request 400 malformed JSON / missing or invalid fields
    session_not_found 404 sessionId expired or never started
    turn_pending 409 a turn owns the session: paused awaiting tool results, or another fresh turn is still mid-flight (wait for its reply, then retry)
    no_pending_tools 409 tool result posted but nothing is pending
    unknown_call 404 tool result for a callId the turn didn't issue
    payload_too_large 413 body/image over the documented cap — the per-endpoint request-body ceilings are 40MB for /input (multimodal images), 4MB for /knowledge, 512KB for every other companion endpoint, plus the field caps documented per route (32k text, 8MB per image)
    rate_limited 429 turn burst ceiling / demo daily budget spent, or the per-IP edge throttle — honor Retry-After (also on CompanionError.retryAfter, seconds, from SDK 0.27.0)
    forbidden 403 authorization denial other than a missing scope
    unavailable 503 backing store/provider not configured or down
    confirm_not_found 404 confirmId unknown or not on this session (API 1.1)
    confirm_resolved 409 confirm already approved/denied/expired — single-use (API 1.1)
    step_up_required 401 approval needs a passkey assertion — fetch confirm/stepup options first (API 1.1)
    step_up_failed 401 the passkey assertion didn't verify; retry the step-up (API 1.1)

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

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

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

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

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

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


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

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

Key types (strictly separated)

Key Prefix Holder Can
Secret Key pchy_sk_… / pchy_sk_test_… your backend mint end-user sessions (/v1/sessions)
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 } }
  • First-seen external_user_ids are auto-provisioned (persona applied from the agent template) — end users never configure anything.
  • ⚠️ external_user_id MUST be a stable id from YOUR account system — never a browser-generated / device-local id (localStorage device id, anonymous visitor id, session cookie). The instance — and ALL of its server-side memory, relationship state, and wallet — is addressed by sha256(projectId + agentId + external_user_id): if the id changes (user clears the browser, reinstalls, switches devices), the next mint creates a brand-new instance and the agent "forgets" the user. The old instance's memories remain intact under the old id, permanently orphaned. This is the single most common integration mistake; the dashboard's End-users page flags the fingerprint (mostly one-shot instances) when it sees it.
  • Instances are isolated PER AGENT — but MAU counts PEOPLE. The same external_user_id under two agents of one project gets two fully separate instances (memory, relationship, wallet) — what the user tells one agent, another never "remembers". Billing does NOT multiply: that member is ONE monthly-active user however many agents they talk to. Pairings created before 2026-07-15 keep their original instance ids (grandfathered; nothing moves).
  • Sessions get the non-sensitive default scopes; a scopes array may only narrow them. Sensitive scopes are not mintable via sessions. On top of the narrowed base, the agent template grants capabilities: genui: true on the agent adds ui.render (Instant UI) to every session it mints.
  • expires_in clamps to 300–86400 s. No CORS — server-to-server only.
  • Plan gate: a new live user beyond the plan's monthly-active-user cap → 402 monthly active user limit reached (N/limit). Existing actives keep working mid-month. Test-key users meter into a separate mauTest bucket — never gated, never billed.
  • 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)

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)

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)

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)
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)
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
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). Rev bump
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 }] ≤8 } replaces;
                                             #  GET returns { cases, lastRun }. Dev tooling —
                                             #  never mirrored onto instances, no rev bump
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
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 } — what a promotion
                                             #  would ship. 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"
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/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),
                                             #  audio/video (Whisper), image (vision
                                             #  caption). Same understanding module as the
                                             #  SDK's ingestFile; caps 20MB pdf / 25MB a-v /
                                             #  8MB image → 413; other types → 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
PATCH     /v1/projects/{id}/custom-skills/{slug}
                                             #  { ratePerMin: 1..120 | null } — per-skill
                                             #  per-minute call budget for HTTP tools
                                             #  (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). Re-pushes the def
                                             #  to running instances; returns { reprovisioned }
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 }
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}/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
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}/requests         # per-request API log (method/path/status/ms
                                             #  for every /v1/projects/** call; 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
                                             #  Telegram/Slack/Matrix/飞书/… — incl. GROUP
                                             #  MODE (config.groupMode; see
                                             #  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.
PATCH/DELETE /v1/projects/{id}/channels/{cid}# enable/disable, rotate inbound URL, remove
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), 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.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.

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. Deliberately excluded: admin-key management (escalation loop), billing grants/checkout (payment bypass; billing is read-only here), and HARD project delete (owner-only destruction). Also owner-auth-only (no admin mirror): agent versions/rollback/promote, evals (except the evals/gate CI poll, which takes the admin key), prompt sweep, knowledge file/url/search/config, 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)
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 (balance + address)
GET/POST  /v1/admin/knowledge                # shared corpus (+ DELETE /{docId})
GET/POST  /v1/admin/skills                   # custom skills — install from
                                             #  { md | url | mcpUrl | openapi | openapiUrl }
PATCH     /v1/admin/skills/{slug}            # { 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). Re-pushes
                                             #  the def to running instances; returns { reprovisioned }
POST      /v1/admin/skills/{slug}/compile    # P2 compile (docs-only prose → declared http
                                             #  tools; same pipeline as the owner route)
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/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.