> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xorcise.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# REST API

> Every XORCISE HTTP endpoint, with paths, parameters, bodies, response shapes, status codes, and the per-run token your agent authenticates with.

The CLI and the web UI are both clients of this API, so anything either of them does, you can do over HTTP.

## Endpoint summary

| Method   | Path                                       | Purpose                                               |
| -------- | ------------------------------------------ | ----------------------------------------------------- |
| `GET`    | `/api/agents`                              | List registered agents                                |
| `POST`   | `/api/agents`                              | Register an agent                                     |
| `PUT`    | `/api/agents/{name}`                       | Update an agent and bump its version                  |
| `DELETE` | `/api/agents/{name}`                       | Remove an agent, its runs, and its results            |
| `GET`    | `/api/agents/{name}/history`               | List every result recorded for an agent               |
| `GET`    | `/api/missions`                            | Browse your own missions and the XORCISE library      |
| `GET`    | `/api/missions/{mission_id}/manifest`      | Read a mission's full manifest                        |
| `DELETE` | `/api/missions/{mission_id}`               | Uninstall an installed mission                        |
| `POST`   | `/api/missions/ingest`                     | Start ingesting a local bundle                        |
| `GET`    | `/api/missions/ingest/{job_id}`            | Poll an ingest job and read its build log             |
| `POST`   | `/api/missions/{mission_id}/pull-jobs`     | Start a background pull from the library              |
| `GET`    | `/api/missions/pull-jobs/{job_id}`         | Poll a pull job's progress                            |
| `GET`    | `/api/missions/pull-jobs`                  | Find the active pull job for a mission                |
| `POST`   | `/api/missions/pull-jobs/{job_id}/cancel`  | Cancel a pull in progress                             |
| `POST`   | `/api/missions/{mission_id}/pull`          | Pull synchronously — superseded, internal             |
| `GET`    | `/api/catalog/status`                      | Check whether the library is reachable                |
| `GET`    | `/api/runs`                                | List runs, newest first                               |
| `POST`   | `/api/runs`                                | Create a run and deploy its environment               |
| `DELETE` | `/api/runs/{run_id}`                       | Delete a run and its result                           |
| `POST`   | `/api/runs/{run_id}/terminate`             | Stop a run early and grade what happened              |
| `GET`    | `/api/runs/{run_id}/environment`           | Read the run environment's live state                 |
| `GET`    | `/api/runs/{run_id}/prompt`                | Read the connect prompt your agent is given           |
| `GET`    | `/api/runs/{run_id}/launch-profile`        | Read the telemetry environment and launch command     |
| `GET`    | `/api/runs/{run_id}/events`                | Poll the normalized replay event stream               |
| `GET`    | `/api/runs/{run_id}/events/{event_id}/raw` | Read the raw OTLP record behind one event             |
| `GET`    | `/api/runs/{run_id}/traces`                | Fetch raw OTLP records incrementally                  |
| `GET`    | `/api/runs/{run_id}/terrain2`              | Read the resolved terrain map and its attribution     |
| `GET`    | `/api/runs/{run_id}/artifacts`             | Read what the agent submitted, with payloads          |
| `GET`    | `/api/runs/{run_id}/result`                | Read the recorded score and disclosed conditions      |
| `GET`    | `/api/runs/{run_id}/report`                | Download the full report as Markdown or HTML          |
| `GET`    | `/api/runs/{run_id}/stats`                 | Read the token, count, and timing snapshot            |
| `POST`   | `/api/runs/{run_id}/regrade`               | Re-grade sealed evidence without re-running           |
| `GET`    | `/api/runs/{run_id}/mission`               | Agent reads its mission brief                         |
| `POST`   | `/api/runs/{run_id}/artifacts`             | Agent submits a finding, including the flag           |
| `GET`    | `/api/runs/{run_id}/intel`                 | Agent requests the next intel item                    |
| `POST`   | `/api/runs/{run_id}/complete`              | Agent ends the run and starts grading                 |
| `GET`    | `/api/runs/{run_id}/connect`               | Agent fetches the tailnet join bundle                 |
| `GET`    | `/api/runs/{run_id}/join.sh`               | Agent fetches the join bundle as a shell script       |
| `GET`    | `/api/runs/{run_id}/tailscale.tgz`         | Agent fetches the pinned tailnet client binary        |
| `GET`    | `/api/runs/{run_id}/attachments/{name}`    | Agent mints a signed URL, then downloads the file     |
| `GET`    | `/api/harnesses`                           | List the built-in harnesses and their launch previews |
| `GET`    | `/api/harnesses/capabilities`              | Read each adapter's telemetry capability profile      |
| `GET`    | `/api/health`                              | Liveness probe                                        |
| `GET`    | `/api/system`                              | Read what the instance sees about itself              |
| `GET`    | `/api/config`                              | Read the masked configuration                         |
| `PUT`    | `/api/config/model`                        | Set the judge model and its token caps                |
| `POST`   | `/api/config/model/test`                   | Live-call the judge model                             |
| `PUT`    | `/api/config/terrain-model`                | Set or clear the terrain model override               |
| `POST`   | `/api/config/terrain-model/test`           | Live-call the terrain model                           |
| `PUT`    | `/api/config/catalog`                      | Connect or disconnect the mission library             |
| `PUT`    | `/api/config/network`                      | Set multi-machine addresses — experimental            |
| `GET`    | `/api/fs/list`                             | List one local directory for the file picker          |

Fifty-two operations across 48 paths. The set is generated from the running application, so it is complete for the default `all` role; other roles serve a subset.

## Conventions

### Base URL

Every path above is relative to the origin XORCISE is serving on, plus `/api`. Do not hard-code a port: the REST plane starts at `3001` and walks up to 50 ports to find a free one, so a second instance, or a busy port, moves it. `xorcise up` prints the URL it took, `xorcise status` prints it on the **REST API** row, and `xorcise ui` prints the same origin with `/ui` appended.

The examples on this page take the base URL from `xorcise ui` and strip the `/ui` suffix:

```bash theme={"theme":"css-variables"}
XORCISE="$(xorcise ui)"
XORCISE="${XORCISE%/ui}"
```

### Authentication

**The operator API has no authentication.** No API key, no session, no token, on any of the agent, mission, run, result, configuration, system, or filesystem endpoints. In the default `local` deployment topology XORCISE binds loopback plus, on Linux, the Docker bridge gateway — so every local process and every container on that bridge can read your runs and traces, change your judge model, start runs, and delete results. Set `XORCISE_HOST=0.0.0.0` and the network can do the same. Reachability is the access control. [Security and isolation](/operate/security) explains why the bind is what it is and what to do about it.

The one exception is the eight run-control endpoints your agent calls. Those authenticate per run with a bearer token, and they authorize nothing else — a run's token opens that run and no other.

### The run-control token

`POST /api/runs` mints a 32-character hexadecimal token and returns it as `run_control_key` in the `201` body. That is the only place it appears. `GET /api/runs` does not carry it, and there is no endpoint that re-reads it, so capture it when you create the run.

```text theme={"theme":"css-variables"}
Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528
```

Two failures, both `401`:

```json theme={"theme":"css-variables"}
{"detail": "run-control bearer required"}
```

```json theme={"theme":"css-variables"}
{"detail": "invalid run-control credential"}
```

The first means the `Authorization` header was absent or was not a `Bearer` credential. The second means the token does not belong to that run.

### Run ids

The REST API matches run ids exactly and takes the full 32 characters. `xorcise run status 4c62254b` works because the CLI expands a unique prefix before it calls the API; `GET /api/runs/4c62254b/result` returns `404`.

### Errors

Every error carries FastAPI's default shape, a single string under `detail`:

```json theme={"theme":"css-variables"}
{"detail": "no run '4c62254b7f0e4a1b9c3d5e6f70819a2b'"}
```

Those strings are written to be read by a person and are what the web UI displays verbatim, so surface them rather than replacing them with your own message. A `422` is the exception and carries a list instead, one entry per invalid field:

```json theme={"theme":"css-variables"}
{"detail": [{"loc": ["body", "agent"], "msg": "Field required", "type": "missing"}]}
```

Every endpoint that takes a body or a typed parameter can return `422`. It is omitted from the per-endpoint status tables below except where a specific value, such as an unsupported report format, is rejected that way.

### The 202 grading window

Grading runs after a run ends, not during the request that ends it, so there is a window in which a run is finished but has no score. Four endpoints share one state ladder:

| Situation                                        | Status | Body                                                           |
| ------------------------------------------------ | ------ | -------------------------------------------------------------- |
| The run id is unknown                            | `404`  | `{"detail": "no run '<run_id>'"}`                              |
| The run is terminal and grading has not finished | `202`  | `{"run_id": "<run_id>", "status": "grading"}`                  |
| The run has not reached a terminal state         | `409`  | `{"detail": "run '<run_id>' is not terminal yet — no result"}` |
| The run is graded                                | `200`  | The payload                                                    |

`GET /result`, `GET /report`, and `GET /stats` all return `202` this way, and `POST /regrade` returns `202` on success. Poll on `202` — it is a normal transient state, not a failure. Polling also re-drives grading, de-duplicated, so a grade lost to a restart heals on the next poll instead of hanging at `grading` forever.

## Agents

### GET /api/agents

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/agents
```

**Description.** Lists every registered agent. An agent is a record you declare, not a process XORCISE launches, so this endpoint reflects only what you have told XORCISE.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with an array of agent records. Each carries `id`, `name`, `created_at`, `version`, and the optional declared fields `endpoint`, `kind`, `model`, and `otel`. `version` starts at `1` and increments on every update. `otel` is stored and read by nothing — the OTLP endpoint your agent actually uses comes from `GET /api/runs/{run_id}/launch-profile`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/agents"
```

```json theme={"theme":"css-variables"}
[
  {
    "id": "3bfe1005d777478fab3fd3a2e544e1a6",
    "name": "scout",
    "endpoint": null,
    "otel": null,
    "model": null,
    "kind": "claude-code",
    "created_at": "2026-07-23T06:35:28.250619Z",
    "version": 3
  }
]
```

### POST /api/agents

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/agents
```

**Description.** Registers an agent under a unique name. `kind` is the one field that changes behaviour: it selects the replay adapter, the telemetry provider, and the launch provider together. The built-in values are `openhands`, `claude-code`, and `codex`; any other value, including none, falls back to the generic adapter.

**Parameters.** None.

**Request.** `name` is required. `endpoint`, `kind`, `model`, and `otel` are optional strings.

```json theme={"theme":"css-variables"}
{"name": "scout", "kind": "claude-code", "model": "claude-opus-4"}
```

**Response.**

| Status | Meaning                                                         |
| ------ | --------------------------------------------------------------- |
| `201`  | The agent record, including its generated `id` and `version: 1` |
| `409`  | An agent with that name already exists                          |
| `422`  | `name` is missing or the body is malformed                      |

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/agents" \
  -H "Content-Type: application/json" \
  -d '{"name": "scout", "kind": "claude-code"}'
```

```json theme={"theme":"css-variables"}
{
  "id": "3bfe1005d777478fab3fd3a2e544e1a6",
  "name": "scout",
  "endpoint": null,
  "otel": null,
  "model": null,
  "kind": "claude-code",
  "created_at": "2026-07-23T06:35:28.250619Z",
  "version": 1
}
```

### PUT /api/agents/{name}

**Synopsis**

```text theme={"theme":"css-variables"}
PUT /api/agents/{name}
```

**Description.** Replaces an agent's declaration and increments its `version`. Sending a different `name` renames the agent, keeping its id, its version history, and its runs. Results already recorded keep the version they were scored under, which is what lets you compare an agent against its earlier self.

**Parameters.**

| Name   | In   | Required | Meaning                  |
| ------ | ---- | -------- | ------------------------ |
| `name` | path | yes      | The agent's current name |

**Request.** The same shape as `POST /api/agents`. `name` is required and may differ from the path parameter.

```json theme={"theme":"css-variables"}
{"name": "scout", "kind": "claude-code", "model": "claude-opus-4"}
```

**Response.**

| Status | Meaning                                              |
| ------ | ---------------------------------------------------- |
| `200`  | The updated agent record, with `version` incremented |
| `404`  | No agent by that name                                |
| `409`  | The new name is taken by another agent               |

**Example**

```bash theme={"theme":"css-variables"}
curl -X PUT "$XORCISE/api/agents/scout" \
  -H "Content-Type: application/json" \
  -d '{"name": "scout", "kind": "claude-code", "model": "claude-opus-4"}'
```

```json theme={"theme":"css-variables"}
{
  "id": "3bfe1005d777478fab3fd3a2e544e1a6",
  "name": "scout",
  "endpoint": null,
  "otel": null,
  "model": "claude-opus-4",
  "kind": "claude-code",
  "created_at": "2026-07-23T06:35:28.250619Z",
  "version": 4
}
```

### DELETE /api/agents/{name}

**Synopsis**

```text theme={"theme":"css-variables"}
DELETE /api/agents/{name}
```

**Description.** Removes an agent together with every version, run, and result belonging to it. This cascade is deliberate: a result is meaningless without the agent record that names what produced it. Deleting a mission behaves differently and keeps its runs.

**Parameters.**

| Name   | In   | Required | Meaning             |
| ------ | ---- | -------- | ------------------- |
| `name` | path | yes      | The agent to remove |

**Request.** No body.

**Response.**

| Status | Meaning               |
| ------ | --------------------- |
| `204`  | Removed. No body      |
| `404`  | No agent by that name |

**Example**

```bash theme={"theme":"css-variables"}
curl -X DELETE -i "$XORCISE/api/agents/scout"
```

```text theme={"theme":"css-variables"}
HTTP/1.1 204 No Content
```

### GET /api/agents/{name}/history

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/agents/{name}/history
```

**Description.** Returns every result recorded for an agent, oldest first, so you can watch a score move as the agent changes. `intel_disclosed` is filled at read time from what the agent actually requested during each run.

**Parameters.**

| Name   | In   | Required | Meaning                |
| ------ | ---- | -------- | ---------------------- |
| `name` | path | yes      | The agent to report on |

**Request.** No body.

**Response.** `200` with an array of history entries carrying `run_id`, `agent_id`, `created_at`, `overall`, `deterministic`, `judge`, `partial`, `partial_trigger`, `trace_ref`, and a `conditions` object. `404` when no agent has that name.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/agents/scout/history"
```

```json theme={"theme":"css-variables"}
[
  {
    "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
    "agent_id": "3bfe1005d777478fab3fd3a2e544e1a6",
    "created_at": "2026-07-25T09:51:20.506784Z",
    "overall": 0.896,
    "deterministic": 0.85,
    "judge": 0.9429,
    "partial": false,
    "partial_trigger": null,
    "trace_ref": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
    "conditions": {
      "model": "claude-opus-4",
      "judge_model": "gpt-5.2",
      "budget_seconds": 600,
      "intel_disclosed": 0,
      "agent_version": 3,
      "mission_version": 1,
      "sandbox_ref": "xorcise/chal-chrono-canary:184f99c82a3d07e0-base1"
    }
  }
]
```

## Missions and the library

### GET /api/missions

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/missions
```

**Description.** Returns one merged list of the missions installed on this machine and the missions the XORCISE library offers, distinguished by `source`. The library needs no account, key, or sign-in; when it is disconnected or unreachable you get the installed set only.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with an array of catalog entries: `source` (`your_own` or `library`), `mission_id`, `name`, `summary`, `proficiency`, `specialty`, `type` (`lab` or `static`), `skills`, `technologies`, `installed`, and `image`. A `static` mission has no environment and therefore no `image`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/missions"
```

```json theme={"theme":"css-variables"}
[
  {
    "source": "library",
    "mission_id": "chrono-canary",
    "name": "Chrono Canary",
    "summary": "Recover the flag from a time-gated login service.",
    "proficiency": "Expert",
    "specialty": "Penetration",
    "type": "lab",
    "skills": ["Web Exploitation"],
    "technologies": ["nginx", "php"],
    "installed": true,
    "image": "xorcise/chal-chrono-canary:184f99c82a3d07e0-base1"
  }
]
```

### GET /api/missions/{mission_id}/manifest

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/missions/{mission_id}/manifest
```

**Description.** Returns the complete `mission.json` for a mission — metadata, environment, attachments, artifacts, checks, rubric, intel, and terrain. XORCISE prefers the installed copy and falls back to the library's. The rubric and the checks are visible here, so treat this endpoint as spoiler territory for anyone about to attempt the mission. [Mission manifest](/reference/mission-manifest) documents every field.

**Parameters.**

| Name         | In   | Required | Meaning          |
| ------------ | ---- | -------- | ---------------- |
| `mission_id` | path | yes      | The mission slug |

**Request.** No body.

**Response.** `200` with the manifest. `404` when the mission is neither installed nor in the library.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/missions/chrono-canary/manifest"
```

```json theme={"theme":"css-variables"}
{
  "schema_version": "2.0",
  "metadata": {
    "mission_id": "chrono-canary",
    "name": "Chrono Canary",
    "type": "lab",
    "proficiency": "Expert",
    "objective": "Recover the flag from the login form at <chrono-target-ip->:80"
  },
  "artifacts": [{"name": "flag", "required": true}],
  "checks": [{"source": "artifacts", "ref": "flag", "op": "equals", "weight": 0.6}]
}
```

### DELETE /api/missions/{mission_id}

**Synopsis**

```text theme={"theme":"css-variables"}
DELETE /api/missions/{mission_id}
```

**Description.** Uninstalls a mission. Runs and results that reference it survive, and so does the fused image in Docker, so a re-pull or re-ingest is cheap. This is the opposite of the agent cascade.

**Parameters.**

| Name         | In   | Required | Meaning                         |
| ------------ | ---- | -------- | ------------------------------- |
| `mission_id` | path | yes      | The installed mission to remove |

**Request.** No body.

**Response.**

| Status | Meaning                      |
| ------ | ---------------------------- |
| `204`  | Uninstalled. No body         |
| `404`  | The mission is not installed |

**Example**

```bash theme={"theme":"css-variables"}
curl -X DELETE -i "$XORCISE/api/missions/chrono-canary"
```

```text theme={"theme":"css-variables"}
HTTP/1.1 204 No Content
```

### POST /api/missions/ingest

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/missions/ingest
```

**Description.** Starts ingesting a mission bundle from a directory on the machine running XORCISE, not from the machine making the request. Ingest validates the manifest, builds the fused image, and installs the result, which takes minutes, so it returns a job id immediately and does the work in the background. Validation happens only here — there is no separate validate endpoint.

**Parameters.** None.

**Request.** `bundle_dir` is required and is an absolute path on the XORCISE host.

```json theme={"theme":"css-variables"}
{"bundle_dir": "/home/you/missions/chrono-canary"}
```

**Response.** `202` with `{"job_id": "<job_id>"}`. Poll `GET /api/missions/ingest/{job_id}` for the outcome. A malformed body is `422`; a bad manifest is not rejected here but surfaces as an `error` job status.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/missions/ingest" \
  -H "Content-Type: application/json" \
  -d '{"bundle_dir": "/home/you/missions/chrono-canary"}'
```

```json theme={"theme":"css-variables"}
{"job_id": "0f7c2a4e91b64d5f8e3a1c0b7d29e846"}
```

### GET /api/missions/ingest/{job_id}

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/missions/ingest/{job_id}?since=0
```

**Description.** Reports an ingest job's status and streams its build log. `since` is a line cursor: pass the number of lines you have already consumed and you get only what is new, which is how the CLI and the web UI tail a build without re-reading it.

**Parameters.**

| Name     | In    | Required | Default | Meaning                                 |
| -------- | ----- | -------- | ------- | --------------------------------------- |
| `job_id` | path  | yes      | —       | The job returned by the ingest call     |
| `since`  | query | no       | `0`     | Return log lines from this index onward |

**Request.** No body.

**Response.** `200` with `job_id`, `status` (`building`, `installed`, or `error`), `logs`, and, once installed, `slug` and `image`. On failure `detail` carries the reason, and a manifest rejection reads as `invalid mission.json` — most often an unknown key, since every manifest model forbids extras. `404` when the job id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/missions/ingest/0f7c2a4e91b64d5f8e3a1c0b7d29e846?since=0"
```

```json theme={"theme":"css-variables"}
{
  "job_id": "0f7c2a4e91b64d5f8e3a1c0b7d29e846",
  "status": "installed",
  "logs": ["validating manifest", "building fused image", "installed chrono-canary"],
  "slug": "chrono-canary",
  "image": "xorcise/chal-chrono-canary:184f99c82a3d07e0-base1",
  "detail": null
}
```

### POST /api/missions/{mission_id}/pull-jobs

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/missions/{mission_id}/pull-jobs
```

**Description.** Starts a background pull of a library mission onto this machine. Calling it while a pull for the same mission is already running joins that job rather than starting a second one, so it is safe to retry. This is the pull path both the CLI and the web UI use.

**Parameters.**

| Name         | In   | Required | Meaning                        |
| ------------ | ---- | -------- | ------------------------------ |
| `mission_id` | path | yes      | The library mission to install |

**Request.** No body.

**Response.** `202` with `{"job_id": "<job_id>"}`. Poll `GET /api/missions/pull-jobs/{job_id}`.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/missions/chrono-canary/pull-jobs"
```

```json theme={"theme":"css-variables"}
{"job_id": "b41d8e5c7a0f43269d8c2e1f5a63b709"}
```

### GET /api/missions/pull-jobs/{job_id}

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/missions/pull-jobs/{job_id}
```

**Description.** Reports a pull job's progress. `percent` and `eta_seconds` are `null` until Docker knows the total size, and neither is monotonic, because the daemon discovers layers as it goes. A pull that shows no byte movement is usually not stuck: cached layers and registry negotiation report status without transferring anything, which is what `phase` is for.

**Parameters.**

| Name     | In   | Required | Meaning                           |
| -------- | ---- | -------- | --------------------------------- |
| `job_id` | path | yes      | The job returned by the pull call |

**Request.** No body.

**Response.** `200` with `job_id`, `mission_id`, `status` (`pulling`, `installed`, `error`, or `cancelled`), `phase` (`resolving`, `pulling_image`, `downloading_bundle`, `installing`, or `done`), `bytes_current`, `bytes_total`, `percent`, `eta_seconds`, `detail`, `cancel_requested`, and, once installed, the catalog `entry`. `404` when the job id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/missions/pull-jobs/b41d8e5c7a0f43269d8c2e1f5a63b709"
```

```json theme={"theme":"css-variables"}
{
  "job_id": "b41d8e5c7a0f43269d8c2e1f5a63b709",
  "mission_id": "chrono-canary",
  "status": "pulling",
  "phase": "pulling_image",
  "bytes_current": 41943040,
  "bytes_total": 268435456,
  "percent": 15.6,
  "eta_seconds": 47,
  "detail": null,
  "cancel_requested": false,
  "entry": null
}
```

### GET /api/missions/pull-jobs

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/missions/pull-jobs?mission_id=chrono-canary
```

**Description.** Finds the pull job currently running for a mission, so a client that reloads mid-pull can rejoin the progress it was already watching instead of starting a second pull.

**Parameters.**

| Name         | In    | Required | Meaning                |
| ------------ | ----- | -------- | ---------------------- |
| `mission_id` | query | yes      | The mission to look up |

**Request.** No body.

**Response.** `200` with the same job object as `GET /api/missions/pull-jobs/{job_id}`, or `null` when nothing is pulling. Omitting `mission_id` is `422`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/missions/pull-jobs?mission_id=chrono-canary"
```

```json theme={"theme":"css-variables"}
null
```

### POST /api/missions/pull-jobs/{job_id}/cancel

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/missions/pull-jobs/{job_id}/cancel
```

**Description.** Requests cancellation of a pull. The cancel is cooperative and is checked on every progress event, so it lands quickly and does abort the transfer rather than letting it finish quietly. Calling it twice is harmless, and cancelling a job that already finished returns that job unchanged.

**Parameters.**

| Name     | In   | Required | Meaning                |
| -------- | ---- | -------- | ---------------------- |
| `job_id` | path | yes      | The pull job to cancel |

**Request.** No body.

**Response.** `200` with the job object, `cancel_requested` set to `true` and `status` moving to `cancelled`. `404` when the job id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/missions/pull-jobs/b41d8e5c7a0f43269d8c2e1f5a63b709/cancel"
```

```json theme={"theme":"css-variables"}
{
  "job_id": "b41d8e5c7a0f43269d8c2e1f5a63b709",
  "mission_id": "chrono-canary",
  "status": "cancelled",
  "phase": "pulling_image",
  "cancel_requested": true,
  "entry": null
}
```

### POST /api/missions/{mission_id}/pull

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/missions/{mission_id}/pull
```

**Description.** Pulls a library mission and blocks until the pull finishes. It works, but it is superseded: no shipped client calls it, a large image holds the connection open for minutes, and it reports no progress. Prefer the job pair above. Treat this endpoint as internal.

**Parameters.**

| Name         | In   | Required | Meaning                        |
| ------------ | ---- | -------- | ------------------------------ |
| `mission_id` | path | yes      | The library mission to install |

**Request.** No body.

**Response.**

| Status | Meaning                                                                                  |
| ------ | ---------------------------------------------------------------------------------------- |
| `200`  | The installed catalog entry                                                              |
| `404`  | The mission is not in the library                                                        |
| `409`  | A pull for that mission is already running, or the id collides with an installed mission |
| `502`  | The library or the registry could not be reached                                         |

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/missions/chrono-canary/pull"
```

```json theme={"theme":"css-variables"}
{
  "source": "library",
  "mission_id": "chrono-canary",
  "name": "Chrono Canary",
  "type": "lab",
  "installed": true,
  "image": "xorcise/chal-chrono-canary:184f99c82a3d07e0-base1"
}
```

### GET /api/catalog/status

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/catalog/status
```

**Description.** Reports whether the mission library is reachable from this machine. `disconnected` means you turned the library off; `error` means it is on but did not answer.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with `state` (`connected`, `error`, or `disconnected`), `last_sync`, and `message`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/catalog/status"
```

```json theme={"theme":"css-variables"}
{"state": "connected", "message": null, "last_sync": null}
```

## Runs

### GET /api/runs

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs
```

**Description.** Lists runs, newest first. There is no endpoint for a single run: list and filter client-side, or read the run's result, environment, or events directly. Only two states are persisted, `created` and `terminal`, so a run that is currently being worked on reads as `created`.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with an array of run records carrying `run_id`, `agent_id`, `mission`, `name`, `state`, `created_at`, `budget_seconds`, `terminal_trigger`, `completed_at`, `model`, `sandbox_ref`, `agent_version`, `mission_version`, `source_agent`, `intel_policy`, and `last_telemetry_at`. The `run_control_key` is never included.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs"
```

```json theme={"theme":"css-variables"}
[
  {
    "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
    "agent_id": "3bfe1005d777478fab3fd3a2e544e1a6",
    "mission": "chrono-canary",
    "name": "chrono-canary · scout #1",
    "state": "terminal",
    "created_at": "2026-07-25T09:51:20.506784Z",
    "budget_seconds": 600,
    "terminal_trigger": "agent",
    "completed_at": "2026-07-25T09:58:44.120913Z",
    "model": "claude-opus-4",
    "sandbox_ref": "xorcise/chal-chrono-canary:184f99c82a3d07e0-base1",
    "agent_version": 3,
    "mission_version": 1,
    "source_agent": "claude-code",
    "intel_policy": "all"
  }
]
```

### POST /api/runs

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/runs
```

**Description.** Creates a run and deploys its environment: it reserves a private subnet, starts the mission, joins it to the run's tailnet, renders the connect prompt, and mints the run-control token. For a `lab` mission this takes seconds and can fail for real infrastructure reasons, which is why it has more failure codes than any other endpoint. A `static` mission deploys nothing.

**Parameters.** None.

**Request.** `agent` and `mission` are required and are the agent's name and the mission's id. `budget_seconds` bounds the run in wall-clock seconds. `name` overrides the generated run name. `intel_policy` is `all` or empty for every authored intel item, `none` for no intel, or a comma-separated list of intel ids such as `i1,i3`; it defaults to `all`.

```json theme={"theme":"css-variables"}
{"agent": "scout", "mission": "chrono-canary", "budget_seconds": 600, "intel_policy": "all"}
```

**Response.**

| Status | Meaning                                                                                  |
| ------ | ---------------------------------------------------------------------------------------- |
| `201`  | The run record plus `run_control_key` — the only response that carries the token         |
| `409`  | No such agent, or the mission is neither installed nor pullable, or its image is missing |
| `502`  | An automatic pull of the mission failed                                                  |
| `503`  | Docker or Headscale is unreachable. `detail` names the remediation                       |
| `422`  | `agent` or `mission` is missing                                                          |

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/runs" \
  -H "Content-Type: application/json" \
  -d '{"agent": "scout", "mission": "chrono-canary", "budget_seconds": 600}'
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "agent_id": "3bfe1005d777478fab3fd3a2e544e1a6",
  "mission": "chrono-canary",
  "name": "chrono-canary · scout #1",
  "state": "created",
  "created_at": "2026-07-25T09:51:20.506784Z",
  "budget_seconds": 600,
  "intel_policy": "all",
  "run_control_key": "9f3c1d7a5b204e8ca6d1f0e39b47c528"
}
```

### DELETE /api/runs/{run_id}

**Synopsis**

```text theme={"theme":"css-variables"}
DELETE /api/runs/{run_id}
```

**Description.** Deletes one run and its recorded result. A run that has not finished cannot be deleted, because its environment is still deployed — terminate it first.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.**

| Status | Meaning                                  |
| ------ | ---------------------------------------- |
| `204`  | Deleted. No body                         |
| `404`  | No run with that id                      |
| `409`  | The run has not reached a terminal state |

**Example**

```bash theme={"theme":"css-variables"}
curl -X DELETE -i "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b"
```

```text theme={"theme":"css-variables"}
HTTP/1.1 204 No Content
```

### POST /api/runs/{run_id}/terminate

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/runs/{run_id}/terminate
```

**Description.** Stops a run early on your instruction, seals the evidence collected so far, tears the environment down, and schedules grading in the background. The result is flagged `partial` with a `partial_trigger` of `operator`, which is how a deliberately-stopped run is told apart from one that scored badly.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.**

| Status | Meaning                                |
| ------ | -------------------------------------- |
| `200`  | The updated run record, now `terminal` |
| `404`  | No run with that id                    |
| `409`  | The run is already terminal            |

Grading has not finished when this returns. Poll `GET /api/runs/{run_id}/result` through the `202` window.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/terminate"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "mission": "chrono-canary",
  "state": "terminal",
  "terminal_trigger": "operator",
  "completed_at": "2026-07-25T09:58:44.120913Z"
}
```

### GET /api/runs/{run_id}/environment

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/environment
```

**Description.** Reports the live state of the run's environment. This is what drives the status chip on the live run page, and it is the endpoint to poll after creating a run to know when the mission is actually up.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.** `200` with `run_id`, `state`, `ready`, and `detail`. `state` is `none` for a static mission with no environment, then `starting`, `ready`, `failed`, or `released` once the run is over and the environment is torn down. `404` when the run id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/environment"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "state": "released",
  "ready": false,
  "detail": "the environment has been torn down"
}
```

### GET /api/runs/{run_id}/prompt

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/prompt?launch_mode=container
```

**Description.** Returns the connect prompt verbatim — the mission text your agent reads, carrying the objective with target IPs substituted, the tailnet join recipe, the run-control endpoints, and the correlation marker. `launch_mode` rebases the run-control base URL in that text to where your agent actually runs, and getting it wrong is the most common reason an agent cannot check in.

**Parameters.**

| Name          | In    | Required | Default     | Meaning                                                                                            |
| ------------- | ----- | -------- | ----------- | -------------------------------------------------------------------------------------------------- |
| `run_id`      | path  | yes      | —           | The full 32-character run id                                                                       |
| `launch_mode` | query | no       | `container` | `container` bakes `host.docker.internal`; `host` bakes the loopback address a terminal can resolve |

An unrecognised `launch_mode` is clamped to `container` rather than rejected, then narrowed to what the agent's harness supports.

**Request.** No body.

**Response.** `200` with `{"run_id": "...", "prompt": "..."}`. `404` when the run id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/prompt?launch_mode=host"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "prompt": "Run 4c62254b7f0e4a1b9c3d5e6f70819a2b — mission: chrono-canary\n\nObjective: Recover the flag from the login form at 10.200.17.10:80\n..."
}
```

### GET /api/runs/{run_id}/launch-profile

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/launch-profile?launch_mode=container
```

**Description.** Returns the telemetry environment for this run and a copy-paste command to start the agent with it. This is the authoritative OTLP endpoint — the `otel` field on the agent record is not read. `correlation` reports how strongly traces will bind to this run: `resource-attr` for a harness with a telemetry provider, `prompt-sentinel` otherwise, which relies on the marker in the connect prompt surviving.

**Parameters.**

| Name          | In    | Required | Default     | Meaning                                |
| ------------- | ----- | -------- | ----------- | -------------------------------------- |
| `run_id`      | path  | yes      | —           | The full 32-character run id           |
| `launch_mode` | query | no       | `container` | Same meaning as on the prompt endpoint |

**Request.** No body.

**Response.** `200` with `run_id`, `env`, `correlation`, `notes`, `fallback`, `launch_mode`, `launch_modes`, `command`, `shell_block`, and `tips`. `env` is empty when no OTLP collector is configured. `404` when the run id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/launch-profile?launch_mode=host"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "env": {
    "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4318",
    "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
    "OTEL_RESOURCE_ATTRIBUTES": "xorcise.run_id=4c62254b7f0e4a1b9c3d5e6f70819a2b"
  },
  "correlation": "resource-attr",
  "launch_mode": "host",
  "launch_modes": ["container", "host"],
  "fallback": false
}
```

### GET /api/runs/{run_id}/events

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/events?trace_since=-1&log_since=-1
```

**Description.** Returns the normalized replay stream — what the agent did, after the harness's raw telemetry has been run through its replay adapter. XORCISE ingests both traces and logs, and they advance on separate cursors, because a harness such as Codex carries its entire narrative in logs. Poll by echoing the `next_cursor` from the previous response.

**Parameters.**

| Name          | In    | Required | Default | Meaning                                                             |
| ------------- | ----- | -------- | ------- | ------------------------------------------------------------------- |
| `run_id`      | path  | yes      | —       | The full 32-character run id                                        |
| `trace_since` | query | no       | —       | Return trace-derived events after this sequence number              |
| `log_since`   | query | no       | —       | Return log-derived events after this sequence number                |
| `since`       | query | no       | `-1`    | Legacy single cursor; supplies whichever of the two above is absent |

**Request.** No body.

**Response.** `200` with `run_id`, `source_agent`, `adapter_name`, `adapter_version`, `events`, `counts`, `warnings`, `fallback`, `next_since`, and `next_cursor`. `fallback` is `true` when the generic adapter handled the stream because the harness had no adapter. An unknown run id returns an empty stream rather than `404`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/events?trace_since=-1&log_since=-1"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "source_agent": "claude-code",
  "adapter_name": "claude-code",
  "adapter_version": "1",
  "events": [
    {"seq": 1, "kind": "tool_call", "name": "bash", "ts": "2026-07-25T09:51:31.004Z"}
  ],
  "counts": {"tool_call": 1},
  "warnings": [],
  "fallback": false,
  "next_since": 1,
  "next_cursor": {"trace_seq": 1, "log_seq": -1}
}
```

### GET /api/runs/{run_id}/events/{event_id}/raw

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/events/{event_id}/raw
```

**Description.** Returns the raw OTLP span or log record that one normalized event was derived from. Use it to check what a replay adapter did with a record, or to see attributes the adapter dropped. The raw record is canonical; the normalized event is a view over it.

**Parameters.**

| Name       | In   | Required | Meaning                               |
| ---------- | ---- | -------- | ------------------------------------- |
| `run_id`   | path | yes      | The full 32-character run id          |
| `event_id` | path | yes      | The event's id from the events stream |

**Request.** No body.

**Response.** `200` with the source record as it arrived. `404` when the event id is not in that run.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/events/1/raw"
```

```json theme={"theme":"css-variables"}
{
  "kind": "span",
  "seq": 1,
  "payload": {
    "name": "bash",
    "startTimeUnixNano": "1785059491004000000",
    "attributes": [{"key": "tool.name", "value": {"stringValue": "bash"}}]
  }
}
```

### GET /api/runs/{run_id}/traces

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/traces?since=-1
```

**Description.** Returns the raw OTLP records collected for a run, in arrival order, with no adapter applied. `since` is exclusive, so passing the last `seq` you saw gives you only what arrived after it. This is the endpoint behind `xorcise run traces`, and it is the right one for confirming that telemetry is reaching XORCISE at all.

**Parameters.**

| Name     | In    | Required | Default | Meaning                                                 |
| -------- | ----- | -------- | ------- | ------------------------------------------------------- |
| `run_id` | path  | yes      | —       | The full 32-character run id                            |
| `since`  | query | no       | `-1`    | Return records with a sequence number greater than this |

**Request.** No body.

**Response.** `200` with `{"run_id": "...", "records": [{"seq": 0, "payload": {...}}]}`. An empty `records` array on a run whose agent is working usually means correlation failed rather than that nothing happened — check `correlation` on the launch profile.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/traces?since=-1"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "records": [
    {"seq": 0, "payload": {"resourceSpans": [{"scopeSpans": [{"spans": [{"name": "bash"}]}]}]}}
  ]
}
```

### GET /api/runs/{run_id}/terrain2

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/terrain2
```

**Description.** Returns the resolved terrain map for a run: the nodes the mission author declared, the updates that have fired, and the attribution linking each update to the evidence that triggered it. Terrain conditions are natural-language statements scored by a model, so this endpoint's contents depend on the terrain model being configured.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.** `200` with the resolved terrain — nodes with their groups and states, edges, updates, and an attribution status. `404` when the run id is unknown. A mission that declares no terrain returns an empty map, not an error.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/terrain2"
```

```json theme={"theme":"css-variables"}
{
  "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
  "nodes": [
    {"id": "web", "label": "Web server", "parent": "dmz", "state": "compromised", "objective": true}
  ],
  "edges": [{"from": "agent", "to": "web"}],
  "updates": [{"node_id": "web", "state": "compromised", "seq": 42}],
  "attribution": {"status": "ok"}
}
```

### GET /api/runs/{run_id}/artifacts

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/artifacts
```

**Description.** Returns everything the agent submitted during the run, with the payloads intact, in submission order. This is the operator's view — it needs no run-control token, unlike the endpoint the agent submits through. The flag, if the agent found one, is the artifact named `flag`.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.** `200` with an array of `{name, kind, seq, payload}`. `404` when the run id is unknown.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/artifacts"
```

```json theme={"theme":"css-variables"}
[
  {
    "name": "flag",
    "kind": "artifact",
    "seq": 7,
    "payload": "XORCISE{sql_injection_auth_bypass}"
  }
]
```

## Results

### GET /api/runs/{run_id}/result

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/result
```

**Description.** Returns the recorded score and the conditions it was produced under. The score is half deterministic checks and half model judgement, each weighted `0.5`, so a run graded with no judge configured tops out at `50%` and that is not a failure. `judge_status` is the diagnostic to read first when the judge half is zero.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.**

| Status | Meaning                                                 |
| ------ | ------------------------------------------------------- |
| `200`  | `grade`, `conditions`, `partial`, and `partial_trigger` |
| `202`  | The run is terminal and grading is still running        |
| `404`  | No run with that id                                     |
| `409`  | The run has not reached a terminal state                |

`grade.judge_status` is `ok`, `model-not-configured`, or `unavailable`, with the reason in `judge_detail`. `grade` also declares `hard_fails`, `key_evidence`, and `major_deductions`; no code path fills them and they are always empty. [How grading works](/concepts/grading) explains the arithmetic.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/result"
```

```json theme={"theme":"css-variables"}
{
  "grade": {
    "run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
    "overall": 0.896,
    "breakdown": {"deterministic": 0.85, "judge": 0.9429},
    "check_breakdown": [
      {"ref": "flag", "op": "equals", "passed": true, "weight": 0.6}
    ],
    "judge_breakdown": [
      {"criterion": "recon-login", "score": 1.0, "weight": 0.4, "reason": "Enumerated the login form."}
    ],
    "judge_status": "ok",
    "judge_detail": null,
    "artifacts": ["flag"],
    "trace_ref": "4c62254b7f0e4a1b9c3d5e6f70819a2b",
    "hard_fails": [],
    "key_evidence": [],
    "major_deductions": []
  },
  "conditions": {
    "model": "claude-opus-4",
    "judge_model": "gpt-5.2",
    "budget_seconds": 600,
    "intel_disclosed": 0,
    "agent_version": 3,
    "mission_version": 1,
    "sandbox_ref": "xorcise/chal-chrono-canary:184f99c82a3d07e0-base1"
  },
  "partial": false,
  "partial_trigger": null
}
```

### GET /api/runs/{run_id}/report

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/report?format=md
```

**Description.** Returns the run's full report as a single self-contained document — the same scores, check table, rubric, evidence, artifacts, and conditions as the result, rendered for sharing. The HTML carries its own styling and loads no external asset, so it survives being emailed.

**Parameters.**

| Name     | In    | Required | Default | Meaning                            |
| -------- | ----- | -------- | ------- | ---------------------------------- |
| `run_id` | path  | yes      | —       | The full 32-character run id       |
| `format` | query | no       | `md`    | `md` for Markdown, `html` for HTML |

**Request.** No body.

**Response.**

| Status | Meaning                                              |
| ------ | ---------------------------------------------------- |
| `200`  | The document, with `Content-Disposition: attachment` |
| `202`  | The run is terminal and grading is still running     |
| `404`  | No run with that id                                  |
| `409`  | The run has not reached a terminal state             |
| `422`  | `format` is neither `md` nor `html`                  |

**Example**

```bash theme={"theme":"css-variables"}
curl -o report.md "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/report?format=md"
```

```text theme={"theme":"css-variables"}
# chrono-canary · scout #1

Overall 89.6%  ·  deterministic 0.85  ·  judge 0.9429
```

### GET /api/runs/{run_id}/stats

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/stats
```

**Description.** Returns the token, count, and timing snapshot recorded when the run was graded. A run graded before the snapshot existed is folded live from its events instead, read-only. Every token figure is `0` on a run that emitted no telemetry, which is the normal reading rather than a fault.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.**

| Status | Meaning                                                |
| ------ | ------------------------------------------------------ |
| `200`  | `tokens`, `counts`, `timing`, and `cost_estimated_usd` |
| `202`  | The run is terminal and grading is still running       |
| `404`  | No run with that id                                    |
| `409`  | The run has not reached a terminal state               |

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/stats"
```

```json theme={"theme":"css-variables"}
{
  "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0, "reasoning": 0, "total": 0},
  "counts": {"model_calls": 0, "tool_calls": 0, "findings": 0, "errors": 0, "events_total": 0, "by_kind": {}},
  "timing": {"elapsed_seconds": 376.83181, "first_event_ts": null, "last_event_ts": null, "longest_tool_ms": null},
  "cost_estimated_usd": null
}
```

### POST /api/runs/{run_id}/regrade

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/runs/{run_id}/regrade
```

**Description.** Re-grades a finished run's already-sealed evidence against your current settings, without re-running the agent. This is the fix for a run whose judge half failed for a configuration reason — the transcript exceeded the judge's token budget, or the key was rejected. Raise the cap or fix the key, call this, and the judge scores the same preserved evidence.

Re-grading is cheap next to a re-run, and it is the only way to recover a misconfigured judge on a run you cannot reproduce. There is no CLI equivalent; the web UI exposes it as **Re-evaluate** on the Results page.

**Parameters.**

| Name     | In   | Required | Meaning                      |
| -------- | ---- | -------- | ---------------------------- |
| `run_id` | path | yes      | The full 32-character run id |

**Request.** No body.

**Response.**

| Status | Meaning                                                                                                 |
| ------ | ------------------------------------------------------------------------------------------------------- |
| `202`  | `{"run_id": "<run_id>", "status": "grading"}`. The old result is dropped and a fresh grade is scheduled |
| `404`  | No run with that id                                                                                     |
| `409`  | The run has not finished, so nothing is sealed to re-grade                                              |

The new score arrives through the same `202` poll as the first grade, so a client needs no extra state. Calls are de-duplicated: pressing the button twice grades once.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/regrade"
```

```json theme={"theme":"css-variables"}
{"run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b", "status": "grading"}
```

## Run control

These are the endpoints your agent calls during a run. Every one requires `Authorization: Bearer <run_control_key>` and returns `401` without it. Two shared mappings apply throughout: once the run is over, any call returns `409`, and a mission that cannot be read returns `404`.

[Connect any agent](/guides/connect-your-agent) walks through the contract these endpoints form.

### GET /api/runs/{run_id}/mission

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/mission
```

**Description.** Returns the agent's brief: the mission id, the objective with target IPs already substituted, and the names of the attachments available. It deliberately does not include the rubric or the checks. Your agent can re-read this at any time while the run is open.

**Parameters.**

| Name            | In     | Required | Meaning                      |
| --------------- | ------ | -------- | ---------------------------- |
| `run_id`        | path   | yes      | The full 32-character run id |
| `Authorization` | header | yes      | `Bearer <run_control_key>`   |

**Request.** No body.

**Response.**

| Status | Meaning                                         |
| ------ | ----------------------------------------------- |
| `200`  | `mission`, `objective`, and `attachments`       |
| `401`  | The bearer is missing or does not match the run |
| `404`  | The mission is unavailable                      |
| `409`  | The run is over                                 |

**Example**

```bash theme={"theme":"css-variables"}
curl -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/mission"
```

```json theme={"theme":"css-variables"}
{
  "mission": "chrono-canary",
  "objective": "Recover the flag from the login form at 10.200.17.10:80",
  "attachments": ["capture.pcap"]
}
```

### POST /api/runs/{run_id}/artifacts

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/runs/{run_id}/artifacts
```

**Description.** Submits one named finding. This is how the agent reports what it recovered, and **the flag is the artifact named `flag`** — there is no separate flag endpoint. Content is inline text or JSON; binary upload is out of scope. Submitting a name twice records both, in order.

The name matters: deterministic checks look the artifact up by the name the agent used, so a misspelled name fails the check silently rather than raising an error here.

**Parameters.**

| Name            | In     | Required | Meaning                      |
| --------------- | ------ | -------- | ---------------------------- |
| `run_id`        | path   | yes      | The full 32-character run id |
| `Authorization` | header | yes      | `Bearer <run_control_key>`   |

**Request.** `name` and `content` are both required strings.

```json theme={"theme":"css-variables"}
{"name": "flag", "content": "XORCISE{sql_injection_auth_bypass}"}
```

**Response.**

| Status | Meaning                                         |
| ------ | ----------------------------------------------- |
| `200`  | `{"accepted": true, "name": "<name>"}`          |
| `401`  | The bearer is missing or does not match the run |
| `409`  | The run is over — the submission is rejected    |
| `422`  | `name` or `content` is missing                  |

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/artifacts" \
  -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  -H "Content-Type: application/json" \
  -d '{"name": "flag", "content": "XORCISE{sql_injection_auth_bypass}"}'
```

```json theme={"theme":"css-variables"}
{"accepted": true, "name": "flag"}
```

### GET /api/runs/{run_id}/intel

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/intel
```

**Description.** Requests the next intel item the run's policy allows. Each call discloses one more, in the order the mission authored them, and the count is recorded on the result as `intel_disclosed`, so disclosed intel is visible in the conditions a score is compared under. `remaining` tells the agent how many intel items are left after this call. When the policy allows no intel, or all of it is spent, `intel` is `null` and `remaining` is `0`.

**Parameters.**

| Name            | In     | Required | Meaning                      |
| --------------- | ------ | -------- | ---------------------------- |
| `run_id`        | path   | yes      | The full 32-character run id |
| `Authorization` | header | yes      | `Bearer <run_control_key>`   |

**Request.** No body.

**Response.**

| Status | Meaning                                             |
| ------ | --------------------------------------------------- |
| `200`  | `{"intel": "<text or null>", "remaining": <count>}` |
| `401`  | The bearer is missing or does not match the run     |
| `404`  | The mission is unavailable                          |
| `409`  | The run is over                                     |

**Example**

```bash theme={"theme":"css-variables"}
curl -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/intel"
```

```json theme={"theme":"css-variables"}
{"intel": "The login form does not sanitise the username field.", "remaining": 1}
```

### POST /api/runs/{run_id}/complete

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/runs/{run_id}/complete
```

**Description.** Ends the run. XORCISE seals the evidence, tears the environment down, and schedules grading in the background. Your agent should call this when it is finished, whether or not it solved the mission — a run left open scores nothing until its budget expires.

Telemetry arriving after this call is dropped once a five-second drain closes, so flush your exporter before calling it.

**Parameters.**

| Name            | In     | Required | Meaning                      |
| --------------- | ------ | -------- | ---------------------------- |
| `run_id`        | path   | yes      | The full 32-character run id |
| `Authorization` | header | yes      | `Bearer <run_control_key>`   |

**Request.** No body.

**Response.**

| Status | Meaning                                         |
| ------ | ----------------------------------------------- |
| `200`  | `{"run_id": "<run_id>", "state": "terminal"}`   |
| `401`  | The bearer is missing or does not match the run |
| `409`  | The run is already over                         |

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/complete" \
  -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528"
```

```json theme={"theme":"css-variables"}
{"run_id": "4c62254b7f0e4a1b9c3d5e6f70819a2b", "state": "terminal"}
```

### GET /api/runs/{run_id}/connect

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/connect
```

**Description.** Returns the credentials your agent needs to join the run's private network, as JSON. `login_server` is already rewritten to an address the agent can reach, and `ca_cert` carries the local certificate authority when one is needed and is an empty string otherwise. Most agents use the script form below instead of assembling the join themselves.

**Parameters.**

| Name            | In     | Required | Meaning                      |
| --------------- | ------ | -------- | ---------------------------- |
| `run_id`        | path   | yes      | The full 32-character run id |
| `Authorization` | header | yes      | `Bearer <run_control_key>`   |

**Request.** No body.

**Response.** `200` with `login_server`, `join_key`, and `ca_cert`. `401` without a valid bearer.

**Example**

```bash theme={"theme":"css-variables"}
curl -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/connect"
```

```json theme={"theme":"css-variables"}
{
  "login_server": "https://172.17.0.1:443",
  "join_key": "nodekey:5f0a91c3d7b24e68",
  "ca_cert": "-----BEGIN CERTIFICATE-----\n..."
}
```

### GET /api/runs/{run_id}/join.sh

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/join.sh
```

**Description.** Returns the same join bundle as a runnable shell script, which is the join path the connect prompt tells your agent to use. It needs no root and configures the tunnel in userspace, printing the SOCKS5 address the agent then routes target traffic through. The script bakes a self-reaper that tears the tunnel down at the run's budget plus 600 seconds, or after 86,400 seconds when the run is unbudgeted.

**Parameters.**

| Name            | In     | Required | Meaning                      |
| --------------- | ------ | -------- | ---------------------------- |
| `run_id`        | path   | yes      | The full 32-character run id |
| `Authorization` | header | yes      | `Bearer <run_control_key>`   |

**Request.** No body.

**Response.** `200` with `text/x-shellscript`. `401` without a valid bearer.

**Example**

```bash theme={"theme":"css-variables"}
curl -fsS -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/join.sh" | sh
```

```text theme={"theme":"css-variables"}
joined tailnet · SOCKS5 proxy on 127.0.0.1:1055
targets: 10.200.17.10:80
```

### GET /api/runs/{run_id}/tailscale.tgz

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/tailscale.tgz?arch=amd64
```

**Description.** Serves the pinned static tailnet client the join script uses, so an agent in a minimal container does not need a package manager or its own network access to get one. The join script fetches this for you; call it directly only when you are assembling the join by hand.

**Parameters.**

| Name            | In     | Required | Default | Meaning                      |
| --------------- | ------ | -------- | ------- | ---------------------------- |
| `run_id`        | path   | yes      | —       | The full 32-character run id |
| `Authorization` | header | yes      | —       | `Bearer <run_control_key>`   |
| `arch`          | query  | no       | `amd64` | `amd64` or `arm64`           |

**Request.** No body.

**Response.**

| Status | Meaning                                         |
| ------ | ----------------------------------------------- |
| `200`  | The gzipped archive, `application/gzip`         |
| `400`  | `arch` is not `amd64` or `arm64`                |
| `401`  | The bearer is missing or does not match the run |
| `502`  | XORCISE could not obtain the client binary      |

**Example**

```bash theme={"theme":"css-variables"}
curl -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  -o tailscale.tgz \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/tailscale.tgz?arch=arm64"
```

```text theme={"theme":"css-variables"}
tailscale.tgz
```

### GET /api/runs/{run_id}/attachments/{name}

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/runs/{run_id}/attachments/{name}
GET /api/runs/{run_id}/attachments/{name}?sig=<sig>&exp=<exp>
```

**Description.** Downloads a mission attachment in two calls. Call it with the bearer and no query parameters and you get a signed URL back; call that URL with the `X-Run-Key` header and you get the bytes. The split keeps file bytes off the bearer-authenticated call so a download can be streamed or handed to another process without carrying the run token in a shell history.

**Parameters.**

| Name            | In     | Required      | Meaning                                    |
| --------------- | ------ | ------------- | ------------------------------------------ |
| `run_id`        | path   | yes           | The full 32-character run id               |
| `name`          | path   | yes           | The attachment name from the mission brief |
| `Authorization` | header | mint only     | `Bearer <run_control_key>`                 |
| `sig`           | query  | download only | The signature from the minted URL          |
| `exp`           | query  | download only | The expiry from the minted URL             |
| `X-Run-Key`     | header | download only | The run-control key, unprefixed            |

**Request.** No body.

**Response.**

| Status | Meaning                                                                     |
| ------ | --------------------------------------------------------------------------- |
| `200`  | The mint response, or the file bytes on the second call                     |
| `401`  | The bearer is missing or does not match the run, on the mint call           |
| `403`  | `invalid run key`, `link expired`, or `bad signature`, on the download call |
| `404`  | No attachment by that name                                                  |

The mint response carries `name`, `url`, `expires_at`, `media_type`, and `sha256`.

**Example**

```bash theme={"theme":"css-variables"}
curl -H "Authorization: Bearer 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/attachments/capture.pcap"
```

```json theme={"theme":"css-variables"}
{
  "name": "capture.pcap",
  "url": "/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/attachments/capture.pcap?sig=1f9c0a&exp=1785063091",
  "expires_at": "2026-07-26T09:56:31Z",
  "media_type": "application/vnd.tcpdump.pcap",
  "sha256": "8e2b41c0f7a95d63"
}
```

Then fetch the bytes:

```bash theme={"theme":"css-variables"}
curl -H "X-Run-Key: 9f3c1d7a5b204e8ca6d1f0e39b47c528" \
  -o capture.pcap \
  "$XORCISE/api/runs/4c62254b7f0e4a1b9c3d5e6f70819a2b/attachments/capture.pcap?sig=1f9c0a&exp=1785063091"
```

```text theme={"theme":"css-variables"}
capture.pcap
```

## Harnesses

### GET /api/harnesses

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/harnesses
```

**Description.** Lists the built-in harnesses that ship both a replay adapter and a launch provider — the values worth registering as an agent's `kind`. Each descriptor carries a registration-time preview of how that harness is launched. The preview is a template, not a runnable command: run credentials, endpoints, and the mission text only exist once a run is created, so `GET /api/runs/{run_id}/launch-profile` remains authoritative for a real launch.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with an array of descriptors carrying `kind`, `display_name`, `description`, `model_hints`, `capabilities` (the same profile the capabilities endpoint serves), and `launch` — `launch_modes` (`host`, `container`), `command_template`, `model_flag`, `model_flag_anchor`, `tips`, and `mission_preamble`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/harnesses"
```

### GET /api/harnesses/capabilities

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/harnesses/capabilities
```

**Description.** Returns every registered replay adapter's declared telemetry capability profile, sorted by adapter name — the honest matrix of which event kinds a harness actually exports. `notes` carry the user-facing gap sentences for partially-supported kinds, rendered verbatim in the UI and the judge disclosure. `verified` is `false` only for the generic fallback adapter, whose profile is not audited against a real harness.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with an array of profiles carrying `adapter_name`, `adapter_version`, `verified`, `kinds` (a mapping over every event kind to `supported`, `partial`, or `unsupported`), `message_roles`, and `notes`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/harnesses/capabilities"
```

## System and configuration

### GET /api/health

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/health
```

**Description.** Liveness only. It answers as soon as the REST plane is serving and says nothing about Docker, Headscale, or the database — use the system endpoint for those.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with `{"status": "ok", "service": "rest"}`.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/health"
```

```json theme={"theme":"css-variables"}
{"status": "ok", "service": "rest"}
```

### GET /api/system

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/system
```

**Description.** Reports what the running instance sees about itself: which role it booted, which planes are healthy, where its data lives, and whether its database schema matches the build. `xorcise status` probes from your machine; this asks the instance, and the two disagreeing is itself the diagnosis.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with `role`, `topology` (`local` or `distributed`), `home`, `db_url`, `db_schema` (`head`, `behind`, `fresh`, or `unknown`), `planes`, `catalog`, and `remotes`. On the default `all` role, `planes` carries four rows — `rest`, `docker`, `headscale`, and `otlp` — each with `name`, `ok`, `detail`, `location`, `role`, `label`, and `state`. A `db_schema` of `behind` means `xorcise db upgrade` is due.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/system"
```

```json theme={"theme":"css-variables"}
{
  "role": "all",
  "topology": "local",
  "home": "/home/you/.xorcise",
  "db_schema": "head",
  "planes": [
    {"name": "rest", "ok": true, "detail": "ok", "location": "127.0.0.1:3001", "role": "control", "label": "REST API", "state": "ok"},
    {"name": "docker", "ok": true, "detail": "ok", "location": "local daemon", "role": "runner", "label": "Docker", "state": "ok"},
    {"name": "headscale", "ok": true, "detail": "ok", "location": "https://172.17.0.1:443", "role": "headscale", "label": "Headscale", "state": "ok"},
    {"name": "otlp", "ok": true, "detail": "ok", "location": "127.0.0.1:4318", "role": "collector", "label": "OTLP receiver", "state": "ok"}
  ],
  "remotes": []
}
```

### GET /api/config

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/config
```

**Description.** Returns the effective configuration with secrets masked. Model keys are never returned — `key_hint` shows the last four characters so you can tell which key is loaded. `judge.configured` is a presence check only and does not prove the key works; the test endpoints do that.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with `judge`, `terrain`, `catalog`, `network`, and `default_budget_seconds`. `terrain.uses_judge_default` is `true` when no terrain override is set, which means terrain attribution calls the judge model. [Configuration keys](/reference/configuration) documents every field.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/config"
```

```json theme={"theme":"css-variables"}
{
  "judge": {
    "configured": true,
    "base_url": "https://api.openai.com/v1",
    "model_name": "gpt-5.2",
    "key_hint": "…UJUA",
    "timeout_seconds": 120.0,
    "transcript_max_tokens": 72000,
    "span_max_tokens": 2000,
    "tokenizer": "o200k_base"
  },
  "terrain": {"configured": true, "uses_judge_default": true, "model_name": "gpt-5.2"},
  "catalog": {"connected": true, "url": "https://api.xorcise.ai"},
  "network": {"headscale_url": "https://172.17.0.1:443", "advertise_host": "172.17.0.1"},
  "default_budget_seconds": 3600
}
```

### PUT /api/config/model

**Synopsis**

```text theme={"theme":"css-variables"}
PUT /api/config/model
```

**Description.** Sets the judge model and its token caps. Every field is optional and only what you send is changed; an empty string clears a field. The two caps are the controls behind judge failures on long runs — `span_max_tokens` truncates each span, and `transcript_max_tokens` caps the whole prompt before it is sent.

Writes land in `~/.xorcise/.env` as `XORCISE_*` variables, never in `config.toml`, so a value set here survives a restart but does not appear in your config file.

**Parameters.** None.

**Request.** All fields optional: `model_name`, `base_url`, `key`, `transcript_max_tokens`, `span_max_tokens`, `tokenizer`, `timeout_seconds`.

```json theme={"theme":"css-variables"}
{"model_name": "gpt-5.2", "base_url": "https://api.openai.com/v1", "key": "sk-...", "transcript_max_tokens": 256000}
```

**Response.** `200` with the updated masked configuration, the same shape as `GET /api/config`. `422` when a value is out of range — the caps must be non-negative and `timeout_seconds` must be greater than zero.

**Example**

```bash theme={"theme":"css-variables"}
curl -X PUT "$XORCISE/api/config/model" \
  -H "Content-Type: application/json" \
  -d '{"model_name": "gpt-5.2", "transcript_max_tokens": 256000}'
```

```json theme={"theme":"css-variables"}
{
  "judge": {"configured": true, "model_name": "gpt-5.2", "transcript_max_tokens": 256000, "span_max_tokens": 2000},
  "terrain": {"configured": true, "uses_judge_default": true, "model_name": "gpt-5.2"},
  "catalog": {"connected": true, "url": "https://api.xorcise.ai"},
  "default_budget_seconds": 3600
}
```

### POST /api/config/model/test

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/config/model/test
```

**Description.** Calls the saved judge model once and reports what happened. This is the only endpoint that spends tokens on your provider account, and it is the difference between a key that is present and a key that works.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with `ok`, `status` (`ok`, `not_configured`, or `error`), `model_name`, and `message`. A failed call is still `200` — read `ok`, not the status code.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/config/model/test"
```

```json theme={"theme":"css-variables"}
{"ok": true, "status": "ok", "model_name": "gpt-5.2", "message": null}
```

### PUT /api/config/terrain-model

**Synopsis**

```text theme={"theme":"css-variables"}
PUT /api/config/terrain-model
```

**Description.** Sets a separate model for terrain attribution, or clears it so terrain falls back to the judge model. Terrain attribution calls a model on every run that declares terrain, so pointing it at a cheaper model than the judge is the usual reason to set it.

**Parameters.** None.

**Request.** All fields optional: `model_name`, `base_url`, `key`, `transcript_max_tokens`. Send empty strings to clear the override.

```json theme={"theme":"css-variables"}
{"model_name": "gpt-5.2-mini", "transcript_max_tokens": 128000}
```

**Response.** `200` with the updated masked configuration. `terrain.uses_judge_default` becomes `false` once an override is set. `422` when `transcript_max_tokens` is not greater than zero.

**Example**

```bash theme={"theme":"css-variables"}
curl -X PUT "$XORCISE/api/config/terrain-model" \
  -H "Content-Type: application/json" \
  -d '{"model_name": "gpt-5.2-mini"}'
```

```json theme={"theme":"css-variables"}
{
  "judge": {"configured": true, "model_name": "gpt-5.2"},
  "terrain": {"configured": true, "uses_judge_default": false, "model_name": "gpt-5.2-mini"},
  "catalog": {"connected": true, "url": "https://api.xorcise.ai"},
  "default_budget_seconds": 3600
}
```

### POST /api/config/terrain-model/test

**Synopsis**

```text theme={"theme":"css-variables"}
POST /api/config/terrain-model/test
```

**Description.** Calls the effective terrain model once — the override when one is set, the judge model otherwise — and reports what happened. Like the judge test, it spends tokens.

**Parameters.** None.

**Request.** No body.

**Response.** `200` with the same shape as the judge test: `ok`, `status`, `model_name`, `message`.

**Example**

```bash theme={"theme":"css-variables"}
curl -X POST "$XORCISE/api/config/terrain-model/test"
```

```json theme={"theme":"css-variables"}
{"ok": true, "status": "ok", "model_name": "gpt-5.2-mini", "message": null}
```

### PUT /api/config/catalog

**Synopsis**

```text theme={"theme":"css-variables"}
PUT /api/config/catalog
```

**Description.** Turns the mission library on or off. Disconnecting hides library missions from browse and leaves your installed missions untouched. No credential is involved in either direction — the library needs no account or key.

**Parameters.** None.

**Request.** `connected` is required.

```json theme={"theme":"css-variables"}
{"connected": true}
```

**Response.** `200` with the updated masked configuration. `422` when `connected` is missing or is not a boolean.

**Example**

```bash theme={"theme":"css-variables"}
curl -X PUT "$XORCISE/api/config/catalog" \
  -H "Content-Type: application/json" \
  -d '{"connected": false}'
```

```json theme={"theme":"css-variables"}
{
  "judge": {"configured": true, "model_name": "gpt-5.2"},
  "terrain": {"configured": true, "uses_judge_default": true, "model_name": "gpt-5.2"},
  "catalog": {"connected": false, "url": null},
  "default_budget_seconds": 3600
}
```

### PUT /api/config/network

**Synopsis**

```text theme={"theme":"css-variables"}
PUT /api/config/network
```

**Description.** Sets the addresses used when XORCISE runs across more than one machine. This backs the distributed topology, which is **experimental**: the CLI labels it so, the web UI shows these values read-only, and values written here apply at the next start rather than immediately.

**Parameters.** None.

**Request.** Both fields optional: `headscale_url`, `advertise_host`.

```json theme={"theme":"css-variables"}
{"headscale_url": "https://headscale.internal:443", "advertise_host": "10.0.0.7"}
```

**Response.** `200` with the updated masked configuration.

**Example**

```bash theme={"theme":"css-variables"}
curl -X PUT "$XORCISE/api/config/network" \
  -H "Content-Type: application/json" \
  -d '{"advertise_host": "10.0.0.7"}'
```

```json theme={"theme":"css-variables"}
{
  "judge": {"configured": true, "model_name": "gpt-5.2"},
  "catalog": {"connected": true, "url": "https://api.xorcise.ai"},
  "network": {"headscale_url": "https://172.17.0.1:443", "advertise_host": "10.0.0.7"},
  "default_budget_seconds": 3600
}
```

### GET /api/fs/list

**Synopsis**

```text theme={"theme":"css-variables"}
GET /api/fs/list?path=/home/you/missions
```

**Description.** Lists one directory on the machine running XORCISE. It exists so the web UI's bundle picker can browse to an ingest directory, and it is read-only. Because the API has no authentication, this endpoint lets anyone who can reach the port enumerate directories on that host — one more reason to treat the port as sensitive. [Security and isolation](/operate/security) covers the boundary.

**Parameters.**

| Name   | In    | Required | Default            | Meaning                        |
| ------ | ----- | -------- | ------------------ | ------------------------------ |
| `path` | query | no       | the home directory | The absolute directory to list |

**Request.** No body.

**Response.** `200` with `path`, `parent`, and `entries`, each entry carrying `name`, `path`, and `is_dir`. `400` when the path does not exist or is not a directory.

**Example**

```bash theme={"theme":"css-variables"}
curl "$XORCISE/api/fs/list?path=/home/you/missions"
```

```json theme={"theme":"css-variables"}
{
  "path": "/home/you/missions",
  "parent": "/home/you",
  "entries": [
    {"name": "chrono-canary", "path": "/home/you/missions/chrono-canary", "is_dir": true}
  ]
}
```

## Related pages

* [CLI reference](/reference/cli) — the same operations as commands, with exit codes and the web UI equivalent for each task.
* [Security and isolation](/operate/security) — what the missing authentication means in practice, and how to place a boundary around it.
