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 (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-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.
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) |
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.