API
Base URL http://localhost:3001 (PORT). JSON bodies. The web dev server proxies /api and /health.
No authentication
| Concern | How it works |
| Credentials | None. No endpoint takes an API key, token, or session cookie. |
| Vote capability | The one credential in the API is the HMAC-signed matchup token (MATCHUP_TOKEN_SECRET), minted on matchup_started and required by POST /api/arena/vote — what stops a caller voting on a matchup it never saw. |
| Public surfaces | Leaderboard, model list, and analytics aggregates. Model-level data only — no per-user or per-session rows. |
| Reading your own rounds | The two exceptions to "model-level only". GET /api/arena/matchups/:id needs the round's unguessable UUID and never returns its token; GET /api/arena/conversations/:id is additionally scoped to the anonymous session that owns the conversation. |
| Browser access | CORS instead of auth: exactly one allowed origin, WEB_ORIGIN (default http://localhost:5173), methods GET and POST. Server-to-server callers are unaffected. See Setup. |
| Error bodies | Always { "error": "<message>" }. The chat route's validation 400 adds details, zod's field-keyed map; other routes' 400s are the plain one-key shape. |
POST/api/arena/chat
Starts a blind matchup, or continues one from its latest winning response, and streams both responses over one SSE connection.
{ "prompt": "Explain JWTs in simple terms",
"sessionId": "anon_123",
"conversationId": "optional UUID from matchup_started",
"arena": true,
"joinKey": "chat-7f3a" }
| Field | Rules |
| prompt | Required, 1–20,000 characters after trimming. |
| sessionId | Optional anonymous session id, 1–200 characters. |
| conversationId | Optional UUID from an earlier matchup_started; omit at turn 0. |
| arena | Optional per-request opt-in, read only under ARENA_TRIGGER=manual (the x-arena: on header does the same for clients that cannot change the body). Ignored under always and sampled. See Setup → Trigger and exposure. |
| joinKey | Optional, 1–200 characters. Opts into slot join — one matchup served over two sibling requests. |
Unknown members are ignored rather than rejected.
| SSE event | Payload | Notes |
| matchup_started | { matchupId, matchupToken?, conversationId?, turnIndex?, slots, mode, votable } | First event. Save the conversation ID; no model identities. mode is matchup, single, or shadow (the last under ARENA_EXPOSURE=shadow). votable is true only for engaged blind matchups; single and shadow emit false (shadow rows are also rejected at vote time with 403). slots lists the slots this response streams: ["A","B"] normally, ["A"] for a single or shadow round, one slot each under slot join. |
| token | { slot, token } | Interleaved tokens for A and B |
| slot_error | { slot, message } | Other slot keeps streaming |
| slot_done | { slot } | Content/latency stripped from public event |
| steered | { instruction } | Mid-stream steer took effect; both slots are about to re-run. Reset slot buffers. |
| run_error | { code, message } | Terminal: the round failed; no matchup_done follows |
| matchup_done | { } | Stream closes |
The metadata is also a response header. Every protocol repeats matchup_started (minus its type) as JSON in x-arena-matchup, because mainstream agentic runtimes drop the in-band copy — assistant-ui's AG-UI aggregator discards CUSTOM events, so on useAgUiRuntime({ url }) the vote token reaches the browser and dies there. A header is readable by a fetch wrapper, a route handler, or a proxy with no cooperation from the runtime. Same omission rules as the event (no token on a single or shadow round; shadow still carries conversationId / turnIndex); browsers get it via Access-Control-Expose-Headers. A client left holding only a matchupId reads the round back from GET /api/arena/matchups/:matchupId.
Linear history: omit conversationId at turn 0. After a decisive left/right vote, resend it for a follow-up. The server reconstructs history from stored winners; ties, both-bad, skips, and unvoted turns cannot continue.
Identifiers are only sent when they can be used. matchupToken,
conversationId, and
turnIndex are
omitted — not empty-stringed, not nulled — when the round has nothing behind them. A
single round persists no matchup: no token to vote with, no conversation to continue. A
shadow round persists the matchup and conversation but still omits
matchupToken (
votable: false). Absence is authoritative and pairs with
votable: false. A
single round is also invisible to the rating worker — a Bradley-Terry fit needs a comparison, and none was recorded (
what the engine cannot rate).
| Status | code | Pre-stream error |
| 400 | invalid_request | Invalid request body — the one response that also carries details |
| 400 | join_requires_session | joinKey sent without sessionId |
| 403 | conversation_forbidden | sessionId does not own the requested conversation |
| 404 | conversation_not_found | Unknown conversationId |
| 409 | conversation_not_ready | The prior turn has no decisive vote |
| 409 | conversation_conflict | Another request already advanced the conversation |
| 409 | join_slots_exhausted | Both slots of this join scope are already claimed |
| 409 | join_expired | The join window closed before this request arrived |
| 500 | default_model_missing | ARENA_DEFAULT_MODEL is not in the enabled roster (single / shadow rounds) |
| 500 | shadow_challenger_missing | Matchmaker could not pick a challenger ≠ ARENA_DEFAULT_MODEL (shadow only) |
| 500 | join_failed | The request holding the shared matchup failed for an unclassified reason |
| 503 | join_unavailable | Too many unpaired joins in flight; retry without joinKey |
| 504 | join_leader_timeout | The request holding the shared matchup never started it |
A failure on the request that owns a shared matchup is forwarded to its sibling, so both halves of one turn fail identically instead of leaving one parked on a matchup that was never created.
Exception — AG-UI. Its clients settle a run on the terminal RUN_ERROR event and read a non-2xx as a dead transport with nothing to render, so for ?protocol=ag-ui these failures come in-band: a 200 stream whose one event is RUN_ERROR with the same message and the code from the table. Every other protocol keeps the statuses above and answers with { "error": … } — the code column is the in-band identity of each failure, not a JSON field.
A failure after streaming starts cannot use a status at all — the response is committed at 200 — so it arrives as a terminal run_error (stream_failed) in the active protocol's idiom, and a client settles instead of awaiting a matchup_done that never comes.
Serving one matchup over two requests (joinKey)
Some compare-view UIs fan a multi-model turn out into one request per model sharing a conversation id (Open WebUI v0.10 is the measured case). Each such request has exactly one answer channel, so the default shape — both slots interleaved on one connection — cannot serve it. The same joinKey on both requests pairs them server-side into one matchup.
Request 1 · leader
claims slot A · runs matchmaking, blindness, persistence once · streams A
Request 2 · follower
claims slot B · waits for the leader's matchup · streams B
▼
One matchup
one matchups row · same matchupId, token, conversationId, turnIndex on both connections · one vote
| Rule | Detail |
| Scope | A join is keyed by anonymous session + conversation + exact prompt + joinKey, not the key alone, so knowing someone else's key buys nothing — a mismatch on any part simply produces a separate matchup. joinKey therefore requires sessionId (400 join_requires_session). |
| Slots | First arrival is the leader (slot A), second the follower (slot B); each connection carries only its own slot's events and its matchup_started narrows slots accordingly. A third request on a live scope is 409 join_slots_exhausted. |
| Window | ARENA_JOIN_WINDOW_MS, default 2000. If it closes with no sibling the round degrades to the default shape — both slots on that one connection, slots: ["A","B"], votable — so nothing is wasted. 0 disables joining and joinKey is then ignored; at most ARENA_JOIN_MAX_PENDING unpaired scopes are tracked (default 256). |
| Durability | Slot B still completes and is still recorded if the sibling disconnects — the leader keeps consuming the shared generation. A stop on the control plane stops both slots. |
| Voting | Unchanged: one token, one vote, one reveal — either connection can cast it. |
| Protocols | joinKey rides each protocol's own extension slot (forwardedProps for AG-UI, omni_arena for OpenAI, top level for useChat) — see the integration guide. |
The field is absent on every existing client, so the single-connection two-slot flow is untouched. Failures are the join_* rows above; 504 join_leader_timeout is the follower's safety net when a leader dies in a way its own error handling misses, not a normal outcome.
Protocol selection
The same matchup stream can be framed in five wire protocols. Precedence: ?protocol= query param → Accept media type → default native SSE. An unknown ?protocol= falls back to SSE, so native SSE is byte-for-byte unchanged and existing clients (demo + SDK) are unaffected.
| Protocol | ?protocol= aliases | Accept media type(s) | Response content-type |
| Native SSE default | sse · native · native-sse | text/event-stream | text/event-stream |
| AG-UI | agui · ag-ui | application/vnd.ag-ui+json | text/event-stream |
| A2UI | a2ui | application/vnd.a2ui+json · application/x-ndjson | application/x-ndjson |
| Vercel AI SDK | vercel · vercel-ai · ai-sdk | application/vnd.vercel.ai.ui-message-stream+json | text/event-stream + x-vercel-ai-ui-message-stream: v1 |
| OpenAI SSE | openai · openai-sse | application/vnd.openai.chat-chunk+json | text/event-stream |
Same semantics, different framing. Every protocol carries the identical matchup_started → token → slot_error → slot_done → run_error / matchup_done sequence over two slots (A, B); only the wire shape differs, and every frame is schema-validated before it is written.
Three adapters are bidirectional. AG-UI accepts a canonical
RunAgentInput, the OpenAI adapter a standard
/chat/completions body, the Vercel adapter the body
useChat posts — so stock clients of those protocols call the endpoint with no translating transport. OmniArena's own body above still works on every protocol; the shapes are told apart by whether the body carries a
messages array. Native SSE and A2UI accept OmniArena's body only. Where
sessionId /
conversationId /
arena ride per protocol: see the
integration guide.
The arena is also served at
POST /chat/completions and
POST /v1/chat/completions, with the OpenAI protocol implied by the path — an OpenAI client appends that path to a base URL and cannot pass
?protocol=. A matchup is two live streams with no buffered
chat.completion object, so
"stream": false is refused with a
400 naming the field; omit
stream or set it to
true. Sampling knobs (
temperature,
max_tokens,
tools, …) are accepted and ignored — the arena chooses the models and the sampling — and
user seeds
sessionId.
| Protocol | Framing |
| Native SSE | event:/data: pairs, one per event; no trailing sentinel (the event table above). |
| AG-UI | Typed AG-UI events, one data: line each, over SSE: RUN_STARTED + CUSTOM arena_matchup (the matchup metadata) + a TEXT_MESSAGE_START per slot → TEXT_MESSAGE_CONTENT (delta, slot) → CUSTOM slot_error plus the same text as marked content → TEXT_MESSAGE_END → RUN_FINISHED, or RUN_ERROR (message, code). threadId and runId are the client's own when its RunAgentInput carried them (AG-UI's contract is that a server echoes them), else conversation-then-matchup and matchup. messageId stays <matchupId>:<slot> — the channel consumers parse the slot out of. |
| A2UI | Schema-validated NDJSON (one flat JSON object per line, versioned a2ui/1), two surfaces A/B: surface_init (matchupId, matchupToken, conversationId, turnIndex, surfaces, mode, votable) → text_append → error → surface_done → session_done, with a terminal failure as session_error. |
| Vercel AI SDK | AI SDK UI Message Stream over SSE. Slot A on the primary text channel (start → data-arena-meta → text-start/-delta/-end); slot B via custom data parts (data-arena-b-delta / -b-done); slot errors via data-arena-error, a dead round via the SDK's own error part; ends finish + data: [DONE]. |
| OpenAI SSE | chat.completion.chunk frames. Every frame lists one choice per active slot in slot order, so choices[0] is the first active slot and choices[1] the second — real clients read choices[0] positionally instead of demultiplexing on index, and one-choice-per-frame framing spliced both models into one incoherent message. With both slots that is A then B; a single or joined response has that slot as its only choice, still carrying its own index (0 for A, 1 for B), so a positional reader gets that slot's text either way. A slot with nothing to say in a frame gets an empty delta; a finished slot keeps finish_reason: "stop" in later frames. The first chunk carries an optional omni_arena object (metadata, including conversationId / turnIndex), a dead slot adds omni_arena_error, a dead round is an { error } frame; ends data: [DONE]. |
The
@omni-arena/react SDK and the demo app consume the default native SSE stream. See the
integration guide for per-protocol wire examples, Model A/B carrying, voting, and the shipped example apps.
Every protocol carries the vote token when there is one, each in its own idiom, so no path needs a second channel: native SSE on
matchup_started, Vercel AI SDK in
data-arena-meta, A2UI on
surface_init, AG-UI in a
CUSTOM event named
arena_matchup, OpenAI SSE in an optional
omni_arena object on the first chunk. All five also carry
mode and
votable, and all five omit the token on a non-votable (
single) round, so a client can hide the vote controls. Every one of them additionally repeats the metadata in the
x-arena-matchup header — the path of last resort for a runtime that discards the in-band copy. See the
integration guide.
WS/api/arena/control
The control plane: a bidirectional WebSocket that acts on an in-flight matchup out-of-band from the token stream. Messages are JSON in both directions.
→ { "type": "stop", "matchupId": "<uuid>" }
← { "type": "stopped", "matchupId": "<uuid>", "ok": true }
→ { "type": "steer", "matchupId": "<uuid>", "instruction": "be more concise" }
← { "type": "steer_ack", "matchupId": "<uuid>", "accepted": true }
← { "type": "steer_ack", "matchupId": "<uuid>", "accepted": false, "reason": "…" }
| Message | Behavior |
| stop | Aborts the matchup's stream via the matchup registry's AbortController (signal threaded through ArenaCore.stream). ok:false when the matchup is unknown — never started, already finished, or already stopped. |
| steer | Abort-and-restart. Delivers the instruction to the live matchup; both slots re-run with the identical system operator turn (blindness preserved). Emits a public steered event and appends to matchups.steers. accepted:false (with reason) for unknown/expired matchups or matchups that already completed a slot. |
Invalid JSON → { type: "error", message: "Invalid JSON control message" }; an unknown/malformed message (including a non-UUID matchupId) → { type: "error", message: "Unknown or malformed control message" }.
@omni-arena/react wraps this as
stop() on
useArenaChat: it derives the
ws:///
wss:// URL from the hook's
baseUrl, opens the socket on first use, and closes it on the
stopped reply or on unmount.
POST/api/arena/vote
Records one vote, then reveals model identities.
{ "matchupId": "3f6e…", "matchupToken": "eyJt….sig", "vote": "left" }
→ { "accepted": true,
"models": { "A": { "id", "displayName" }, "B": { "id", "displayName" } },
"continuable": true,
"conversationId": "…" }
leftrightboth_goodboth_badskip
| Field | Rules |
| matchupId | Required UUID, from matchup_started. |
| matchupToken | Required. The token from the same event, verbatim. |
| vote | Required, one of the five values above. |
| Status | Meaning |
| 400 | Invalid body (non-UUID matchupId, unknown vote, missing token) |
| 401 | Bad signature, expired token, or claims that don't match the stored matchup (its id or either slot's model) |
| 403 | Shadow matchups are not votable (matchups.mode='shadow'; human votes are rejected) |
| 404 | Unknown matchup |
| 409 | Vote already recorded |
The token is the only credential. A payload.signature pair (base64url JSON claims, HMAC-SHA-256 over them, MATCHUP_TOKEN_SECRET) whose SHA-256 hash is stored on the matchup row, so a token must be both well-signed and the one this matchup was served with. It expires 15 minutes after issue — a later vote gets a 401. The anonymous session credited with the vote comes from the token's claims, not the request body.
A left/right vote records the winning model; both_good, both_bad, and skip record no winner — which is why only a decisive vote lets a conversation continue. continuable states that outright and conversationId is echoed with it, so a client neither re-encodes the rule nor has to have kept the id — a wrong guess used to cost a 409 conversation_not_ready on the next turn.
GET/api/arena/matchups/:matchupId
Reads one round back out of band: its shape, whether it is still open, and — only against a recorded vote — the identities. For a client whose runtime dropped the stream's metadata, or one left after a reload with nothing but a matchupId parsed out of a messageId.
{ "matchupId": "3f6e…", "conversationId": "…", "turnIndex": 0,
"mode": "matchup", "votable": false, "continuable": true, "vote": "left",
"models": { "A": { "id", "displayName" }, "B": { "id", "displayName" } } }
| Field | Meaning |
| votable | true until a vote is recorded; vote is null for the same span and models is null with it — identities travel only with a vote, on every read path. |
| continuable | Same left/right rule as the vote response. |
| mode | Always matchup: a single round writes no row and is never readable here (404). |
| No token | The matchup token is the capability that authorises a vote, minted once onto the stream. An unauthenticated read that handed it out would let anyone holding a matchup id vote on a round they never saw. |
Errors: 400 for a non-UUID matchupId, 404 for an unknown one.
GET/api/arena/conversations/:conversationId
Rehydrates a whole thread after a reload — every turn, both blind answers, the vote, and the reveal where one exists. Query: sessionId (optional, 1–200 characters). Without this endpoint a host either lost the thread on refresh or rebuilt it from client-only state it had invented.
{ "conversationId": "…", "continuable": false, "nextTurnIndex": 2,
"turns": [
{ "turnIndex": 0, "matchupId": "…", "prompt": "Explain JWTs…",
"votable": false, "vote": "left",
"answers": [ { "slot": "A", "content": "…", "error": null },
{ "slot": "B", "content": "…", "error": null } ],
"models": { "A": { "id", "displayName" }, "B": { "id", "displayName" } } },
{ "turnIndex": 1, "prompt": "Go deeper", "votable": true, "vote": null,
"answers": [ … ], "models": null } ] }
| Rule | Detail |
| The open turn is included | A pending pair awaiting a decision is precisely the state a reload has to restore. Its models is null, so restoring a thread cannot leak the round it is still blind on. |
| continuable · nextTurnIndex | Describe the next turn: whether it may pass this conversationId at all, and the index it will be given. |
| Session-scoped | This read returns a caller's own prompts and answers, so it is scoped to the anonymous session owning the conversation — the same check POST /api/arena/chat makes before continuing one. A conversation started without a sessionId is readable without one. |
| Errors | 400 malformed id or sessionId · 403 session mismatch · 404 unknown conversation |
GET/models · /v1/models
The enabled roster in OpenAI's model-list shape, for the OpenAI-compatible clients the OpenAI SSE adapter serves. Open WebUI probes this on connect and shows an empty model picker without it.
{ "object": "list",
"data": [ { "id": "8b0f…", "object": "model", "created": 1770000000,
"owned_by": "google", "name": "Gemini 3.5 Flash" } ] }
| Ids | id is OmniArena's model id — the same one the leaderboard and vote reveal use |
| Extension | name is non-standard; Open WebUI renders it, other OpenAI clients ignore it |
| created | The server's boot time — the roster has no creation timestamp of its own and OpenAI's model object requires one |
| owned_by | The model's provider key (google, openai, mock, …) |
| Both paths | Served with and without the /v1 prefix, because a deployment may configure the arena's base URL either way |
| Blindness | Unaffected: the leaderboard already lists the roster publicly. What stays hidden is which two models a given round used. |
| No siblings | Naming a model in a chat body does nothing — matchmaking picks both slots — so there is no GET /models/{id}. The chat-completions surface those clients need is POST /chat/completions · POST /v1/chat/completions above. |
Unmatched paths. An unmatched GET is answered with index.html only when a built web bundle is being served and the path is not API-ish (/api/*, /health, /v1/*, /models, /chat/completions, /completions, /embeddings). Those get a JSON 404 — a missing route served as HTML at 200 reads to a client as a mimetype bug rather than a 404.
GET/api/arena/leaderboard
Per-model records and ratings, plus the context needed to read those ratings honestly.
{ "components": { "count": 1, "groups": [ { "componentId": 0, "models": 4 } ] },
"styleControl": {
"effects": [
{ "feature": "position", "logOdds": 0.05, "points": 8.7,
"basis": "absolute", "perUnit": null },
{ "feature": "verbosity", "logOdds": 0.1, "points": 17.4,
"basis": "per_std_dev", "perUnit": { "points": 34.7, "unit": "100 output tokens" } }
],
"votesObserved": 412, "computedAt": "2026-07-24T09:00:00.000Z" },
"models": [ { "id", "displayName", "wins": 12, "losses": 8, "ties": 3,
"skips": 1, "totalVotes": 24, "winRate": 0.5217,
"rating": 1184.3, "ratingStdError": 41.7,
"confidenceInterval": { "lower": 1102.6, "upper": 1266.0 },
"componentId": 0,
"styleControlledRating": 1147.9, "styleControlledStdError": 44.2,
"styleControlledConfidenceInterval": { "lower": 1061.3, "upper": 1234.5 } } ] }
winRate = wins / (wins + losses + ties) — skips excluded, and
0 when that denominator is zero; the count fields are always present. Ordered by
rating (nulls last), then
wins, then
totalVotes, then
displayName, so the list is stable before the worker has ever run. Enabled models only;
totalVotes counts every vote on the model's matchups, skips included. See
where the counts come from.
| Rating field | Meaning |
| rating | Bradley-Terry rating, Elo-like scale 1000 + (400/ln10)·r |
| ratingStdError | Standard error (same scale) |
| confidenceInterval | 95% CI { lower, upper } from Fisher information |
| componentId | Connected component; ratings only comparable within a component |
| styleControlledRating | BT rating with verbosity/formatting/latency/position regressed out jointly (worker style pass) |
| styleControlledStdError | Standard error of the style-controlled rating (same scale) |
| styleControlledConfidenceInterval | 95% CI { lower, upper } for the style-controlled rating |
The
rating*/
componentId fields are
null until the default worker pass runs; the
styleControlled* fields are
null until the heavier style pass runs. They also stay
null for a model the worker has no comparisons for: one only ever served on non-votable (
single) rounds is never compared, so it remains unrated however much traffic it answers. Clients treat them as optional and fall back to
winRate. See the
rating methodology for how these are computed and what the engine cannot rate.
components — is the leaderboard comparable at all?
Bradley-Terry ratings are identified only up to an additive constant per connected component of the comparison graph, so ratings from different components sit on unrelated scales and must never be compared.
| Field | Meaning |
| count | Components spanned by the rated roster, or null before the worker has run |
| groups | { componentId, models } per component, ascending by id; models counts rated models |
count: 1 is the healthy case. Anything higher means the matchmaker has not yet played bridging games, and a client showing ratings should say so — the demo UI labels each row with its group and shows a banner.
styleControl — what superficial style is worth
The worker fits voter biases as covariates inside the Bradley-Terry regression and stores them in style_control_coefficients. effects restates each one in leaderboard points, ordered as the worker fits them: position, verbosity, formatting, latency_ttft, latency_duration.
| Field | Meaning |
| logOdds | The fitted coefficient exactly as stored |
| points | logOdds on the rating scale ((400/ln10)·logOdds) — read it per basis |
| basis | absolute for constant covariates (position, the outright left-slot advantage) or per_std_dev for the standardised per-vote deltas |
| perUnit | { points, unit } restating the effect per readable amount of the raw feature, e.g. 100 output tokens; null when it cannot be derived |
| votesObserved | Votes whose deltas backed the perUnit conversion |
| computedAt | When the worker last wrote the coefficients; null when it never has |
A positive value favours the response with more of the trait: +34.7 points per 100 output tokens means 100 extra tokens buy as much apparent strength as a 34.7-point rating gap.
| feature | basis | perUnit.unit |
| position | absolute | — (perUnit is always null) |
| verbosity | per_std_dev | 100 output tokens |
| formatting | per_std_dev | 0.1 markdown density |
| latency_ttft | per_std_dev | 100 ms of TTFT |
| latency_duration | per_std_dev | second of streaming |
A feature the worker adds later that the server has no unit for is still returned — basis: "per_std_dev", perUnit: null, ordered last.
perUnit is indicative. The worker z-scales its continuous covariates and does not persist the scale, so the stored coefficient is per standard deviation of the vote-level delta. The server recovers that standard deviation from today's non-skip votes — a superset of the worker's sample whenever its anomaly screen excluded sessions or a model was later disabled. So logOdds/points are exact while perUnit drifts as votes accumulate. It is null for position and whenever the deltas have no spread.
On a fresh install effects is [], votesObserved is 0, and components.count is null. Both keys are always present.
GET/api/arena/analytics/*
Read-only aggregates behind the demo's /insights dashboard. Model-level aggregates only — no per-user or per-session data — and no auth, matching the leaderboard. Implemented by AnalyticsPort on PostgresRepository, registered in routes/analytics.ts. The shipped server always wires that port; an embedding host that omits it gets a 404 on this whole prefix rather than a half-working dashboard.
| Endpoint | Returns | Params |
| /analytics/summary | Matchup/vote totals, decisive/tie/skip split, slot-A vs slot-B decided wins (global position bias), enabledModels, pairsSampled of pairsPossible = n·(n−1)/2, ratingComponents (null pre-worker) | — |
| /analytics/head-to-head | models + one { modelAId, modelBId, aWins, bWins, ties, games } per sampled canonical pair (modelAId = smaller id; unsampled pairs absent; skips excluded) | — |
| /analytics/model-metrics | Per enabled model: responses, p50/p90 ttftMs and durationMs, mean tokens and markdown density (error-free responses only, null until one exists), decisive record split by display slot (slotAWins/slotAGames/slotBWins/slotBGames) | — |
| /analytics/activity | UTC-bucketed vote counts by outcome (left/right/bothGood/bothBad/skip/total) plus cumulativeGames — running non-skip games per model per bucket | bucket=day|hour (default day; else 400) |
| /analytics/style-control | Raw style_control_coefficients rows (standardized-feature scale) + models rated by both worker passes with rating and styleControlledRating | — |
| /analytics/rating-history | Append-only snapshots from model_rating_history, one per model per refit: { modelId, rating, ciLower, ciUpper, games, computedAt }, ordered by computedAt then model id, plus models for labelling | since=<ISO 8601>, optional, timezone required (2026-07-01T00:00:00Z); anything else 400 |
For the reader-friendly points-per-unit view of the style coefficients, use the leaderboard's
styleControl block;
/analytics/style-control returns the coefficients as stored. Rating history is empty until the worker's first refit after migration
005_rating_history.sql, and its
models list is the
current enabled roster — it can name models with no points yet and omit a disabled model older points still reference. See
rating methodology → rating history.
summary.ratingComponents counts distinct component_ids across every rated model, where the leaderboard's components.count covers the enabled roster only — the two can differ after a model is disabled.
GET/health
Returns { "status": "ok" }.