Endpoint summary
Sixty-three operations across 59 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:
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 defaultlocal 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 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 liveness probe the join script polls, GET /api/runs/{run_id}/alive, takes a narrower watch token instead.
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 response that carries it as a field. It also reaches your agent in two places: the prompt from GET /api/runs/{run_id}/prompt carries it inline, and the shell_block from GET /api/runs/{run_id}/launch-profile exports it as XORCISE_RUN_BEARER. GET /api/runs does not carry it.
401:
Authorization header was absent or was not a Bearer credential. The second means the token does not belong to that run.
Keep the token off command lines. Any user on the machine can read a process’s arguments with ps, so curl -H "Authorization: Bearer …" exposes the token for as long as the request runs. Pass the header to curl on stdin instead:
-H for brevity.
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 the API raises on purpose carries FastAPI’s default shape, a single string underdetail:
422 is the exception and carries a list instead, one entry per invalid field:
503 on a server whose role has no runner, such as the experimental control role: this server has no runner to <action>: role '<role>' does not run Docker or Headscale itself, and a remote runner is not supported yet. Use role 'all' ('xorcise up'). Only all and runner own Docker and Headscale.
Two answers do not follow that shape. A 409 refusing a delete carries reason, run_ids, and forceable beside detail, as Delete a run describes. And an exception the API does not map to a status, such as SQLite reporting database is locked on POST /api/runs, is not JSON at all: it is a 500 whose body is the plain text Internal Server Error, with the cause in the server’s log rather than in the response.
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: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.
A run that ended before its agent started, or was cut short by an environment failure, is never graded, so those three endpoints answer it with a final 200 rather than a 202 that would never resolve. Branch on status before reading a score: "not_graded" is an answer, and a poll loop should stop on it. POST /regrade refuses such a run with 409.
run_status is the run’s status wording, the same status object every run record carries. detail is the reason recorded when the run ended. For a deploy_failed run the readiness gate closed, it is the gate’s verdict and log tails. For a deploy_failed run whose deploy never finished, it is one line naming why — the create failed before this run's deploy finished (…) or the server stopped before this run's deploy finished, each followed by ; its environment is torn down automatically. For crashed, cancelled and never_started, it is null, except on a run the upgrade to 0.2.1 reclassified as cancelled or never_started, where it says what the run ended as before and which grade the upgrade removed.
Agents
GET /api/agents
Synopsis200 with an array of agent records. Each carries id, name, kind, created_at, version, and the optional declared fields endpoint, model, otel, launch_command_template, launch_mode, launch_tips, and mission_preamble. 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.
kind is always a string, never null, here and in the POST and PUT responses. An agent registered without one reads "kind": "generic". This is a breaking change: earlier releases returned null for such an agent, so a client that tests kind for null must test for generic instead. xorcise db upgrade rewrites every stored agent whose kind is missing or blank to generic.
Example
POST /api/agents
Synopsiskind 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. An omitted or blank kind is stored as generic. Every kind is stored without surrounding whitespace. A kind that names no registered harness is still saved, and the response warns about it.
Parameters. None.
Request. name is required. endpoint, kind, model, and otel are optional strings. launch_command_template, launch_mode, launch_tips, and mission_preamble are optional launch overrides.
The four launch fields are optional per-agent overrides of the harness defaults served by GET /api/runs/{run_id}/launch-profile and GET /api/runs/{run_id}/prompt. null inherits the harness default.
Example
warnings lists what you should know about the saved record, and never blocks the save. Today the only warning is a kind no harness answers to:
kind first. Failing that, if exactly one harness’s slug appears as a span’s instrumentation scope or name, that harness’s adapter is used, and only then generic. A mislabelled run of a real harness can still replay with that harness’s adapter. GET /api/agents never includes warnings.
PUT /api/agents/{name}
Synopsisversion. 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.
Request. The same shape as
POST /api/agents, including the launch overrides. name is required and may differ from the path parameter. The body replaces the whole declaration, so a body without kind sets it to generic and a body without the launch overrides clears them.
Example
DELETE /api/agents/{name}
SynopsisDELETE /api/runs/{run_id} removes for one. 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.
Request. No body.
Response.
The cascade applies the same rules as deleting one run to every run the agent has. It is refused while any of them has not finished or is being graded — it no longer deletes live runs — and each finished run whose environment is still up is torn down first, one after another, before anything is removed. The
409 body is the same, with detail prefixed cannot remove agent '<name>': .
Example
GET /api/agents/{name}/history
Synopsisintel_disclosed is filled at read time from what the agent actually requested during each run.
Parameters.
Request. No body.
Response.
200 with an array of history entries carrying run_id, agent_id, created_at, overall, deterministic, judge, judge_status, partial, partial_trigger, trace_ref, a conditions object, and the run’s status wording. 404 when no agent has that name.
judge_status is the recorded grade’s, as on the result, so a 0 in judge can be told apart from a judge that never ran (model-not-configured or unavailable). It is null when the stored grade cannot be read back.
Example
Missions and the library
GET /api/missions
Synopsissource. 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.
Each entry also carries these fields, which are null (or [] for platforms) wherever XORCISE has nothing to report, such as for a mission you ingested yourself or a catalog that does not serve them:
Example
GET /api/missions/{mission_id}/manifest
Synopsismission.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 documents every field.
Parameters.
Request. No body.
Response.
200 with the manifest. 404 when the mission is neither installed nor in the library.
Example
GET /api/missions/{mission_id}/terrain
Synopsis
Request. No body.
Response.
Example
POST /api/missions/{mission_id}/update
Synopsisupdated: false means the install already matched the catalog’s current artifact — compared by index digest first — and nothing was touched.
Parameters.
Request. No body.
Response.
Example
DELETE /api/missions/{mission_id}
Synopsispurge, the fused image also stays in Docker, so a re-pull or re-ingest is cheap, and POST /api/missions/prune removes it later. With purge=true, XORCISE also removes the image, under the same rules as prune. The image stays when it is not a XORCISE mission image, or when another install, an unfinished run, or a container still uses it. The mission is uninstalled whatever happens to its image.
Parameters.
Request. No body.
Response.
The
200 body has four fields. mission_id is the mission you uninstalled. image is the image its install used, and is null for a static mission. detail is a sentence for a person, such as kept <image>: used by an active run. image_outcome is one of these values:
Example
POST /api/missions/prune
Synopsisapply: true, removes them. Without a body, or with apply: false, it is a dry run that removes nothing. xorcise mission prune calls this endpoint, and xorcise mission prune documents what counts as a XORCISE mission image.
Prune never removes the image of an installed mission, of a run that has not finished, of any container, or of a mission that is being pulled. It never touches an image that is not a XORCISE mission image. Removal is never forced. If Docker refuses to remove an image, prune lists it under failed and still removes the rest.
A mission counts as being pulled only while it has a pull job, which is how POST /api/missions/{mission_id}/pull-jobs pulls. POST /api/missions/{mission_id}/pull and the automatic pull inside POST /api/runs run without one, so a prune at that moment can remove the image they just downloaded before their install record names it. Repair that mission with xorcise mission pull <id> --refresh.
Parameters. None.
Request. Optional. apply defaults to false. image_ids limits the removal to the images you confirmed from a dry run. Prune still plans again, and removes only the confirmed images that are still candidates. Without image_ids, it removes every candidate it finds.
The report has six fields:
appliedistruewhen the request setapply: true.candidateslists every image prune found, whether or not it was removed. Withimage_ids, it lists only the confirmed images that are still candidates. Each entry hasimage_id,tags,slug,size_bytes, andreason.reasonissupersededwhen the image’s mission is installed under a different image, andnot installedwhen its mission is not installed. An untagged image hastags: [].removedlists the ids of the images prune removed. It is empty on a dry run.failedlists each image prune could not remove, withimage_id,tags, anderror. It is empty on a dry run.skippedlists the ids inimage_idsthat are not candidates now, because something uses them, they are gone, or they never were one. Prune left them alone. It is empty withoutimage_ids.reclaimable_bytesis the sum of the candidates’size_bytes. It is an upper bound, because images built on the same base share layers, and a shared layer stays on disk while any image still uses it.
apply: true with their ids:
POST /api/missions/ingest
Synopsisbundle_dir is required and is an absolute path on the XORCISE host.
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
GET /api/missions/ingest/{job_id}
Synopsissince 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.
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
POST /api/missions/{mission_id}/pull-jobs
Synopsiserror when the registry cannot be reached. Docker downloads only the layers it does not already have.
Parameters.
Request. Optional.
refresh defaults to false. Without it, the job installs a mission that is not installed, and leaves an installed one as it is.
refresh: true, the job repairs an installed mission. It removes the installed image, never forced, then pulls the library’s current image and reinstalls. It does not reuse the image the install recorded, so if the library has moved on, the repair also lands on its current release. On a mission that is not installed, refresh changes nothing. The job ends in error, with nothing removed, when another tag names the same image, when the registry does not answer for the image it will pull, or when a container, running or stopped, still uses the image. It does not check for a run that has been created but has no container yet, so a repair at that moment can remove the image that run is about to start. If the pull fails after the removal, detail says the image is already gone. A repair cancelled after the removal also leaves the mission without an image until a repair succeeds. A refresh never replaces one of your own missions. The job ends in error instead.
A refresh that arrives while a pull for the same mission is already running joins that pull, and joined is true. The running job does not become a refresh.
Response. 202 with job_id and joined. joined is true when the call joined a pull that was already running instead of starting one. The running job keeps the options it started with, so a joined refresh did not happen. Poll GET /api/missions/pull-jobs/{job_id}.
Example
GET /api/missions/pull-jobs/{job_id}
Synopsispercent 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.
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
GET /api/missions/pull-jobs
Synopsis
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
POST /api/missions/pull-jobs/{job_id}/cancel
Synopsis
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
POST /api/missions/{mission_id}/pull
Synopsis
Request. No body.
Response.
Example
GET /api/catalog/status
Synopsisdisconnected 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
GET /api/announcements
Synopsiscatalog_url on the caller’s behalf, as GET /v1/announcements/active, and keeps the answer in memory for two minutes per catalog URL. The web UI calls this once per document load and never polls; it retries up to twice only when this local endpoint itself did not answer, for example while the server restarts.
The answer is an empty list, never an error, whenever there is nothing to show. With the library disconnected, catalog_url cleared, or use_stubs on, no request leaves the machine. Otherwise an unreachable catalog, a reply over 64 KiB or in the wrong shape, and a catalog too old to serve announcements all give the same empty list. This call waits at most five seconds for the catalog. If the catalog has not answered by then, it returns the last announcements the server received from that catalog URL, even if they are older than two minutes, or an empty list if there are none. The fetch carries on in the background, and its reply is kept for the next call. Only one fetch per catalog URL runs at a time.
Parameters. None.
Request. No body.
Response. 200 with announcements, holding at most one entry per placement. Each entry has id, revision, placement (application for the banner below the header, catalog for the one in the XORCISE Remote tab), tone (information, maintenance, incident, or resolved), body_md, and dismissible. body_md is a restricted Markdown subset of at most 600 characters. revision goes up when the publisher edits an announcement, and the web UI remembers a dismissal per id and revision, so an edited banner comes back. dismissible is always false for an incident. The response carries Cache-Control: no-store.
Example
Runs
GET /api/runs
Synopsisstate is created until the agent starts, xorcising from then until the run ends, and terminal once it is over.
Parameters. None.
Request. No body.
Response. 200 with an array of run records carrying run_id, agent_id, mission, name, state, created_at, started_at, start_trigger, start_trigger_label, budget_seconds, terminal_trigger, completed_at, model, sandbox_ref, agent_version, mission_version, source_agent, intel_policy, last_telemetry_at, outcome, and status. The run_control_key is never included.
started_at is when the agent started, and start_trigger is what showed it had: telemetry (its first OTel export was admitted), run_control (its first run-control call with the run’s key), or tailnet_key (it fetched its tailnet join key, on a lab mission). Both are null until the agent starts, and stay null on a run whose agent never did. start_trigger_label is the server’s wording for the trigger — first OTel export, first run-control call, or fetched its tailnet key — and is read-only. What starts a run has the rules.
terminal_trigger is null until the run ends, then one of done, timeout, operator, cancelled, never_started, deploy_failed, or crashed. The server derives the last two fields from it on every read; they are never stored:
outcomeis how the run ended for every rate:completed(done),partial(timeoutoroperator; graded),not_started(cancelledornever_started; never graded, and left out of every performance view),infra_failed(deploy_failedorcrashed; never graded, and in no score or rate), ornullfor a run that has not ended. Whether a graded run is in the median is itsbucketonGET /api/results/runs: a timeout is, an operator stop is not.statusis how the run is shown: alabelsuch asCreated,Xorcising,Completed,Timed out,Stopped,Cancelled,Never started,Deploy failed, orCrashed; atoneofok,active,err, ormuted; and a one-sentencedetailsaying why it ended,nullfor a run that has not ended and fordone. The CLI and the web UI both print these words rather than mapping the trigger themselves.
install_revision (the local install counter), mission_version and mission_base_version (SemVer strings), platform (for example linux/amd64), platform_digest, index_digest, and content_hash. All but install_revision are null for a mission you ingested yourself or one installed before XORCISE recorded provenance. POST /api/runs returns the same fields.
Every record also carries terminal_detail, why the run ended when the trigger alone does not say. On a run the readiness gate closed out as deploy_failed, it holds the gate’s verdict followed by the environment’s log tails, captured before the environment was torn down and capped at 8,000 characters; The readiness window says what goes in it. It is null while the run is live, and for a run that ended done, timeout, operator, cancelled, never_started, or crashed. The exception is a run the upgrade to 0.2.1 reclassified as cancelled or never_started: its terminal_detail says what it ended as before, the score an earlier version gave it, and that the upgrade removed that grade.
POST /api/runs
Synopsislab 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.
Before it reserves anything, a lab run checks that this host can nest containers at the platform the mission’s image was installed for, which on an arm64 host is linux/amd64 for a mission with no arm64 image. When the mission is not installed yet and this endpoint pulls it, the check runs at the platform the pull selects, before the image downloads.
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.
Example
DELETE /api/runs/{run_id}
Synopsisruns/<run_id>/ directory under the XORCISE home. A run that has not finished cannot be deleted, because its environment is still deployed — terminate it first.
The steps run in an order that keeps a partial failure from doing lasting harm, but they are not one transaction. A delete that fails before the run row goes leaves the run listed with its result, possibly without its telemetry or submissions, and repeating the delete finishes it. Past that point a failure leaves only records with no run behind them — a run directory that cannot be removed is logged and the delete still answers 204 — and the next start removes them. The OTLP receiver does not refuse a deleted run’s id, because the run’s seal is deleted with it, so whatever a harness still exports for that id is stored again until the next start clears it.
Parameters.
Request. No body.
Response.
The run record is the only handle XORCISE keeps on a run’s container and tailnet nodes, so by default a delete does not remove it while the environment may still be up. If the run is finished but its environment has not been released yet, the server tears it down first, synchronously, so the call can take as long as that teardown. If the teardown fails because Docker or Headscale is unreachable, the delete is refused. With
force=true it goes ahead anyway and logs a warning: the container then stays until XORCISE next stops or starts and reaps it, and nothing is left that can remove its tailnet nodes.
A refusal is a 409 whose body carries three fields beside detail:
run_ids names the runs the refusal is about. force=true overrides unreleased only; a running or grading refusal is returned whatever force says.
Example
POST /api/runs/{run_id}/terminate
Synopsispartial with a partial_trigger of operator, which is how a deliberately-stopped run is told apart from one that scored badly. A run whose agent has not started is cancelled instead, and is not graded.
Parameters.
Request. No body.
Response.
Grading has not finished when this returns. Poll
GET /api/runs/{run_id}/result through the 202 window.
A run still created, whose agent has not started, is cancelled instead of stopped: the record comes back with terminal_trigger cancelled, outcome not_started and the label Cancelled, and nothing is graded, so its result is the final not-graded answer straight away.
Example
GET /api/runs/{run_id}/environment
Synopsisdetail is the readiness gate’s latest reading in words: which mission services are still starting, that the Docker daemon inside the run’s container is not answering, or that the run’s subnet router has not joined the tailnet yet.
Parameters.
Request. No body.
Response.
200 with run_id, state, ready, detail, and release_attempts. state is none for a static mission with no environment, then starting, ready, or failed while the run is live. Once the run is over it reads releasing until its container and tailnet nodes are actually gone, then released. 404 when the run id is unknown.
Every finished lab run passes through releasing, because the environment is torn down only once grading finishes. releasing with a release_attempts of 0 is that normal wait. A non-zero release_attempts counts failed teardowns, almost always Docker or Headscale being unreachable, and detail then carries the count and the last error. XORCISE retries the teardown automatically until it lands; see release_sweep_interval_seconds.
Example
GET /api/runs/{run_id}/prompt
Synopsislaunch-profile refers to $XORCISE_RUN_BEARER instead. 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.
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
GET /api/runs/{run_id}/launch-profile
Synopsisotel 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.
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. When the harness has a launch command, shell_block also exports the run’s token as XORCISE_RUN_BEARER before the command, and notes says to pass it through when the command starts the agent elsewhere. The prompt inside command refers to the variable rather than carrying the token. env never carries the token, because xorcise run launch-profile prints it as a file you might save anywhere. fallback is true when the generic telemetry provider supplied env: for openhands, which has no provider of its own, for generic, and for a kind of your own. 404 when the run id is unknown.
Example
GET /api/runs/{run_id}/events
Synopsisnext_cursor from the previous response.
Parameters.
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 exactly when the generic adapter handled the stream, which includes every run of a generic agent. An unknown run id returns an empty stream rather than 404.
Each event carries id, run_id, kind, title, body, ts, raw_ref and source_agent, plus optional detail such as subkind, role, severity, status, duration_ms, data and the grouping and actor ids. raw_ref points back at the stored record the event came from.
counts and warnings describe the whole run on every page, whatever the cursor. GET /api/runs/{run_id}/telemetry lists their keys and returns them without the events.
Example
GET /api/runs/{run_id}/events/{event_id}/raw
Synopsis
Request. No body.
Response.
200 with event_id, raw_ref, spans and logs. raw_ref has run_id, signal (trace or log), raw_seq, span_id and trace_id. For a trace-derived event, spans holds the raw OTLP span or spans at that sequence whose spanId matches and logs is empty. For a log-derived event, logs holds the raw log records of the export batch at that sequence and spans is empty. 404 when the event id is not in that run.
Example
GET /api/runs/{run_id}/telemetry
SynopsisGET /api/runs/{run_id}/events without the events, read from one cached row, which is rebuilt from the raw trace first whenever new telemetry has arrived or the renderer has changed. xorcise run status reads it for a graded run, prints it as the telemetry: line, and adds it to --json output as telemetry. The run report reads it too. It is derived from the raw trace and is never a grading input.
Parameters.
Request. No body.
Response.
The
200 body has run_id, source_agent, adapter_name, adapter_version, fallback, counts, and warnings. fallback is true exactly when the generic adapter rendered the run. A run with no telemetry yet returns zero counts and no warnings. counts has these keys:
Each entry in
warnings has code, message, span_id (always null for the codes below), and count. The message is the sentence the replay, the CLI and the report show word for word. The code is one of these values:
The warnings cover whatever has arrived so far. On a live run,
no_content can appear before the first content-bearing span lands, which is why the web UI withholds it until the run has ended.
Example
GET /api/runs/{run_id}/traces
Synopsissince 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.
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
GET /api/runs/{run_id}/otlp.jsonl
SynopsisresourceSpans) in sequence order, then log batches (resourceLogs).
There is deliberately no XORCISE framing. No header line, no envelope; every line is a plain OTLP/JSON object. The file is the Collector’s own otlpjson format, so it feeds straight into an OpenTelemetry Collector and on to Jaeger, Tempo, or any other backend that reads OTLP.
Works mid-run as a partial export of what has been ingested so far. The evidence stores are append-only with whole-batch commits, so a partial export is always a valid prefix of the final stream rather than a torn one.
Parameters.
Request. No body.
Response.
The suggested filename is
xorcise-run-<id8>-<mission-slug>-otlp.jsonl.
Example
xorcise run traces --export; the web UI offers it as OTLP Traces (.jsonl) on the result page.
GET /api/runs/{run_id}/events.jsonl
Synopsis{"type": "header", …} line carrying run and adapter metadata, then one AgentEvent per line with clean bodies rather than raw OTLP. This is the same content the on-seal artifact under ~/.xorcise/runs/<run_id>/ carries, and what xorcise run events export fetches.
Works mid-run as a partial projection. For the unnormalized stream, use GET /api/runs/{run_id}/otlp.jsonl.
Parameters.
Request. No body.
Response.
The suggested filename is
xorcise-run-<id8>-<mission-slug>-events.jsonl.
Example
GET /api/runs/{run_id}/terrain2
Synopsis
Request. No body.
Response.
200 with the resolved terrain: run_id, mission_id, objective_id, summary, groups, nodes, edges, updates, and attribution. 404 when the run id is unknown. A mission that declares no terrain returns an empty map, not an error.
Example
state is defined, discovered or completed. Each update targets a node, group or edge by target_id and sets whichever of state, discovered and active it carries. event_id is null for updates XORCISE derived from its own records, and ts is null when an update has no timestamp to anchor to. attribution.running is true while the terrain model is still working through the run’s events.
GET /api/runs/{run_id}/artifacts
Synopsisflag.
Parameters.
Request. No body.
Response.
200 with an array of {name, kind, seq, payload}. 404 when the run id is unknown.
Example
Results
GET /api/runs/{run_id}/result
Synopsis0.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. grade.transcript_items says how many distilled transcript items the judge was handed: 0 means it saw no telemetry content and graded from the artifacts alone, and it is null on a result graded by an earlier release.
Parameters.
Request. No body.
Response.
models_reported is the model or models the agent’s harness named in the run’s telemetry, in the order it first named them, and [] when it named none. It is not conditions.model, which is only what you declared with --model: both are self-reported, XORCISE verifies neither, and Conditions shows how the two are displayed together. The list holds at most eight names, and a name longer than 120 characters is cut short and ends in …. models_reported_truncated counts the further distinct names past the eighth. It stops growing once the telemetry has named 512 distinct models, so it is never more than 504. The same values are models and models_truncated on GET /stats.
agent_model and telemetry_window are the run report’s Agent model and Telemetry window cells, word for word, computed by the same code, so a client can show them without re-deriving the report’s rules. agent_model weighs the declared model against the names the telemetry carried, as Conditions tabulates, and is null where the report would say not disclosed. telemetry_window is the stretch the telemetry covers, or why it is withheld when the harness’s clock contradicts the server’s, and is null when the report omits the row: no telemetry, or a span nobody measured. Both are reported by the agent’s harness and say so in their text.
Each check_breakdown entry is one check’s verdict: id, source, ref, op, the resolved value, passed, the effective weight, error, and blocked_by. blocked_by lists the checks named in the check’s requires that did not pass. A check with a non-empty blocked_by reports passed: false even if its own op succeeded. Each judge_breakdown entry carries criterion_id, text, weight, score, reason, and status.
grade.judge_status is ok, partial, model-not-configured, or unavailable, with the reason in judge_detail. grade.grading_failed is true when grading itself raised and the 0.0 result is XORCISE’s stand-in: nothing graded the run, and re-grading it is the fix. grade also declares hard_fails, key_evidence, and major_deductions; no code path fills them and they are always empty. How grading works explains the arithmetic.
evidence_digest is the 64-character hex SHA-256 recorded when the run was sealed. evidence_verified is true or false only when this request hashed the evidence again, and null otherwise. Branch on evidence_status:
The digest covers the raw trace and log batches, the run-control record, and the observed facts. It does not cover the result itself, and it is not a signature: Security and isolation sets out what it can and cannot show.
Example
verify=true. The seal fields of the same run then read:
GET /api/runs/{run_id}/report
Synopsis
Request. No body.
Response.
Example
0.76 (76%)–0.91 (91%), followed by a line giving the share of rubric weight the judge scored.
GET /api/runs/{run_id}/stats
Synopsisprojection: the event projection it was folded from and the version of the fold. When that does not match the current one, because the run was graded by an earlier release or before the snapshot existed, the snapshot is folded again from the run’s events on this read and stored in place of the old one, so this endpoint, GET /result and the report agree. The grade itself is never rewritten. Every token figure is 0 on a run that emitted no telemetry, which is the normal reading rather than a fault.
Parameters.
Request. No body.
Response.
models and models_truncated are the names and the overflow count that GET /result returns as models_reported and models_reported_truncated. In timing, elapsed_seconds is the server’s wall clock from the run’s creation to its close-out, which the report shows as Duration. first_event_ts and last_event_ts are the earliest and latest event timestamps as the harness dated them, and a span’s timestamp is its start. last_event_end_ts is the latest point any event reaches, its start plus the duration it reported, and equals last_event_ts when no event reported a duration. The report’s Telemetry window runs from first_event_ts to last_event_end_ts; Budgets says when it is withheld. projection reads like claude-code@8+normalizer.3+stats.3: the adapter and its version, the normalizer version, and the fold version, so it changes whenever any of the three does.
Example
POST /api/runs/{run_id}/regrade
Synopsisxorcise run regrade; the web UI exposes it as Re-evaluate on the Results page.
Parameters.
Request. No body.
Response.
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.
Before grading, XORCISE checks the run’s evidence against its seal. If it no longer matches, the server logs a warning and grades the run anyway; the response is the same 202. Read evidence_status with verify=true on the result to see it.
Example
GET /api/results/runs
Synopsiscancelled or never_started). A run still being graded is, with the bucket grading, and reading this route re-drives its grading as polling its result does, so a grade lost to a restart heals here too.
Parameters.
Request. No body.
Response.
200 with an array of run scores carrying run_id, agent_id, mission, mission_version, current_version, created_at, completed_at, outcome, terminal_trigger, bucket, timed_out, deterministic, judge, judge_upper, judge_status, overall, overall_upper, judge_model, and intel_disclosed.
bucket is how the run counts, and the one rule every view reads:
mission_version is the version label the web UI shows after a v: the mission’s own version, or its install revision for a mission that declares none. current_version is false for a run on a version that is no longer installed; for a mission no longer installed at all, its newest run’s version counts as current. It is decided over every run, so a filter never changes it. The scores are null for a run with no grade.
Example
GET /api/results/summary
SynopsisGET /api/results/runs lists: as one group, per agent, per mission version, or per agent on each mission version. This is the summary the web UI’s Results page and dashboard, and xorcise leaderboard, all show. A run that ended before its agent started is not in it at all. The median is over the runs in the scored bucket; every other run is counted on its own and never in it. Like the runs route, reading it re-drives grading for a run still grading.
Parameters.
Request. No body.
Response.
200 with an array of summaries; [] with no finished runs. 422 for any other by. Each summary carries:
Every score is a fraction between
0 and 1, and null when the group has no scored run. Groups with an agent come back ranked: best median first, a group with no median last, then more scored runs first, then the name. A mission_agent summary keeps the mission versions in order and ranks the agents within each. Mission groups come back in the order of their oldest run.
Example
Run control
These are the endpoints your agent calls during a run. Every one requiresAuthorization: Bearer <run_control_key> and returns 401 without it, except /alive, which the join script calls with its watch token. Two shared mappings apply to the calls that read or write the run record: once the run is over, GET /mission, POST /artifacts, GET /intel, POST /complete, /alive, and the call that mints an attachment link return 409, and a mission that cannot be read returns 404. /connect, /join.sh, /tailscale.tgz and the signed attachment download do not check the run state. The first call that passes the bearer check moves a run that is still created to xorcising. Its start_trigger is tailnet_key for connect, join.sh and tailscale.tgz, and run_control for the rest. The join script’s /alive check takes a different token and does not start a run.
Connect any agent walks through the contract these endpoints form.
GET /api/runs/{run_id}/mission
Synopsis
Request. No body.
Response.
Example
POST /api/runs/{run_id}/artifacts
Synopsisflag — 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.
Request.
name and content are both required strings.
Example
GET /api/runs/{run_id}/intel
Synopsisintel_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.
Request. No body.
Response.
Example
POST /api/runs/{run_id}/complete
Synopsistelemetry_drain_seconds, 5 seconds by default — so flush your exporter before calling it.
Parameters.
Request. No body.
Response.
Example
GET /api/runs/{run_id}/connect
Synopsislogin_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.
Request. No body.
Response.
200 with login_server, join_key, and ca_cert. 401 without a valid bearer.
Example
GET /api/runs/{run_id}/join.sh
SynopsisGET /api/runs/{run_id}/alive reports the run over, and at the latest at the run’s budget plus 600 seconds, or after 86,400 seconds when the run is unbudgeted. The script keeps the join key and the token off every command line it runs; Connect any agent lists the variables it reads.
Parameters.
Request. No body.
Response.
200 with text/x-shellscript. 401 without a valid bearer.
Example
GET /api/runs/{run_id}/alive
Synopsis409. It authenticates with the run’s watch token, not the run-control token, and the two never substitute for each other: the watch token is refused everywhere else, and the run-control token is refused here. The join script carries the watch token where the run-control token must not go — BusyBox wget in the Docker sidecar can only send a header as a command-line argument — so a leaked watch token reveals only whether the run is still going.
Parameters.
Request. No body.
Response.
A deleted run answers
409 rather than 401, so a reaper polling for it stops.
GET /api/runs/{run_id}/tailscale.tgz
Synopsis
Request. No body.
Response.
Example
GET /api/runs/{run_id}/attachments/{name}
SynopsisX-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.
Request. No body.
Response.
The mint response carries
name, url, expires_at, media_type, and sha256.
Example
Harnesses
GET /api/harnesses
Synopsiskind. 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
GET /api/harnesses/capabilities
Synopsisnotes 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
System and configuration
GET /api/health
Synopsis200 with {"status": "ok", "service": "rest"}.
Example
GET /api/system
Synopsisxorcise 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, remotes, pid, host_platform, and mission_base. 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. pid is the serving process’s id, which lets xorcise up, xorcise down, and xorcise db upgrade find the running instance through its REST port when the xorcise.pid file is missing or stale. A server older than 0.2.0 reports no pid, so against one of those xorcise up can restore only the port record, not the pid file.
host_platform is the platform this host runs missions on, or null when it is unknown. mission_base is null or an object with required_major (the mission base generation this XORCISE needs), client_version, promoted_version, and promoted_index_digest; the last two are null when no promoted base is known.
Two more objects report on runs rather than planes:
releasescounts finished runs whose environment a teardown failed to release, so their container or tailnet nodes are still up:pending,oldest_ended_at, andoldest_error, the most recent teardown error of the longest-waiting run (not the newest error across all of them). A run that is only waiting for its grade to finish is not counted. XORCISE keeps retrying each one, andpendingfalls back to0once Docker and Headscale are reachable again.nullwhen the count cannot be read.boot_reconcileis what the start-up reconcile did with the runs that were live when the server last stopped.stateispending,running,done, orfailed, withfinished_at.adoptedcounts runs whose container survived the restart and were left running,abortedruns whose container was gone and were closed out ascrashed(not graded), andreleasedunfinished deploys that were torn down and dropped.release_pendinglists unfinished deploys whose teardown failed: they are closed asdeploy_failed, not graded, and their teardown is retried with the others.failedlists runs the reconcile could not converge, left as they were for the next start.stateisfailedonly when a whole step failed, with the reason inerror; the steps after it still ran.
POST /api/system/nested-support/refresh
Synopsisxorcise doctor calls this whenever XORCISE is running.
With nested_container_check = "skip" nothing is probed: native is ok with the detail nested-container check skipped by configuration, and foreign is empty.
Parameters. None.
Request. No body.
Response. 200 with native, the verdict for the daemon’s own platform, and foreign, one verdict per other platform. Each verdict has platform ("" for the native one), ok, detail, and remediation ("" when there is nothing to fix).
Example
GET /api/config
Synopsiskey_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 documents every field.
Example
PUT /api/config/model
Synopsisspan_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.
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 — or when a text field, such as the key, contains a line break: <field> must be a single line — it contains a line break, which would corrupt ~/.xorcise/.env. Nothing was changed. Unlike other 422s, that detail is a string.
Example
POST /api/config/model/test
Synopsis200 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
PUT /api/config/terrain-model
Synopsismodel_name, base_url, key, transcript_max_tokens. Send empty strings to clear the override.
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, or when a text field contains a line break, as for PUT /api/config/model.
Example
POST /api/config/terrain-model/test
Synopsis200 with the same shape as the judge test: ok, status, model_name, message.
Example
PUT /api/config/catalog
Synopsisconnected is required.
200 with the updated masked configuration. 422 when connected is missing or is not a boolean.
Example
PUT /api/config/network
Synopsisheadscale_url, advertise_host.
200 with the updated masked configuration. 422 when a field contains a line break, as for PUT /api/config/model.
Example
GET /api/fs/list
Synopsis403. That includes LAN peers under an explicit XORCISE_HOST=0.0.0.0 bind and agent containers reaching the API over the Docker bridge. A caller whose address cannot be parsed is refused too — it fails closed. Security and isolation covers the boundary.
Parameters.
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. 403 when the caller is not on loopback.
Example
Related pages
- CLI reference — the same operations as commands, with exit codes and the web UI equivalent for each task.
- Security and isolation — what the missing authentication means in practice, and how to place a boundary around it.