{"openapi":"3.1.0","info":{"title":"Pouchy Companion API","version":"1.1.0","description":"Embed the Pouchy companion: \"Login with Pouchy\" (OAuth 2.1 Authorization Code + PKCE) and the Cloud Companion Runtime (sessions, world-state, agentic tools, memory, voice). Bearer tokens are opaque pchy_… access tokens (RFC 6750).\n\nVersioning: pin a major version in the URL via the /api/v1 alias (the unversioned /api paths also work). Every response carries the running version in the X-Pouchy-Api-Version header; a retiring endpoint announces an RFC 8594 Deprecation + Sunset header with at least a 90-day runway. See docs/companion-api-versioning.md.\n\nScope: this spec covers the INTEGRATION surface (session mint, OAuth, the session data plane, memory/knowledge, pair, avatar, wallet, MCP). Owner-side provisioning routes (PAT/app/skill management under /api/companion/*) are driven from the Pouchy app UI and documented prose-only in docs/companion-api-reference.md — deliberately out of the machine-readable contract."},"servers":[{"url":"https://pouchy.ai","description":"Production"}],"tags":[{"name":"oauth","description":"Login with Pouchy (Authorization Code + PKCE)"},{"name":"session","description":"Companion sessions + event channels"},{"name":"memory","description":"Recall / remember (app-namespaced + shared)"},{"name":"knowledge","description":"Ingest documents into shared memory"},{"name":"pair","description":"Instance-to-instance A2A social"},{"name":"avatar","description":"Companion avatar asset"},{"name":"wallet","description":"Read-only instance wallet"},{"name":"mcp","description":"Inbound MCP (Pouchy companion as a provider)"}],"components":{"securitySchemes":{"companionToken":{"type":"http","scheme":"bearer","bearerFormat":"pchy_…","description":"Opaque Pouchy access token (scope-gated)."},"firebaseUser":{"type":"http","scheme":"bearer","bearerFormat":"Firebase ID token"},"secretKey":{"type":"http","scheme":"bearer","bearerFormat":"pchy_sk_…","description":"Project Secret Key. Server-side only; never embed in a client."}},"schemas":{"TokenResponse":{"type":"object","required":["access_token","token_type"],"properties":{"access_token":{"type":"string","example":"pchy_…"},"token_type":{"type":"string","enum":["Bearer"]},"expires_in":{"type":"integer","description":"Access-token lifetime (seconds)."},"refresh_token":{"type":"string","example":"pchyr_…"},"scope":{"type":"string","description":"Space-separated granted scopes."}}},"OAuthError":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"error_description":{"type":"string"}}},"OAuthApp":{"type":"object","properties":{"clientId":{"type":"string","example":"pcap_…"},"name":{"type":"string"},"redirectUris":{"type":"array","items":{"type":"string","format":"uri"}},"scopes":{"type":"array","items":{"type":"string"}}}},"Envelope":{"type":"object","description":"The shared control/data-plane envelope.","required":["v","id","ts","type","payload"],"properties":{"v":{"type":"integer","const":1},"id":{"type":"string"},"session":{"type":"string"},"ts":{"type":"integer"},"type":{"type":"string"},"payload":{}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}},"responses":{"RateLimited":{"description":"Rate limited (turn burst ceiling or demo daily budget). Body: { ok:false, error, code:\"rate_limited\", retryAfterSec }; the Retry-After header carries the same seconds value.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window frees up."}},"content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"ok":{"type":"boolean","enum":[false]},"error":{"type":"string"},"code":{"type":"string","enum":["rate_limited"]},"retryAfterSec":{"type":"integer"}}}}}}}},"security":[{"companionToken":[]}],"paths":{"/v1/sessions":{"post":{"tags":["session"],"summary":"Mint a per-user session token (platform entry point)","description":"The developer's backend exchanges its project Secret Key + its own user id for a short-lived session token. First-seen external_user_ids are auto-provisioned (instance + persona from the agent template). The returned token is an ordinary companion access token — the client SDK connects with it exactly like a PAT. Backend-only: no CORS headers are sent.","security":[{"secretKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["agent","external_user_id"],"properties":{"agent":{"type":"string","description":"Agent id the instance binds to."},"external_user_id":{"type":"string","example":"user_4211"},"expires_in":{"type":"number","description":"Requested token TTL in seconds (clamped to the allowed range; default 3600)."},"scopes":{"type":"array","items":{"type":"string"},"description":"Narrow-only: intersected with the non-sensitive default set. Omit for the full default set."},"referral_code":{"type":"string","description":"Growth attribution — only applies to a NEWLY provisioned instance."}}}}}},"responses":{"201":{"description":"{ session_token, expires_in, agent, instance: { id, external_user_id, created } }."},"400":{"description":"Missing/invalid agent, external_user_id, or scopes narrowed to empty."},"401":{"description":"Invalid or revoked secret key."},"402":{"description":"Plan gate (MAU cap) — see error text."},"403":{"description":"Instance suspended."},"404":{"description":"Unknown project, or unknown agent for this project — the most common integrator typo is an agent id copied from a DIFFERENT project than the secret key; copy it from the dashboard Agents page (or GET /v1/admin/agents) of the key's project."},"429":{"description":"Mint rate limited. Only the route-level per-project burst guard sets a Retry-After header; the per-key per-minute cap and the test-provisioning ceiling answer 429 without one — retry shortly either way."},"503":{"description":"Storage unavailable (server-side deployment problem) — retry later."}}}},"/api/oauth/authorize":{"get":{"tags":["oauth"],"summary":"Validate an authorization request (consent-screen backend)","security":[],"parameters":[{"name":"client_id","in":"query","required":true,"schema":{"type":"string"}},{"name":"redirect_uri","in":"query","required":true,"schema":{"type":"string"}},{"name":"scope","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Validated; returns appName + granted scope subset."},"400":{"description":"Invalid client/redirect/scope."}}},"post":{"tags":["oauth"],"summary":"Approve consent and mint a one-time authorization code","security":[{"firebaseUser":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["client_id","redirect_uri","code_challenge"],"properties":{"client_id":{"type":"string"},"redirect_uri":{"type":"string"},"response_type":{"type":"string","enum":["code"]},"code_challenge":{"type":"string"},"code_challenge_method":{"type":"string","enum":["S256"]},"scope":{"type":"array","items":{"type":"string"}},"state":{"type":"string"}}}}}},"responses":{"200":{"description":"{ redirectTo } with code + state."},"400":{"description":"invalid_request"}}}},"/api/oauth/token":{"post":{"tags":["oauth"],"summary":"Exchange an auth code (or refresh token) for an access token","description":"Public client (PKCE, no client_secret) — accepts a form-encoded OR JSON body, same fields either way.","security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["grant_type"],"properties":{"grant_type":{"type":"string","enum":["authorization_code","refresh_token"]},"code":{"type":"string"},"code_verifier":{"type":"string"},"redirect_uri":{"type":"string"},"refresh_token":{"type":"string"},"client_id":{"type":"string"}}}},"application/json":{"schema":{"type":"object","required":["grant_type"],"properties":{"grant_type":{"type":"string","enum":["authorization_code","refresh_token"]},"code":{"type":"string"},"code_verifier":{"type":"string"},"redirect_uri":{"type":"string"},"refresh_token":{"type":"string"},"client_id":{"type":"string"}}}}}},"responses":{"200":{"description":"Token pair.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"400":{"description":"OAuth error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"401":{"description":"Unknown client_id (error invalid_client).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}}},"/api/oauth/register":{"get":{"tags":["oauth"],"summary":"List the developer's registered clients","security":[{"firebaseUser":[]}],"responses":{"200":{"description":"{ apps: OAuthApp[] }"}}},"post":{"tags":["oauth"],"summary":"Register a public PKCE client","security":[{"firebaseUser":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","redirectUris"],"properties":{"name":{"type":"string"},"redirectUris":{"type":"array","items":{"type":"string","format":"uri"}},"scopes":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthApp"}}}},"400":{"description":"No valid redirect_uri."}}}},"/api/oauth/register/{clientId}":{"delete":{"tags":["oauth"],"summary":"Delete a client the caller owns","security":[{"firebaseUser":[]}],"parameters":[{"name":"clientId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted."},"404":{"description":"Not found."}}}},"/api/oauth/revoke":{"post":{"tags":["oauth"],"summary":"Revoke a refresh-token family (RFC 7009)","description":"Presents a refresh token; its whole rotation family is revoked so no successor can renew the session. Idempotent and non-leaking per RFC 7009 §2.2: unknown/expired/foreign tokens still return 200. Access tokens self-expire and are not revocable here.","security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["token","client_id"],"properties":{"token":{"type":"string","description":"The refresh token to revoke."},"client_id":{"type":"string","description":"Public client id that owns the token."}}}},"application/json":{"schema":{"type":"object","required":["token","client_id"],"properties":{"token":{"type":"string"},"client_id":{"type":"string"}}}}}},"responses":{"200":{"description":"Empty body — success regardless of whether a matching token was found."}}}},"/api/companion/session":{"post":{"tags":["session"],"summary":"Start or resume a session (hello handshake)","security":[{"companionToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"surface":{"type":"string","example":"game"},"modalities":{"type":"array","items":{"type":"string"}},"tools":{"type":"array","items":{"type":"object"}},"appContext":{"type":"object"},"handles":{"type":"array","items":{"type":"string"}},"contextKinds":{"type":"array","items":{"type":"string"}},"visitor":{"type":"object"}}}}}},"responses":{"200":{"description":"hello.ack { session, grantedScopes, modalities, resumeCursor, representative?, visitorPaired?, pendingToolCalls? }. pendingToolCalls (non-empty only when resuming mid-pause) lists the outstanding tool calls of a paused turn — [{ id, name, args, turnId?, pausedAt? }] — so a reloaded embed can complete the turn by POSTing each result to /tool-result (idempotent per id) instead of abandoning it via /end."},"400":{"description":"Invalid visitor.id — 8-64 url-safe chars required (code invalid_request)."},"403":{"description":"A visitor was supplied but the token lacks the represent scope (code missing_scope). Deliberate reject-don't-degrade: falling back to an owner-facing session would expose the owner's full session to the visitor."},"503":{"description":"Session storage unavailable (code unavailable) — a server-side deployment problem; retry later."}}}},"/api/companion/session/{sessionId}/input":{"post":{"tags":["session"],"summary":"Send a user text turn","security":[{"companionToken":["chat"]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":32000},"images":{"type":"array","maxItems":4,"description":"data: URLs only (≤8MB each); anything else is a 400 invalid_request.","items":{"type":"string","format":"uri"}},"stream":{"type":"boolean","description":"true → the response is an SSE stream: `delta` frames token-stream the reply, the final `done` frame carries { seq, text, envelope } (or { kind: \"tool_calls\", toolCalls })."},"turnId":{"type":"string","maxLength":64,"description":"Client-minted correlation id — echoed back as `replyTo` on the reply companion.message (API 1.1). Also the idempotency key: retrying a COMPLETED turnId within ~10 minutes returns the recorded reply ({ seq, duplicate: true }) instead of running a second billed turn."}}}}}},"responses":{"200":{"description":"Buffered: { ok, kind: \"message\", seq } (reply arrives on the event stream), or { ok, kind: \"tool_calls\", toolCalls } when the turn paused on app-declared tools — post their results next. A turnId retry adds duplicate: true. With stream:true: an SSE stream ending in a `done` frame."},"400":{"description":"Missing/invalid text or images (code invalid_request)."},"403":{"description":"Images were supplied but the token lacks the \"files\" scope (code missing_scope)."},"409":{"description":"One turn owns the session at a time (code turn_pending): a paused turn is awaiting tool results, or another fresh turn is mid-flight. Post the pending tool results (or wait for the running reply), then retry; the lease self-expires so a crashed turn can’t wedge the session."},"413":{"description":"text over 32,000 chars or an image over 8MB (code payload_too_large)."},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/companion/session/{sessionId}/context":{"post":{"tags":["session"],"summary":"Push live world-state (CloudEvents envelope)","description":"One event, or a batch under { events: [...] }. Caps: 64 events per POST, type ≤ 64 chars, data ≤ 2KB JSON — anything over a cap counts into the response’s `dropped` (never silent truncation); `accepted + dropped` always sums to what you sent.","security":[{"companionToken":["worldstate.write"]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ingested into the session buffer — { accepted, dropped, injected?, reacted? }."},"400":{"description":"Invalid JSON body (code invalid_request)."},"404":{"description":"Session not found — expired or never started; POST /api/companion/session first (code session_not_found)."}}}},"/api/companion/session/{sessionId}/stream":{"get":{"tags":["session"],"summary":"Outbound companion event stream (SSE)","description":"Server-Sent Events of companion.* envelopes. Reconnect with a cursor to resume.","security":[{"companionToken":["events.subscribe"]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"cursor","in":"query","schema":{"type":"integer"}},{"name":"access_token","in":"query","schema":{"type":"string"},"description":"The bearer token, for header-less clients — a browser EventSource cannot set the Authorization header. Consulted only when that header is absent."},{"name":"last_event_id","in":"query","schema":{"type":"integer"},"description":"Resume cursor fallback, mirroring the SSE Last-Event-ID header. Precedence: ?cursor → Last-Event-ID header → ?last_event_id → the session high-water mark."}],"responses":{"200":{"description":"text/event-stream of Envelope objects."},"404":{"description":"Session not found — expired or never started; POST /api/companion/session first (code session_not_found)."}}}},"/api/companion/session/{sessionId}/tool-result":{"post":{"tags":["session"],"summary":"Return the result of a companion.tool_call the app performed","security":[{"companionToken":["chat"]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["callId"],"properties":{"callId":{"type":"string","description":"The id from the companion.tool_call envelope."},"ok":{"type":"boolean","default":true,"description":"false reports the call as failed; the model sees the failure and can react."},"result":{"description":"Any JSON (non-strings are JSON-stringified). Capped at 32,000 chars — longer results are truncated with a visible …[truncated] marker."}}}}}},"responses":{"200":{"description":"Recorded. { ok, allDone: false } while other calls in the turn are still outstanding; { ok, allDone: true, resumed: \"already\" } when a concurrent post already claimed the resume (duplicate final result); otherwise the resume outcome — { ok, allDone: true, resumed: \"message\", seq } (the reply arrives on the SSE stream) or { ok, allDone: true, resumed: \"tool_calls\", toolCalls }."},"400":{"description":"Missing callId or invalid JSON body (code invalid_request)."},"404":{"description":"Unknown callId for the paused turn (code unknown_call)."},"409":{"description":"No pending tool calls for this session (code no_pending_tools)."},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/companion/session/{sessionId}/call":{"post":{"tags":["session"],"summary":"Start a realtime voice call (mint WebRTC credentials)","security":[{"companionToken":["call"]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"voice":{"type":"string","description":"Voice id override for this call."},"locale":{"type":"string","description":"The user's locale when the client knows it (browser detection / host preference) — drives the voice pick and the agent language."},"dryRun":{"type":"boolean","description":"Assemble and return the voice instructions WITHOUT minting provider credentials, marking the call active, or emitting call_ready — a deterministic probe channel."}}}}}},"responses":{"200":{"description":"control.call_ready credentials."},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/companion/session/{sessionId}/confirm":{"get":{"tags":["session"],"summary":"List still-pending confirmations for this session (display-safe)","security":[{"firebaseUser":[]},{"companionToken":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ ok, pending: [{ confirmId, scope, summary, createdAt }] }."},"404":{"description":"Session not found (code session_not_found)."}}},"post":{"tags":["session"],"summary":"Approve/deny a sensitive op from a trusted surface","description":"Trusted-surface auth: the Pouchy user's Firebase ID token, or — for a platform instance session — the instance's own companion token (the SDK's confirmAction path). Approving executes the gated tool server-side; the response carries { status: approved|denied|exec_failed, outcome?, retryable? }.","security":[{"firebaseUser":[]},{"companionToken":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["confirmId","approve"],"properties":{"confirmId":{"type":"string"},"approve":{"type":"boolean"},"assertion":{"type":"object","description":"WebAuthn AuthenticationResponseJSON from navigator.credentials.get() — required when a prior approve returned 401 step_up_required; mint the challenge via POST …/confirm/stepup."}}}}}},"responses":{"200":{"description":"{ status: approved | denied | exec_failed, outcome?, retryable? } — outcome is the executed tool's result line; exec_failed + retryable:true means an idempotent tool flaked and the SAME confirmId can be re-approved."},"400":{"description":"Missing confirmId (code invalid_request)."},"401":{"description":"Step-up needed/failed (code step_up_required | step_up_failed)."},"404":{"description":"Session not found (code session_not_found), or no such pending confirm for this session (code confirm_not_found)."},"409":{"description":"Already resolved or expired (code confirm_resolved)."}}}},"/api/companion/session/{sessionId}/confirm/stepup":{"post":{"tags":["session"],"summary":"Mint the WebAuthn step-up challenge for a money confirm","description":"First-party surface only (the user's Firebase ID token — never a companion token): returns the passkey assertion options for the pending confirm; pass the resulting AuthenticationResponseJSON as `assertion` on the confirm POST.","security":[{"firebaseUser":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["confirmId"],"properties":{"confirmId":{"type":"string"}}}}}},"responses":{"200":{"description":"{ ok, options } — WebAuthn PublicKeyCredentialRequestOptions."},"400":{"description":"Missing confirmId (code invalid_request)."},"404":{"description":"Unknown confirmId (code confirm_not_found)."},"409":{"description":"No passkey enrolled for this account."}}}},"/api/companion/mcp":{"post":{"tags":["mcp"],"summary":"Inbound MCP (JSON-RPC 2.0): companion_chat / recall_memory / remember / notify_world_state","security":[{"companionToken":[]}],"responses":{"200":{"description":"JSON-RPC result."},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/companion/session/{sessionId}/history":{"get":{"tags":["session"],"summary":"Fetch the session's recent turn history","description":"Tenant-safe by construction: turns are read under the TOKEN's own user, so an unknown or foreign sessionId resolves to an empty log — a 200 { ok, count: 0, history: [] }, never a 404.","security":[{"companionToken":["chat"]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"{ ok, count, history: [...] }."}}}},"/api/companion/session/{sessionId}/end":{"post":{"tags":["session"],"summary":"End the session (consolidate it into long-term memory)","security":[{"companionToken":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{ ok, facts?, skipped? } — memory-consolidation diagnostic."},"404":{"description":"Session not found."}}}},"/api/companion/session/{sessionId}/modalities":{"post":{"tags":["session"],"summary":"Update the session's active modalities","security":[{"companionToken":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"modalities":{"type":"array","items":{"type":"string","enum":["text","voice","call","files"]}}}}}}},"responses":{"200":{"description":"Updated."},"400":{"description":"Invalid JSON body, or modalities missing/empty (code invalid_request)."},"404":{"description":"Session not found (code session_not_found)."}}}},"/api/companion/session/{sessionId}/ping":{"post":{"tags":["session"],"summary":"Keep-alive ping (extend the session TTL)","security":[{"companionToken":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pong { ok: true }."},"404":{"description":"Session not found (code session_not_found)."}}}},"/api/companion/memory":{"get":{"tags":["memory"],"summary":"Recall memories (semantic search over the instance memory)","description":"Any valid token may CALL this; the memory.read:app / memory.read:core scopes gate what comes back. A token holding NEITHER read scope gets an empty 200 ({ ok, count: 0, memories: [] }) — never a 403.","security":[{"companionToken":["memory.read:app"]}],"parameters":[{"name":"q","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"{ ok, count, memories: [...] }."}}},"post":{"tags":["memory"],"summary":"Remember a fact (app-namespaced write, stamped with provenance)","description":"Writes to the app namespace by default; namespace \"core\" needs the memory.write:core scope.","security":[{"companionToken":["memory.write:app"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content"],"properties":{"content":{"type":"string"},"importance":{"type":"number"},"confidence":{"type":"number"},"category":{"type":"string","enum":["relationship","shared_experience"],"description":"Only these two are kept; any other value is silently dropped (still a 200)."},"kind":{"type":"string"},"namespace":{"type":"string","enum":["app","core"]},"sensitivity":{"type":"string"}}}}}},"responses":{"200":{"description":"{ ok, cloudId, namespace }."},"403":{"description":"Namespace not permitted for this token, or an intimate-sensitivity write was refused (code forbidden)."},"503":{"description":"Memory storage unavailable (code unavailable)."}}}},"/api/companion/knowledge":{"post":{"tags":["knowledge"],"summary":"Ingest a knowledge document (text)","description":"Writes shared (core) memory — the token needs the memory.write:core scope.","security":[{"companionToken":["memory.write:core"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string"},"name":{"type":"string"},"kind":{"type":"string"},"locale":{"type":"string"}}}}}},"responses":{"200":{"description":"Ingested."},"403":{"description":"Token missing memory.write:core."},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/companion/knowledge/file":{"post":{"tags":["knowledge"],"summary":"Ingest a knowledge document from a file / URL","security":[{"companionToken":["memory.write:core"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["dataUrl"],"properties":{"dataUrl":{"type":"string"},"name":{"type":"string"},"kind":{"type":"string"},"locale":{"type":"string"}}}}}},"responses":{"200":{"description":"Ingested."},"403":{"description":"Token missing memory.write:core."},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/companion/pair":{"post":{"tags":["pair"],"summary":"Pair the instance with another agent (A2A social)","security":[{"companionToken":["represent:pair"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["visitorToken","visitorId"],"properties":{"visitorToken":{"type":"string"},"visitorId":{"type":"string"}}}}}},"responses":{"200":{"description":"Pairing created."}}}},"/api/companion/generated-image/{imageId}":{"get":{"tags":["session"],"summary":"Serve a companion-generated image by capability id","description":"Replies from the generate_image tool carry this URL. No bearer auth BY DESIGN — an <img src> cannot send an Authorization header, so possession of the unguessable 128-bit id is the permission (the id only appears inside the owning session's reply). Ephemeral (7-day retention), immutable-cached.","security":[],"parameters":[{"name":"imageId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The image bytes (Content-Type as generated)"},"404":{"description":"Unknown or expired image id"}}}},"/api/companion/avatar":{"get":{"tags":["avatar"],"summary":"Fetch the companion avatar descriptor (the same asset the app renders)","security":[{"companionToken":[]}],"responses":{"200":{"description":"JSON descriptor — { ok, name, archetype, modelId?, vrmUrl, portraitUrl } with absolute asset URLs (the VRM 3D model, plus a flat 2D portrait when one exists). Never image bytes and never a redirect: fetch the URLs to render."}}}},"/api/companion/wallet":{"get":{"tags":["wallet"],"summary":"Read the instance's own wallet (balances + total USD)","description":"Read-only + receive-only. The instance's OWN wallet, keyed by the token's uid — nothing here can move funds.","security":[{"companionToken":["wallet.read"]}],"responses":{"200":{"description":"{ ok, currency, balances: [{ currency, amount }], totalUsd }."},"403":{"description":"Token missing wallet.read (or wallet.spend)."},"404":{"description":"No wallet set up yet."},"503":{"description":"Wallet service unavailable."}}}}}}