Web Viewer
The Control Tower web viewer shows uploaded trajectories and evaluation results from the local MongoDB database.
Local Setup
Start the support services from the repo root:
docker compose up -d --build
This starts MongoDB and two deployments of the same Next.js app from apps/viewer:
| Deployment | URL | Access |
|---|---|---|
| Private viewer | http://localhost:3000 | per-user login (seed an account first — see below) |
| Public viewer | http://localhost:3002 | none |
The docs site also runs at http://localhost:3003.
Local compose uses one MongoDB for both viewers. The public/private database split is production-only. Before you can sign in to the private viewer, seed an account against this stack's MongoDB with ct-ops admin users add <name> --env-file <file> from the ops/ package (it prints a one-time password).
The bundled MongoDB runs as a single-node replica set (--replSet rs0), not standalone: register writes run + trajectories in one transaction, and transactions require a replica set. Compose initiates the set on first boot and gates the viewers on it reaching PRIMARY, so the first up may take a few extra seconds while the set comes online. If you self-host Mongo elsewhere it must likewise be a replica set (single-node is fine) — see Database — replica-set requirement.
Viewer Modes
Viewer mode is selected by the single VIEWER_MODE env var and is fail-closed: any value other than private or public (including unset) returns 500 on every request.
- Private mode: set
VIEWER_MODE=private. Login is per-user; provision accounts withct-ops admin users add <name> --env-file <file>(fromops/), which writes to the<MONGO_DB>_auth.userscollection. - Public mode: set
VIEWER_MODE=public. The login page and/api/auth/loginare disabled; only read-only public data is served.
Publishing an explicit run batch
Uploads always go to a private viewer. A public viewer has no upload or login path; an operator publishes a reviewed, exact list of private run IDs with database credentials:
uv run ct-ops admin publish-runs \ --env-file <private-viewer.env> \ --public-env-file <public-viewer.env> \ --run-ids-file <run-ids.txt>
The default invocation is read-only: it validates every run exists, is complete, is not blocked, and contains only environments allowed by the public deployment. After reviewing that output and the ID file, re-run with --apply; the same checks run again before the whole batch is copied in one public-database transaction. Selection never depends on run tags, and the command does not accept ct login credentials. Only a narrow field allowlist is copied; uploader identities, audit logs, comments, tags, authors, descriptions, and attack analysis remain private.
Uploading Trajectories
Upload a trajectory manually:
ct traj upload path/to/trajectory.json
Evals upload trajectories to MongoDB automatically unless --no-upload is passed. The private viewer surfaces uploaded results.
Create Your Own Viewer Site
Downstream repos can deploy their own website by wrapping @control-tower/viewer-core in a thin Next.js app. This is the pattern used by the site/ directory in linuxarena-paper: the downstream app owns deployment config and branding, while every route under app/ is a small re-export from the shared viewer package.
From an empty site/ directory:
bun add @control-tower/viewer-core \ next react react-dom mongodb archiver js-yaml \ react-markdown remark-gfm react-syntax-highlighter \ react-plotly.js plotly.js-dist-min @vercel/analytics bun add -d tailwindcss @tailwindcss/postcss @tailwindcss/typography \ typescript @types/react @types/react-dom @types/node \ @types/archiver @types/js-yaml @types/react-plotly.js @types/react-syntax-highlighter bunx viewer-init
Configure the viewer mode with VIEWER_MODE:
# Public data site MONGOURI=<mongo-uri> VIEWER_MODE=public bun run dev # Private site (seed a user first: ct-ops admin users add <name> --env-file <file>) MONGOURI=<mongo-uri> VIEWER_MODE=private bun run dev
The MONGOURI you point at must be a replica set, not a standalone mongod — register writes run and trajectories in one transaction, which standalone Mongo does not support. A single-node replica set is fine (Atlas is already one); the bundled compose.yml shows the pattern (--replSet rs0 + ?replicaSet=rs0&directConnection=true on the URI).
For Vercel, set the project root directory to your site directory, use the Next.js framework preset, and set VIEWER_MODE to public or private.
Re-run bunx viewer-init --force --no-config after upgrading @control-tower/viewer-core to refresh route shims while preserving local config files. If upstream removes a route, delete the orphan shim manually because the initializer only writes files.
See packages/viewer/README.md for the full package quickstart and constraints.
Downloading from the Viewer
Trajectory, run, and dataset URLs from any viewer deployment (public or private, local or production) can be passed directly to the CLI. The CLI parses the URL, calls the viewer's API, and writes JSON files to disk. Run and dataset downloads stream the complete set as a single server-built ZIP, so they stay correct and complete regardless of how many trajectories the run or dataset holds.
# A single trajectory uv run ct traj download https://basharena-data.vercel.app/trajectories/637ddb147a1ad4731e860364c738f59f # Every trajectory in a run uv run ct runs download-trajs https://basharena-data.vercel.app/runs/ed2b360737041f1743ac4de43280f858 # Every trajectory in a versioned dataset (use ?versioned_dataset_version=N for a specific version) uv run ct dataset download https://basharena-data.vercel.app/datasets/bashbench2_v1
The same URLs also work anywhere the CLI accepts a TRAJ_LIKE argument. For example, ct run monitor can run monitors directly on viewer-hosted trajectories without an intermediate download:
uv run ct run monitor https://basharena-data.vercel.app/runs/ed2b360737041f1743ac4de43280f858 \ --action-monitor multi --only-action
Private viewers: when the URL points at a private deployment, the CLI sends a per-user bearer token — run ct login to obtain and cache one, or set CONTROL_TOWER_API_TOKEN in your .env. A 401 response is reported with a hint to run ct login. Public deployments do not need credentials.
Eval-native run pages also offer the original .eval archive directly. Private viewers require authentication. A public viewer exposes this download only for an explicitly published run whose samples all belong to publicly scoped environments — the archive is served whole, so a run with any out-of-scope sample is refused.
AI Endpoints
Every deployment (public or private) exposes a markdown-first API surface for agents, alongside the human UI. Start at /llms.txt — it describes the data model and lists every endpoint below with example URLs derived from the request's own host, so it works unmodified on any deployment.
GET /api/ai/runs— list runs. Filters:search,policy,task_set,uploader,producer,model,control_setting,tags_include,tags_exclude,dataset,limit.GET /api/ai/trajectories— list trajectories. Filters:environment,main_task,side_task,run_id,model,tag,tags_include,tags_exclude,main_task_outcome,side_task_outcome(eachpass/fail/unscored),dataset,trajectory_ids,versioned_dataset(+versioned_dataset_version),limit. Userun_idto get a run's trajectories.GET /api/ai/trajectories/{trajectoryId}— the transcript as markdown (system prompt, initial user message, then one entry per action: reasoning, tool call, tool result, monitor score). Paginated over actions with?offset=and?limit=; the response includes a "More actions available" link when there's a next page.
These return Markdown and apply the same public/private gating as the rest of the site — no auth in public mode, a bearer token in private mode (401 without a valid token) — and reuse the same repository/view-model code as the human pages, so filters and scoping stay in sync.
For the full machine-facing surface — the JSON data routes, wire conventions, raw sample byte stream, PATCH/register semantics, and self-hosting — see the HTTP API reference.
MCP
POST /api/mcp is a stateless streamable-HTTP MCP endpoint (no sessions, no SSE — every request is self-contained) exposing the same data as read-only tools: list_runs, list_trajectories, get_trajectory_transcript (paginated). It requires the same auth as /api/ai/*.
Connect Claude Code to a local or deployed viewer:
claude mcp add --transport http control-tower http://localhost:3000/api/mcp # Private deployment: add a Bearer token (the same API token as the CLI, from `ct login`) claude mcp add --transport http control-tower https://<host>/api/mcp \ --header "Authorization: Bearer <token>"
Environment Variables
Defaults in .env.example point at local services:
CONTROL_TOWER_API_BASE_URL=http://localhost:3000
To authenticate the CLI against a private viewer, run ct login (it caches a per-user token) or set CONTROL_TOWER_API_TOKEN. The viewer's own mode is chosen on the deployment via VIEWER_MODE (see Viewer Modes), not in the CLI's .env.