# HTTP API

> Shardflux HTTP API reference. Base URL, /v1 versioning, API keys, people's sessions, tool tokens, idempotency, pagination, operations, rate limits and cells.

## Overview

The Shardflux API has two parts:

| Part | Base URL | Authorization | Serves |
| --- | --- | --- | --- |
| Application API | `https://api.shardflux.dev/v1` | `Authorization: Bearer sfk_<key id>_<secret>` (a project API key), or `Authorization: Bearer sfu_<token>` (a [person's session](#a-persons-session)) | Workspaces and their lifecycle, operations, templates, secrets, volumes, egress, usage; with a person's session also the account, organizations, members, API keys and billing |
| Cell gateway | the workspace's `cell_endpoint`, `/v1/workspaces/{workspace_id}/...` | `Authorization: Bearer <tool token>` | Workspace tools: exec, terminals, processes, files, git, browser |

Every application API endpoint an API key can call is in [Endpoints](https://docs.shardflux.dev/reference/http-api/endpoints.md), generated from
the OpenAPI document at [https://docs.shardflux.dev/openapi.json](https://docs.shardflux.dev/openapi.json). The cell
gateway's endpoints are listed in [Cell endpoints](#cell-endpoints) below. The
[TypeScript SDK](https://docs.shardflux.dev/reference/typescript.md), [Python SDK](https://docs.shardflux.dev/reference/python.md) and [CLI](https://docs.shardflux.dev/reference/cli.md) call this API and
handle retries, idempotency keys, operation waits and tool tokens for you.

## Versioning

All routes are under `/v1`. Within `/v1`, new fields, enum values, error codes and `details.reason` values can appear
at any time: ignore fields you do not know, and treat an unknown error code or reason as a generic error (show
`message`, use `retryable`).

`GET /v1/client-versions` (no authentication, cached for an hour) names, for every Shardflux client package, the
latest published version and the oldest one this API still supports:

```json
{ "clients": [{ "package": "@shardflux/cli", "ecosystem": "npm", "latest": "0.5.0", "minimum_supported": "0.1.0", "upgrade_command": "npm install -g @shardflux/cli@latest", "release_notes_url": "https://www.npmjs.com/package/@shardflux/cli?activeTab=versions" }] }
```

The CLI (0.5.0+), the TypeScript SDK (0.9.0+), the Python SDK (0.5.0+) and the MCP server (0.4.0+) read it to tell you
when to update. `latest` is `null` for a package that is not distributed yet.

## Authentication

### API keys

A project API key has the form `sfk_<key id>_<secret>`. The key id is public; the secret is shown once, when you
create the key in the console. Send it on every application API request:

```sh
curl -sS https://api.shardflux.dev/v1/me -H "Authorization: Bearer $SHARDFLUX_API_KEY"
```

- An API key belongs to one project. Workspaces, secrets and volumes it creates are in that project; other projects'
  resources answer `404 not_found`.
- A key carries tool permissions, a subset of `exec`, `files`, `pty`, `process`, `git` and `browser`, which bound
  what its tool tokens may do. `GET /v1/me` returns the key's organization, project and `tool_permissions`.
- An API key is a server credential. Keep it out of browsers and client-side code.
- A missing or non-Bearer `Authorization` header, and a malformed, unknown, revoked or expired key, are
  `401 unauthenticated`. Cookies are ignored.

### A person's session

The second credential on `/v1` is a person's CLI session, `sfu_` followed by 43 characters. The
[`shard` CLI](https://docs.shardflux.dev/reference/cli.md#accounts-and-sign-in) (0.5.0+) and the SDKs' `ShardfluxAccount`
([TypeScript](https://docs.shardflux.dev/reference/typescript.md#account) 0.9.0+, [Python](https://docs.shardflux.dev/reference/python.md#account) 0.5.0+) sign in with it, so
everything a person does in the console works without a browser. Use them rather than these routes directly.

```sh
curl -sS https://api.shardflux.dev/v1/auth/session -H "Authorization: Bearer $SHARDFLUX_SESSION_TOKEN"
```

- **Signing in.** `/v1/auth` has the console's sign-in routes: `register`, `verify-email` (and `verify-email/resend`),
  `login`, `mfa/challenge`, `session`, `logout`, `logout-all`, `sessions`, `step-up`, `password/change`,
  `password/reset/request`, `password/reset/confirm`, `email/change`, `email/change/confirm` and the `mfa/totp` and
  `mfa/recovery-codes` routes. Where the console gets a cookie, the answer carries `session_token` (`sfu_...`) and
  `session_expires_at`. Emailed links are the console's (`https://app.shardflux.dev/auth/verify-email#token=...`):
  post the `token` of the link.
- **Rotation.** Completing a sign-in, a step-up, a password change and turning two-factor authentication on or off
  return a new `session_token` and revoke the previous one.
- **Lifetime.** A session lasts 30 days from its last use and at most 90 days. The person sees it in the console
  (Settings > Account & security) and can sign it out there; `POST /v1/auth/logout` ends it.
- **What it can do.** Every `/v1` route takes it as the person: their memberships and roles decide, and routes that
  need a project take `project_id`. Account routes (account and organization deletion and exports, the audit export,
  Checkout, the billing portal, invoices, spend alerts, template publish and archive, members, invitations and API
  keys) accept only a person's session; an API key gets `403 forbidden` there.
- **Refusals.** An unknown, revoked or expired session is `401 unauthenticated`. A session waiting for its second
  factor gets `403 mfa_required`, an unverified email `403 email_unverified`, and a sensitive action without a recent
  step-up `403 step_up_required`. See [Errors](https://docs.shardflux.dev/reference/errors.md#with-a-persons-session).
- A project API key is not a session: the `/v1/auth` routes that need a session answer it with `401`.

### Tool tokens

The cell gateway does not accept API keys. It accepts workspace tool tokens: short-lived (at most 15 minutes) ES256
tokens bound to one workspace, one set of tools and the workspace's current ownership epoch.

```sh
curl -sS -X POST "https://api.shardflux.dev/v1/workspaces/$WORKSPACE_ID/tool-tokens" \
  -H "Authorization: Bearer $SHARDFLUX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_label": "my-agent", "tools": ["exec", "files"]}'
```

| Request field | Meaning |
| --- | --- |
| `agent_label` | Optional attribution label, 1-100 characters. One agent session per workspace, principal and label. |
| `tools` | Optional subset of the key's tool permissions. Asking for more is `403 forbidden` with `details.denied_tools`. |

The response (`ToolToken`) has `token`, `token_type` (`Bearer`), `issued_at`, `expires_at`, `workspace_id`,
`agent_session_id`, `ownership_epoch`, `tools`, `audience`, `kid` and `cell_endpoint`. Send `token` to
`cell_endpoint`.

- A token works for tool calls while the workspace runs. A suspended workspace gets a token too: with it the cell
  answers only reads of the workspace's disk (`GET .../files`, `files/stat`, `files/list`, `POST .../files/search`,
  with `X-Served-From: disk`) and every other call with `409 workspace_not_running`. While a suspend or a requested
  resume is in progress, the request is `409 conflict` with `details.reason: workspace_not_running` and the operation;
  a deleted workspace is `409 conflict` `workspace_deleted`.
- After a suspend, resume or move the workspace's epoch changes and the cell answers `409 stale_epoch`: get a new
  token and reconnect.
- Revoking an API key or deleting a workspace stops new tool calls within 30 seconds.
- `POST /v1/workspaces/open` returns a tool token with a ready workspace (see [Open a workspace](#open-a-workspace)).

## Requests and responses

- Bodies are JSON: send `Content-Type: application/json` only with a body. Unknown properties are rejected with
  `422 validation_failed`.
- IDs are UUIDv7 strings in lowercase canonical form. Your own names (`workspace_key`, template slugs) are separate
  fields.
- Timestamps are RFC 3339 in UTC with `Z`. Units are in field names: `cpu_millis` (1000 = one vCPU), `memory_mib`,
  `disk_gib`, `cpu_seconds`, `egress_bytes`.
- Every response carries `X-Request-Id`. Send your own `X-Request-Id` (8-64 characters of `A-Z a-z 0-9 . _ -`) to
  correlate requests; otherwise the server generates one. Errors carry it as `request_id`.
- Errors use one envelope, described in [Errors](https://docs.shardflux.dev/reference/errors.md):

```json
{
  "error": {
    "code": "conflict",
    "message": "The workspace has a suspend operation in progress; retry when it completes.",
    "request_id": "req-9b0e1d2c3f4a5b6c7d8e9f00",
    "retryable": true,
    "operation_id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33",
    "details": {
      "reason": "operation_in_progress",
      "active_operation_id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33",
      "active_operation_kind": "suspend"
    }
  }
}
```

## Idempotency

Send an `Idempotency-Key` header (1-255 visible ASCII characters) on requests that create something or start an
operation, so a retry after a network failure cannot do it twice.

- The server stores the response per principal, route and key for 24 hours. A retry with the same key and the same
  body returns the original status and body.
- The same key with a different body is `422 idempotency_mismatch`.
- A duplicate that arrives while the first request is still running waits and then replays; in a rare race it gets a
  retryable `409 conflict`. Retry the same request with the same key.
- Generate one key per intent (for example a UUID per user action) and reuse it for every retry of that intent.

Routes that accept it include `POST /v1/workspaces/open`, `POST /v1/workspaces/{id}/suspend`,
`.../suspend-when-idle`, `.../resume`, `.../snapshot`, `.../fork`, `.../close`, `.../reset`, `.../save-as-template`,
`DELETE /v1/workspaces/{id}`, secret creation and rotation, template builds, draft create, discard, states, test
instances and publish, and volume create, delete, attach and detach. On the cell gateway, `PUT .../files` and
`POST .../files/patch` accept it too (up to 100 characters of `A-Z a-z 0-9 . _ : -`); an execution of a file-first
workspace is idempotent by its `execution_id` instead.

## Pagination

Lists take `?limit=` and `?cursor=` and answer:

```json
{ "data": [], "next_cursor": null }
```

Pass `next_cursor` as `cursor` to get the next page; `null` means the last page. Most lists take `limit` from 1 to 200
(default 50); file trees, diffs and workspace changes take up to 1000. Cursors are opaque.

## Operations

Lifecycle calls (open, suspend, resume, snapshot, fork, delete, close, reset) create an operation and answer `202`:

```json
{ "operation": { "id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33", "kind": "suspend", "state": "queued", "workspace_id": "01a0e5a8-3edd-74ba-b489-d62b8925e342", "created_at": "2026-09-28T12:00:00Z", "updated_at": "2026-09-28T12:00:00Z" } }
```

| Field | Meaning |
| --- | --- |
| `kind` | `open`, `suspend`, `resume`, `fork`, `snapshot`, `restore`, `delete`, `reset`, `layer_snapshot`, volume operations, ... |
| `state` | `queued`, `capacity_pending`, `running`, `succeeded`, `failed` or `canceled`. The last three are terminal. |
| `state_reason` | Why it is in that state, for example `no_ready_host` while `capacity_pending`, or `template_downloading` while `running`. |
| `created_at`, `started_at`, `completed_at` | When it was created, first entered `running`, and finished. |
| `error` | On failure: `{code, message, retryable, details}`. |
| `result` | On success: what the operation produced (for example a checkpoint id). |

Poll `GET /v1/operations/{operation_id}` until the state is terminal. Operations stay readable after their workspace
is deleted. `GET /v1/workspaces/{id}/operations` lists a workspace's operations, newest first.

`POST /v1/workspaces/{id}/suspend-when-idle` with `{"after_seconds": 60}` (30 to 3600) creates no operation: it
records a [suspend when idle](https://docs.shardflux.dev/concepts/lifecycle.md#suspend-when-idle) and answers `202` with
`{workspace, operation: null, suspend_request: {requested_at, after_seconds, not_before}}`. The suspend, when the
workspace has been idle that long, is a `suspend` operation of its own. If a suspend is already in progress, the answer
is `{workspace, operation: <that suspend>, suspend_request: null}` and nothing is recorded. `DELETE` on the same path
cancels a pending request (`200` with the workspace, idempotent). The workspace view shows it as
`idle.suspend_request`.

### Bounded waits

Instead of polling in a loop, send `Prefer: wait=<seconds>` (at most 20):

```sh
curl -sS "https://api.shardflux.dev/v1/operations/$OPERATION_ID" \
  -H "Authorization: Bearer $SHARDFLUX_API_KEY" \
  -H "Prefer: wait=20"
```

While the operation is not terminal, the server holds the response until its `state` or `state_reason` changes, the
wait elapses, or you disconnect. The response is always the normal representation, with
`Preference-Applied: wait=<seconds>`. Without `Preference-Applied` the server did not wait: poll with backoff. Template
builds (`GET .../template-builds/{build_id}`) take the same header.

### Starts that wait for capacity

An open, resume or fork that no host can admit yet is `capacity_pending`, with `error.details.deadline_at` set to its
deadline, 15 minutes after the operation was created. A start still pending then fails with `error.code:
capacity_unavailable` and `retryable: true`: nothing was started, and a suspended workspace stays suspended. Send the
same request again later.

### Open a workspace

`POST /v1/workspaces/open` creates the workspace on first use and reconnects to or resumes it afterwards. It never
resets an existing workspace.

```sh
curl -sS -X POST https://api.shardflux.dev/v1/workspaces/open \
  -H "Authorization: Bearer $SHARDFLUX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Prefer: wait=20" \
  -d '{"key": "customer-42/main", "template": "python-node-browser"}'
```

| Status | Meaning |
| --- | --- |
| `200` | The workspace is running and ready. `cell_endpoint` and `tool_token` are set. |
| `202` | Poll `operation` (see above), then request a tool token. |

The body is `{workspace, operation, cell_endpoint, tool_token}`. With `Prefer: wait`, the open is held until its
operation is terminal or the wait elapses. Concurrent opens of one key share one workspace and one operation.

Body fields: `key` (required, 1-200 characters without control characters), `template` (required, a slug), `caps`
(`cpu_millis`, `memory_mib`, `disk_gib`), `agent_label`, `tools`, `secrets` (up to 50 names), `inputs`
(`{NAME: value}`), `lifetime` (`persistent` or `session`) and `mode` (`processful`, the default for a new key, or
`file_first`; see [File-first workspaces](#file-first-workspaces)). The workspace view reports `mode` and
`tree_revision` (`null` for a processful workspace). See [Workspaces](https://docs.shardflux.dev/concepts/workspaces.md) and
[Lifecycle](https://docs.shardflux.dev/concepts/lifecycle.md).

### Resume a workspace

`POST /v1/workspaces/{id}/resume` creates a `resume` operation (or joins the resume or open already running) and
answers `202` with it. Send `Prefer: wait=<seconds>` (at most 20) and an optional body `{"agent_label": ..., "tools":
[...]}` to have it held like an open:

```sh
curl -sS -X POST "https://api.shardflux.dev/v1/workspaces/$WORKSPACE_ID/resume" \
  -H "Authorization: Bearer $SHARDFLUX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Prefer: wait=20" \
  -d '{"agent_label": "my-agent", "tools": ["exec", "files"]}'
```

| Status | Meaning |
| --- | --- |
| `200` | The workspace runs: `{workspace, operation, cell_endpoint, tool_token}`, with `Preference-Applied: wait=<seconds>`. The token is for `agent_label` and `tools`, so the next tool call needs no token request. A workspace that was already running answers `200` at once with `operation: null` and a token. |
| `202` | Not finished within the wait, or the resume failed: `{operation, workspace}`. Poll the operation, then request a token. |

Without the preference, a workspace that is already running is `409 conflict` (`already_running`). `tools` beyond the
API key's tool permissions are `403 forbidden` before anything is created. The SDKs (`@shardflux/sdk` **0.9.0+**,
`shardflux` **0.5.0+**), the CLI (**0.5.0+**) and the MCP server (**0.4.0+**) wake a suspended workspace with this one
request.

## Rate limits

A request over a limit is `429 rate_limited` with `retryable: true` and a `Retry-After` header in seconds (also in
`details.retry_after_seconds` when the API sets it). Wait that long before retrying.

| Limit | Scope |
| --- | --- |
| Tool tokens (including the token of a ready open): 3600 per hour | per API key |
| Template package lookups: 120 per minute | per organization |
| Cell gateway: a workspace at a resource limit answers `429` with `Retry-After: 1` | per workspace |

An edge rate rule can also answer `429` with `Retry-After: 60` and `request_id: "edge"`. Plan limits (concurrent
workspaces, sizes, allowances) are not rate limits: they answer `402` or `403`. See [Limits](https://docs.shardflux.dev/limits.md).

## Cell endpoints

The cell gateway serves a running workspace's tools at its `cell_endpoint`. Every call carries
`Authorization: Bearer <tool token>`; the token must name the workspace and include the tool the call needs.

```sh
CELL=$(jq -r .cell_endpoint token.json); TOKEN=$(jq -r .token token.json)
curl -sS -X POST "$CELL/v1/workspaces/$WORKSPACE_ID/exec" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"session_id": "build-1", "argv": ["bash", "-lc", "make test"]}'
curl -sS "$CELL/v1/workspaces/$WORKSPACE_ID/exec/build-1/output?follow=true" -H "Authorization: Bearer $TOKEN"
```

| Method and path (under `/v1/workspaces/{workspace_id}`) | Tool | Does |
| --- | --- | --- |
| `POST /exec` | `exec` | Starts a command (`argv`, no shell). Idempotent by `session_id`: `201` created, `200` it already existed and nothing ran again. `cwd` is an absolute path (default `/home/user`); a relative one is `422 validation_failed` (`invalid_cwd`). |
| `GET /exec/{session_id}` | `exec` | Session status. |
| `GET /exec/{session_id}/output` | `exec` | Output from byte offsets (`stdout_offset`, `stderr_offset`) as NDJSON, or Server-Sent Events with `Accept: text/event-stream`. `follow=true` (default) keeps the stream open until `exit`. |
| `GET /exec/{session_id}/attach` | `exec` | WebSocket: output with offsets, signal and cancel. |
| `POST /exec/{session_id}/signal` | `exec` | Signals the session's process group. |
| `POST /exec/{session_id}/cancel` | `exec` | SIGTERM to the process group, SIGKILL after a grace. |
| `POST /pty` | `pty` | Opens a terminal session (idempotent by `session_id`). |
| `GET /pty/{session_id}`, `DELETE /pty/{session_id}` | `pty` | Status; close (SIGHUP, then SIGKILL after a grace). |
| `POST /pty/{session_id}/resize`, `POST /pty/{session_id}/input` | `pty` | Resize; write input bytes. |
| `GET /pty/{session_id}/attach` | `pty` | WebSocket: input, output with offsets, resize. |
| `GET /processes`, `POST /processes/{pid}/signal` | `process` | Lists and signals guest processes. |
| `GET /files?path=` | `files` | Reads a file: `offset` and `length`, or a single `Range: bytes=start-end`. `X-File-Size` gives the size, `X-File-Revision` the SHA-256 of the whole file (files up to 16 MiB), and `X-Served-From: disk` a read of a sleeping workspace's disk. |
| `PUT /files?path=` | `files` | Writes a file from a raw `application/octet-stream` body: atomic replace, or `append=true`. `mode`, `create_parents`. Acknowledged after fsync (`durable: true`). |
| `DELETE /files?path=` | `files` | Removes a file or directory (`recursive`). |
| `GET /files/stat`, `GET /files/list`, `POST /files/mkdir`, `POST /files/move` | `files` | Stat (`revision=true` adds the content's SHA-256, regular files up to 256 MiB), list, create a directory, move or rename. |
| `POST /files/search` | `files` | Searches file contents under `path`: `pattern` (literal, or RE2 with `regex`), `case_insensitive`, `include` and `exclude` globs, `max_matches`, `max_file_bytes`, `context_lines`. Returns `{matches, truncated, stop_reason, files_scanned}`. Read-only. |
| `POST /files/patch` | `files` | Applies `edits` (`[{old_text, new_text, replace_all}]`) or a whole `content` atomically, optionally only at `expected_revision` (a SHA-256, or `absent`). Returns `{path, revision, previous_revision, bytes_written, durable, replacements, file}`. Accepts `Idempotency-Key`. |
| `GET /changes` | `files` | A layered workspace's changes against its template (`path_prefix`, `limit`, `cursor`, `hash`, `summary`). |
| `POST /git/clone`, `GET /git/status`, `POST /git/commit` | `git` | Clone (HTTPS only), status, add and commit. |
| `POST /browser/screenshot`, `POST /browser/content` | `browser` | Navigates the workspace's headless Chromium; returns a PNG, or the rendered page as HTML or text. |
| `POST /keepalive` | any | Keeps the workspace from idle suspend for `seconds`, and resident (not [parked](https://docs.shardflux.dev/concepts/lifecycle.md#idle-running-workspaces-are-parked)). Not tool activity. |
| `POST /wake-hint` | any | A tool call is coming: a parked workspace starts waking. `202 {residency}` (`resident`, `frozen`, `hibernated`, `restoring`); a suspended workspace is `409 workspace_not_running`. Not tool activity. |
| `GET /executions/{execution_id}` | `exec` | File-first workspaces: an execution's result (`202` while it runs). See [File-first workspaces](#file-first-workspaces). |
| `GET /idle` | any | The idle policy with its current `timeout_seconds` and `basis`, recent activity, `idle_since`, a pending suspend-when-idle request (`suspend_request`, else `null`) and `suspend_at`, the projected automatic suspend: the earlier of the policy's time and the request's. |

### Sessions and offsets

Exec and terminal sessions have a `session_id` you choose (or the server generates). Starting an existing session
never runs anything again. Output is kept in the workspace and addressed by byte offsets that only grow: after a
dropped connection, reconnect with the last offsets you processed instead of starting a new command. Output events
have `type` (`output`, `exit`, `heartbeat` every 15 seconds, `error`), `stream`, `offset` and `data` (base64). Session
ids starting with `sfstart.` belong to the template's start commands: you can read them but not start one.

A command that cannot start (a `cwd` that is not a directory, a program that is not on `PATH`, an unknown `user`) is
not an HTTP error: the start answers `201` with `state: "failed_to_start"` and `error` naming the cause, for example
`working directory "/home/user/app" is not a directory`. Nothing ran, so the session has no exit code and no output;
its output stream sends only the `exit` event.

Every exec and terminal runs with, lowest first: the guest's environment, the template's `settings.env`, the
workspace's text inputs, then the request's `env`. Bound secrets and `secret_refs` are added on top; a secret whose
name collides with any of those keys is `422 validation_failed` (`env_collision`).

### During lifecycle transitions

The cell does not resume a workspace by itself:

| Situation | Answer | What to do |
| --- | --- | --- |
| A lifecycle operation holds the workspace (suspend, resume, fork, snapshot, ...) | `409 workspace_busy`, `retryable: true`, `operation_id`, `details.tool_gate`: `read_only` during a snapshot capture (reads still work), else `closed` | Wait for the operation, then retry. |
| The workspace is suspended | `409 workspace_not_running`, except reads of its disk (file read, stat, list and search) while a host still holds it | Call `POST /v1/workspaces/{id}/resume` (with `Prefer: wait`, it returns a token) or open the key again, then retry with the new token. |
| The workspace is parked while idle | Nothing: the call wakes it first. `503 service_unavailable` with `host_capacity` or `wake_failed` when it cannot be woken right now. | Retry after `Retry-After`. |
| The token's epoch is old | `409 stale_epoch` | Get a new tool token and retry. |

A refused call was never executed, so retrying it is safe. The SDKs, the CLI and the MCP server do this for you
("wake on use").

### File-first workspaces

A workspace opened with `"mode": "file_first"` has no VM between commands (see [File-first
workspaces](https://docs.shardflux.dev/concepts/file-first.md)). Its cell endpoint answers the same `files` routes from the workspace's file tree,
and runs each `POST /exec` as an execution:

```sh
curl -sS -X POST "$CELL/v1/workspaces/$WORKSPACE_ID/exec" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"execution_id": "build-42-attempt-1", "argv": ["bash", "-lc", "make -C /home/user/app"], "timeout_ms": 600000}'
```

- `execution_id` is required (8-128 characters of `A-Z a-z 0-9 . _ : -`, starting with a letter or a digit); it is
  the idempotency key. `output_limit_bytes` (1 to 16,777,216, default 1 MiB) bounds stdout and stderr each.
  `session_id` is ignored.
- The call answers when the command has ended: `201` when this call ran it, `200` with the recorded result for any
  other call with the same id (after waiting for a running one). The same id with a different request is
  `409 conflict` (`execution_id_reused`).
- The result: `execution_id`, `state` (`succeeded`, `failed`, `lost`), `base_revision`, `tree_revision`, `exit_code`,
  `term_signal`, `timed_out`, `stdout` and `stderr` (base64) with `stdout_truncated` and `stderr_truncated`, `changed`
  (`[{path, change, type}]`, up to 10,000, `changed_truncated`), `timings`, `error`, `created_at`, `finished_at`.
- `GET /executions/{execution_id}` returns the result: `200` once it ended, `202` while it is queued or running,
  `404` for an unknown id. Results are kept for 7 days.
- While an execution runs, a second one and every file change are `409 workspace_busy` (`execution_in_progress`,
  `details.execution_id`). `503 service_unavailable` (`no_execution_host`, with `Retry-After`) means no host had room:
  retry with the same id.
- Every files response carries `X-Tree-Revision`. `PUT` and `DELETE /files`, `files/mkdir`, `files/move` and
  `files/patch` take `If-Match: <tree revision>`: another current revision is `409 conflict`
  (`tree_revision_mismatch`, `details.current_tree_revision`) and nothing changes. `Idempotency-Key` replays answer
  with `Idempotent-Replayed: true`.
- Only paths under `/home/user` exist (`outside_tree_root`). Exec sessions, terminals, processes, git, browser,
  changes, keepalive and idle are `409 conflict` (`not_supported_for_mode`), and `wake-hint` answers `resident`.

### WebSocket close codes

Exec and terminal attach streams close with `1000` after the `exit` event and `1001` when the gateway shuts down
(reconnect with your offsets). Otherwise the code is 4000 plus the HTTP status of the error: `4401`
unauthenticated, `4403` forbidden, `4409` `stale_epoch`, `workspace_busy`, `workspace_not_running` or `conflict`,
`4429` rate limited, `4500`, `4503` and `4504`; `4410` means the workspace is gone. Before closing, the gateway sends
an `error` message with the full error envelope; the close reason is compact JSON with the error `code`.
