AoE2 · LLM Arena

Chapter 21 — Running the UI Locally

This is the recipe for getting from a fresh clone to “click around past runs in a browser” in three commands.

Prerequisites

  • Python env set up per the root README (uv sync or pip install -e ".[dev,server]"). The web backend is part of the base install; no extra extra is needed.
  • Bun for the frontend (brew install oven-sh/bun/bun on macOS). The justfile recipes assume bun. If you prefer pnpm/npm, the project is a standard Vite app and works fine — adjust the commands.
  • At least one DuckDB log under logs/arena/. If the directory is empty, run just arena-smoke once to generate logs/arena/<today>/smoke-*.duckdb.

The fastest path — two terminals

# Terminal 1: backend on :8000
just arena-web-dev

# Terminal 2: frontend on :5173 (proxies /runs, /events, /forks, /health to :8000)
just arena-ui-dev

Browse to http://localhost:5173. / redirects to /runs; pick a run from the left sidebar.

just arena-web-dev resolves to python -m arena_web --port 8000 (apps/api/src/__main__.py). just arena-ui-dev runs bun install (idempotent) then bun run dev.

The training tracker (third terminal, optional)

The same SPA hosts the detection training tracker under /training/*, backed by a separate service on :8100 (Chapter 24). It’s independent of the arena backend — you can run either, both, or neither:

just training-seed        # once: populate logs/training/tracker.db from disk
just training-api-dev     # Terminal 3: tracker API on :8100

Then browse to http://localhost:5173/training. Without :8100 running, the arena routes work normally and only /training/* shows load errors.

Watching a run live (cross-process)

The recipe above replays finalized runs. To watch a run as it happens, the producer (the arena CLI) and the consumer (the web backend) must share a Redis broker — an in-process broker is private to each process, so the only thing they’d otherwise share is the writer-locked DuckDB file. The *-redis recipes set ARENA_BROKER_BACKEND=redis for you (set dotenv-load puts REDIS_PASSWORD in scope, and make_broker() builds the AUTH’d URL):

just arena-infra-up          # 1. Redis (+ compose stack) up
just arena-web-dev-redis     # 2. backend on :8000, redis mode
just arena-ui-dev            # 3. dashboard on :5173
just arena-rank-redis        # 4. a run, in another shell (or arena-race-redis)

No API key? ARENA_BROKER_BACKEND=redis just arena-smoke does a mock-LLM run with the same wiring (it’s fast, so the live window is short). The run shows in the sidebar with a pulsing live badge while in progress (status: "running" from /runs) and the World panel fills in as turns arrive. The list is fetched once per page load, so reload to pick up newly-started runs; when the run finishes it becomes a normal finalized run (status: "complete"), readable from its DuckDB file even after Redis reaps the stream. See Runbook: switching-broker-backend.

Configuration knobs

Backend

Env VarDefaultPurpose
ARENA_LOGS_ROOTlogs/arenaWhere to look for DuckDB files. Useful for test isolation or pointing at a remote-mounted log dir.
ARENA_WEB_CORS_ORIGINShttp://localhost:5173, http://localhost:8000Comma-separated allow-list. Add your SPA origin when running cross-host.
ARENA_BROKER_BACKENDinprocessSet to redis to live-tail a CLI run started in another process. See Runbook: switching-broker-backend.
AOE2_LLM_API_KEYRequired only if you use the Operator panel (it calls /forks, which runs an LLM-driven replay). Listing and replaying runs works without it.

Frontend

Env VarDefaultPurpose
VITE_API_BASE_URLunset (use Vite dev proxy)Set to a full http(s)://host:port URL to bypass the proxy. Necessary when the backend is on a different machine. Live in apps/dashboard/.env.local; the example is apps/dashboard/.env.example.

Training tracker (apps/training-api, :8100)

Env VarDefaultPurpose
TRAINING_API_DBlogs/training/tracker.dbSQLite file. Delete it and re-run just training-seed to rebuild from disk.
TRAINING_API_RAW_IMAGESpackages/detection/src/real_screenshots/rawWhere ingest.py looks for screenshots.
TRAINING_API_DATASET_ROOTpackages/detection/srcParent of the training_data_* dirs.
TRAINING_API_CLASSES_YAML.../training/config/classes.yamlThe 60-class schema served at /classes.
TRAINING_API_THUMB_CACHElogs/training/thumbsGenerated thumbnails; safe to delete.
TRAINING_API_CORS_ORIGINShttp://localhost:5173,http://localhost:8100Comma-separated allow-list.

All paths resolve relative to the repo root, not the process CWD — uv run resolves from odd places, so config.py anchors them off its own file location.

The proxy paths are listed in apps/dashboard/vite.config.ts: /runs, /events, /forks, /health → :8000, and /classes, /images, /annotations, /datasets, /stats, /thumbs, /raw → :8100. When VITE_API_BASE_URL is set, the frontend prepends it to every API call instead of using relative URLs.

Three deployment modes

1. Local dev (default)

Backend and frontend both on localhost, Vite proxy in the middle. No CORS work, no env vars beyond defaults. This is what just arena-web-dev + just arena-ui-dev give you.

2. Backend on a VM, frontend on your laptop

# On the VM
export ARENA_WEB_CORS_ORIGINS="http://localhost:5173,http://localhost:5174"
just arena-web-dev   # binds to 127.0.0.1 by default; pass --host 0.0.0.0 if needed
# On the laptop
echo 'VITE_API_BASE_URL=http://vm.local:8000' > apps/dashboard/.env.local
just arena-ui-dev

The CORS list on the backend must include the laptop’s SPA origin. If you change it after the server is up, restart arena-web-dev — the lifespan reads the env var once at startup.

3. Production build

just arena-web-build     # writes apps/dashboard/dist/

The dist/ bundle is a standard Vite static site — drop it behind any HTTP server, or mount it from FastAPI with a StaticFiles mount. Wiring that into server.py is intentionally not done; the URL contract in Chapter 19 is the stable surface, and how you serve the bundle is up to your deployment.

Smoke test

After bringing both halves up, hit the API directly to confirm the backend is fine before debugging the UI:

curl -s http://localhost:8000/health
# {"status": "ok"}

curl -s http://localhost:8000/runs | jq '. | length'
# 1   (or more, depending on how many runs you have)

curl -sN "http://localhost:8000/events?run_id=$(curl -s http://localhost:8000/runs | jq -r '.[0].run_id')" | head -3
# data: {"kind":"turn_start","turn_num":0,"state":{...}}
# data: {"kind":"observation","entity_count":...,...}
# data: {"kind":"llm_prompt","state_summary":"..."}

If /events hangs with no output, the run id is for a live run on a broker the web server can’t see (e.g. you started a CLI race in another process without ARENA_BROKER_BACKEND=redis on both ends). See Runbook: switching-broker-backend.

Fork-and-replay flow

The end-to-end Operator workflow:

  1. Open a finalized run from the sidebar.
  2. Scrub the timeline to an interesting turn (say t=15).
  3. Switch to the Operator tab. parent_t is pre-filled with 15.
  4. Tick “mutation” and set food: 1500, age: Feudal Age (or whatever counterfactual you’re testing).
  5. Set n_turns: 20, give a reason, submit.
  6. The new run appears in the sidebar; the timeline starts streaming live as _replay publishes events.
  7. Switch to the Diff tab to see parent vs child side-by-side.

For this to work you need AOE2_LLM_API_KEY exported in the shell that started arena-web-dev — the fork replay drives a real LLM. The default fork profile is Haiku at temperature 0.5 (apps/api/src/forks.py:95, DEFAULT_FORK_PROFILE). Cost per 10-turn fork is roughly the same per-profile cost as one race-instance variant from arena race — see Chapter 17 for the breakdown.

Troubleshooting

SymptomLikely causeFix
Sidebar emptyNo DuckDB files under logs/arena/just arena-smoke to generate one, or set ARENA_LOGS_ROOT to a populated directory.
404 /runs from the UIVite proxy not running, or VITE_API_BASE_URL is set but the backend isn’t reachable at that URLcurl the backend directly; if it works, the issue is in the frontend wiring.
SSE shows Error statusCORS rejection (you’ll see a console error in DevTools) or the backend diedCheck the backend logs and ARENA_WEB_CORS_ORIGINS.
Operator tab returns 500AOE2_LLM_API_KEY not set on the backendExport it in the shell that runs arena-web-dev and restart.
/events returns 404Run isn’t in any DuckDB under ARENA_LOGS_ROOTConfirm with curl /runs; the run may have been written to a different log root.
Run shows as Streaming but no eventsLive broker mode but the producer’s broker doesn’t match the server’sBoth producer and server need the same ARENA_BROKER_BACKEND. See Runbook: switching-broker-backend.
Navigating to /runs/<id> shows raw JSONDev proxy matched /runs before the SPA fallbackThe bypass on the /runs proxy entry handles this — check it wasn’t removed from vite.config.ts.
/training/* pages error, arena pages fineTracker API on :8100 isn’t runningjust training-api-dev. The two backends are independent.
/training/coverage loads but everything is zeroTracker DB is emptyjust training-seed.
Tracker images 404 on /raw or /thumbsDB rows point at paths that movedRe-run just training-seed; it upserts by path.