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 thecontrol_tower_admin_sessioncookie that the web login sets. Tokens live in the deployment's own<MONGO_DB>_auth.userscollection, 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.evalarchives (see Presigned.evaldownloads).
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=Nreturns the newest N (page one at size N);pageandpage_sizepaginate 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_countis always the true match count, andpage_sizeandtotal_pagesdescribe 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
| Route | Purpose |
|---|---|
GET /api/trajectories | List 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}/sample | The 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}/eval | 302 redirect to a presigned S3 GET of the raw .eval archive (see Presigned .eval downloads). |
GET /api/trajectories/zip | Streaming 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/register | Register 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
| Route | Purpose |
|---|---|
GET /api/runs | List 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.
| Route | Purpose |
|---|---|
POST /api/runs/{id}/comments | Append 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}/comments | Append 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}/comments | Append 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:
POSTandPATCHtake{"text": "..."}and nothing else; a body without a stringtextis a400.PATCHaccepts an empty string.DELETEtakes no body.- The server sets the comment's
id,created_by/created_at,edited_by/edited_at, anddeleted_by/deleted_atfrom 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_byset. POSTreturns201with the new comment,PATCHreturns200with the updated comment, andDELETEreturns200 {"ok": true}. An unknown parent, an unknown comment id, or an already soft-deleted comment is a404; atool_call_idoutside[A-Za-z0-9_-]+is a400.
Datasets
Datasets are named, versioned collections of run ids or trajectory ids. The server assigns the version number on publish.
| Route | Purpose |
|---|---|
GET /api/datasets | List 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}/versions | Every version, newest first. |
GET /api/datasets/{name}/versions/{v} | A single version (v is a positive integer). |
POST /api/datasets/{name}/versions | Publish a new version (body shapes below). 409 if the dataset is locked. |
PUT /api/datasets/{name}/lock | Lock a dataset (optional {"reason": "..."} body), blocking membership writes into it. Returns the lock document. |
DELETE /api/datasets/{name}/lock | Unlock a dataset. Returns {name, was_locked}. |
POST /api/datasets/{name}/versions takes one of two body shapes:
- Absolute:
{"item_ids": [...]}plus optionalkind(trajectoryorrun, allowed only on this shape) anddescription. Returns the new version document. - Relative:
{"add": [...]}and/or{"remove": [...]}plus optionaldescription. The server merges the delta against the latest version and returns{version, added, already_present, not_found, changed}.ct dataset addandct dataset removesend 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).
| Route | Purpose |
|---|---|
GET /api/pricing | Current rates (model, pricing, manual). Uniform list envelope; paginate with page/page_size or cap with limit. |
Sabotage evals
| Route | Purpose |
|---|---|
GET /api/sabotage-evals | List 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-evals | Store 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)
| Route | Purpose |
|---|---|
POST /api/eval-logs/upload | Plan 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/head | Preflight: does filename already exist in S3? Returns {size_bytes, e_tag, version_id, bucket} or 404. |
Auth
| Route | Purpose |
|---|---|
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
nullclears a field and is legal only on nullable fields (descriptionandpolicyon runs,attack_analysison trajectories); anullanywhere else is a400. A run'snamemay 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 itsGET.PATCH /api/trajectories/{id}returns only the{metadata}overlay and does not fetch the S3 sample, so it cannot fail with a502when S3 is unavailable; useGETfor the full{metadata, sample}bundle. An unknown id is a404.
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_versionmust equal the version the server supports, else400with a "your control-tower checkout is incompatible — rungit pull" message. - Eval logs must exist first: the server checks with a HEAD request that every referenced
.evalobject is already in S3; a missing object is a400. - Coherent payloads only: a
run_id, when present, must be a string, and a postedrunheader requires every trajectory document to carry itsrun_id. Either violation is a400. - Idempotent retries and attach-completion: if every posted trajectory id already exists under the
run_idit is posted for, and the posted header (if any) already exists, the call returns200 {"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 itstrajectory_count, and returns200 {"ok": true}. Any other overlap is a409: a partial overlap, or a full overlap posted under a differentrun_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
409when 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_iddoes not rewrite the header: therun_idis the idempotency key, and header fields change only throughPATCH. The exception istrajectory_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_idis a soft reference.runmay be omitted (run=null), in which case only the trajectory documents are written. Each trajectory keeps its client-suppliedrun_idas given, and no run header is created for it, so arun_idmay resolve to no run. Run-less trajectories list and render normally, and a danglingrun_idnever produces a run:GET /api/runs/{id}for it is a404, 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.