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, monitors, cost, and timing. monitors holds, per monitor named in the eval's monitor score, its highest score (max), how many verdicts it could not give (unscored), and its cost. 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 --upload and ct runs make both 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.