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/v1prefix — e.g.https://pouchy.ai/api/v1/companion/session. The server maps it back to the canonical/api/companion/sessionroute, so the versioned and unversioned forms are byte-for-byte the same handler today. The mapping is one pure function (canonicalApiPathinsrc/lib/utils/api-path.ts) applied at two layers: avercel.jsonrewrite 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 universalreroutehook insrc/hooks.tsfor 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-onlybaseUrl(https://pouchy.ai) and appends/api/companion/...itself, so pass the origin, neverhttps://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-Versionheader (CORS-exposed), and the OpenAPI document (GET /api/companion/openapi.json) reports the same value ininfo.version. The single source of truth isCOMPANION_API_VERSIONinsrc/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-05…2025-11-25).initializenow NEGOTIATES: a requestedprotocolVersionthe server speaks is echoed back; anything else answers the2024-11-05floor (the old behavior — pre-negotiation clients see byte-identical responses). - Modern era (
2026-07-28, stateless). A request carryingparams._meta["io.modelcontextprotocol/protocolVersion"]is served per the 2026-07-28 revision: results carryresultType+ server identity in_meta;server/discoveris implemented (in both eras); an unsupported_metaversion gets-32022with thesupportedlist; on single (non -batch) requests theMCP-Protocol-Version/Mcp-Methodheaders are cross-checked against the body when present (mismatch → HTTP 400 +-32020, or — for a NOTIFICATION, which JSON-RPC 2.0 forbids answering — an empty202with the request refused unexecuted, matching how the dispatcher declines to answer its own-32022notification bounces). No protocol sessions are minted (this endpoint never did);Mcp-Session-Idfrom clients is ignored; GET stays 405. - Deliberate leniencies (this policy forbids removals):
pingand JSON-RPC batches keep working in both eras; absent modern transport headers are tolerated. - Kill switch.
POUCHY_MCP_INBOUND_MODERN=0reverts the whole modern leg to the pre-2026-07-28 surface:_metais ignored (every request is served as legacy),server/discoveranswers-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 returns429+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.