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 (hooks.canonicalApiPath), so the versioned and unversioned forms are byte-for-byte the same handler today.
  • Unversioned still works. The bare /api/companion/* paths keep working unchanged; the alias is additive. New integrations SHOULD pin /api/v1.
  • 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.

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)

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

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.