Every route is gated by either the browser cookie session (passphrase +
CSRF) or the bearer token auth path. Bearer
requests skip CSRF; both flow through the same middleware.
| Method |
Path |
Notes |
POST |
/api/battle |
Start a battle. Body: {prompt, category, model_a?, model_b?, reasoning_effort?}. Supplying one model draws a random opponent. reasoning_effort is low, medium, high, or omitted/off. Returns {battle_id, reasoning_effort}. Rate-limited to 10/min/IP. |
GET |
/api/battle/{id}/stream |
Server-Sent Events. Emits model_a, model_b, model_a_thinking, model_b_thinking, model_a_notice, model_b_notice, model_a_done, model_b_done, battle_complete. The *_done payload carries reasoning_effort (as applied) and reasoning_tokens. |
POST |
/api/battle/{id}/vote |
Body: `{winner: “a” |
POST |
/api/battle/{id}/judge |
Runs the configured judge, casts an automated vote, returns the reveal payload + judge reasoning. See LLM-as-Judge. |
GET |
/api/battle/{id} |
Permalink read. Only voted battles are returned; unvoted / in-flight is 404. Includes ELO deltas + vote_method + judge reasoning if judged. |
Presenter side, behind the normal auth gate:
| Method |
Path |
Notes |
POST |
/api/battle/{id}/poll |
Open (or fetch) the poll for a finished, unvoted battle. Returns {code, join_path, status, tally}. 400 before both responses land, 409 once voted. |
GET |
/api/battle/{id}/poll |
Live tally {a, b, tie, total} for the presenter screen. |
POST |
/api/battle/{id}/poll/close |
Closes the poll and records the plurality as the vote (vote_method: "audience"). Returns the reveal payload plus audience_tally. 400 with zero votes, 409 if already closed or voted by hand. |
Audience side, public (no passphrase, no CSRF). Nothing here can create a battle or reach a provider:
| Method |
Path |
Notes |
GET |
/vote/{code} |
Phone page. |
GET |
/api/audience/{code}?voter_id= |
Prompt, both responses, status (open, closed, expired), vote_count, and the caller’s your_choice. The tally and model names appear only once closed. |
POST |
/api/audience/{code}/vote |
Body: {voter_id, choice}. voter_id is 8 to 64 chars of [A-Za-z0-9_-], minted by the phone. Re-posting changes the vote. 409 once closed, 429 past 300/min per IP. |
| Method |
Path |
Notes |
GET |
/api/leaderboard?category=overall |
Ranked models, with provisional entries appended. Provisional threshold is 5 battles. Tied ELO shares a rank. |
GET |
/api/stats |
{total_battles, total_voted, battles_today}. |
GET |
/api/models |
Enabled models with categories, provider, and reasoning (auto, yes, off). |
GET |
/api/features |
Server-side flags (judge, suites, reasoning.efforts, audience) so the frontend renders conditionally. |
GET |
/api/export?format=csv|json |
Full history of voted battles. |
| Method |
Path |
Notes |
GET |
/api/suites |
List suites loaded from suites/*.yaml. |
GET |
/api/suites/{name} |
Suite detail including all prompts. |
POST |
/api/suites/{name}/run |
Kicks off a background run. 400 if no judge is configured. Returns {run_id, battles_total, status}. |
GET |
/api/suites/{name}/runs |
List runs of a suite (most-recent first, up to 20). |
GET |
/api/suites/runs/{run_id} |
Full detail: status, cost, per-battle rows, per-model tally. |
| Method |
Path |
Notes |
GET |
/api/costs?days=N |
Per-model spend + measured cost per 1k output tokens. days in [1, 3650]. |
GET |
/api/metrics |
Prometheus scrape endpoint. See Metrics. |
| Method |
Path |
Notes |
POST |
/api/login |
Body: {passphrase}. Sets arena_token + arena_csrf cookies. |
GET |
/login |
HTML login page. Public. |
GET |
/healthz |
{"status": "ok"}. Public. Container healthcheck target. |
| Method |
Path |
Notes |
GET |
/ |
Arena view (SPA entry). |
GET |
/leaderboard |
Leaderboard view (SPA entry). |
GET |
/battle/{id} |
Permalink view (SPA entry). Rehydrates the reveal from /api/battle/{id}. |
4xx and 5xx return {"detail": "human-readable message"}. Rate-limit
429 returns "slow down — max 10 battles per minute".