# Errors and headers

> Errors come back in the shape your client already expects, with a stable type and a message in plain English. Headers that steer the gateway, and the ones it sends back.

Source: https://immiscible.fly.dev/docs/api/errors

## The error shape

```json
{
  "error": {
    "type": "agent_stopped",
    "message": "this agent is stopped under an owner hold, so nothing it sends goes upstream and nothing was sent; the person it acts for, an owner or an admin can restart it in the console under Agents",
    "hold": "owner",
    "stopped": "agent",
    "agentId": "agt_4f2c91a7",
    "fix": "Stop: the kill switch is on (error.hold says which hold, error.message who may lift it). Do not retry; tell the person you act for.",
    "docs": "https://immiscible.fly.dev/docs/guides/kill-switch#what-a-stop-stops",
    "requestId": "d210346a-c44e-4be8-bca7-1d8503868ce8"
  }
}
```

`requestId` is the same id as the `x-request-id` response header, on every error: quote it when you write to us and we can find the request. Every `4xx` carries `fix`, one sentence saying what to send differently, and `docs`, the page that explains it, so an agent can correct its next request from the error alone. Both are additions to the shape your client already reads; a route that has its own more specific `fix` keeps it. On the MCP server, JSON-RPC errors carry them in `error.data`, and a refused tool call puts them in the tool result's text.

A validation failure also lists each problem with the field it is about, so a form can mark the field. `message` joins them with "; ":

```json
{
  "error": {
    "type": "invalid_request",
    "message": "summary is required: say in one sentence what the agent is about to do; payment.amount must be a whole number of minor units (pence) above zero, for example 6420 for £64.20",
    "errors": [
      { "field": "summary", "message": "summary is required: say in one sentence what the agent is about to do" },
      { "field": "payment.amount", "message": "payment.amount must be a whole number of minor units (pence) above zero, for example 6420 for £64.20" }
    ]
  }
}
```

`field` is a dotted path into the body, or `null` when the problem is the body as a whole. `invalid_request`, `invalid_mandate`, `invalid_budget` and the other validation types (`invalid_application`, `invalid_upstream`) carry `errors`.

Every response, error or not, carries an `x-request-id` header. It is the id of the request in our logs: quote it when you ask for help with one.

On Anthropic-shaped routes errors use Anthropic's envelope (`{ "type": "error", "error": { ... } }`), so SDK error handling keeps working. `error.type` is stable and safe to branch on; `error.message` is for people and may change. Some refusals carry the numbers a client needs to act (for example `oldCount` and `newCount` on `selection_changed`). Every refusal that reflects a policy decision carries an `evidenceRecord` id: the refusal itself is in the ledger.

## Common to every route

| Status | Type | Meaning |
|---|---|---|
| 400 | `invalid_json` | the body is not JSON |
| 400 | `invalid_request` | a field is missing or malformed; `errors` lists each problem |
| 401 | `unauthenticated`, `invalid_api_key` | no credential, or one that is unknown, expired or revoked |
| 403 | `forbidden` | the credential is valid but this caller may not do this; the message says why |
| 403 | `csrf` | a console write without `x-immiscible-csrf`, or from another origin |
| 404 | `not_found` | no such object in this workspace (an id from another workspace is simply not found) |
| 404 | `route_not_found` | no route has this method and path; `didYouMean` names the nearest real one, for example `POST /v1/actions/authorize` |
| 405 | `method_not_allowed` | the path exists with other methods; the `allow` header lists them |
| 403, 409 | `separation_of_duties`, `second_owner_required`, `second_person_required` | another person must do this |
| 413 | `body_too_large` | over the limit: 1 MB for most routes, larger for model traffic and billing imports |
| 429 | `rate_limited` | honour `retry-after` |
| 500 | `internal` | our fault; in production the message says only "internal error"; send us the `x-request-id` |

## The gate

| Status | Type | What to do |
|---|---|---|
| 400 | `invalid_request` | no `type`, no `summary`, a one-word `type` that is not built in (`paymnt`: the message suggests `payment`; custom types are dotted, such as `crm.update`), an amount that is not whole minor units, a currency that is not three letters; `errors` lists each |
| 400 | `invalid_status`, `invalid_amount` | a settlement needs `completed`, `failed` or `cancelled`, and whole minor units |
| 402 | `plan_limit_reached` | the plan's agent or mandate allowance is used |
| 400 | `invalid_mandate` | creating or changing a mandate: its limits, merchants, fields or actions are malformed; `errors` lists each |
| 403 | `agent_key_required` | the key is not an agent key; agent routes need one (**Agents**, **Add an agent**, then **Collect the agent's key** on its setup page) |
| 403 | `agent_not_found` | an agent key whose agent has since been removed |
| 200 | (`deny`, signal `agent_frozen`) | the kill switch is on for this agent: a stopped agent's action requests are decisions, not HTTP errors |
| 404 | `not_found` | no action with that id belongs to this agent |
| 409 | `idempotency_conflict` | the key was used for a different request |
| 409 | `confirm_broaden` | a new rule would allow anywhere (any domain, any merchant) beside a narrower rule for the same agent; `reason` says which, and sending `confirmBroaden: true` saves it |
| 409 | `not_allowed`, `already_settled` | only an allowed, unsettled action can be settled |

## The gateway

| Status | Type | What to do |
|---|---|---|
| 400 | `unclassified_request` | send `x-immiscible-task-class` or bind a default class to the key (enforce mode only) |
| 400 | `sensitive_data_blocked` | the workspace's DLP setting is `block`; the message counts what was found by kind, never the values |
| 402 | `approval_required` | the budget's approval step; the body names the owner |
| 402 | `seat_limit_reached` | Free's people limit is in use this month, and its seven days of grace have ended |
| 403 | `agent_stopped` | the key's agent is stopped, or Stop every agent stands and the key is not bound to one agent; `hold` names the hold, `message` who may lift it, and nothing was sent ([what a stop stops](https://immiscible.fly.dev/docs/guides/kill-switch.md#what-a-stop-stops)). The MCP proxy refuses the same way, in `error.data` |
| 409 | `policy_conflict` | no model satisfies the policy; `relaxations` lists what would admit one, at what cost, signed off by whom |
| 429 | `budget_exhausted` | the budget's hard ceiling |
| 429 | `plan_limit_reached` | the plan's monthly request allowance is used |
| 502 | `upstream_failure` | every eligible model failed; `attemptedModels` lists them |

## Machine admin

| Status | Type | What to do |
|---|---|---|
| 401 | `invalid_token`, `token_expired` | unknown, revoked or expired, or presented from an address the token is not pinned to |
| 400 | `invalid_budget` | `POST /v1/admin/budgets`: a scope, amount, period or ladder that is not valid; `errors` lists each |
| 403 | `missing_scope` | the token lacks the scope this route needs |
| 400 | `template_only`, `unknown_template` | a token attaches mandates from templates only |
| 400 | `invalid_selector`, `nothing_selected`, `reason_required` | a bulk freeze needs a selector that matches and a reason |
| 409 | `confirm_count_required`, `selection_changed` | send `confirmCount` equal to the current count |
| 429 | `drill_cooldown` | a token may start one drill an hour per workspace |
| 202 | (pending) | a freeze past a ceiling waits for a person; not an error, but nothing is frozen yet |

## Headers

### Request

| Header | Purpose |
|---|---|
| `x-immiscible-task-class` | what kind of work this is, for example `code.feature`, `support.triage` |
| `x-immiscible-task-id` | group this call into an existing task |
| `x-immiscible-objective` | `balanced` (default), `cost`, `quality`, `latency` or `sovereign` |
| `x-immiscible-dry-run` | `true` to route and budget-check without calling a model |
| `x-immiscible-human-oversight` | `acknowledged` for high-risk work a person reviews |
| `x-immiscible-client-session` | the agent's session id, 1 to 128 printable characters; name the same id as `session.id` on action requests |
| `idempotency-key` | the action request's idempotency key, if not in the body |
| `traceparent` | a W3C trace to continue |
| `x-immiscible-csrf` | `1` on every console write |

### Response

| Header | Meaning |
|---|---|
| `x-request-id` | this request's id in our logs; quote it when asking for help |
| `immiscible-version` | the API version that answered, as a date (`2026-10-01`). There is one version; changes within it only add (new operations, optional fields, new values to ignore when unknown). A change that removes or renames anything would come under a new date. |
| `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset` | on the gate's routes, every answer: the limit per minute, what is left of it, and seconds until it is full again |
| `allow` | on `405`, the methods the path accepts |
| `traceparent` | the trace this request ran in; join your own spans to it |
| `x-immiscible-session` | a server-issued session id for a client that named none; send it back |
| `x-immiscible-task-id` | the task this call was recorded against |
| `x-immiscible-call-id` | this call |
| `x-immiscible-routed-to` | the model that served it (enforce mode) |
| `x-immiscible-would-route-to` | what routing would have chosen (shadow mode) |
| `x-immiscible-enforcement` | `shadow`, `observe`, `nudge` or `downgrade` |
| `x-immiscible-cost-micros` | cost in millionths of a US dollar, from reconciled usage |
| `x-immiscible-jurisdiction` | serving domicile and data region |
| `x-immiscible-budget-scope`, `x-immiscible-yield-multiplier` | the budget that bound this request, and its multiplier |
| `x-immiscible-advisory` | a plain-English note when a budget is close |
| `retry-after` | seconds to wait, on `429` |
