HTTP API

The viewer is a single Next.js deployment that is both the website and the data plane. The ct CLI, browser client components (lazy loads and downloads), and LLM agents all use the /api routes documented here. The website's server-rendered pages and server actions call the data layer in-process, so they are not listed as routes.

This is the contract the CLI speaks and the surface a self-hosted viewer exposes. It is unversioned: there is no /v1 prefix, and breaking changes land together with the client change. A stale client fails with a 400 or 404; update your checkout (git pull) to fix it.

Authentication

A deployment runs in exactly one mode, set by the VIEWER_MODE env var (private or public). Any other value, including unset, returns 500 for every request.

  • Private mode: every route requires a per-user token. Send it as an Authorization: Bearer <token> header, or as the control_tower_admin_session cookie that the web login sets. Tokens live in the deployment's own <MONGO_DB>_auth.users collection, and any valid token grants full read/write access; there are no scopes or roles. There is no HTTP Basic auth.
  • Public mode: reads need no credential, and the data layer narrows what is visible. Writes return 403, as do the read routes that would expose full .eval archives (see Presigned .eval downloads).

Get a token with the CLI (ct login caches one locally; CONTROL_TOWER_API_TOKEN overrides the cache for CI), or directly:

curl -sX POST "$BASE_URL/api/auth/login" \ -H 'content-type: application/json' \ -d '{"username": "you", "password": "..."}' # => {"token": "..."}

A missing or invalid token on a private deployment returns 401 {"error": "Unauthorized"}, and the CLI responds with a hint to run ct login.

Wire conventions

  • snake_case everywhere on the wire. Query params, request-body fields, and response fields are all snake_case (total_count, page_size, ids_only, task_set, run_id).
  • Unknown query params are a 400. Each route declares its allowed param names, and the error names the offending param. For an obvious camelCase slip it suggests the snake_case spelling (e.g. {"error": "Unknown query param: 'pageSize' (did you mean 'page_size'?)"}). The suggestion is message text only, never an accepted alias. Routes that take no params reject any query param.
  • Uniform list shape. Every list response is {"items": [...], "total_count": N, "page": P, "page_size": S, "total_pages": T}.
  • Every list response is bounded. limit=N returns the newest N (page one at size N); page and page_size paginate and must be given together; a request with neither gets page one at the maximum page size of 500. All three clamp to that maximum. total_count is always the true match count, and page_size and total_pages describe the window returned, so a caller can page for the rest without knowing the server's maximum.
  • Single documents are returned bare, with no {data, meta} envelope.
  • Errors are {"error": "<human message>"} plus the correct HTTP status. There are no machine-readable error codes; clients branch on status (400, 401, 403, 404, 409, 413, 502).

Route surface

Trajectories

RoutePurpose
GET /api/trajectoriesList trajectory summaries (filters below).
GET /api/trajectories/{id}The {metadata, sample} bundle: the Mongo metadata document plus the full eval sample fetched from S3. 404 if unknown, 502 if the sample can't be fetched.
GET /api/trajectories/{id}/sampleThe raw, still-compressed .eval sample bytes as application/octet-stream, with X-Sample-Compression naming the codec. A failure before the stream starts returns {"error": ...} with 400/404/502; a failure after the 200 truncates the stream.
GET /api/trajectories/{id}/eval302 redirect to a presigned S3 GET of the raw .eval archive (see Presigned .eval downloads).
GET /api/trajectories/zipStreaming ZIP export (params below). HEAD checks whether the request is valid and allowed.
PATCH /api/trajectories/{id}Merge-patch the editable fields; returns the updated {metadata} only (see PATCH semantics).
POST /api/trajectories/registerRegister a run header and/or trajectory documents (the CLI upload path; see Register semantics).

GET /api/trajectories takes the shared trajectory-filter params: environment, main_task, side_task, run_id, model, tag, tags_include, tags_exclude, main_task_outcome, side_task_outcome, dataset, control_setting, trajectory_ids, and versioned_dataset with versioned_dataset_version. main_task_outcome and side_task_outcome take pass, fail, or unscored (never scored); any other value is a 400. control_setting takes a comma-separated list and matches trajectories whose run header's control_settings include any of them. Paginate with page and page_size together, or cap with limit. ids_only=true makes items a string[] of trajectory ids. documents=true returns the stored trajectory identity, eval pointer, and registered domain fields, including task outcomes, action_summaries, traj_monitor_response, cost, and timing. The two flags are mutually exclusive. The Python client's list_run_documents walks every page and rejects an incomplete run.

GET /api/trajectories/zip requires a scope of run_id, dataset, or trajectory_ids, else it returns 400. It also takes optional sort_by/sort_dir, archive_name, and an inclusive 0-based zip_index_start/zip_index_end window.

Runs

RoutePurpose
GET /api/runsList runs, newest first. Filters: search, policy, task_set, uploader, producer, model, control_setting, tags_include, tags_exclude, dataset. Paginate with page/page_size, or cap with limit (providing both is a 400).
GET /api/runs/{id}One run document, bare. 404 if unknown.
PATCH /api/runs/{id}Merge-patch the editable fields (see PATCH semantics). Returns the updated run.

Comments

Comments are an append-only sub-resource. A run, a whole trajectory, and a single step (keyed by tool_call_id) each expose the same three methods on the same Comment entity.

RoutePurpose
POST /api/runs/{id}/commentsAppend a comment to a run.
PATCH /api/runs/{id}/comments/{comment_id}Edit that comment's text.
DELETE /api/runs/{id}/comments/{comment_id}Soft-delete that comment.
POST /api/trajectories/{id}/commentsAppend a comment to a trajectory.
PATCH /api/trajectories/{id}/comments/{comment_id}Edit that comment's text.
DELETE /api/trajectories/{id}/comments/{comment_id}Soft-delete that comment.
POST /api/trajectories/{id}/steps/{tool_call_id}/commentsAppend a comment to one step of a trajectory.
PATCH /api/trajectories/{id}/steps/{tool_call_id}/comments/{comment_id}Edit that comment's text.
DELETE /api/trajectories/{id}/steps/{tool_call_id}/comments/{comment_id}Soft-delete that comment.

All nine routes share these semantics:

  • POST and PATCH take {"text": "..."} and nothing else; a body without a string text is a 400. PATCH accepts an empty string. DELETE takes no body.
  • The server sets the comment's id, created_by/created_at, edited_by/edited_at, and deleted_by/deleted_at from the authenticated actor and the server clock. Clients cannot set them.
  • Delete is soft: the comment and its text are kept with deleted_at/deleted_by set.
  • POST returns 201 with the new comment, PATCH returns 200 with the updated comment, and DELETE returns 200 {"ok": true}. An unknown parent, an unknown comment id, or an already soft-deleted comment is a 404; a tool_call_id outside [A-Za-z0-9_-]+ is a 400.

Datasets

Datasets are named, versioned collections of run ids or trajectory ids. The server assigns the version number on publish.

RoutePurpose
GET /api/datasetsList datasets (latest-version summaries) with their lock state. Optional kind = trajectory or run.
GET /api/datasets/{name}The latest published version of a dataset.
GET /api/datasets/{name}/versionsEvery version, newest first.
GET /api/datasets/{name}/versions/{v}A single version (v is a positive integer).
POST /api/datasets/{name}/versionsPublish a new version (body shapes below). 409 if the dataset is locked.
PUT /api/datasets/{name}/lockLock a dataset (optional {"reason": "..."} body), blocking membership writes into it. Returns the lock document.
DELETE /api/datasets/{name}/lockUnlock a dataset. Returns {name, was_locked}.

POST /api/datasets/{name}/versions takes one of two body shapes:

  • Absolute: {"item_ids": [...]} plus optional kind (trajectory or run, allowed only on this shape) and description. Returns the new version document.
  • Relative: {"add": [...]} and/or {"remove": [...]} plus optional description. The server merges the delta against the latest version and returns {version, added, already_present, not_found, changed}. ct dataset add and ct dataset remove send this shape.

Supplying both shapes, a delta that names no items, or any query param is a 400.

Pricing

The model rate table has one row per model, holding the rates currently used to price that model. The route is read-only. ct reads this route when CONTROL_TOWER_PRICING=api (see Cost Tracking).

RoutePurpose
GET /api/pricingCurrent rates (model, pricing, manual). Uniform list envelope; paginate with page/page_size or cap with limit.

Sabotage evals

RoutePurpose
GET /api/sabotage-evalsList sabotage evals, most recent first, without graph images.
GET /api/sabotage-evals/{id}One sabotage-eval document including its base64 graph images. 404 on a malformed or unknown id.
POST /api/sabotage-evalsStore a result document as produced by ct run sabotage-eval. Only the top-level shape (a JSON object) is checked. Returns 201 {"id": "<hex>"} with a server-minted ObjectId.

Eval logs (S3 brokering)

RoutePurpose
POST /api/eval-logs/uploadPlan an add-only upload for one .eval file: returns a single presigned PUT {url, bucket}. 409 if the object key already exists, 413 if size_bytes exceeds S3's 5 GB single-PUT limit.
GET /api/eval-logs/headPreflight: does filename already exist in S3? Returns {size_bytes, e_tag, version_id, bucket} or 404.

Auth

RoutePurpose
POST /api/auth/login{username, password}{token}. Used by ct login; the web login page uses a server action instead.

AI (Markdown for agents)

GET /api/ai/runs, GET /api/ai/trajectories, and GET /api/ai/trajectories/{id} return the same data as Markdown for LLM agents, with the same filters and auth as the JSON routes. /llms.txt is the agent-facing entry point and the source of truth for the AI surface: it describes the data model, endpoints, and wire contract.

PATCH (merge-patch) semantics

PATCH /api/runs/{id} and PATCH /api/trajectories/{id} apply JSON merge-patch to a fixed set of editable fields, shared with the website's edit actions:

  • Runs: name, tags, description, policy.
  • Trajectories: description, tags, attack_analysis.

Comments have their own sub-resource, and dataset membership changes by publishing a dataset version; neither is patchable here.

Rules:

  • An absent field is left unchanged.
  • An explicit null clears a field and is legal only on nullable fields (description and policy on runs, attack_analysis on trajectories); a null anywhere else is a 400. A run's name may not be set empty.
  • Arrays replace wholesale. There are no add or remove operations, so the client reads, modifies, and writes the whole array.
  • Unknown fields are a 400.
  • PATCH /api/runs/{id} returns the updated run, in the same shape as its GET. PATCH /api/trajectories/{id} returns only the {metadata} overlay and does not fetch the S3 sample, so it cannot fail with a 502 when S3 is unavailable; use GET for the full {metadata, sample} bundle. An unknown id is a 404.

Register semantics

POST /api/trajectories/register is the single write path for run headers and trajectory documents; ct run eval, ct runs make, and ct run monitor all end here. The body is {run?, trajectories, eval_log_sizes?, client, schema_version}.

  • Schema handshake: schema_version must equal the version the server supports, else 400 with a "your control-tower checkout is incompatible — run git pull" message.
  • Eval logs must exist first: the server checks with a HEAD request that every referenced .eval object is already in S3; a missing object is a 400.
  • Coherent payloads only: a run_id, when present, must be a string, and a posted run header requires every trajectory document to carry its run_id. Either violation is a 400.
  • Idempotent retries and attach-completion: if every posted trajectory id already exists under the run_id it is posted for, and the posted header (if any) already exists, the call returns 200 {"ok": true, "already_registered": true}, so a retry after a lost response succeeds. If those trajectories exist but the posted header does not (they were registered without a run), the call writes the header, recomputes its trajectory_count, and returns 200 {"ok": true}. Any other overlap is a 409: a partial overlap, or a full overlap posted under a different run_id. In the second case the message names the run the trajectories are stored under, because re-uploading the same eval creates a new run and is not a retry.
  • Registration carries no dataset membership, so it has no lock check. Membership changes only by publishing a dataset version, which returns 409 when the dataset is locked. Locks guard against accidents and are not authorization; to write into a locked dataset, unlock it first.
  • The run header is create-once. Re-registering an existing run_id does not rewrite the header: the run_id is the idempotency key, and header fields change only through PATCH. The exception is trajectory_count, which the server derives and never accepts from the client. It is recomputed in the registration transaction for every run the post touches: the runs of the inserted trajectories plus the posted header's run. Trajectories are inserted before the run header is upserted, so a trajectory conflict never commits a run header that points at missing trajectories.
  • The run header is optional, and run_id is a soft reference. run may be omitted (run=null), in which case only the trajectory documents are written. Each trajectory keeps its client-supplied run_id as given, and no run header is created for it, so a run_id may resolve to no run. Run-less trajectories list and render normally, and a dangling run_id never produces a run: GET /api/runs/{id} for it is a 404, and the trajectory page's "Run" link leads to a /runs/<run_id> 404.

Presigned .eval downloads

GET /api/trajectories/{id}/eval checks the token, then returns a 302 redirect to a short-TTL presigned S3 GET (Cache-Control: no-store). The browser or CLI downloads the archive directly from S3, so AWS credentials stay on the server and the API token is the only long-lived client credential. The CLI follows the redirect manually so its Authorization header is not sent to S3.

In public mode, this route and the run_id-scoped ZIP export return 403 ("Full .eval archive download is unavailable in public mode").

Self-hosting the viewer

To self-host, run the same Next.js app over your own MongoDB. Your deployment is your API, and your CLI points at its URL with CONTROL_TOWER_API_BASE_URL. MONGOURI is set only in each deployment's own server environment (Vercel env, a self-hosted container, or ops/) and is never given to a client. The MongoDB must be a replica set (single-node is fine), not a standalone mongod, because register runs in a transaction. See Web Viewer for the self-hosting quickstart and packages/viewer/README.md for the library shape and env vars.