Errors
Error semantics for all /api/* endpoints. Grounded in hub/src/web/routes/guards.ts and the individual route files under hub/src/web/routes/; reference consumer web/src/api/client.ts (ApiError, parseErrorCode).
Body shape
Error responses are JSON:
{ "error": "Session is inactive", "code": "session_inactive" }error— human-readable message. Never match on it: consumers i18n it and the hub may reword it (this rule is stated inguards.tsitself).code— optional stable machine-readable discriminator. Clients branch on(status, code).issues— present on some 400s: Zod validation details (parsed.error.issuesor.flatten()output). Useful for logging, not for branching.- A few endpoints add context fields (e.g. 413 export adds
count/limit; 422 reopen addsmissing[]).
When no code is present, branch on status alone and treat the failure generically. The web reference falls back to using error as a pseudo-code when code is absent (parseErrorCode) — acceptable for logging, not for logic.
Status × code table
| Status | code | Where (source) | Meaning / client action |
|---|---|---|---|
| 400 | — | All routes with Zod bodies ({error: 'Invalid body'}, some with issues) | Client bug — fix the request; do not retry |
| 400 | — | Flavor gates (sessions.ts: wrong-flavor model/mode endpoints) | Hide the control for this flavor |
| 400 | scratchlist_attachment_invalid, scratchlist_entry_empty, attachment-limit codes from validateScratchlistAttachmentsForWrite | sessions.ts scratchlist routes | Surface validation message |
| 401 | — | Middleware (middleware/auth.ts) and POST /api/auth (routes/auth.ts) | See Auth → 401 bodies; middleware 401 → silent re-auth once, /api/auth 401 → re-pair |
| 403 | — | guards.ts (Session access denied, Machine access denied); owner-only routes (usage.ts, storage.ts, hubSettings.ts, voice.ts) | Namespace mismatch / not hub owner — hide the surface, don't retry |
| 403 | access_denied | RPC-flow results mapped in sessions.ts (resume/reopen/cursor-chat-store) | Same as above |
| 404 | — | guards.ts (Session not found, Machine not found); permissions.ts (Request not found); scratchlist entry/attachment; events.ts visibility (Subscription not found) | Stale reference — refresh the parent list |
| 404 | session_not_found, machine_not_found | Coded variants from resume/reopen/restart-runner result mapping | Same |
| 409 | session_inactive | guards.ts requireSession(requireActive) — send/steer/abort/approve/deny/config on an inactive session | Offer Reopen (the web router does exactly this on this code) |
| 409 | scratchlist_at_cap | sessions.ts scratchlist create (200-entry cap) | Show cap notice; do not retry |
| 409 | scratchlist_attachment_in_use | sessions.ts attachment delete while still referenced | Detach from entry first |
| 409 | resume_unavailable | sessions.ts resume/reopen result mapping | Session can't be resumed (e.g. unsupported state) |
| 409 | metadata_conflict | sessions.ts reopen result mapping | Refetch session, retry once at most |
| 409 | runner_upgrade_required | machines.ts Agent availability | Upgrade and restart the runner; disable session creation |
| 409 | control_mode_not_applicable | sessions.ts switch | Concurrent clients do not use ownership switching; hide takeover controls |
| 409 | — (version conflict) | sessions.ts PATCH rename/summary, machines.ts PATCH rename — message mentions version/concurrently; no code | Concurrent edit — refetch and reapply |
| 409 | — | sessions.ts delete-while-active, archive of plain inactive row, fork/rewind refusals, remote-only config on terminal-controlled sessions (controlledByUser) | Surface message; refresh session state |
| 413 | — | sessions.ts upload (> 50 MB decoded), export too large ({error, count, limit}); voice.ts transcription (Audio file too large, 25 MB audio / ~26 MB body) | Reduce payload |
| 413 | scratchlist_attachment_too_large | sessions.ts scratchlist upload | Reduce attachment |
| 422 | — | sessions.ts reopen with incomplete metadata ({error, missing[]}); title-suggestion pass-through (TitleSuggestionError, statuses 422/429/502/503) | Not reopenable / feature unavailable |
| 429 | — | Title suggestion (provider rate limit) | Back off |
| 500 | — | Catch-all in most routes ({error: message}) | Log; generic failure UI |
| 502 | — | Title suggestion upstream failure; restart-runner unknown error | Retry later |
| 503 | — | guards.ts requireSyncEngine → {error: 'Not connected'} — hub subsystems not up (startup/shutdown window); also Telegram-disabled on /api/auth initData path | Retry with backoff |
| 503 | no_machine_online | resume/reopen/spawn-flow result mapping (sessions.ts) | The machine that owns the session is offline — tell the user to start the runner |
| 503 | machine_offline | machines.ts restart-runner | Same |
| 503 | rpc_target_missing | machines.ts pi/codex model catalogs (RPC_TARGET_MISSING_ERROR_CODE in shared/src/rpcMethods.ts — RPC handler unregistered or socket disconnected) | CLI-side target gone — treat as offline |
RPC-wrapped endpoints
Many endpoints do not answer from hub state — the hub relays the request over Socket.IO to the session's CLI process (or the machine's runner) and forwards the result: git/file/directory/search, generated images, uploads, model catalogs, Agent availability, slash-commands, skills, spawn, list-directory, paths/exists. (The mode/model/effort config endpoints are RPC-backed too, but map apply-failures to 409 with a message.) Their failure modes differ from plain endpoints:
- CLI reachable, command failed → HTTP 200 with
{success: false, error}(e.g.runRpcinhub/src/web/routes/git.tscatches RPC errors, including the 30 s RPC timeout, and returns them as a JSON envelope). Clients must check thesuccessfield on every RPC-shaped response; HTTP 200 alone means nothing. - CLI offline / handler missing → depends on the route: the model-catalog routes in
machines.tsmapRpcTargetMissingErrorto 503rpc_target_missing;git.ts-style routes fold it into the 200{success: false}envelope; resume/reopen surface 503no_machine_online. - Hub subsystems not up → 503
Not connectedfromrequireSyncEngine(brief startup/shutdown window).
Explicit rpc_target_missing / no_machine_online failures mean the execution host or handler is unavailable. Do not infer that from success: false alone: a reachable CLI can report a command, path, permission, or validation failure. Preserve that error for display; HTTP success is not operation success.
One route qualifies that rule. GET /api/machines/:id/agy-models keeps serving the last catalog the machine got out of agy models while the CLI re-checks in the background, so it can answer success: true and carry an error: the list is usable, and error says why it may be stale (typically the machine's agy sign-in has lapsed). Render it beside the catalog rather than instead of it, and offer ?refresh=true as the way to ask again — a plain repeat is answered from the same cache.
When that background re-check lands a different listing, the machine says so over the existing event stream rather than making clients ask: machine-agy-models-updated (see SSE) carries the machineId and nothing else. Refetch that machine's route on it — the answer comes from the machine's cache, so it costs no agy run and produces no further event. It is emitted whenever the re-check changes what this route would answer — a different listing, or a sign-in warning that appeared or cleared — and not when it changes neither, so a failed re-check that raises a warning does announce. The machine's very first listing is never announced: whoever triggered it is already awaiting it. Requests during that window are answered from the machine's cache and do not launch agy.
Retry guidance
| Class | Retry? |
|---|---|
| 400 / 403 / 404 / 409 / 413 / 422 | No (fix input, refresh state, or hide surface) |
| 401 (middleware) | Once, after silent re-auth (Auth) |
| 429 / 502 / 503 | Reads: retry with backoff. Mutations: follow the endpoint's recovery contract; do not replay a write whose outcome is unknown |
200 {success: false} | Inspect the error and endpoint contract; manual retry only when safe. This envelope can also represent a missing RPC target or timeout |