---
title: HTTP API
description: Every REST route and WebSocket stream `lev serve` exposes, with auth, payload shapes, and the published OpenAPI spec.
group: Reference
group_order: 3
order: 2
---

# HTTP API (`lev serve`)

`lev serve` exposes a REST + WebSocket API in front of the [daemon](/docs/daemon), so anything that
speaks HTTP can drive Leviath, including [The Lair](https://leviath.dev/lair), the browser console.

```bash
lev serve --port 3000 --token "$(openssl rand -hex 16)" --cors https://leviath.dev
```

Every route on this page is also published as a machine-readable
[OpenAPI spec](https://leviath.dev/docs/stable/openapi.json), kept in lockstep with the server by
a test, so a client generator or an agent can consume the contract directly.

## Security model

- **A token is required.** The server refuses to start without `--token <t>` (or
  `LEVIATH_API_TOKEN`). Every request must send `Authorization: Bearer <t>`; WebSocket clients
  pass it as `?token=<t>` because browsers can't set WS headers. On shared machines prefer the
  environment variable: a `--token` value is visible to other local users in the process table
  (`ps`).
- **CORS is closed by default.** Pass `--cors <origin>` (e.g. `https://leviath.dev`) or `--cors "*"`
  to allow a browser to call it cross-origin. The server does not check the `Host` header, so the
  bearer token is what stands between a DNS-rebinding page and your local API. Never embed the
  token in a page served from somewhere else, and avoid `--cors "*"` on a machine that browses.
- **Binds to `127.0.0.1`** by default. `--host 0.0.0.0` exposes it on your network. Without
  `--tls-cert`, that puts the bearer token on the wire in cleartext for anyone on that network to read.
  If the address is publicly routable, that is the open internet. See
  [reaching a Leviath on another machine](#reaching-a-leviath-on-another-machine).
- **`--tls-cert` / `--tls-key`** serve HTTPS instead of HTTP. Off by default, bring your own
  certificate; Leviath never generates one.
- **`GET /` needs no token.** It returns a fixed "Leviath is running." page and nothing else: no
  version, no run counts, no endpoint list. It exists so a certificate can be accepted in a browser
  tab; see the section below.
- **`--allow-admin`** mounts the mutating admin routes. `GET /api/config` and
  `GET /api/mcp/servers` are always available. The writes are only mounted with `--allow-admin`, and
  the route is genuinely absent without it rather than gated by a check inside the handler. What you
  get back depends on whether the path exists at all for another method:

  | Without `--allow-admin` | Response |
  |---|---|
  | `PUT /api/config` | 405, because `GET /api/config` is mounted |
  | `PUT /api/mime` · `DELETE /api/mime` | 405, because `GET /api/mime` is mounted |
  | `POST /api/mcp/servers` | 405, because `GET /api/mcp/servers` is mounted |
  | `DELETE /api/mcp/servers/{name}` | 404, because nothing else is mounted on that path |
  | `POST /api/update` | 405, because `GET /api/update` is mounted |
  | `POST /api/models/probe` | 404, because nothing else is mounted on that path |
- **`--workdir-root`** confines agent workdirs. **`--no-remote-yolo`** forbids `"yolo": true` and
  `"allow": [...]` on spawn, which are one lever rather than two.
- **`--no-remote-seed-commands`** runs every spawn as if it carried `"no_seed_commands": true`, so
  a blueprint's `seed = { command = ... }` regions, which execute at spawn before any approval
  prompt, never run for a run that arrived over the API. `lev run` on the host is unaffected;
  `[security] allow_seed_commands = false` is the machine-wide version.

> [!CAUTION]
> `lev serve` runs LLM-driven tools with whatever permissions the blueprint grants. Treat it as
> trusted-network only unless hardened. See [Security](/docs/security).

## Limits

The server holds a bounded number of requests in flight and gives each one a deadline. Both have
a default, both can be set in the config file or on the command line, and `0` switches either
off. A flag wins over the config file, and the config file over the default.

| Limit | Default | Flag | Config key | Over it |
|---|---|---|---|---|
| Requests in flight | 64 | `--max-concurrent-requests <N>` | `[serve] max_concurrent_requests` | 503 at once, not queued |
| Seconds per request | 30 | `--request-timeout-secs <SECS>` | `[serve] request_timeout_secs` | 408, and the handler is dropped |
| Bytes per request body | 32 MiB | none | `[serve] max_upload_bytes` | 413; this is what bounds a multipart upload |

Both answers carry the usual `{"error": "..."}` body. The websocket routes (`/ws` and
`/ws/agents/{id}`) are outside both: a subscription is meant to stay open. They are the only
place the API streams, so every other route has built its whole body before the deadline could
cut it. An unauthenticated request takes a slot while it is being refused, so a flood without a
token is refused at the cap like any other.

Three routes take a slot but have no deadline, because each waits on something slower than the
default by design. Dropping one partway does damage a late answer would not:

| Route | What it waits on |
|---|---|
| `POST /api/mcp/servers/{name}/login` | The operator at the consent page, up to 300 s |
| `POST /api/mcp/servers/{name}/test` | The MCP server's handshake and tool listing, under the MCP client's own 30 s and 120 s deadlines. |
| `POST /api/doctor/live` | Two billed provider calls and a throwaway run, up to the doctor's own 90 s. |

Each is bounded by the deadline named in the table, so none can hold its slot forever. Dropping
the login request closes the loopback listener the browser redirects to.

Neither limit is a ceiling on the runs behind the API. A spawn whose daemon takes a minute still
spawns; the route answers as soon as the daemon has accepted it. `GET /api/config` reports the
values in force under `limits.max_concurrent_requests` and `limits.request_timeout_secs`, so a
client sees what this server resolved rather than the default it would guess.

## Reaching a Leviath on another machine

The short version: **`http://` only works on loopback.** Everything else needs HTTPS or a tunnel.

A browser treats `http://localhost` and `http://127.0.0.1` as potentially trustworthy, which is the
only reason the default setup works from a page served over HTTPS. Every other address is blocked,
and **a LAN address is blocked exactly like a public one**. `http://192.168.1.50:3000` fails the same way
`http://203.0.113.10:8080` does:

```
Mixed Content: The page at 'https://leviath.dev/lair' was loaded over HTTPS, but requested an
insecure resource 'http://203.0.113.10:8080/api/config'. This request has been blocked.
```

Two things that are *not* the problem, because they are what people reach for first:

- **It is not CORS.** The request is killed inside the browser before it is sent, so it never reaches
  Leviath and `--cors` is never consulted. No response header on either side lifts a mixed-content
  block.
- **The site cannot fix it.** leviath.dev is HTTPS-only, and an HTTPS page may not call `http://`.

Pick whichever of these suits you.

### mkcert, if the browser and Leviath are on machines you control

The best outcome: a certificate that is *fully* trusted, with no interstitial and nothing to accept.
[mkcert](https://github.com/FiloSottile/mkcert) installs a local CA into your OS and browser trust
stores and will issue for a bare IP.

```bash
TRUST_STORES=system,nss mkcert -install        # once, on the machine running the BROWSER
TRUST_STORES=system,nss mkcert 192.168.1.50    # on the machine running Leviath
lev serve --host 0.0.0.0 --port 3000 \
  --tls-cert ./192.168.1.50.pem --tls-key ./192.168.1.50-key.pem \
  --cors https://leviath.dev --token "$LEVIATH_API_TOKEN"
```

The `TRUST_STORES=system,nss` prefix limits mkcert to the OS and browser stores, which are the
only ones The Lair needs. Without it, mkcert 1.4.4 also looks at the Java trust store whenever
`JAVA_HOME` is set, on both commands. It then stops with `failed to execute "keytool -list"` and
`Keystore file does not exist` when that directory has a `keytool` but no `lib/security/cacerts`.
That is the layout `brew --prefix openjdk` has
([mkcert issue #472](https://github.com/FiloSottile/mkcert/issues/472)). If the install command
hit that error, the CA was already in your OS and browser stores before the Java step ran, so
rerun only the certificate command, with the prefix. Pointing `JAVA_HOME` at the real JDK home,
`$(brew --prefix openjdk)/libexec/openjdk.jdk/Contents/Home`, fixes it for good.

Installing a CA into your trust store is a real trust decision: anything holding that CA's key can
issue a certificate your browser will believe. `mkcert` keeps the key on the machine that made it.

### Tailscale, for a publicly-trusted name

`tailscale cert` issues a real certificate for your `*.ts.net` hostname, so nothing needs installing
in a trust store and the port never faces the internet.

```bash
tailscale cert my-box.tail1234.ts.net
lev serve --host 0.0.0.0 --tls-cert my-box.tail1234.ts.net.crt \
  --tls-key my-box.tail1234.ts.net.key --cors https://leviath.dev
```

### Self-signed, as a fallback

Works, with one manual step and one caveat.

```bash
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem -subj "/CN=leviath" \
  -addext "subjectAltName=IP:192.168.1.50"
lev serve --host 0.0.0.0 --tls-cert cert.pem --tls-key key.pem --cors https://leviath.dev
```

Then **open `https://192.168.1.50:3000/` in a browser tab and accept the warning.** That is what the
unauthenticated `GET /` page is for. The Lair's requests are subresource `fetch` calls, which get
no interstitial to click through, so the exception has to be established in a tab first. Afterwards
The Lair works.

Chrome discards accepted exceptions when the browser restarts, so this comes back. Firefox keeps
them. iOS Safari is unreliable about it.

### SSH forward, if you would rather not deal with certificates

Nothing to install on either end, and it puts you back inside the loopback exemption.

```bash
ssh -N -L 3000:127.0.0.1:3000 you@that-machine
```

Then point The Lair at `http://127.0.0.1:3000`. Leave Leviath on its default `127.0.0.1` bind for
this. `--host 0.0.0.0` is not wanted and only widens the exposure.

### Compressed responses

A client that says it takes `gzip` or `br` in `Accept-Encoding` gets any body over a kilobyte
compressed, with `Vary: Accept-Encoding` on the response. A run listing is JSON with a great deal of
repetition, so over a tunnel that is roughly a tenth of the bytes. Byte ranges, images and event
streams are left alone, as is anything already carrying a `Content-Encoding`.

## Auth flow

```mermaid
sequenceDiagram
  participant Client
  participant Serve as lev serve
  participant Daemon
  Client->>Serve: request + Authorization: Bearer <token>
  alt token missing / wrong
    Serve-->>Client: 401 Unauthorized
  else authorized
    Serve->>Daemon: control-socket call
    Daemon-->>Serve: result
    Serve-->>Client: 200 JSON
  end
```

## Endpoints

Base path `/api`; all JSON unless noted.

Every route below can answer `401` when the bearer token is missing or wrong, so a client has to
handle that on all of them rather than on a few. The body is a line of plain text, not JSON.
`GET /`, the fixed status page, is the only route that does not check a token.

| Method · Path | Purpose |
|---|---|
| `GET /api/runs` · `DELETE /api/runs` | List runs: paginated, sortable, searchable · prune many at once. See [below](#listing-and-searching-runs) |
| `DELETE /api/runs/{id}` | Delete one finished run's record, and its sub-agent runs. Not the same as cancelling it. See [below](#deleting-runs) |
| `GET /api/agents` · `POST /api/agents` | List runs *(deprecated, use `/api/runs`)* · spawn an agent. Reads the persisted records, so finished runs stay listed |
| `GET /api/agents/{id}` · `DELETE …` | Get one · cancel. Cancelling stops the work and **keeps** the record; see [deleting runs](#deleting-runs) for removing it |
| `GET /api/agents/{id}/result` · `/context` | The run's answer and log tail · current context window |
| `GET /api/agents/{id}/logs?stage=&stream=&tail=` | A run's logs. `stage`, `stream` and `tail` pick which stage, which stream, and how much |
| `GET /api/agents/{id}/context/history` | How the context window changed over the run, paginated |
| `GET /api/agents/{id}/stages` | The per-stage ledger: tokens, dollars and regions per stage. See [below](#where-a-runs-cost-went) |
| `GET /api/agents/{id}/files` | List a run's files, or read one with `?path=`. `offset` pages a large one. See [below](#a-runs-files) |
| `GET /api/agents/{id}/files/raw?path=` | A workdir file's bytes under its own content type, for an `<img>` or a download. See [below](#a-runs-parts) |
| `GET /api/agents/{id}/blobs` · `/blobs/{sha256}` | The stored parts a run holds, and one part's bytes. See [below](#a-runs-parts) |
| The three byte routes above | Also take a short-lived `?exp=&sig=` link in place of the bearer token, which is what a browser needs for an `<img src>`. Mint one with the [GraphQL API](/docs/graphql) |
| `GET /api/agents/{id}/artifacts/{name}` | The bytes of one file the run handed back, by the name its answer lists. See [below](#a-runs-parts) |
| `GET /api/agents/tree` · `/{id}/tree-status` · `/{id}/children` | Sub-agent tree + token roll-ups |
| `POST /api/agents/{id}/pause` · `/resume` | Pause a run · resume it |
| `POST /api/agents/{id}/message` | Steer a running agent. Takes files too; see [attaching files](#attaching-files) |
| `GET/POST /api/agents/{id}/interaction` | Read / answer a pending question. See [below](#answering-a-question) |
| `GET/POST/PUT/DELETE /api/blueprints[/{name}]` · `/validate` | Blueprint CRUD + validation, paginated and searchable with `q` |
| `GET /api/config` · `PUT /api/config` *(admin)* · `POST /api/config/validate` | Read redacted config · write or clear keys (`null` clears one) · validate a key |
| `GET /api/models?provider=&refresh=` | Enumerate models with their token limits and where they came from. See [the model catalogue](#the-model-catalogue) |
| `POST /api/models/probe` *(admin)* | Ask an OpenAI-compatible server what it serves, before writing a gateway for it. See [below](#gateways) |
| `GET /api/providers?quota=&refresh=` · `POST …/{name}/login` *(admin)* · `/logout` *(admin)* · `/check` *(admin)* | The providers that sign in with a browser instead of taking a key, and the sign-in itself. See [below](#signing-in-to-a-subscription-provider) |
| `GET /api/tools?agent=` | What an agent here can actually call. See [below](#tools-and-scripts) |
| `GET /api/mime` · `PUT /api/mime` *(admin)* · `DELETE /api/mime` *(admin)* | Read the effective [mime registry](/docs/mime#the-registry), and write to it. The writes need admin. See [below](#writing-a-mime-row) |
| `GET /api/scripts?agent=&include=` · `GET/PUT/DELETE /api/scripts/{kind}/{name}` · `POST /api/scripts/validate` | Read and write the machine's Rhai. Writes need admin. See [below](#tools-and-scripts) |
| `GET /api/mcp/servers` · `POST …` · `DELETE …/{name}` · `GET …/{name}/status` · `POST …/{name}/login` · `POST …/{name}/test` | List, add, remove, check, log in, test. All but the two `GET`s need admin |
| `GET /api/doctor` · `POST /api/doctor/live` *(admin)* | The checks `lev doctor` runs, as data. See [below](#the-doctor-routes) |
| `GET /api/yolo` · `GET /api/yolo/{name}` · `POST /api/yolo/test` · `PUT /api/yolo` *(admin)* | The profiles behind `--yolo=<name>`. See [below](#yolo-profiles) |
| `GET /api/update` | Whether anything newer exists, how this copy was installed, and the command that upgrades it. See [below](#asking-how-to-upgrade) |
| `POST /api/update` *(admin)* · `GET /api/update/jobs/{id}` | Carry that plan out, and read where it got to. See [below](#pressing-the-button) |
| `GET /api/fs/dirs?path=&hidden=` | One directory level of subdirectory names, for a folder picker |
| `POST /api/fs/dirs` | Make one directory: `{"path": "<absolute parent>", "name": "<one segment>"}` → `201 {"path", "parent"}` |
| `GET /ws` · `GET /ws/agents/{id}` | Live event stream (all agents / one run) |
| `GET /api/exports/{id}` | Collect a bulk export. Signed, five minutes, no bearer token. See [below](#exporting-the-whole-store) |
| `POST /graphql` · `GET /ws/graphql` | The same server answered field by field, and the same events over a subscription. See [GraphQL](/docs/graphql) |

Several routes in that table take more explaining than a cell allows.

`GET /api/agents/{id}/stages` reports what each stage spent per visit as well as in total, and
whether the stage ran at all. The blueprint detail route carries the manifest, the regions and the
[fan-out limits](#fan-out-limits).

A `PUT /api/config` that changes a provider key, a gateway, `default_provider`,
[`override_model` or `fallback_model`](#override-and-fallback-models) applies to the next run
spawned, with no daemon restart. An MCP server added or removed through `/api/mcp/servers` reaches
the next run the same way.

`GET /api/models` answers from a catalogue the server keeps. An OpenAI-compatible gateway's
detected models are listed under the gateway's name. `provider` narrows the listing to one; see
[two providers, one model id](#two-providers-one-model-id). `refresh=1` asks the providers again;
see [the model catalogue](#the-model-catalogue). `POST /api/models/probe` takes
`{"base_url", "api_key"?, "headers"?}` and answers `{"models": [ids]}`, or a 502 carrying the
server's own error text.

On `GET /api/providers`, `quota=true` adds what each subscription has used; see
[subscription usage](#subscription-usage). `refresh=1` reads the accounts again.

`PUT /api/mime` adds or updates a row in `mime_types.toml`. It takes
`{"mime_type", "family"?, "text"?, "tokens"?, "extensions"?, "magic"?, "stand_in"?, "check"?}`.
Only the fields you send are changed, and a bad type or rule is a 400.
`DELETE /api/mime?mime_type=` takes a row out, and answers 404 when there is no such row.

`GET /api/scripts` covers the agent's tools, hooks, validators and mime checks, and the global
model providers and mime checks. `include=candidates` also lists the files nothing declares yet.

`GET /api/fs/dirs` takes absolute paths only, fenced by `--workdir-root`, and `hidden=true`
includes dot-prefixed names. `POST /api/fs/dirs` uses the same fence, answers `409` when the
directory already exists, and is announced as `fs.mkdir`.

### The doctor routes

`GET /api/doctor` is `lev doctor --offline`: config, search and resolve, nothing billed.
`POST /api/doctor/live` runs the whole chain, which is two billed calls and a throwaway run. It
answers 409 while one is already going. A failing check is `ok: false` inside a 200, never an HTTP
error.

### Spawning under a yolo profile

`POST /api/agents` takes `"yolo": true` for a plain unattended run, and `"yolo_profile":
"<name>"` to run under a named profile from [`yolo.toml`](/docs/yolo) instead.
A profile implies `yolo`, so the two need not both be sent. A name the file does not have fails
the spawn with a 400 that lists the profiles it does have. Under `--no-remote-yolo` a profile is
refused with `yolo` and `allow`: the operator's flag says nothing about which one.

The run listing and `GET /api/agents/{id}` carry the name back as `yolo_profile` beside
`unattended`, so a console can show that a run is unattended *under* something rather than
unattended outright.

### Capturing one run's prompts

`POST /api/agents` takes `"capture_model_input": true` to write that run's exact requests into its
journal, once per provider attempt. It applies to the one run and leaves every other alone, so it
is the switch to reach for when the question is about a single run rather than a machine.

**Read the warning before you send it.** A captured request is the whole prompt, holding whatever
the run's context held: file contents a tool read, command output, and anything somebody pasted.
There is no size cap, so the journal grows by roughly the context size per attempt. See
[capturing what went to the model](/docs/observability#capturing-what-went-to-the-model).

`[observability] capture_model_input` does the same for every run on the machine, and either one is
enough. Read the captured requests back on `InferenceAttemptOutput.modelInput` over
[GraphQL](/docs/graphql#what-one-call-sent).

### Answering a question

`GET /api/agents/{id}/interaction` is the request the run is parked on: its `id`, `kind`, `prompt`
and `options`, plus `tool_name` and `tool_arguments` on a tool approval. `POST` the answer with
the request's id as `request_id` and one of three fields. `value` is free text or an edited
document. `choice_index` is a multiple choice, zero-based. `approved` carries an optional `scope`
(`once`, `stage` or `session`) for a tool approval or a confirm. It answers `202` once the daemon
has it and `404` when nothing with that id is open.

A deny may carry `feedback`, a string the model reads as part of the tool result for the refused
call, so its next turn is a redirect rather than a guess:

```json
{"request_id": "coder-1788924523-abc123-approve-1", "approved": false,
 "feedback": "use git log, not git show"}
```

The model sees `[denied] User declined tool call 'bash'. Feedback: use git log, not git show`.
Without `feedback` the result is the plain `[denied] User declined tool call 'bash'.` it always was.
`feedback` beside `approved: true` is a `400`, because there is nothing to redirect. The same text
is what the tool approval's fifth option, "Deny with feedback", collects in the dashboard, and
what `lev respond <id> --deny --feedback "..."` sends. Announced as `interaction.feedback`.

On `/logs`, `stage` takes a stage index or `all`, and defaults to the current stage. `stream` is
either `output`, the assistant's own text, or `logs`, which carries tool calls, token counts and
errors. `tail` is a byte budget for how much of the end you get back.

> [!NOTE]
> A run object carries both `updated_at` and `last_progress_at`. The first advances on a 30-second
> heartbeat and stays fresh on a run that has stopped; the second moves only when the run does. Age
> a run against `last_progress_at`. `pid` is always 0 and means nothing: the daemon hosts every run
> in one shared world, so there is no process per run. If you are tracking slots from outside, read
> [reconciling an external work queue](/docs/work-queues) first.

### What a run flags about itself

A run object carries a `flags` object: post-hoc diagnostics that tell an empty or degraded run
from a healthy one without parsing its logs. Counters like `modified_file_count`, `searches_run`
beside `searches_empty`, and `gates_forced` say whether the run actually did anything, and
`empty_output` sums up the verdict. `flags.broken_scripts` names each Rhai script the run needed
but could not use, such as an [output validator](/docs/rhai-validators#when-the-validator-itself-fails)
that threw. It is the same list `lev ps` renders as `(broken script)`, and the key is omitted
while the list is empty. The flags live in the run's `meta.json`, so they come back wherever a
run object does.

### How long a run has taken

Three spans, and they answer different questions. A run paused overnight is hours old and spent
almost none of them working. Report one where the reader wanted the other, and a healthy run looks
stuck while a stuck one looks healthy.

| span | key | what it means |
| --- | --- | --- |
| **age** | `age_secs` | How long since the run was launched. Says nothing about whether it has done anything |
| **working** | `working_secs` | How long it actually spent working. Call this the run's duration |
| **last moved** | `last_progress_at` | When it last actually moved. A health signal, not a duration |

`last_progress_at` is how you tell a wedged run from a slow one.

`age_secs` and `working_secs` are computed server-side and appear on every run object this API
serves: `GET /api/runs`, `GET /api/agents`, `GET /api/agents/{id}` and
`GET /api/agents/{id}/children`, alongside the raw stamps they come from. `?fields=` selects them
like any other key. The same two keys, on the same definitions, come back from `lev ps --json`.

The working clock stops for everything that is not the run's doing: paused, blocked on a person,
parked until the machine is fixed, finished. It keeps running while the run is inferring, calling
tools, or held for its own fan-out workers and sub-agents. Each stage in `stages.json` keeps one of
its own on the same rule.

To track a live run between the daemon's writes, read the clock itself rather than the computed
figure, which was true at `server_time`:

```json
"active": { "banked_secs": 412, "since": 1787720786 }
```

`banked_secs` is the time from spans that have already ended. `since` is when the span in progress
began, or `null` when the clock is stopped. The working total is `banked_secs`, plus `now` minus
`since` whenever `since` is set.

`active` is `null` on runs written before this existed, and `working_secs` then falls back to
`updated_at` minus `started_at`. A finished run has `since: null`, so its total never moves again.

## Statuses

A run's status is one word, and it is the same word everywhere: on the run itself, in a tree node,
on `GET /api/agents/{id}/result`, and on the `agent_status` frames coming off the WebSocket.

| Status | Means |
|---|---|
| `starting` | Accepted and being set up. No inference has been issued yet |
| `running` | Working: inferring, calling tools, or moving between stages |
| `waiting_input` | Parked. `wait_reason` says on what, and only some of those want a person |
| `paused` | Paused, by somebody or by Leviath. Resumes on request |
| `complete` | Finished, with nothing further to accept |
| `complete_interactive` | Every required stage is done and the run still takes follow-up input |
| `error` | Stopped by a failure. The run's `error` carries what went wrong |
| `cancelled` | Stopped from outside. Nothing went wrong, somebody decided |

Leviath pauses a run itself when something outside the run has to change first, and `wait_reason`
is then `needs_setup`. A paused run comes back paused after a daemon restart.

The engine keeps its own vocabulary inside the daemon, where a run that is going is `idle` or
`active` and a parked one is `waiting`. Those words used to reach the socket untranslated, so a
client watching `/ws` was matching on three words no route ever sent, and a status frame quietly did
nothing for it. They are translated on the way out now.

Two older spellings are gone with them: `GET /api/agents/{id}/result` and the two tree routes
rendered the status for a human reader, which meant `WaitingInput` and `CompleteInteractive` where
every other route said `waiting_input` and `complete_interactive`.

`events.run_status` in the `capabilities` list is how you tell. A server without it sends the
engine's words on `agent_status` and the older spellings on those three routes.

The `status=` filter on `GET /api/runs` stays looser than this list on purpose: it also takes
`waitinginput` and `Waiting-Input`, so a status read off any response can be handed straight back as
a filter.

## Region kinds

A region's `kind`, wherever one appears (`GET /api/agents/{id}/context`, its history, the blueprint
detail route), is the word the blueprint's own TOML uses: `pinned`, `temporary`, `clearable`,
`sliding_window`, `compacting`, `compact_history`, `hashmap`, `checklist`, `custom`.

Context snapshots written by an older daemon say `sliding` and `history` for the two multi-word
kinds, and those files stay on disk, so accept both spellings wherever you render one.
`context.region_kinds` says a server writes the blueprint's words.

## Listing and searching runs

`GET /api/runs` returns a page, not the whole list:

```json
{ "items": [{ "meta": { "run_id": "…" }, "highlights": [] }],
  "next_cursor": "7b2276…", "total": 340, "server_time": 1785869070 }
```

Pass `next_cursor` back as `cursor` and loop until it comes back null. Do not count pages against
`total`. It is what matched at the moment of that one request, and runs are being created and
finished underneath you.

Paging is keyset rather than offset, because an offset into a list that is changing skips and
repeats items, and does it most often at the head. **`sort=started_at` is the default because it is
the only sort key that never changes.** `updated_at` moves on the daemon's 30-second heartbeat, so
every live run shifts under a walk. A run whose sort value changes mid-walk can be missed or
repeated. To poll for what changed, use `since=` with no cursor rather than deep-paginating.

`since=` filters whichever field `sort` names, and is inclusive. Pass the previous response's
`server_time` and you may see one item twice, which is the safe direction when the granularity is
whole seconds.

Two parameters exist so a browser client does not have to make N requests: `ids=a,b,c` fetches exactly
those runs, and `fields=run_id,status,title` trims each one. Ids that no longer exist come back in
`missing` rather than failing the request.

### Listing by place in the tree

A run's sub-agents are runs, so a listing that pages by runs is not paging by the rows a console
draws when it nests workers under the run that started them. At `limit=50`, seven visible rows and
forty-three workers hanging off them is a real page.

`parent=` fixes that from the server side:

| Value | Keeps |
|---|---|
| omitted | Every run, sub-agents included. What this route has always returned |
| `none` | Only runs nobody started. What a top-level list wants |
| a run id | That run's direct children, one level down |

`total` then counts what you asked for, which is what makes it worth printing beside a list: `382`
under a sidebar drawing forty rows is comparing runs the reader can see against runs they cannot.

`parent=<run_id>` is also the paged, sorted, searchable form of
[`GET /api/agents/{id}/children`](#endpoints), which answers the same question in one unbounded
array. A fan-out of two hundred workers has no windowed form there.

A run id that names nothing gives an empty page rather than a `404`: a run with no children yet is
a normal answer, not a missing resource. `none` is the only keyword, and no run can collide with it,
since a run id is `<agent>-<timestamp>-<hash>`.

Announced as `runs.parent`. Without it, page until enough top-level rows exist to fill the viewport
and filter client-side, which is what The Lair does today.

### Search

`q=` is a case-insensitive substring. It is not a regular expression, there are no boolean
operators or phrase quoting, and case folding is ASCII-only.

`q_in=` chooses where to look, defaulting to `meta,files`:

| Source | Looks at | Cost |
|---|---|---|
| `meta` | title, task, agent name, workdir, run id, error, metadata values | free |
| `files` | the paths the run recorded modifying | free |
| `context` | the run's current context window | one file read per run |
| `logs` | the tail of each stage's logs | two reads per stage per run |
| `journal` | the whole run journal: tool calls and context history | one file read per run |

The last three read from disk, which is why they are opt-in. Surface them as a "search inside
runs" toggle rather than making every keystroke pay for them. They also stop after a bounded number
of runs, newest first. When that happens the response says `scan_truncated: true` and sets `total`
to null, because a count taken from a partial scan would be read as fact.

Matching items carry `highlights` saying *why* they matched: the field, a snippet, and the stage
where there is one, which you can pass straight to `/logs?stage=`. This is the part that cannot be
done in the browser, because The Lair never holds a run's transcript.

One honest limit: the deep sources match the raw JSON on disk, so a query containing a quote,
a backslash or a newline may not match text that does contain it.

`descendant_of=<run_id>` is the other tree question: every run under that one, at
any depth, and not the run itself. `parent=` is one level, which is what a tree
view expands a node with; `descendant_of=` is the flat read of a whole fan-out,
which is what a cost roll-up or an export wants. Setting both is a 400, because
they name two different sets and the one you meant is not recoverable from the
pair.

`parent=sub` is the mirror of `parent=none`: only runs somebody started, at any
depth. `blueprint=<name>` narrows to the runs of one blueprint, by the name each
run recorded, and composes with everything above.

## Deleting runs

Cancelling and deleting are different verbs on purpose. `DELETE /api/agents/{id}` stops the run and
leaves everything it wrote; `DELETE /api/runs/{id}` removes the record. One stops the work, the
other forgets it happened.

Deletion is real and irreversible. The run's directory goes, transcript included. That is the point
of the route: a "Delete" button that only hid the run in one browser would tell somebody clearing a
sensitive transcript that it was gone when it was not.

```
DELETE /api/runs/deep-researcher-1786839472-d908ad2d9455
→ 204
```

- **409** if the run is still going. Removing a directory out from under a running agent is a much
  larger feature than this, so cancel it first and delete it after.
- **404** if it is already gone, so a client that lost the response to its own delete can send
  it again instead of treating a missing run as a failure.

### Sub-agent runs go with their parent

A fan-out worker and a `sub_agent` spawn are runs of their own, but they exist because something
started them, and they are drawn nested under it. Deleting the parent deletes them too.

Leaving them behind was not a matter of a few stale rows. A client that nests runs under their
parent has nowhere to draw a run whose parent is missing, except the top level. Deleting a
research run with nine workers under it emptied one row and promoted nine.

The walk only goes downwards. Deleting one worker out of a fan-out is an ordinary thing to do and
leaves the run that started it, and the workers beside it, exactly where they were.

A live sub-agent is a **409** on the parent's delete, and the reason names the run to cancel.
Half a tree is not a state anything downstream knows how to read.

A run whose `meta.json` will not parse is a **409** too, overridden with `?force=true`:

```
DELETE /api/runs/{id}?force=true
```

A record that cannot be read says nothing about whether the run finished, and "cannot read it" must
not quietly read as "finished". That is exactly what a live run looks like to a binary whose
`RunMeta` has moved on. Such a run is also skipped by the listing, which would leave it both
invisible and permanent, so the escape hatch stays; it is something you type rather than
something that happens to you. The bulk route never forces.

### Clearing out old runs

One request per run is its own problem once there are a few hundred, so there is a bulk form. It
takes either an age or an explicit list:

```
DELETE /api/runs?before=1785869070
DELETE /api/runs?ids=run-a,run-b,run-c
```

`before` is a unix timestamp and matches `updated_at`; only finished runs are considered. `ids` is
capped at `max_ids` from `GET /api/config`, the same cap as the batch fetch. Sending neither is a
400 rather than "every run". A bulk delete with no predicate is far more likely to be a client
that failed to build its query than somebody asking to erase the machine's history.

Partial success is the normal outcome, not an error. A sweep that runs into one live run has still
correctly deleted the rest, so the response is a 200 with a verdict per run:

```json
{ "deleted": ["run-a", "run-c"],
  "skipped": [{ "id": "run-b", "reason": "Run 'run-b' is Running; cancel it before deleting it" }] }
```

Read `skipped` when the list does not empty. A run that is still going and a run that was already
gone are both non-deletions, and only the reason tells them apart. Use the single-run route when you
want a status code per outcome instead.

`deleted` can hold ids you never named: every run named takes its sub-agent tree with it here too,
and those are runs that are now gone. Naming a parent and one of its own children in the same
request is fine. Each is deleted once, and the child is reported as deleted rather than skipped as
missing.

## Exporting the whole store

Paging five thousand runs is a hundred requests, and a client that wants the lot wants it once. So
there is an export. It is started over [GraphQL](/docs/graphql), because that is where the filter
already lives:

```graphql
mutation {
  startRunExport(request: {
    filter: { status: { eq: COMPLETE } }
    fields: ["run_id", "status", "cost_usd"]
  }) {
    export { id status }
  }
}
```

The answer comes back before the file exists. That is the point of a job: the request returns in
milliseconds however large the store is. Poll it, and fetch it when it is ready:

```graphql
{ runExport(id: "export-1789865498-0") { status written error downloadUrl } }
```

`status` is `QUEUED`, `RUNNING`, `COMPLETE` or `FAILED`. `written` counts the runs on disk so far.
`downloadUrl` is null until the export is complete, and then it is a signed link to
`GET /api/exports/{id}`, good for five minutes. That link needs no bearer token, so it can be the
`href` of a download button.

The filter is the run listing's own, so a predicate that works for one works for the other.
`fields` narrows each row to the top-level run fields you name, using the same names the listing
uses. A name no run carries is a 400 rather than a column quietly missing from the file.

The body is JSONL: one JSON object per line, not one array. A reader can start on it before the
writer has finished, and neither side ever holds the whole store in memory. Ten thousand runs is a
loop over ten thousand lines at either end.

```
{"run_id":"run-a","status":"complete","cost_usd":0.0142}
{"run_id":"run-b","status":"complete","cost_usd":0.0071}
```

A file is kept for one hour, then removed along with its job record. Neither outlives the other, so
an id that has expired and one that was never started both answer 404. While the export is still
running, or after it failed, the file route answers 409 and `runExport` carries the reason.

## Where a run's cost went

`GET /api/agents/{id}/stages` returns one record per declared stage, in blueprint
order:

```json
{
  "run_id": "analyst-1786409275-d17e8f82",
  "stages": [
    { "name": "plan",           "status": "complete", "entered": true,
      "prompt_tokens": 8420, "completion_tokens": 610,
      "cached_tokens": 6100, "cache_write_tokens": 240,
      "cost_usd": 0.0412, "unpriced_calls": 0, "cost_is_exact": false,
      "cost_priced_usd": 0.0412,
      "visit_count": 1,
      "visits": [
        { "entered_at": 1786409280, "left_at": 1786409461,
          "prompt_tokens": 8420, "completion_tokens": 610,
          "cached_tokens": 6100, "cache_write_tokens": 240,
          "cost_usd": 0.0412, "unpriced_calls": 0, "cost_is_exact": false,
          "cost_priced_usd": 0.0412,
          "active": { "banked_secs": 181, "since": null } }
      ],
      "models": [
        { "provider": "anthropic",  "model": "claude-opus-5" },
        { "provider": "openrouter", "model": "anthropic/claude-opus-5" }
      ],
      "region_tokens": { "task": 24, "data_preview": 4004 },
      "runaway_warned": false },
    { "name": "error_recovery", "status": "skipped",  "entered": false },
    { "name": "answer",         "status": "complete", "entered": true }
  ]
}
```

Five things here are not derivable from any other route.

**`entered` says whether the run was ever in that stage.** The alternative is to
fetch `context/history` and diff consecutive snapshots to see which stages
produced entries. That is expensive, because every point carries a whole context
window. It is also wrong in the case that matters: a stage that ran and wrote
nothing to any region leaves no trace to find. `status: "skipped"` is the same fact
stated from the other side, and means the run finished without reaching this
stage, as distinct from `"pending"` on a run that is still going.

**The per-stage cost split.** The run-level totals are on the run record; which
stage spent them, and the cache read/write split within a stage, are only here.
A stage showing no cache reads cannot be told apart from one paying to write a
prefix nothing reuses without `cache_write_tokens`.

`cost_usd` is what that stage spent. It means exactly what it means on a run:

- **`null` is unknown, never free.** Some call in that stage was served by a
  model with no reported cost and no rates the daemon knows, so any total would
  understate by an unknown amount. `unpriced_calls` says how many.
- **`cost_is_exact` says which number you have.** `true` means every priced call
  carried the provider's own figure: the invoice. `false` means at least one was
  reconstructed from published rates, which is arithmetic on numbers that drift
  for reasons outside the daemon: negotiated pricing, a gateway's margin, a
  request rerouted to another backend.
- **`cost_priced_usd` is the priced subtotal**, kept even while `cost_usd` is
  `null` so a resumed run does not restart its accounting from zero. It is not a
  substitute for `cost_usd`: showing it while calls went unpriced is exactly the
  partial total that looks authoritative and is not.

Do not multiply the tokens by a rate card of your own. Pricing is the daemon's
job, deliberately. A rate card in a console produces a fourth answer that
disagrees with the run's figure, the stage's, and the provider's, and none of the
four says which is wrong.

Every call a run bills is counted against the stage it was made in. That covers
the stage's own turns, the compaction calls that summarize its context when the
window fills, and the routing call it makes at its own boundary to choose where
to go next. The one exception is the run's title call, which happens once at
spawn beside the run rather than inside any stage of it. The stage costs can
therefore sum to slightly less than the run's own `cost_usd`.

**`visits` splits a stage by each stay in it.** The record above accumulates
across revisits. That is the right total for the stage and the wrong shape for a
graph of the path a run took, where a stage entered twice is two nodes. Each
entry covers one entry into the stage: `entered_at`, `left_at` (`null` on the
visit in progress), the same four token counts, the same four cost fields, and an
`active` working clock of its own. That clock follows the rule described under
[how long a run has taken](#how-long-a-run-has-taken).

A stage that loops back to itself starts a new visit; iterations within one stay
do not. `visit_count` counts every entry, and the list stops at 128. So
`visit_count > visits.length` means the per-visit split is partial and the
accumulated figures on the record are the complete ones. `visits` is empty on a
stage the run never entered, and on records written by a daemon older than this
field, which is the other reason to keep falling back to the stage record itself.

**`models` is what that stage actually ran on.** The run's own `model` is the
entry stage's resolution and is never rewritten to follow the stages after it,
so it answers for one stage of the run and no other. This answers for each.

It is a list because a stage that fails over runs on more than one. The first
entry is where the stage started, the last is where it ended up, and one entry
means it never moved. A pair appears once however many calls it served, so read
it as what ran rather than as how often.

A stage lists a pair once a call against it came back. So `models` is absent on
a stage the run never entered, on a stage whose first call is still in flight,
and on a stage whose only provider could not be reached at all. Choosing a model
is not running on one, and there is nothing here to read as a stage's intended
model.

**`region_tokens` is what decides whether a region is earning its place.** It is
the largest each region reached while that stage was active. This is the number to
look at before trimming a layout.

`runaway_warned` is set when a stage's per-call prompt passed four times its
first call, which is the shape of a region accumulating without a cap.

The list is bounded by the blueprint's stage count, so it is not paginated. A run
that has not reached its first stage boundary returns an empty list rather than a
404. The run exists and has nothing to report yet.

`lev stages <run-id>` prints the same ledger as a table, `--visits` breaks each
stage into its stays, and `--json` is this shape read straight off disk.

> [!NOTE]
> `entered` is `false` for every stage of a run recorded before Leviath tracked
> it, because the field is not in those files at all. Read it together with
> `status`: a stage recorded `complete` with tokens against its name ran,
> whatever `entered` says on an old run. `models` is absent on every stage of
> such a run for the same reason, and must not be filled in from the run's
> `model`, which is the entry stage's and wrong for every stage after it.

## Attaching files

A run takes files three ways, and all three end as typed [parts](/docs/mime) on the region they
were aimed at, exactly as `lev run --attach` sends them.

`multipart/form-data` on `POST /api/agents` and `POST /api/agents/{id}/message` carries the bytes
themselves. A `request` field holds the JSON the route takes as a plain body. After it come any
number of file fields named `part` (bound for the task region, or the message's region) or
`part:<region>`, each with a `filename` and a `Content-Type`:

```bash
curl -X POST http://localhost:3000/api/agents -H "Authorization: Bearer $TOKEN" \
  -F 'request={"blueprint":"storyteller","task":"a 30 second trailer"}' \
  -F 'part:voice_samples=@voice.wav;type=audio/wav' \
  -F 'part:storyboard=@frame1.png'
```

`POST /api/agents/{id}/interaction` takes both forms too, for a text answer: the files land beside
the words in the tool result, and a choice or an approval with files is refused with 400.

A JSON body instead names files already inside the run's working directory under `parts`, each
`{ path, region?, name?, mime_type?, deliver?, caption? }`. And a `@path` token inside `task`, a
region's text, or a message names a workdir file the same way; the text keeps the token, so the
model reads the same name the part carries. A path that escapes the working directory is refused
with 403, a missing or empty file with 400, and a file over `[serve] max_upload_bytes` with 413.
The daemon types every part with its registry, so `Content-Type` and `mime_type` only need to be
right when the bytes and the name do not say. A part aimed at a region whose `accepts` excludes it
refuses the spawn with the region's list, and drops from a message with the text still delivered.

## A run's parts

`GET /api/agents/{id}/blobs` lists every stored part the run's context holds: a user's attachment,
a file `read_file` stored, an image an MCP tool returned, an artifact the run submitted. Each item
carries `sha256`, `mime_type`, `name`, `size`, `width`, `height`, `duration_ms`, `tokens`, the
`regions` carrying it, and `stored`, which is false for a part the context names but the run
directory no longer holds. `GET /api/agents/{id}/blobs/{sha256}` serves one part's bytes under its
own `Content-Type`, so an `<img src=...>` pointed at it renders, and `?download=1` adds a
`Content-Disposition: attachment` carrying the part's name.

Both byte routes advertise `Accept-Ranges: bytes` and honour a single-range `Range` request, so a
player can scrub a video or a client can resume a download. `Range: bytes=1024-2047` is answered
`206 Partial Content` with a `Content-Range: bytes 1024-2047/<total>` header and just those bytes;
an open end (`bytes=1024-`) or a suffix (`bytes=-4096`, the last 4 KiB) works too. A range that
starts past the end is `416 Range Not Satisfiable` with `Content-Range: bytes */<total>`. A
malformed or multi-range header is ignored and the whole body served.

`GET /api/agents/{id}/files/raw?path=` does the same for any file inside the working directory,
typed by the registry from its bytes and name, where the JSON `files` route wraps text. A path the
directory does not hold but the answer lists as an artifact is served from the blob store instead,
so a file a model made and nothing wrote to disk answers here too.

`GET /api/agents/{id}/artifacts/{name}` is the route that follows an artifact the way the runtime
does: by the name the answer's `artifacts` list it under. It looks in the blob store by hash first
and the working directory second, and serves the bytes under the artifact's own mime type. It is
the one to use when a client has the answer in hand. The answer's `artifacts` carry each file's
`path` and, when the run could store it, its `sha256`, so the other two routes reach the same
bytes.

## A run's files

`GET /api/agents/{id}/files` answers two different questions, and neither substitutes for the other.

`source=modified` (the default) is the run's own record of what it changed. It is free, but it is a
claim about the run rather than about the disk, and it is capped when recorded, so check
`modified_files_truncated`.

`source=workdir` reads the filesystem, **one directory level per request**; pass a directory as
`path` to descend. That bound is deliberate: a workdir containing `node_modules` cannot be
enumerated in one response, so walk it the way a file tree does.

> [!WARNING]
> `modifying_tool_calls` counts modifying tool *calls*, not files. A run that edits one file three
> times records three. Do not subtract it from the entry count to get "how many more files";
> that number is meaningless. Use `modified_files_truncated`, or `source=workdir` for ground truth.

Every listing entry carries `name`, `path`, `is_dir`, `size`, `exists`, `outside_workdir`, and
`mime_type`. The last is what the run's registry makes of the file from its name. A client can then
decide whether to render an image, or offer a file to a region that `accepts` a type, without a
request per row or a guess of its own. It is typed by extension only, not sniffed, and is empty for
a directory. `GET .../files/raw` types the same bytes, sniffing them, when an exact answer is
needed.

With `?path=<file>` the response is the file's contents, unchanged from earlier versions. A listing
carries `"kind": "listing"`, so check that field rather than guessing from the shape.

### Reading a file larger than one response

One request returns at most 1 MiB. A run's dataset can be far larger than that, so read it a window
at a time with `offset`:

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/agents/$RUN/files?path=data/dataset.csv&offset=0"
```

Each response carries `next_offset`. Ask again from there until it comes back `null`, and
concatenate the windows to get the file back exactly.

An offset landing inside a multi-byte character is moved forward to the next boundary, and `offset`
in the response says where the window actually began. That is what keeps the pieces lining up. An
offset past the end of the file returns 416 rather than an empty window, so a loop cannot spin.

A whole-file read serializes exactly as it always has. `offset` is omitted when it is zero.

## Fan-out limits

A [fan-out stage](/docs/sub-agents#fan-out) has two caps, and neither is the stage's
`max_iterations`. `max_workers` is how many workers run at once and `max_items` is how many work
items the split may produce at all. `GET /api/blueprints/{name}` reports both for every fan-out
stage, resolved the way the daemon will apply them:

```json
{
  "name": "reviewer",
  "fan_outs": [
    {
      "stage": "split_review",
      "worker_stage": "review_worker",
      "merge_stage": "deep_review",
      "max_workers": 30,
      "max_items": 30,
      "on_worker_failure": "continue",
      "results_region": "worker_findings"
    }
  ],
  "regions": ["…"],
  "manifest": "…"
}
```

`max_workers` is the default (30) when the manifest names none, and `null` when the stage is
unlimited. `max_items` is `null` when there is no ceiling. Whichever of `worker_agent`,
`worker_stage` or `worker_query` the stage uses is the one present. A blueprint that never fans out
has an empty list. `blueprints.fan_outs` in the `capabilities` list on `GET /api/config` says the
daemon reports this.

Changing a cap is a manifest write: `PUT /api/blueprints/{name}` with the manifest text, the stage's
`max_workers` or `max_items` set to the number you want, or to `0` for no cap at all. `POST
/api/blueprints/validate` will tell you first if the value is not a whole number or is negative,
which are errors rather than quiet fallbacks. The workers still share the daemon's inference pool
(`[limits] max_concurrent_inferences`, 8 by default), so an unlimited fan-out queues at the model
rather than running away.

### Stage routing

`GET /api/blueprints/{name}` also carries `stage_routing`: one entry per stage that routes the
model's produced parts to a region by mime type (`output_routing`), or empties a region when it is
entered (`context.reset`). A console shows or checks them without parsing the manifest.

```json
{
  "stage_routing": [
    {
      "stage": "draw",
      "output_routing": [{ "pattern": "image/*", "region": "artwork" }]
    },
    {
      "stage": "describe",
      "context_reset": ["conversation"]
    }
  ]
}
```

`output_routing` is ordered by pattern, and a part goes to the most specific match's region. Only
stages that do one or the other appear; a stage that does neither is left out, and a blueprint that
does neither has an empty list. Changing either is a manifest write through `PUT
/api/blueprints/{name}`. `blueprints.stage_routing` in the `capabilities` list on `GET /api/config`
says the daemon reports this.

`dependencies` on the same detail route lists what the agent declares it needs before it runs (see
[dependencies](/docs/agents#dependencies)). Each entry has a `name`, a `kind` (`mcp_server`,
`env`, `binary` or `script`), whether it is `required`, and an optional `remedy` and `description`.
It also carries the kind-specific field (`server` and `env`, `var`, `command`, or `check`), and
`installable` for whether the blueprint says how to set it up. A console can show these and warn
before a spawn that would fail the dependency gate. `blueprints.dependencies` in the `capabilities` list says the daemon
reports this.

## Yolo profiles

The profiles a run can be launched under with `--yolo=<name>` live in
[`yolo.toml`](/docs/yolo) beside the config, and these four routes are `lev
yolo` over HTTP. Every one of them reads the file as it stands at that moment, the same way a
spawn does, so what they report is what the next run gets.

`GET /api/yolo` lists what is there:

```json
{
  "path": "/home/you/.leviath/yolo.toml",
  "exists": true,
  "profiles": [
    {
      "name": "careful",
      "default": "ask",
      "questions": "ask",
      "checkpoints": "ask",
      "gate": "auto",
      "tool_rules": [1, 2, 0],
      "shell_rules": [3, 1, 1]
    }
  ]
}
```

`tool_rules` and `shell_rules` are `[allow, ask, deny]` counts. A file that does not load comes
back with `exists: true`, an `error` naming the line, and no profiles, because that is what a
spawn naming one would be refused with. A missing file is `exists: false` with no error.

`GET /api/yolo/{name}` is one profile in full: `{"name", "spec", "holds"}`. `spec` is the profile
as parsed, with the same keys the file has. `holds` is the list of things this profile still puts
to a person, as `lev run` prints before a run starts. `404` for a name the file does
not have, and for no file at all; `422` when the file does not load.

`POST /api/yolo/test` asks what a profile would decide for one call, without running anything:

```json
{
  "profile": "careful",
  "tool": "shell",
  "command": "rm -r target/debug",
  "workdir": "/home/you/project"
}
```

`command` is for the shell; any other tool takes its `arguments` as an object. `workdir` is where
relative paths in the command resolve, defaulting to the server's own. `configured` (`allow`,
`ask` or `deny`) stands in for what the config layers resolve the tool to. Without it, that comes
from the config in force. `kind` (`builtin`, `subagent`, `script`, `mcp`) says where the tool
comes from for `@group` rules. Without it, the kind is guessed from the name. `allowed: true`
decides as if `--allow <tool>` had been passed. The answer:

```json
{
  "profile": "careful",
  "tool": "shell",
  "configured": "ask",
  "policy": "allow",
  "reason": "shell allow rule \"rm -r*\""
}
```

`policy` is what the run would do: `allow` runs it without a prompt, `ask` opens the ordinary
approval prompt, `deny` refuses it. `reason` names the rule, the config, or the profile's default
that decided it. This is the same code path the daemon runs on a real call, so a decision here is
the decision a run would make. `400` for a `kind` or `configured` word that is not one of the
listed values, or `arguments` that are not an object.

`PUT /api/yolo` replaces the whole file: `{"text": "<the file, as TOML>"}`. The text is parsed
first, and a save that would not load is refused with `400` and the same message a spawn would
give, leaving the file on disk as it was. It answers with the listing `GET` returns. Admin only,
for the reason `PUT /api/config` is: a profile is a grant of permissions.

## Asking how to upgrade

`GET /api/update` answers how this copy of Leviath was installed and what command brings it
up to date. Like `GET /api/tools`, it exists because the answer is a fact about the machine
that no client can work out for itself, and guessing it wrong is worse than not saying.

The body is exactly what `lev update --check --json` prints, from the same planner:

```json
{
  "version": "0.4.0",
  "install_method": "scoop",
  "channel": "stable",
  "binary": {
    "action": "run",
    "commands": [["scoop", "update"], ["scoop", "update", "leviath"]],
    "command": ["scoop", "update", "leviath"]
  },
  "agents": [],
  "migrations": [],
  "config_error": null,

  "latest": "0.4.2",
  "update_available": true,
  "checked_at": 1787438706
}
```

`install_method` is one of `homebrew`, `scoop`, `cargo`, `script` or `unknown`. `binary.action`
is either `run`, carrying a `commands` list of argv lists to run in order, or `advise`, carrying
a `message` to show instead. A `cargo install` copy is always `advise`: rebuilding it is a full
compile, which is not something to start on someone's behalf. So is a binary sitting somewhere
no installer puts one, where the honest answer is to point at the install docs.

Render `binary.commands` rather than composing your own. That is the whole point of the route:
a client that hard-codes one package manager's command is right for the users who happen to
share its author's machine and wrong for everyone else. Where a daemon does not announce
`update.plan`, send people to the install page rather than picking a package manager for them.

`latest` is the newest version on this copy's own channel, and `update_available` says whether
that is newer than the version it is running. `checked_at` is when the daemon last found out, in
unix seconds, so you can say how fresh the answer is rather than presenting an hour-old one as
current. All three are `null` together when the check has not run yet, could not reach the
network, or had no channel to ask about. That is one state, "cannot tell", which is the honest
thing to render. Treat a missing key as an older daemon and a `null` key as an answer.

The daemon looks this up on its own schedule and the route reports whatever the last lookup
found, so asking on every page load costs nothing and never waits. The lookup runs the same code
`lev update` does, against the releases published for each channel, so the console and the
terminal cannot come to different conclusions about the same binary.

Do the comparison with `update_available` rather than against `version` yourself. A client that
compares against a number it was built with only knows the stable line, so it reports a daemon on
`alpha` or `beta` as out of date for running something newer.

The route is read-only and available without `--allow-admin`. It works out what an update would
do and does none of it, makes no network call on the request path, and cannot run a command even
if asked.

## Pressing the button

`POST /api/update` carries out the plan the `GET` prints: it runs `binary.commands` in order,
installs the blueprints the plan marks `preselected`, and applies the migrations. It needs
`lev serve --allow-admin`, which is the line it crosses and the read half does not. It runs a
package manager, replaces the blueprints in your agents directory and rewrites your config.

The body names which parts to do. Every field defaults to `true`, so an empty body is the whole
plan and a body naming one part leaves the others on. A field this route does not know is a `400`
rather than a silent default:

```json
{ "binary": true, "agents": true, "keys": true, "migrations": false }
```

It answers `202` straight away, with the id to watch:

```json
{
  "job_id": "update-1787438706-1",
  "status": "running",
  "applying": { "binary": true, "agents": true, "keys": true, "migrations": false }
}
```

An upgrade is a download and an install: a minute on a good day, and it can fail halfway. The
request does not stay open for it. Watch `/ws`, where each step change arrives as it happens:

```json
{ "type": "update_progress", "job_id": "update-1787438706-1", "step": "binary",
  "status": "running", "detail": "running `scoop update && scoop update leviath`" }
```

`step` is `binary`, `agents`, `keys` or `migrations`, always in that order, and `status` is one
of `running`, `done`, `skipped`, `advised` or `failed`. The `keys` step respells blueprint keys
that changed name, in the blueprints you wrote rather than the bundled ones. The last frame is `update_finished`, carrying
the whole record so a client that connected mid-run needs no follow-up request. Both frames are
about the machine rather than a run, so `/ws` receives them and a per-run subscription does not.

`GET /api/update/jobs/{id}` answers that same record, for a client that would rather poll than
hold a socket open:

```json
{
  "id": "update-1787438706-1",
  "status": "complete",
  "steps": [
    { "step": "binary", "status": "done", "detail": "ran `scoop update && scoop update leviath`" },
    { "step": "agents", "status": "done", "detail": "installed researcher, coder" },
    { "step": "keys", "status": "skipped", "detail": "every blueprint uses the current key names" },
    { "step": "migrations", "status": "skipped", "detail": "not asked for" }
  ],
  "restart_required": true,
  "restart_hint": "the new binary is on disk, but this server and the daemon it talks to are still running the old one...",
  "started_at": 1787438706,
  "finished_at": 1787438771
}
```

The last few runs are kept, so reading back after the fact finds the job rather than a `404`.
One update runs at a time: a second `POST` while one is going is a `409` naming the job already
running, not a second package manager over the same binary.

### What it will not do

`binary.action == "advise"` stays advice. A `cargo install` copy is a full rebuild of the
workspace, and a binary somewhere no installer writes is not something to guess at. Both are
yours to do, so the step is recorded as `advised` with the plan's own sentence and no compile is
started. That is neither a success nor a failure: the job carries on to the other two steps and
still finishes `complete`.

A blueprint you edited locally is never installed. Installing removes the destination directory
first, so it would take your edits and any file you added with them. `lev update` asks about each
one on its own and no flag covers it, and there is nobody to ask over HTTP. The `agents` step says
how many it left alone and why.

A binary step that *fails* stops the two after it, the same way `lev update` stops there: the
blueprints and the config worth having are the ones the new binary ships. A failed blueprint
install does not. It is named in the step's detail and the run carries on. Most of the blueprints
plus a named failure is a better place to be left than a step that gave up in the middle.

### The restart

Upgrading replaces the binary on disk. The daemon answering the request is the old one and stays
the old one, and so does `lev serve`, until each restarts. A console that updates and then
reports the version it can see has told the truth in the least useful way possible.

`restart_required` is `true` when the binary step actually ran and succeeded, and `restart_hint`
carries the sentence to show. Say it; do not report the running version as the result of the
update. Restarting `lev serve` picks up the new binary, and `lev daemon restart` does the same for
the daemon. Any `lev` command also does that on its own, since the daemon's build marker is
checked before a run is spawned.

## Tools and scripts

`GET /api/tools` answers what an agent on **this** machine can call, which is not a question a
client can answer for itself. Every entry carries a `source`:

| `source` | Means |
|---|---|
| `builtin` | Compiled into this Leviath. Every agent has it |
| `subagent` | A sub-agent tool, for an agent that may spawn children |
| `agent` | A `.rhai` in that agent's own `tools/`. Only that agent has it |
| `global` | A `.rhai` in `~/.leviath/tools/`. Every agent on the machine has it |

Pass `?agent=<name>` to include the fourth. Every entry also carries the `description` and the
`arguments` schema the model is given, so a picker shows what a tool takes without compiling
anything. Script-backed entries carry the `path` they came from and the capabilities they
`requires`. A separate `skipped` list carries the `.rhai` files that were found and cannot be offered,
with the reason each was passed over. A file with a syntax error is then told apart from a file
nobody wrote. MCP tools are not here: they depend on a server being reachable rather than on
anything installed, and `/api/mcp/servers/{name}` already answers for them.

`GET /api/scripts` is the same ground from the editor's side, over the six kinds of Rhai a machine
can carry: `tool`, `region_hook`, `stage_hook`, `output_validator`, `mime_check` and `provider`.
Only tools have a directory an agent owns: `<agent>/tools/`, plus the global one. The hooks and
the validator are named by path in the manifest and resolved against the agent's own directory.
The listing derives them from what the manifest declares, and the read and write routes address
them at `<agent>/<name>.rhai`.

A [mime check](/docs/rhai-mime-checks) is named by a mime row's `check`, and rows live in two
places, so the kind is listed from both. The operator's rows (`mime_types.toml` and
`[mime_types]` in the config) put their checks in the global half, resolved against the config's
directory. A blueprint's own `[mime_types]` puts its checks beside the agent's hooks. Address
one with `?agent=<name>` for the blueprint's, or without for the operator's. Check
`scripts.mime_checks` in the `capabilities` list before offering the kind.

Every entry carries a `declared` flag, and an agent-scoped one also carries `relative_path`: where
the file sits relative to the agent's own directory, `validators/a2ui.rhai`, which is the spelling
that goes into a manifest. A global mime check carries it too, relative to the config's directory,
since that is the spelling that goes into the row. A global tool or a provider has no
`relative_path`, since nothing names either by path.

`GET/PUT/DELETE /api/scripts/{kind}/{name}` reads and writes one file, scoped by `?agent=<name>` or,
with no `agent`, the machine's own directory for that kind. `{name}` is the file without its `.rhai`
extension, and it may be a relative path when the manifest declared one: percent-encode the
separator, so `validators/a2ui.rhai` is `output_validator/validators%2Fa2ui`. Every part of it may
hold only letters, digits, `.`, `_` and `-`. The result has to land inside the directory the route
is fenced to, once symlinks are followed. A declaration that climbs out of the agent's directory
is left out of the listing, as is one that names something that is not a `.rhai` file. The
alternative would be reporting it under a name that would fetch a different file.
`POST /api/scripts/validate` takes `kind` and `content` and compiles without writing, so an editor
can check before saving instead of saving and waiting for a run to fail.

### Offering a file nobody has named yet

The listing above answers "what will load", which is circular for a picker: a hook or a validator
appears once the manifest declares it, and declaring it is the thing the picker exists to do. So
`GET /api/scripts?agent=<name>&include=candidates` adds the other half, the `.rhai` files under that
agent's directory that nothing declares:

```json
{
  "kind": "unknown",
  "name": "validators/draft",
  "source": "agent",
  "agent": "picker",
  "path": "/home/you/.leviath/agents/picker/validators/draft.rhai",
  "relative_path": "validators/draft.rhai",
  "declared": false
}
```

`kind` is `unknown` because nothing about the file says which of the four agent-owned kinds it is;
the declaration says that, and it has not happened yet. `unknown` is not a `{kind}` the read and
write routes accept, so a client picks a real one to open the file with. `compiles` is absent
for the same reason: which compiler would have to accept it is not yet decided. Write
`relative_path` into `validator = "..."` or a `[stages.<name>.hooks]` entry and the next listing
reports the same file as declared.

Without the parameter the listing is exactly what it was, so a client that reads it as "what will
load" keeps getting that. `include` takes a comma-separated list and refuses a token it does not
serve, rather than answering a misspelling with a short list. With no `?agent=` it changes nothing:
both global directories are already listed file by file, so nothing there is undeclared. Check
`scripts.candidates` in the `capabilities` list before offering the picker.

The scan is bounded and stays inside the agent's directory: four levels deep, 128 directories and
256 files per request, `.rhai` files only, and no symlink is followed out of the agent's own
directory. A file whose name could not be written into a manifest, because of a space or a character
that is not a safe path component, is left out rather than guessed at.

> [!WARNING]
> `PUT` and `DELETE` are **not mounted at all** without `lev serve --allow-admin`, exactly like the
> MCP add/remove routes and `PUT /api/config`. A `.rhai` file is executable code every agent then
> runs, so a session that can write one can run code on the host. The `GET` routes stay open, so an
> editor degrades to read-only rather than disappearing.

A write that does not compile is still saved, with `compiles: false` and the compiler's complaint in
the response. A draft is worth keeping, and a tool that does not compile is skipped at spawn rather
than breaking the agent.

### Providers

A [provider](/docs/rhai-providers) is the one kind no agent owns. It lives in `~/.leviath/providers/`
and a stage reaches it by name, so these routes take **no** `?agent=` and refuse one rather than
writing a file into an agent's directory that nothing would ever load. Providers are listed with or
without an `agent`, since the answer is the same either way.

Each listed provider carries a `provider` object with what its leading `// @` comments declare:
`description`, `default_model`, `max_context_tokens`, `max_output_tokens` and `supports_streaming`.

## The model catalogue

`GET /api/models` answers from a listing the server keeps, not from the providers on each
request. The providers are asked once per config, side by side, each given five seconds. A
complete listing is served for fifteen minutes, and one missing a provider's answer for one
minute. After that, the next request gets the list in hand and starts a refresh behind it.
Only the very first request for a config waits for the providers, and that wait is bounded.
A `PUT /api/config` starts the refresh for the new config as it returns.

Two headers say what you got. `X-Leviath-Catalog-Age` is how many seconds ago the listing
was built. `X-Leviath-Catalog-Complete` is `true` when every provider answered when it was, and
`false` when one timed out, errored, or could not be built. That provider's models are then
absent. `?refresh=1` asks the providers again and waits for them, for a settings page that
has just changed a key and wants to show the result rather than the memory of the old one.
Announced as the `models.cached` capability.

## Two providers, one model id

`GET /api/models` returns a flat list, and each entry carries the `provider`
that serves it. That matters more than it looks: **`openai` and `codex` serve
the same model ids.** Both answer to `gpt-5.5`. They bill to entirely
different places: one to an API balance, one to a ChatGPT subscription.

So `id` is not a key. `provider` + `id` is:

```json
[
  { "id": "gpt-5.5", "provider": "openai", "max_context_tokens": 400000 },
  { "id": "gpt-5.5", "provider": "codex",  "max_context_tokens": 400000 }
]
```

Measured against a live daemon signed in to both, that is four ids each served
twice: `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra` and `gpt-5.6-luna`.

A client keying a picker on `id` alone silently collapses those two into one
row, and whichever it kept decides what the user pays. Build the key the way a
blueprint names a model: `provider/id`, the same `codex/gpt-5.5` that goes in
`models = [...]`.

`?provider=` asks the server instead:

```
GET /api/models?provider=codex     only the subscription's models
GET /api/models?provider=openai    only the keyed ones
```

A provider this machine has not configured lists nothing rather than 404ing.
The set of providers is whatever the config has, so "no models" is the honest
answer to asking about one it does not have.

`pricing` is `null` on a Codex entry, and on any other model whose provider
did not quote a rate in its listing. It reports what the *listing* said, not
what a run would be billed at, so a console should not read `null` as free.
For Codex it happens to be free, because a subscription has no per-call price
and a run on it reports `cost_usd: 0`. The two facts arrive by different routes.
See [where a run's cost went](#where-a-runs-cost-went) for the figure that is
actually charged.

## Signing in to a subscription provider

Most providers take an API key, which is a string a form can post. One does not: Codex bills a
ChatGPT subscription and its credential is an OAuth grant, taken in a browser and stored outside
`config.toml` entirely. `PUT /api/config` can turn it on and can never sign anybody in, so a
console that only wrote the flag would leave the user enabled and unable to run anything.

These four routes are the rest of it.

```
GET  /api/providers                    what exists, and what state it is in
POST /api/providers/{name}/login       start the browser flow            (admin)
POST /api/providers/{name}/logout      forget the grant                  (admin)
POST /api/providers/{name}/check       prove the grant still works       (admin)
```

`GET /api/providers` is open to any caller holding the bearer token. It reports the account
address and the plan tier, the same facts `lev auth status` prints, and never a token. Two
providers sign in this way: `codex` (a ChatGPT plan) and `grok` (a SuperGrok or X Premium+ plan).

```json
{
  "providers": [
    {
      "id": "codex",
      "display": "OpenAI Codex",
      "enabled": true,
      "signed_in": true,
      "account": "someone@example.com",
      "plan": "plus",
      "expires_at": 1735689600
    }
  ]
}
```

### Subscription usage

`GET /api/providers?quota=true` adds a `quota` object to each signed-in provider that is enabled:
`{"report": {...}}` with what the subscription has used, or `{"error": "..."}` when the account
could not be read. It is off by default because it is a reading of the accounts rather than of
this machine, and a console polls this route while a sign-in is waiting.

```json
"quota": {
  "report": {
    "plan": "plus",
    "windows": [
      { "label": "5h", "used_percent": 42.0, "resets_at": 1735700000 },
      { "label": "week", "used_percent": 12.5, "resets_at": 1736200000 }
    ],
    "limit_reached": false
  }
}
```

A window carries `used_percent` when the provider reports a share (Codex), or `used`, `limit`
and `unit` when it reports amounts (Grok, in credits). `balance` is a prepaid balance as the
provider words it, when there is one. `lev providers quota --json` prints the same reports.

The accounts are asked side by side, each given five seconds, so the reading is bounded by five
seconds however many subscriptions are signed in. One that does not answer inside that carries
`{"error": "the account did not answer within 5s"}` and the rest of the reading still arrives;
nothing holds the response.

The answer comes from a reading the server keeps, not from the accounts on every request. A
complete reading is served for one minute and one missing an account's answer for fifteen
seconds, after which the next request gets the reading in hand and starts a new one behind it.
Two headers say what you got: `X-Leviath-Quota-Age` is how many seconds ago the accounts were
read, and `X-Leviath-Quota-Complete` is `true` when every one of them answered. `complete` names
no provider, so the per-provider `error` is the one to read; the header is there to say whether
asking again is likely to help. `?refresh=1` reads the accounts again and waits, for a "check
again" button, and means nothing without `quota=true`. Signing in or out gets its own reading
rather than the one taken before it, so a console does not have to invalidate anything itself.

Announced as the `providers.quota` capability. Safe to ask for whenever the providers page
opens.

`enabled` and `signed_in` are separate on purpose. Either can be true alone, they are set by
different routes, and the combination that breaks runs, enabled but not signed in, is the one a
console has to be able to see. `expires_at` is when the *access* token lapses, and it is refreshed
automatically well before that. It is here so a UI can show that the session is live, rather
than implying anybody has to act on it.

### The flow

`POST .../login` does not hold the request open for the whole sign-in. It answers as soon as
there is a URL, which is immediately, and the flow carries on behind it:

```mermaid
sequenceDiagram
  participant UI as Console
  participant Serve as lev serve
  participant Browser as Browser on the serving host
  participant OpenAI

  UI->>Serve: POST /api/providers/codex/login
  Serve->>Browser: open the authorize URL
  Serve-->>UI: 202 {status: waiting, authorize_url}
  Note over UI: render the URL, in case no browser opened
  Browser->>OpenAI: sign in
  OpenAI->>Serve: redirect to localhost:1455/auth/callback
  Serve->>OpenAI: exchange the code
  loop until it settles
    UI->>Serve: GET /api/providers
    Serve-->>UI: signin.state = waiting
  end
  UI->>Serve: GET /api/providers
  Serve-->>UI: signed_in: true, no signin state
```

The MCP login route does hold its request open, for up to five minutes. That is fine for a CLI
and wrong for a browser. The tab has nothing to draw while it waits, no way to show the URL, and
no way to give up without losing the flow. One extra poll buys a UI that can render the whole
thing.

While it runs, the provider carries a `signin` object:

```json
{ "state": "waiting", "authorize_url": "https://auth.openai.com/oauth/authorize?…",
  "started_at": 1735689000 }
```

and if it does not finish:

```json
{ "state": "failed", "message": "could not listen on port 1455 or 1457 (…)", "at": 1735689100 }
```

A success leaves no `signin` at all. The grant store is the answer, and a second copy of "signed
in" is how two answers come to disagree. A failure stays until the next attempt, so a console
that was not watching still finds out what happened.

Starting a second sign-in while one is waiting answers `409`, with the first one's
`authorize_url` in the body. A console that lost the response can pick the flow back up instead
of having to cancel it.

### The browser has to be on the serving host

The redirect goes to `localhost:1455` on the machine running `lev serve` and nowhere else,
because that is the address OpenAI registered the public client id against. Leviath cannot choose
a different one.

For a console driving a daemon on the same machine, this is invisible: the browser opens and the
callback lands. For a remote daemon, the flow still starts and `authorize_url` still comes back,
but somebody has to open that URL on the daemon's host. Show it rather than relying on the
opener, which silently does nothing over SSH and in a bare console.

### Checking, and why the check is real

`POST .../check` asks the account, not a table:

```json
{ "status": "ok", "provider": "codex", "models": ["gpt-5.6-sol", "gpt-5.6-terra", …] }
```

Codex's model list is compiled into the build, so a check that read it would answer "5 models"
for a revoked session, an expired one, and for never having signed in. This one makes an
authenticated call, so a `200` means the subscription really did agree, and the `models` are the
ones *this plan* can reach. A refusal is a `502` carrying the provider's own reason.

It also refreshes a lapsed access token on the way in, which makes it the cheapest way to keep a
rarely-used sign-in alive.

### Turning it on

Signing in and enabling are two separate acts, and `logout` deliberately does not do both.
Signing out is not the same as turning the provider off. `PUT /api/config` carries the setting:

```json
{ "codex_enabled": true, "codex_reasoning_effort": "medium", "codex_verbosity": "medium" }
```

`codex_reasoning_effort` takes `none`, `minimal`, `low`, `medium`, `high` or `xhigh`;
`codex_verbosity` takes `low`, `medium` or `high`. Both are validated before anything is written,
because the provider silently ignores a value it does not recognise. A typo would otherwise be
saved, read back by `GET /api/config`, and quietly do nothing.

`ollama_enabled` turns Ollama on, and `GET /api/config` reports it. It is
opt-in like everything else: off, no run registers it. Setting
`ollama_base_url` counts as choosing it too, and the `GET` reports `true` for
either. A console asking "is Ollama on" wants one answer, not two fields to
reconcile.

`codex_replay_reasoning` is on by default and worth leaving on. It is writable so it can be
turned off from a console the day the route stops accepting a replayed reasoning blob, without
editing `config.toml` by hand on the serving machine.

The three write routes need `--allow-admin`, like the MCP login they mirror: they open a browser
on the serving host, delete a credential, and make an authenticated outbound call. The read half
is always mounted, so a console without admin can still show what is signed in and offer nothing
else.

## When the config file will not load

`GET /api/config` describes the config **in force**, which is not always the file on disk. A save
that does not parse, or that sets a value Leviath refuses, leaves the last config that loaded in
force: runs keep starting on it, and nothing about them changes. Without a way to say so, that
looked exactly like an edit nobody had made.

`config_error` is how the server says so. It is absent while the file loads, and present with the
whole story while it does not:

```json
{
  "default_provider": "anthropic",
  "config_mtime": 1756000000,
  "config_error": {
    "kind": "parse",
    "path": "/Users/you/.leviath/config.toml",
    "message": "expected `.`, `=`",
    "line": 12,
    "column": 8,
    "since": 1756000420,
    "note": "The running config is the last one that loaded; edits to this file take effect only once it parses again."
  }
}
```

`kind` is `parse` for a syntax error or a value of the wrong type. It is `validation` for a value
that parsed and was then refused, and `read` for a file that could not be read at all. A parse failure
carries `line` and `column`, both 1-based; a validation failure carries `key` instead, the dotted
config key it is about, such as `model_providers.local`. `message` is one line with no caret art in
it, ready to put in a banner. `since` is when this server first saw the file in this state, in unix
seconds.

`config_mtime` is always there, error or not: it is the mtime of the config in force, so a client
that just wrote the file can tell whether the write was picked up. While `config_error` is set it
names the last good save rather than the file on disk.

Nothing has to be restarted. Fix the file, and the next request answers with no `config_error` and
the new values. Announced as the `config.health` capability; a server that does not announce it
omits the field whether or not the file loads, so its absence proves nothing there.

`PUT /api/config` is checked before it writes, with the same rules the loader applies, so this API
cannot be the thing that breaks the file. A body that would produce a config this build refuses to
read back answers **400**, and the file is left byte for byte as it was.

## Override and fallback models

`override_model` on `GET /api/config` is the model every stage that allows a user default starts on
while it is set, ahead of what its blueprint names. `fallback_model` is the model a stage falls back
to when none of the models it names is configured on that machine, never ahead of them. Both are
bare model ids on `default_provider`. Both are always sent, `null` included, and that is the point:
a server that omits a key predates the field, which is a different answer from "nothing is set".
Read the absence as "cannot say" and show that, rather than showing an empty picker over a machine
that has a model pinned. Servers before 0.6 sent `default_model` instead, which behaved as
`override_model` does.

On `PUT /api/config` each key has three states, which the other fields in that body do not have:

| Body                          | What happens                                       |
|-------------------------------|----------------------------------------------------|
| no `override_model` key       | the setting is left exactly as it was              |
| `"override_model": null`      | the setting is written away                        |
| `"override_model": "gpt-5"`   | the setting is pinned to `gpt-5`                   |

`fallback_model` reads the same way, and so do the seven provider keys (`anthropic_key`,
`openai_key`, `google_key`, `xai_key`, `meta_key`, `openrouter_key`, `bedrock_key`). `null` clears
a key, which takes the provider out of this install the way the setup wizard's remove does. An
empty string is a 400. Clearing `override_model` matters as much as setting it. A pinned model
runs every stage of every blueprint on that one model, and the cheap stages then pay a top-tier
price. Unset is the state most machines want: see
[which entry a stage starts on](/docs/providers#which-entry-a-stage-starts-on). Sending `null` is
the only way to get back there through this API, in the same way `remove_gateways` is the only way
to delete a gateway.

`"override_model": ""` (or `"fallback_model": ""`) is a **400**, not a clear. An empty string is not
a model id, and a form that posts its empty box should be told rather than quietly lose the
setting. Nothing is written when it is refused.

`default_provider` takes no `null`. It is not optional in `config.toml`, because a machine always
has one, defaulting to `anthropic`. There is no unset state to write, and sending a different name
is the whole vocabulary.

## Gateways

`gateways` on `GET /api/config` lists every `[model_providers.<name>]` entry, name-sorted, and
each one says what backs it: `kind` is `script` for a Rhai provider or `openai-compatible` for a
server that speaks OpenAI's chat API. Beside `name`, `base_url`, `has_api_key` and `script`, an
endpoint reports `header_names` and `models`. `header_names` holds the names of its extra headers,
never their values, because a header is where a second credential goes. `models` holds the ids it
falls back to when its server will not list them. `extra_keys` names a script's forwarded keys
the same way.

`PUT /api/config` takes the same fields on each gateway in `gateways`: `kind`, `base_url`,
`api_key`, `script`, `headers` (a name-to-value map) and `models`. Every field is optional and an
absent one leaves what the entry already had, so a console can edit a URL without knowing the
key or sending the headers back. An unknown `kind`, or an `openai-compatible` gateway with no
`base_url`, is refused with a 400 and nothing is written.

`POST /api/models/probe` is for the form before the write: it sends `GET /models` to `base_url`
with the given `api_key` and `headers`, exactly as the gateway would, and answers
`{"models": [ids]}` sorted. A server that refuses or does not answer is a 502 whose `error` is the
server's own text, and a `base_url` with no scheme is a 400. It is mounted only with
`--allow-admin`, like the write it precedes: it makes the serving host open a connection to any
address the caller names.

Each entry from `GET /api/models` also carries `limits_source`: `api` when the provider reported the
token limits itself, `builtin` when this build matched them off the model's name, and `override`
when a `[model_capabilities]` entry set them. Read it before treating a window as a fact. A
`builtin` figure for a model the table does not know is a guess, and region budgets resolve against
it. Beside it: `supports_temperature` and `supports_tools`. `learned` is true when the provider's
own listing described the model, and false for a row from this build's table. When the listing
carries them, `released` (Unix seconds), `retires` (the date the provider published) and `pricing`
(USD per million tokens: `input_per_mtok`, `cached_input_per_mtok`, `cache_write_per_mtok`,
`output_per_mtok`) come too, each `null` otherwise. Two lists say what the model takes and hands back:
`input_types` and `output_types`, mime type patterns such as `text/*`, `image/*` or
`application/pdf`, from this build's table corrected by the provider's listing and by
`[model_capabilities]`. A stage holding an image picks a model whose `input_types` cover it; see
[More than text](/docs/mime). Which providers can report what, and from where, is in
[where a model's capabilities come from](/docs/configuration#where-a-models-capabilities-come-from).
That is what lets a console show the catalog without fetching and re-parsing every script. No other
kind carries the key at all.

Validation checks more than syntax here. A provider needs `initialize(config)` and
`inference(state, request)`, and a script defining only the first used to compile, initialize, cache
and then fail at the first inference, part-way into a run. `POST /api/scripts/validate` with
`kind: "provider"` answers that before the file is saved, and the loader refuses the same script
rather than accepting one the API called invalid. Nothing runs during validation: `initialize` is
read off the compiled AST, never called.

`GET` returns the source verbatim. A provider's key comes from `initialize(config)`, which is the
`[model_providers.<name>]` table `GET /api/config` already reports as a boolean plus a list of key
names, or from `env_var`, which reads the daemon's environment. Neither value is in the file, so
there is nothing here for redaction to protect, and an editor that saved what it was shown would
write the redaction back over the real script.

Check `scripts.providers` in the `capabilities` list before offering the kind.

## Feature detection

`GET /api/config` reports `api_version`, a `capabilities` list, and the server's `limits`. Check
those instead of calling a route and treating a 404 as "unsupported": a 404 also means "no such
run", and it costs a round trip per feature. The limits matter as much as the capability names: they
are where the page cap, file cap and listing cap actually live, so a client never has to hardcode
one.

`runs.delete` and `runs.delete.bulk` are the ones worth checking before drawing a button rather than
after clicking it. Finding out whether the other routes exist costs a wasted request; finding out
this way costs a deleted run.

### What each capability means

Every string this server can announce, and the thing it promises. A server that omits one is older
than that feature, not broken.

| Capability | The server has |
|---|---|
| `runs.envelope` | `GET /api/runs` answering `{items, next_cursor, total, server_time}` rather than a bare array |
| `runs.cursor` | Keyset paging on that route: `cursor=` in, `next_cursor` out |
| `runs.search` | `q=`, a case-insensitive substring over the run listing |
| `runs.search.context` | `q_in=context`, searching each run's current context window |
| `runs.search.logs` | `q_in=logs`, searching the tail of each stage's logs |
| `runs.search.journal` | `q_in=journal`, searching the whole run journal |
| `runs.fields` | `fields=`, trimming each item to the named top-level fields |
| `runs.ids` | `ids=a,b,c`, fetching exactly those runs in one request |
| `runs.since` | `since=`, filtering on whichever timestamp `sort` names |
| `runs.parent` | `parent=none` / `parent=<run_id>`. See [listing by place in the tree](#listing-by-place-in-the-tree) |
| `runs.files.listing` | `GET /api/agents/{id}/files`, the run's own record of what it changed |
| `runs.files.workdir` | `source=workdir` on that route, reading the filesystem a directory at a time |
| `runs.files.mime_type` | `mime_type` on every file-listing entry, typed by the run's registry from the file's name |
| `models.mime_types` | `input_types` and `output_types` on every `GET /api/models` entry: the mime type patterns a model takes and hands back |
| `models.cached` | `GET /api/models` answers from a catalogue the server keeps. See [the model catalogue](#the-model-catalogue) |
| `spawn.parts` | `parts` and `multipart/form-data` on `POST /api/agents`. See [attaching files](#attaching-files) |
| `messages.parts` | The same on `POST /api/agents/{id}/message` |
| `runs.blobs` | `GET /api/agents/{id}/blobs` and `/blobs/{sha256}`: the stored parts a run holds and their bytes. See [a run's parts](#a-runs-parts) |
| `runs.files.raw` | `GET /api/agents/{id}/files/raw?path=`, a workdir file's bytes under its own content type |
| `runs.artifacts` | `GET /api/agents/{id}/artifacts/{name}`, the bytes of one file the run handed back, from the blob store or the workdir |
| `runs.result.artifacts` | `artifacts` on a run's answer as `{ name, path, mime_type, size, sha256 }` objects rather than paths |
| `mime.registry` | `GET /api/mime`, the effective mime registry with each row's source |
| `mime.write` | `PUT /api/mime` and `DELETE /api/mime`, admin-gated, write a row into `mime_types.toml` or take one out |
| `runs.stages` | `GET /api/agents/{id}/stages`, the per-stage ledger |
| `runs.stages.cost` | `cost_usd`, `unpriced_calls` and `cost_is_exact` on each stage record, and the `visits` split beneath them |
| `runs.stages.models` | `models` on each stage record: the provider and model pairs that stage ran on, oldest first |
| `runs.waiting_on` | `wait_reason` on a run, saying what a parked run is parked on |
| `runs.delete` | `DELETE /api/runs/{id}`, which removes the record rather than cancelling the run |
| `runs.delete.bulk` | `DELETE /api/runs` with `before` or `ids`, bounded by `max_ids` |
| `logs.stage` | `?stage=` on the logs route: an index, or `all` |
| `logs.stream` | `?stream=` on it: `output` or `logs` |
| `context.history.page` | Paging on `GET /api/agents/{id}/context/history` |
| `context.region_kinds` | Region kinds spelled as the blueprint spells them. See [region kinds](#region-kinds) |
| `events.waiting_on` | `wait_reason` on the socket too, not only on the run |
| `events.stage_and_tool` | `stage_transition`, `tool_call_started` and `tool_call_finished` as flat frames instead of the old `world` envelope |
| `events.spawn_parent` | `parent_id` on `agent_spawned`, placing a sub-agent in the tree the moment it starts |
| `events.title` | The `run_renamed` frame, plus `title` on every `agent_status` |
| `events.run_status` | One status vocabulary across the whole API. See [statuses](#statuses) |
| `events.spend` | The `agent_spend` frame, sent as a run passes a figure in `[limits] notify_spend_usd` |
| `runs.cost` | `cost_usd` and `subtree_cost_usd` on the agent tree routes |
| `blueprints.envelope` | The paginated envelope on the blueprint listing |
| `blueprints.query` | `q=` on that listing |
| `blueprints.manifest` | The manifest itself on the blueprint detail route |
| `blueprints.validate.name` | `POST /api/blueprints/validate` accepting an installed name, not only a body |
| `blueprints.fan_outs` | `fan_outs` on the detail route. See [fan-out limits](#fan-out-limits) |
| `blueprints.stage_routing` | `stage_routing` on the detail route: `output_routing` and `context.reset` per stage. See [stage routing](#stage-routing) |
| `tools.list` | `GET /api/tools?agent=`, what an agent here can actually call |
| `update.plan` | `GET /api/update`, how this copy was installed and the command that upgrades it. See [asking how to upgrade](#asking-how-to-upgrade) |
| `update.apply` | `POST /api/update` and `GET /api/update/jobs/{id}`, carrying that plan out. See [pressing the button](#pressing-the-button) |
| `scripts.read` | The `GET` half of the scripts routes |
| `scripts.write` | That this build serves the write half |
| `scripts.providers` | `provider` as a fifth script `kind`, the machine's drop-in model providers |
| `scripts.candidates` | `?include=candidates` on the script listing, plus `relative_path` and `declared` on every entry |
| `scripts.mime_checks` | `mime_check` as a sixth script `kind`: the byte checks mime rows name |
| `config.gateways` | `gateways` on `GET /api/config`, the custom providers this machine has |
| `config.gateways.kinds` | `kind`, `header_names` and `models` on each gateway, and `kind`, `headers` and `models` accepted by `PUT /api/config` |
| `models.probe` | `POST /api/models/probe`, which asks an OpenAI-compatible server what it serves before a gateway for it is written; admin only |
| `fs.mkdir` | `POST /api/fs/dirs`, so a folder picker can offer "New Folder" rather than one that 404s |
| `interaction.feedback` | `feedback` beside `approved: false` on `POST /api/agents/{id}/interaction`. See [answering a question](#answering-a-question) |
| `providers.signin` | `GET /api/providers` and the three admin routes under it. See [signing in to a subscription provider](#signing-in-to-a-subscription-provider) |
| `providers.quota` | `?quota=true` on `GET /api/providers`, and the `quota` object it adds. See [subscription usage](#subscription-usage) |
| `graphql` | `POST /graphql`, the [GraphQL API](/docs/graphql) beside these routes |
| `graphql.subscriptions` | `GET /ws/graphql`, the live frames with server-side filtering |
| `graphql.executions` | `RunOutput.executions`: what a run tried, with each call typed |
| `graphql.interactions` | `RunOutput.interactions`: every question a run put to a person, with the settlement typed |
| `graphql.inferences` | `RunOutput.inferences`: every trip a run made to a provider, retries and failovers included |
| `graphql.context_changes` | `RunOutput.contextChanges`: why each of a run's regions changed, beside the snapshots |
| `bytes.signed_urls` | Short-lived `exp`/`sig` links on the byte routes, minted by the [GraphQL API](/docs/graphql) |
| `runs.blueprint_snapshot` | `blueprint_digest` on every run, and the manifest copy each run keeps |
| `runs.export` | `startRunExport` and `GET /api/exports/{id}`, the whole store as one file. See [below](#exporting-the-whole-store) |
| `config.health` | `config_error` and `config_mtime` on `GET /api/config`, and the `config_health` frame on the socket. See [below](#when-the-config-file-will-not-load) |

A few of those promises carry a consequence worth spelling out.

`models.cached` also covers `X-Leviath-Catalog-Age` and `X-Leviath-Catalog-Complete` on the
response, and `?refresh=1` to ask the providers again. It is safe to call when a page opens.
`spawn.parts` also covers `@path` tokens in `task` and region text, resolved inside the working
directory.

Without `runs.stages.cost`, a stage record carries tokens and no price, and the missing field is
not a zero. Without `runs.stages.models`, no stage record says what it ran on, which is not the
same as a stage that has yet to run. `scripts.mime_checks` puts the byte checks beside the config for the operator's rows,
and beside the agent for a blueprint's. `config.gateways.kinds` is how you tell that a gateway can
be an OpenAI-compatible endpoint rather than a script.

`update.apply` and `scripts.write` say what this build serves, not what *this* daemon mounts. That
is `--allow-admin`, which you find out by calling one of the routes and reading the status.

`interaction.feedback` also covers the "Deny with feedback" option on a tool approval. An older
daemon drops the field without a word, so offer the box only where this is announced.

`providers.signin` is the browser sign-in for a provider that has no API key. Without it a console
can write `codex_enabled` and has no way to complete the sign-in. That leaves the user enabled and
unable to run anything.

`providers.quota` also covers `X-Leviath-Quota-Age` and `X-Leviath-Quota-Complete` on the answer,
and `?refresh=1` to read the accounts again. Without it a missing `quota` says two things at once:
a daemon that ignored the parameter, or a subscription with nothing to report.

Without `config.health`, a missing `config_error` means nothing, so a console cannot tell a file
that loads from a daemon that would not say.

## Writing a mime row

A [custom mime type](/docs/mime#the-registry) is where the registry earns its keep. A family, a
token rule, extensions, a magic prefix and a byte check turn a format Leviath has never heard of
into one it types, sizes and validates, rather than one that behaves like
`application/octet-stream`. `GET /api/mime` shows what is there; these two writes change it,
without leaving the browser for the config file.

`PUT /api/mime` adds a row or updates the one already there, writing `mime_types.toml` beside the
config, the same file `lev mime add` writes and the operator's own rows live in:

```jsonc
PUT /api/mime
{
  "mime_type": "application/x-acme-scene",
  "family": "model",
  "text": false,
  "tokens": { "per_pixel": 750, "max": 1600 },
  "extensions": ["scene"],
  "magic": "41434D45",
  "stand_in": "[{type} {size}] {name}",
  "check": "checks/scene.rhai"
}
```

Every field but `mime_type` is optional, and only the fields sent are changed, so a later `PUT`
that carries just `{"mime_type": "...", "extensions": [...]}` adds an extension and leaves the
rest. `tokens` is one of `{ per_byte }`, `{ per_pixel, max? }`, `{ per_second }`, `{ per_page }` or
`{ fixed }`.
The answer is `{"mime_type", "created"}`, where `created` is false when the row was already there.
The row is validated the way `lev mime add` validates it, before anything is written. A 400 comes
back for a type that is not `type/subtype`, a token rule naming none or more than one rate, a
`magic` that is not hex, or a `check` script that will not compile. The file is untouched.

`DELETE /api/mime?mime_type=<type>` takes a row out; a type with no row of its own there is a 404.
Both need `--allow-admin`, and both are announced as `mime.write`, so a console offers "New
type…" where it will land and hands over the TOML to paste where it will not.

## Live updates over WebSocket

Connect to `/ws` (all agents) or `/ws/agents/{id}` (one run) with `?token=<t>`; the server streams
`ServerEvent` frames as the run progresses:

```mermaid
sequenceDiagram
  participant Browser
  participant Serve as lev serve
  Browser->>Serve: GET /ws/agents/{id}?token=…
  Serve-->>Browser: 101 Switching Protocols
  loop while the run is live
    Serve-->>Browser: {stage changed}
    Serve-->>Browser: {tokens updated}
    Serve-->>Browser: {awaiting input}
  end
  Serve-->>Browser: {done}
```

### The frames

Every frame is a JSON object with a `type`. Every frame except `daemon_link` and `config_health`
carries a `run_id`, which is what `/ws/agents/{id}` filters on. `daemon_link` reaches every
subscription including a per-run one, because it explains why a run's frames stopped;
`config_health` reaches only `/ws`, because a run in flight keeps going on the config it started
with either way.

| `type` | Sent when | Beyond `agent_id` and `run_id` |
| --- | --- | --- |
| `agent_spawned` | A run first appears | `blueprint`, and `parent_id` for a sub-agent |
| `agent_status` | Status, stage, iteration or tool count moves | `status`, `stage`, `iteration`, `tool_calls`, `accepts_messages`, `wait_reason`, `title` |
| `run_renamed` | The run acquires a generated title | `title` |
| `tokens` | The run's token totals move | `prompt_tokens`, `completion_tokens`, `cached_tokens`, `cache_write_tokens` |
| `context_update` | The context window's usage moves | `total_tokens`, `max_tokens` |
| `stage_transition` | A new stage is entered | `from`, `to`, `iteration` |
| `tool_call_started` | A tool call goes to the async lane | `call_id`, `tool` |
| `tool_call_finished` | That call returns | `call_id`, `tool`, `ok`, `summary` |
| `log` | A log or output line is written | `line` |
| `agent_spend` | The run's spend passes a figure named in `[limits] notify_spend_usd` | `threshold_usd`, `total_usd`, `complete`, `stage` |
| `interaction_needed` | The run is blocked on a person | `request` |
| `agent_completed` | The run reaches a terminal status | `status`, `result` (its error), `final_output` |
| `daemon_link` | This server's link to the daemon changes | `connected`, `daemon`, `restarted`, `restart_advised` |
| `config_health` | `config.toml` stops loading, loads again, or breaks for a different reason | `healthy`, `path`, `error`, `config_mtime` |

`agent_spend` arrives while the run is still going, which is the point: a run that quietly spends
far more than intended looks, from the outside, exactly like one making ordinary progress. Each
figure in `[limits] notify_spend_usd` is announced once per run, the first time the total passes it,
and `stage` names the stage that was running when it crossed. Nothing is emitted for an operator who
has not listed any figures.

`complete` says whether every call behind `total_usd` could be priced. When it is false the run has
spent at least that much and more by an unknown amount. It is a different question from whether the
priced part came from the provider's own figures or was reconstructed from published rate cards.
That is what `cost_is_exact` on the run record answers, so a total can be complete and still be a
reconstruction.

A run is created untitled and named a moment later, once a model has shortened its prompt into a
title. `run_renamed` is that moment. The same `title` then rides every `agent_status` frame, so a
client that connected or reconnected after the rename reads the name off the next status instead of
fetching the run. Both are the `events.title` capability; without it a client has to poll each new
run until it has a name. `title` is absent, not null, while a run has none.

Naming can also fail. The call retries a transient refusal and then walks the run's own model
candidates, the same chain its stage inference fails over along, so one provider being unreachable
no longer costs the run its name. When every candidate is spent, `title_error` on the run says what
stopped it, and stays `null` while titling is still under way. Poll that field rather than waiting
forever on a `run_renamed` frame that is not coming.

`status`, on `agent_status` and on `agent_completed`, is the word `GET /api/runs` uses for the same
run. See [Statuses](#statuses) for the list and for what a server that predates
`events.run_status` sends instead.

`wait_reason` is present only on a parked run, and says what it is parked on rather than making
you fetch the run to find out. A run Leviath paused carries
`{"reason":"needs_setup","blocker":"...","remedy":"..."}`, where `remedy` is a sentence with the
provider's own error in it and `blocker` is one of `provider_missing`, `credits_exhausted`,
`auth_failed`, `forbidden`, `provider_unreachable`, `provider_timed_out`, `provider_failed` or
`providers_unavailable`. [Troubleshooting](/docs/troubleshooting#a-run-says-paused-and-i-did-not-pause-it)
says what each one asks of you. `ok` on `tool_call_finished` is `false` for a result the engine
refused or could not run, so a client should not read a finish frame as a success on its own.

`stage_transition`, `tool_call_started` and `tool_call_finished` used to arrive wrapped as
`{"type":"world","event":{…}}`. They are flat frames of their own as of API version `0.4.0`,
announced as the `events.stage_and_tool` capability on `GET /api/config`; `parent_id` on
`agent_spawned` is `events.spawn_parent`. There is no longer a `world` frame.

### When the daemon restarts

The stream stays open across a [daemon](/docs/daemon) restart. `lev serve` reconnects to the daemon
on its own, so your socket never has to. What you see is one `daemon_link` frame when the daemon's
events stop, and one when they resume:

```json
{"type":"daemon_link","connected":false,"daemon":{"version":"0.4.0","build":"3ba95219","pid":4242},"restarted":false}
{"type":"daemon_link","connected":true,"daemon":{"version":"0.4.0","build":"3ba95219","pid":4301},"restarted":true}
```

`restarted` says whether the daemon that came back is a different process from the one before.
`daemon` is absent until the daemon has introduced itself, which every current daemon does on
connect.

The daemon can come back on a different build than the running `lev serve`. The usual cause is a
`lev update` with the server left running. The frame then also carries `restart_advised`, a
sentence that names both builds and says to restart `lev serve`. Every subscriber that connects
while that is true, or while the daemon is unreachable, gets a `daemon_link` frame first thing. A healthy
stream sends none, so a client that ignores the type sees exactly what it always saw.

Requests keep working across a version gap as long as the two ends still understand each other. A
request that fails because they no longer do answers **502** with the same sentence, where a
daemon that is not answering at all is a **503**. Retrying helps the second; only restarting
`lev serve` helps the first.

## Asking for a shape

Add `output_format` to ask for the answer in a particular shape. Any label works, because nothing
converts between shapes: the label reaches the model, which produces the bytes.

```bash
curl -X POST http://localhost:3000/api/agents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"blueprint":"reviewer","task":"Review the auth module",
       "output_format":"a2ui",
       "output_instructions":"One card per finding, highest severity first."}'
```

Then read it back from `GET /api/agents/{id}/result`, where `final_output` carries the answer, its
format label, and the stage that produced it. Add `output_schema` when you want the answer validated
against a JSON Schema. A format that differs from the blueprint's retires any Rhai validator and
JSON schema the blueprint declared, and the spawn response says so: a `warnings` array beside the
run id names each retired check. [Final outputs](/docs/outputs) covers the whole cascade.

## Spawning with a signed webhook

```bash
curl -X POST http://localhost:3000/api/agents \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"blueprint":"coder","task":"Add input validation",
       "callback_url":"https://example.com/hook","callback_secret":"whsec_…"}'
```

Four things to know about the delivery.

**It carries the answer.** `final_output` holds whatever the agent submitted, so your receiver
learns what the run concluded without a second request. The `result` field beside it is the run's
error, which is what it has always been. See [Final outputs](/docs/outputs).

**It is signed.** Verify the `X-Leviath-Signature: sha256=<hex>` header against your
`callback_secret` before trusting the body. A `callback_secret` without a
`callback_url` is refused with a `400`: the secret signs the callback body, so a
request that sends one and no URL is asking for a signed callback that can never
fire.

**It carries a stable `delivery_id`**, of the form `agent_completed:<run_id>`, in both the signed
body and the `X-Leviath-Delivery` header. Stable is the important word: a retried attempt, and a
completion re-fired after a daemon restart, both send the same id. So your receiver can deduplicate
with a plain key check and handle each completion exactly once.

**It retries on transient failures**, meaning network errors, timeouts, 5xx, 429, and 408, with
exponential backoff. Every field below has a safe default, so you can leave the block out entirely:

```toml
[webhook]
max_retries = 3        # retries after the first attempt; 0 disables retries
base_delay_ms = 500    # first backoff; doubles per retry
max_delay_ms = 30000   # cap on any single backoff
timeout_secs = 10      # per-attempt request timeout
```

> [!TIP]
> [The Lair](https://leviath.dev/lair) is a full reference client for this API (connection, spawn, live
> dashboard, blueprint editing, MCP and policy management), built on the same typed endpoints.
