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 (admin / linuxbench seeded) |
| Public viewer | http://localhost:3002 | none |
The docs site also runs at http://localhost:3003.
Locally both viewers share one MongoDB; only production splits public and private databases, and an operator copies chosen runs to the public one with ct-ops admin publish-runs. Compose seeds the private viewer account admin / linuxbench. To add more accounts, run ct-ops admin users add <name> --env-file <file> from the ops/ package against this stack's MongoDB (it prints a one-time password).
The bundled MongoDB runs as a single-node replica set (--replSet rs0), because registering a run writes the run and its trajectories in one transaction, which requires a replica set. Compose initiates the set on first boot and starts the viewers once it reaches PRIMARY, so the first up may take a few extra seconds. A self-hosted Mongo must likewise be a replica set; single-node is fine.
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;ct-ops admin users addprovisions accounts in 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.
Uploading Trajectories
Upload a trajectory manually:
ct runs make path/to/log.eval
Evals upload trajectories to MongoDB automatically unless --no-upload is passed. The private viewer surfaces uploaded results.
eval2 Trajectories
eval2 is the evaluation path run by ct run eval2, and eval1 the one run by ct run eval (see Evaluation Config Files). A trajectory whose eval_metadata (the .eval header metadata, carried verbatim on every eval2 trajectory document) has an eval_version opens in a separate eval2 view; eval1 logs carry no version. Its header shows the environment, tasks with scorer markers, duration, cost, and three game-settings cards (protocol, policy, and white's harness plus granted affordances), read from eval_metadata (protocol_selection, untrusted_policy_selection, white). Below it, each ct_step lists its model calls on the left and the tool calls that ran on the right. An eval2 trajectory whose run document is missing opens in the eval1 viewer.
Every model Control Tower builds carries a role on its model events, declared in src/control_tower/models/roles.py: untrusted for the untrusted model's (U's) calls (in eval2, the action the harness sees after the blue protocol's decision) and hidden_untrusted for eval2 U's private calls, red_planner for the red team's out-of-band helpers, trusted for the trusted model as agent, editor or reviewer, monitor, diagnostic for the diagnostic scorers' graders, scorer for task-success judges, simulator for the simulated sandbox, and analyst for offline trajectory analysis. A cost breakdown by role therefore keeps graders and monitors separate from the agent's spend.
To browse local eval2 .eval files without uploading them, set CONTROL_TOWER_LOCAL_EVAL_ROOT=<dir> on the viewer and open /eval2, which lists every .eval file under that directory and opens a selected sample in the same view. The page returns 404 when the variable is unset.
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 database behind MONGOURI must carry a ct-ops record (ct-ops setup install --env-file <file> apply, or the ops-init service in compose.yml); the viewer reads its collection names from it and refuses a database without one. It must also be a replica set (see Local Setup); Atlas already is one, and 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 one server-built ZIP, however many trajectories it holds.
# A single trajectory ct traj download https://basharena-data.vercel.app/trajectories/637ddb147a1ad4731e860364c738f59f # Every trajectory in a run ct runs download-eval https://basharena-data.vercel.app/runs/ed2b360737041f1743ac4de43280f858 # Every trajectory in a versioned dataset (use ?versioned_dataset_version=N for a specific version) 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:
ct run monitor https://basharena-data.vercel.app/runs/ed2b360737041f1743ac4de43280f858 \ -m multi
For 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 comes with a hint to run ct login. Public deployments need no credentials.
Eval-native run pages also offer the original .eval archive directly. Private viewers require authentication. A public viewer offers it only for an explicitly published run whose samples all belong to publicly scoped environments.
AI Endpoints
Every deployment (public or private) exposes a markdown-first API surface for agents, alongside the human UI. Start at /llms.txt, which describes the data model and lists every endpoint below with example URLs built from the request's own host.
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,control_setting,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 use the same gating as the rest of the site: no auth in public mode, a bearer token in private mode (401 without a valid token). Filters and scoping match the human pages.
For the full machine-facing surface (JSON data routes, wire conventions, PATCH/register semantics, and self-hosting), see the HTTP API reference.
MCP
POST /api/mcp is a stateless streamable-HTTP Model Context Protocol (MCP) endpoint (no sessions or SSE) 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.