# 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: ```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 in `guards.ts` itself). - `code` — optional stable machine-readable discriminator. Clients branch on `(status, code)`. - `issues` — present on some 400s: Zod validation details (`parsed.error.issues` or `.flatten()` output). Useful for logging, not for branching. - A few endpoints add context fields (e.g. 413 export adds `count`/`limit`; 422 reopen adds `missing[]`). 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](./auth.md#401-error-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: 1. **CLI reachable, command failed** → HTTP **200** with `{success: false, error}` (e.g. `runRpc` in `hub/src/web/routes/git.ts` catches RPC errors, including the 30 s RPC timeout, and returns them as a JSON envelope). Clients must check the `success` field on every RPC-shaped response; HTTP 200 alone means nothing. 2. **CLI offline / handler missing** → depends on the route: the model-catalog routes in `machines.ts` map `RpcTargetMissingError` to **503 `rpc_target_missing`**; `git.ts`-style routes fold it into the 200 `{success: false}` envelope; resume/reopen surface **503 `no_machine_online`**. 3. **Hub subsystems not up** → **503 `Not connected`** from `requireSyncEngine` (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](./sse.md)) 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](./auth.md#silent-re-auth-401-handling)) | | 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 |