Companion API — versioning & deprecation policy

The external Companion API (the "Login with Pouchy" OAuth endpoints + the Cloud Companion Runtime under /api/companion, plus the inbound MCP endpoint) follows a small, explicit stability contract so an integrator can commit to it without guessing what their URL / error / scope shape will look like in six months.

Version pinning

  • Path alias. Pin a major version in the URL with the /api/v1 prefix — e.g. https://pouchy.ai/api/v1/companion/session. The server maps it back to the canonical /api/companion/session route, so the versioned and unversioned forms are byte-for-byte the same handler today. The mapping is one pure function (canonicalApiPath in src/lib/utils/api-path.ts) applied at two layers: a vercel.json rewrite on the production host (the build is split into per-route functions there, so the alias has to be resolved before a function is chosen) and the universal reroute hook in src/hooks.ts for every single-function host (preview, node).
  • Unversioned still works. The bare /api/companion/* paths keep working unchanged; the alias is additive. New integrations that speak raw HTTP SHOULD pin /api/v1. The official SDKs use the unversioned base: every client takes an origin-only baseUrl (https://pouchy.ai) and appends /api/companion/... itself, so pass the origin, never https://pouchy.ai/api/v1 — that form produces /api/v1/api/companion/... and a 404.
  • Discovery. Every Companion API response carries the running semver in the X-Pouchy-Api-Version header (CORS-exposed), and the OpenAPI document (GET /api/companion/openapi.json) reports the same value in info.version. The single source of truth is COMPANION_API_VERSION in src/lib/server/companion/api-version.ts.

MCP protocol revisions (inbound /api/companion/mcp)

The inbound MCP endpoint is dual-era (additive — nothing was removed):

  • Legacy era (initialize handshake, revisions 2024-11-052025-11-25). initialize now NEGOTIATES: a requested protocolVersion the server speaks is echoed back; anything else answers the 2024-11-05 floor (the old behavior — pre-negotiation clients see byte-identical responses).
  • Modern era (2026-07-28, stateless). A request carrying params._meta["io.modelcontextprotocol/protocolVersion"] is served per the 2026-07-28 revision: results carry resultType + server identity in _meta; server/discover is implemented (in both eras); an unsupported _meta version gets -32022 with the supported list; on single (non -batch) requests the MCP-Protocol-Version / Mcp-Method headers are cross-checked against the body when present (mismatch → HTTP 400 + -32020, or — for a NOTIFICATION, which JSON-RPC 2.0 forbids answering — an empty 202 with the request refused unexecuted, matching how the dispatcher declines to answer its own -32022 notification bounces). No protocol sessions are minted (this endpoint never did); Mcp-Session-Id from clients is ignored; GET stays 405.
  • Deliberate leniencies (this policy forbids removals): ping and JSON-RPC batches keep working in both eras; absent modern transport headers are tolerated.
  • Kill switch. POUCHY_MCP_INBOUND_MODERN=0 reverts the whole modern leg to the pre-2026-07-28 surface: _meta is ignored (every request is served as legacy), server/discover answers -32601, and the transport cross-checks above are skipped — so a mismatched header executes as ordinary legacy traffic rather than -32020.

Breaking changes

A change that alters an existing endpoint's request/response/scope contract in a non-additive way ships under a new major path (/api/v2, a new alias) while the previous major keeps serving through its sunset. Additive changes (a new optional field, a new endpoint, a new scope) do not bump the major.

Deprecation runway

When an endpoint (or a major) is being retired, its responses carry RFC 8594 headers:

  • Deprecation: true — the endpoint is deprecated.
  • Sunset: <HTTP-date> — when it stops serving. Set at least 90 days out (MIN_DEPRECATION_DAYS) from the deprecation announcement.
  • Link: <doc-url>; rel="deprecation" — the migration note.

Emit them with deprecationHeaders({ sunsetIso, docUrl }) from api-version.ts and merge the result into the endpoint's Response headers.

Not yet covered (tracked, not promised)

  • Per-request X-RateLimit-* headers (the limiter enforces caps and returns 429 + Retry-After, but does not yet advertise remaining quota).
  • A public status page / SLA.

Error codes (v0.21+)

Companion-plane error responses (/api/companion/**) carry a stable machine-readable code alongside the human error message — { ok: false, error, code? } — surfaced by the SDK as CompanionError.code. The vocabulary (defined in $lib/server/companion/api-error.ts, append-only: renaming or removing a tag is a breaking SDK change):

code status meaning
missing_token 401 no Authorization: Bearer header
invalid_token 401 token unknown, revoked, or expired — re-mint and retry (the SDK's onAuthError hook 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 — POST /api/companion/session first
turn_pending 409 a turn is paused awaiting tool results
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
rate_limited 429 turn burst ceiling / demo daily budget spent — honor Retry-After / body retryAfterSec
forbidden 403 authorization denial other than a missing scope
unavailable 503 backing store/provider not configured or down
confirm_not_found 404 confirm id unknown or expired — the pending action is gone (API-1.1)
confirm_resolved 409 the confirm was already approved/denied — don't re-resolve (API-1.1)
step_up_required 401 a biometric/passkey step-up factor is required before this action runs (API-1.1)
step_up_failed 401 the step-up assertion did not verify — retry the factor (API-1.1)
quota_exhausted 402 the account's monthly allowance is spent (credits, or realtime voice minutes) — don't retry; upgrade the plan or wait for the 1st (UTC) reset
not_found 404 a resource addressed by id does not exist, or is one this token cannot see — deliberately the same answer, so refusals cannot map the space. First user: DELETE /api/companion/memory/{factId} (SDK 0.53.0)

Errors a route can't classify stay code-less (code absent) rather than guessing — switch on code when present, fall back to HTTP status.

Inside a JSON-RPC batch (POST /api/companion/mcp with an array body) the table above cannot be delivered as an HTTP response: a batch answers per entry and other entries may have succeeded, so the transport status stays 200. A rate-limited entry instead carries the same information in its JSON-RPC error data member (JSON-RPC 2.0 §5.1):

{ "jsonrpc": "2.0", "id": 7, "error": {
  "code": -32000,                        // unchanged; switch on this
  "message": "demo daily limit reached", // the gate's own message
  "data": { "code": "rate_limited", "retryAfterSec": 3600, "hint": "…" }
}}

data.code is always present; retryAfterSec and hint appear only when the gate named them. Treat data exactly as you would the single-request 429 body — in particular, the burst ceiling is retryable after retryAfterSec while a spent daily budget is not, and says so in hint. A single (non-array) request is unaffected: it still returns the real 429 with Retry-After.

The stream plane has its own (also append-only) control.error code vocabulary, distinct from the HTTP table above: agent_error (a server-side turn failed after the input was accepted), call_mint_failed (start_call couldn't mint the voice-provider credential) and the SDK-synthesized stream_unauthorized (event-stream 401/403 — a 401 exhausted the token-refresh retries; a 403 is immediately terminal, commonly a missing events.subscribe scope). See the protocol doc §3.