Docs · Architecture · API · Integration · Rating methodology · Data model · Setup · SDK

Integration guide

One blind matchup — two anonymous models, one connection — framed in five wire protocols through a pluggable adapter layer (server/src/adapters/). Three of the five also parse their protocol's own request body, so a stock AG-UI, OpenAI, or AI SDK client can call the arena with no translation layer. Pick the protocol that matches your stack — and if your client fans a compare view out into one request per model, see slot join.

One stream, five framings

Every protocol carries the identical internal sequence over two slots (A, B); only the framing differs. Each frame is schema-validated (publicArenaEventSchema) before it is written.

PublicArenaEvent stream
matchup_started · token · slot_error · slot_done · matchup_done · run_error (terminal)
▼ selectProtocol(?protocol → Accept → default)
Native SSE
default · votable
AG-UI
typed run/text events
A2UI
schema NDJSON surfaces
Vercel AI SDK
UI message stream · votable
OpenAI SSE
chat.completion.chunk

Which protocols accept their own request body

Three of the five adapters are bidirectional — they parse their own protocol's canonical request envelope, so a stock client posts an unmodified native body and needs no translating transport. OmniArena's own body still works on every protocol. The two shapes are told apart structurally: a body with a messages array and no prompt is the protocol envelope; anything else is OmniArena's { prompt, sessionId?, conversationId?, arena?, joinKey? }. A body carrying both stays on the OmniArena path, so existing transports keep their exact meaning. The x-arena: on header works either way (see Setup → trigger modes).
ProtocolNative request body acceptedsessionId / conversationId / arena / joinKey ride in
AG-UIRunAgentInput — what HttpAgent, useAGUIRuntime, CopilotKit and LangGraph postforwardedProps
OpenAI SSEa standard /chat/completions bodythe omni_arena extension · usersessionId
Vercel AI SDKthe useChat body (v5 parts or v4 content)top level, i.e. useChat({ body })
Native SSE— its native body is OmniArena'stop level
A2UI— no canonical client request envelope existstop level

All five read the same four arena fields through one arenaPropsSchema (request-adapter.ts), so slot join works on every path, not only the native one.

Three things to know before pointing a stock client at the arena. 1. Only the newest user message becomes the prompt; earlier messages are ignored, because a multi-turn round rebuilds history from the winning responses the arena persisted (which needs a decisive vote) — trusting a client transcript would let the caller pick which blind answer becomes shared context. Multi-turn still means passing conversationId back. 2. Fields the arena cannot honour (temperature, tools, state, trigger) are ignored, not rejected; non-text message parts contribute their text only. 3. Validation stays strict on what is read (request-adapter.ts + each envelope's schema): a malformed conversationId, an envelope with no user message, or stream: false fails loudly with a field-named error.

Selecting a protocol

selectProtocol resolves response framing and request parsing from one decision, so the client that asks for a protocol's framing is the one allowed to post its body. Precedence: ?protocol= query param → Accept media type → default native SSE. An unknown ?protocol= falls back to SSE. The arena is also served at POST /chat/completions and POST /v1/chat/completions, where the OpenAI protocol is implied by the path — an OpenAI client appends the path to a base URL and can never send ?protocol=.

Protocol?protocol= aliasesAccept media type(s)Response content-typeVote token on wire?Slot A / B carried by
Native SSE defaultsse · native · native-ssetext/event-streamtext/event-streamYesslot field per event
AG-UIagui · ag-uiapplication/vnd.ag-ui+jsontext/event-streamYes (CUSTOM)parse messageId = <matchupId>:<slot> (top-level slot is stripped)
A2UIa2uiapplication/vnd.a2ui+json · application/x-ndjsonapplication/x-ndjsonYestwo surfaces A/B
Vercel AI SDKvercel · vercel-ai · ai-sdkapplication/vnd.vercel.ai.ui-message-stream+jsontext/event-stream + header v1YesA = text · B = data parts
OpenAI SSEopenai · openai-sseapplication/vnd.openai.chat-chunk+jsontext/event-streamYes (omni_arena)choices[0] / choices[1]

Model A vs B, and voting

Vote flow (all protocols): obtain matchupId + matchupToken → user picks a winner once both slots finish → POST /api/arena/vote with { matchupId, matchupToken, vote }{ accepted, models: { A, B } } reveal. Votes: leftrightboth_goodboth_badskip
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 — plus, on every protocol, the x-arena-matchup response header for runtimes that drop the in-band copy. All five also carry mode and votable, so a client can hide the vote controls on a non-votable (single) round. mode is matchup or single in practice; shadow is declared in the shared event schema so consumers can be exhaustive, but no request currently resolves to it.
Identifiers you cannot use are not sent. A single round persists no matchup — no token to vote with, no conversation to continue, no turn to number — so all five protocols omit matchupToken, conversationId, and turnIndex rather than emitting an empty string or an id nothing can resolve (the old behaviour handed clients a conversationId that answered 404 Conversation not found next turn). The Vercel AI path also omits dataSlot on a single round. Absence is the signal; votable: false says it positively. Clients that stored the old matchupToken: "" sentinel should treat empty and absent alike. Recording no comparison, such a round also contributes nothing to the ratings: Bradley-Terry fits pairwise data only, so a single round is not a weaker rating signal but no rating signal at all (what the engine cannot rate).
Failures. One dead slot is that protocol's per-slot error and the other keeps streaming. A dead round is a terminal run_errorRUN_ERROR (AG-UI), session_error (A2UI), an error part (AI SDK), an { error } frame (OpenAI), event: run_error (native) — with no matchup_done after it. Pre-stream failures stay HTTP status codes except on AG-UI, whose clients treat a non-2xx as a dead transport, so there they arrive in-band at 200.

Slot join: one matchup over two requests

Everything above assumes the default shape — both slots interleaved on one connection. A chat UI with a compare view does not send that shape: it fans a multi-model turn out into one request per model, each with a single answer channel and a shared conversation id. Open WebUI v0.10 is the measured case. Served naively it garbles both answers into one column, or produces two unrelated matchups and two half-votes.

Leader — first arrival joinKey: "chat-7f3a"
claims slot A · runs matchmaking, the conversation, persistence
Sibling joinKey: "chat-7f3a"
claims slot B · mirrors the leader's round
▼ same matchupId · matchupToken · conversationId · turnIndex
slots: ["A"]
one column, one connection
slots: ["B"]
one column, one connection
One matchup row · one vote
mode: "matchup" · votable: true
Opt in with joinKey — a correlation id the client already has (Open WebUI's chat_id). It rides the same extension slot as sessionId, so it works on every protocol, a stock OpenAI body's omni_arena object included. Matchmaking, blindness, the conversation and persistence happen exactly once, on the first request's path (server/src/arena/join.ts).
sessionId requiredAuthority is the whole scope — session + conversation + exact prompt, HMAC'd with a per-process secret — not the joinKey, which is only a correlation id. A joinKey without a session is refused rather than downgraded to a guessable string. A sibling differing in any scope field simply gets its own matchup.
Unpaired degradesWindow closes with no sibling → both slots on that one connection (slots: ["A","B"], votable). Nothing generated in vain, and the vote stays honest because the user sees both answers either way.
Slot B cannot be lostThe leader owns the shared generation and all persistence, so slot B finishes and is recorded even if its sibling disconnects. The sibling has its own control-plane handle, so stopping its stream does not stop the round.
Failures are symmetricA pre-stream error on the leader's path (404 Conversation not found, a write conflict) is forwarded to the sibling, so both halves of one turn fail the same way instead of one hanging.
SituationResponse
joinKey with no sessionId400 join_requires_session
A third request on a scope whose slots are taken409 join_slots_exhausted
A sibling arriving after the window closed409 join_expired
Too many unpaired scopes in flight503 join_unavailable
The leader never started the round504 join_leader_timeout
Those are HTTP statuses on every protocol except AG-UI, whose clients read a non-2xx as a dead transport and so receive them in-band as a 200 stream carrying RUN_ERROR. Tuning: ARENA_JOIN_WINDOW_MS (rendezvous window, default 2000; 0 disables joining and a joinKey is then ignored), ARENA_JOIN_MAX_PENDING (unpaired scopes held in memory, default 256), and ARENA_JOIN_MAX_QUEUED_EVENTS (per-connection event backlog once joined, default 4096). Joining is opt-in per request, so a client that never sends a joinKey is unaffected by any of them. See API → chat for the request contract and Setup for all three.

Native SSE default

?protocol=sse · sse.ts. One event:/data: pair per event; no trailing sentinel.

event: matchup_started
data: {"type":"matchup_started","matchupId":"m1","matchupToken":"eyJ….sig",
       "conversationId":"c1","turnIndex":0,"slots":["A","B"]}

event: token
data: {"type":"token","slot":"A","token":"He"}

event: matchup_done
data: {"type":"matchup_done"}
Slotsevery event carries a slot field
Vote tokenOn matchup_started — self-contained
Errorsone slot → event: slot_error · whole round → terminal event: run_error (code, message); pre-stream failures are HTTP statuses
Suitsthe demo, the @omni-arena/react SDK, custom EventSource/fetch clients — the only path that needs no transport of your own

AG-UI

?protocol=ag-ui · ag-ui.ts · SSE, one data: line per typed AG-UI event. Two slots become two text messages in one run.

data: {"type":"RUN_STARTED","threadId":"c1","runId":"m1"}
data: {"type":"CUSTOM","name":"arena_matchup","value":{"matchupId":"m1","matchupToken":"eyJ….sig",
       "slots":["A","B"],"mode":"matchup","votable":true,"conversationId":"c1","turnIndex":0}}
data: {"type":"TEXT_MESSAGE_START","messageId":"m1:A","role":"assistant","slot":"A"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1:B","delta":"Yo","slot":"B"}
data: {"type":"CUSTOM","name":"slot_error","value":{"slot":"B","message":"boom"}}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1:B","delta":"\n\n[omni-arena:slot-error] boom\n","slot":"B"}
data: {"type":"TEXT_MESSAGE_END","messageId":"m1:A","slot":"A"}
data: {"type":"RUN_FINISHED","threadId":"c1","runId":"m1"}
RequestA canonical RunAgentInput is accepted, so new HttpAgent({ url }) — and useAGUIRuntime, CopilotKit, LangGraph — works with no subclass. Prompt = newest user message; forwardedProps carries sessionId / conversationId / arena; state, tools, context ignored. threadId is deliberately not read as a conversationId — clients mint their own (assistant-ui mints a UUID), which would 404 every stock client's first turn.
Mappingstarted → RUN_STARTED + CUSTOM arena_matchup + TEXT_MESSAGE_START×2 · token → TEXT_MESSAGE_CONTENT · slot error → CUSTOM slot_error + marked TEXT_MESSAGE_CONTENT · done → TEXT_MESSAGE_END / RUN_FINISHED · run error → RUN_ERROR
Slot identity (normative)Clients MUST parse messageId, always <matchupId>:<slot> (e.g. m1:A). A top-level slot field is also emitted but is advisory only — conformant AG-UI parsers whitelist known fields and strip it. Do not rely on slot. threadId=conversation (or matchup id when there is none — AG-UI requires a thread). Echoed client runId never changes the messageId shape.
Vote tokenOn CUSTOM arena_matchup (right after RUN_STARTED) — CUSTOM is AG-UI's sanctioned escape hatch; clients that ignore it still render correctly. Because mainstream runtimes do ignore it, the same metadata is repeated in the x-arena-matchup response header and readable from GET /api/arena/matchups/:id.
Run idsA RunAgentInput carrying threadId/runId gets them echoed on RUN_STARTED / RUN_FINISHED. The slot channel does not move with them — messageId stays <matchupId>:<slot>.
Dead slot is visiblemainstream runtimes drop CUSTOM (assistant-ui's aggregator has no case for it), which left a failed slot as a blank column — so the failure also goes out as text prefixed [omni-arena:slot-error], distinguishable from the model saying those words. The CUSTOM event stays authoritative.
Errors in-bandan AG-UI client settles a run on RUN_ERROR and reads a non-2xx as a dead transport, so every failure here — including the 409 "vote before continuing" — is a 200 stream carrying RUN_ERROR with a code. Mid-stream failures too: the response used to end with neither RUN_FINISHED nor an error, hanging clients.
Suitsboth directions: CopilotKit, LangGraph, CrewAI, assistant-ui's react-ag-ui runtime can call the arena directly, and the response drops into such a runtime unchanged, two concurrent messages and all. One caveat survives — mainstream runtimes discard CUSTOM, so voting reads the metadata from the x-arena-matchup header (or GET /api/arena/matchups/:id); a raw AgentSubscriber beside the runtime also works and is what the shipped integration did before the header existed. integrations/assistant-ui/ runs against the real runtime; it now votes via the x-arena-matchup header; its remaining custom agent only injects forwardedProps / x-arena that useAgUiRuntime cannot set. integrations/copilotkit/ is the flagship consumer: owned Next.js registering ArenaHttpAgent in CopilotRuntime at /api/copilotkit/api/arena/chat?protocol=ag-ui with forwardedProps and x-arena — blind two-column voting in CopilotKit's stock chat UI. Pinned @copilotkit/* 1.63.2, @ag-ui/client 0.0.57, next 16.2.10, react 19.2.8. Server-side x-arena-matchup capture → matchup cache → GET /api/arena/matchup poll (plus CUSTOM arena_matchup on CK 1.63.2); vote via same-origin POST /api/arena/vote.

A2UI

?protocol=a2ui · a2ui.ts · NDJSON, one flat JSON object per line, versioned a2ui/1. Two side-by-side surfaces.

{"v":"a2ui/1","kind":"surface_init","matchupId":"m1","matchupToken":"eyJ….sig","conversationId":"c1",
 "turnIndex":0,"surfaces":["A","B"],"mode":"matchup","votable":true}
{"v":"a2ui/1","kind":"text_append","surface":"A","text":"He"}
{"v":"a2ui/1","kind":"error","surface":"B","message":"boom"}
{"v":"a2ui/1","kind":"surface_done","surface":"A"}
{"v":"a2ui/1","kind":"session_done"}
Mappingstarted → surface_init · token → text_append · slot error → error · done → surface_done / session_done · run error → session_error (code, message)
Slotseach message names its surface (A/B)
Vote tokenOn surface_init — flat and self-describing, alongside matchupId, mode, votable
RequestOmniArena's own body — output-only on ingress. A2UI describes how an agent paints a UI, not how a client asks for one, so there is no canonical envelope to accept; inventing one would match no third-party client. If a de-facto request body emerges it plugs into the same RequestAdapter port the other three use.
Suitsgenerative-UI frontends painting their own design system per surface

Vercel AI SDK

?protocol=vercel-ai · vercel-ai.ts · AI SDK UI Message Stream over SSE (header x-vercel-ai-ui-message-stream: v1), trailing data: [DONE]. A stock useChat can both post to and render it unchanged.

data: {"type":"start"}
data: {"type":"data-arena-meta","data":{"matchupId":"m1","matchupToken":"eyJ….sig",
       "conversationId":"c1","turnIndex":0,"mainSlot":"A","dataSlot":"B"}}
data: {"type":"text-start","id":"m1"}
data: {"type":"text-delta","id":"m1","delta":"He"}
data: {"type":"data-arena-b-delta","data":{"text":"Yo"}}
data: {"type":"data-arena-error","data":{"slot":"B","message":"boom"}}
data: {"type":"text-end","id":"m1"}
data: {"type":"finish"}
data: [DONE]
RequestThe useChat body is accepted in both message shapes (v5 parts, v4 content). Arena inputs are read from the top level, where useChat({ body }) puts extras — so useChat({ api: "…?protocol=vercel", body: { sessionId } }) needs no route of its own. id, trigger, messageId ignored. A Next.js app will often still want a route in front, but as a proxy, not a translator.
SlotsA = primary text (text-delta) · B = data-arena-b-delta parts (then data-arena-b-done). dataSlot: "B" is advertised on duels only — omitted on a single round.
Errorssingle-slot → data-arena-error · whole round → the AI SDK's own error part (errorText), which useChat surfaces as the chat's error state
Vote tokenOn data-arena-meta — the AI SDK path is votable with no second channel
Suitsthe React + Vercel AI SDK family — AI Chatbot template, Lobe Chat, any useChat app. Lowest friction in practice: such an app already has a server route between useChat and the model, and that route becomes the transport.
Exampleexamples/vercel-ai-chatbot (Next.js 16)

OpenAI-compatible SSE

?protocol=openai · openai-sse.ts · chat.completion.chunk frames, trailing data: [DONE]. Every frame lists both slots in slot order: choices[0] is always A, choices[1] always B. Point a client at {base} and it works: POST /chat/completions and /v1/chat/completions serve the arena with this protocol implied by the path, and GET /models answers the connection probe.

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1770000000,
       "model":"omni-arena","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null},
                                       {"index":1,"delta":{"role":"assistant"},"finish_reason":null}],
       "omni_arena":{"matchupId":"m1","matchupToken":"eyJ….sig","conversationId":"c1",
                     "turnIndex":0,"mode":"matchup","votable":true}}
data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1770000000,
       "model":"omni-arena","choices":[{"index":0,"delta":{"content":"He"},"finish_reason":null},
                                       {"index":1,"delta":{},"finish_reason":null}]}
data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1770000000,
       "model":"omni-arena","choices":[{"index":0,"delta":{},"finish_reason":null},
                                       {"index":1,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
Why both choices in every frame. OpenAI's schema allows several choices (that is n > 1) and each carries an index — but no real client demultiplexes on it: Open WebUI reads choices[0].delta.content in its SvelteKit parser and choices[0] in its Python middleware; langchain-openai takes the first choice only. Readers are positional. Emitting one choice per frame with the slot in index therefore spliced both models' tokens into one incoherent message — at 200, with a clean [DONE], i.e. silently. Pinning A to choices[0] gives a positional reader one coherent answer while index-aware clients still get B from the same frame.
RequestA standard /chat/completions body is accepted. Prompt = newest user message (string or text parts); user seeds sessionId; sampling knobs and tool declarations ignored. No standard home exists for a conversation id or the arena opt-in, so those ride an omni_arena request extension — the mirror of the omni_arena object written onto chunk one. stream: false is refused with a 400 naming the field: a matchup is two live streams, with no buffered chat.completion to return.
SlotsA = choices[0], B = choices[1]; role on start, content deltas, finish_reason:"stop" on done (kept in later frames, not retracted); a slot idle in a frame gets an empty delta. A single round declares one slot, so one choice.
Errorsslot → top-level omni_arena_error ({ slot, message }) plus the same text in that choice's delta.content behind an [omni-arena:slot-error] marker, since a plain OpenAI client has no other channel and would otherwise read it as the model's own words · run → an { "error": { message, type, code } } frame, then [DONE]
Vote token & multi-turnOptional omni_arena on the first chunk only, now including conversationId / turnIndex so multi-turn works here too. OpenAI's contract dictates the chunk shape, so it cannot be a required field; compatible clients ignore unknown top-level fields, so Open WebUI is unaffected.
Model listOpenAI UIs probe GET {base}/models on connect; OmniArena answers that and /v1/models with the enabled roster (see API)
SuitsOpenAI-compatible chat UIs — Open WebUI (largest, non-React), LibreChat, Lobe Chat, Chatbot-UI, any OpenAI SDK client. They can now call the arena directly. What "OpenAI-compatible" still does not give you is the interaction: the protocol has no vote, no reveal, and usually one message channel per turn, so rendering two columns and collecting a vote stays the app's problem. integrations/open-webui/ solves that for Open WebUI — duel rendering, typed !a/!b votes, reveal, and multi-turn continuation (the bridge keeps the last continuable conversationId per Open WebUI chat id). Two thirds of that bridge have since moved into the server: its request translation is redundant now that this adapter parses /chat/completions bodies, and its pairing of Open WebUI's two parallel model requests is what slot join does natively. What stays genuinely bridge-shaped is the interaction layer.

Reference example apps

ExampleStackAdapterNotes
vercel-ai-chatbotNext.js 16 App Router · @ai-sdk/react?protocol=vercel-aiServer route forwards to OmniArena; A on main channel, B from data-arena-b-delta, token from data-arena-meta.
assistant-uiVite + React · assistant-ui?protocol=vercel-aiuseAISDKRuntime renders Model A; Model B alongside from the same data parts. assistant-ui also ships an AG-UI runtime.
Zero-key local run: start the server with the deterministic mock provider (ARENA_MOCK_PROVIDER=1 + npm run db:seed:mock --workspace server). npm run e2e from the repo root boots OmniArena (pg-mem + mock) plus both example servers and drives stream → vote → reveal in headless Chromium (Playwright, e2e/), and asserts the raw vercel-ai / ag-ui wire streams. See Setup.

Integration into a real third-party app

The examples above are purpose-built scaffolds. integrations/vercel-ai-chatbot/ answers the harder question — does arena mode survive inside an app that was never written for it?

Pinned clone
real vercel/ai-chatbot → gitignored .upstream/
Committed overlay
arena routes · matchup UI · protocol helpers
Anchored patches
splice into the template's own wiring; a miss fails loudly
The upstream app keeps its NextAuth guest login, Postgres history, and message list — every chat is a matchup instead of a single model call. Its Playwright suite (npm test in that directory) needs no credentials: PGlite stands in for Postgres, the mock provider for the models.

Three more integrations exercise the other adapters against real upstream apps, and each documents what it found:

IntegrationUpstream appAdapterWhat it took
open-webuiOpen WebUI (SvelteKit + FastAPI)?protocol=openaiA bridge presenting an OpenAI surface — model list included — that renders the duel, vote, reveal, and multi-turn continuation (per-chat conversationId) inside Open WebUI's message channel.
assistant-uiassistant-ui's with-ag-ui example?protocol=ag-uiA route forwarding the AG-UI stream (and x-arena-matchup) to the stock @assistant-ui/react-ag-ui runtime, plus arena UI for vote, reveal, multi-turn, and reload rehydration.
copilotkitMinimal owned Next.js + CopilotKit?protocol=ag-uiArenaHttpAgent in CopilotRuntime at /api/copilotkit/api/arena/chat?protocol=ag-ui with forwardedProps and x-arena. Stock CopilotKit chat UI: blind columns, five-way vote, reveal + your-pick badge, decisive-vote continuation; arena off → single non-votable column. Vote tokens: server-side x-arena-matchup → cache → GET /api/arena/matchup poll (+ CUSTOM arena_matchup on 1.63.2); vote via POST /api/arena/vote proxy. Pinned @copilotkit/* 1.63.2, @ag-ui/client 0.0.57, next 16.2.10, react 19.2.8. Harness 3031 / app 3300. See README.
Their findings are the reason for several contracts above — positional choices[0], RUN_ERROR, marked slot failures, omitted identifiers, the request envelopes themselves, and slot join — each came from watching a real client mishandle the alternative. Both were built against the output-only adapters and still carry their own request translation, and the Open WebUI bridge still does its own request pairing; both are redundant now but are left in place (and still passing) rather than rewritten. Read their READMEs as records of what the arena looked like when each was built, not as a current description of the server.

What the adapters look like in a real app

Both Next.js integrations photograph themselves: every image below is the real app against the real server. Regenerate a set with npm run screenshots in that integration's directory, which drives it with Playwright against a key-free provider.

Vercel AI SDK, inside vercel/ai-chatbot

Two anonymous answers streaming side by side in the vercel/ai-chatbot message list
Slot A on the main text channel, slot B from data-arena-b-delta parts — two anonymous columns, one connection; vote controls present but disabled until both finish.
The finished pair with the five-way vote bar enabled and identities still hidden
Both answers done. Five ways to vote — A is better, B is better, Both good, Both bad, Skip — and still no idea which model wrote which.
After voting: both models named, the winning column marked
POST /api/arena/vote returns the reveal; the client marks the winner and continues from it.
A follow-up question rendered as turn 2 of the same arena conversation
Turn 2 of the same OmniArena conversation, continued from the answer that won.
The leaderboard popover listing both models with their win rates and records
The trophy in the composer toolbar opens the leaderboard, proxied server-side so the arena is never exposed to the browser.
A single, non-votable round: one column and no vote bar
votable: false in data-arena-meta → one column, no vote controls. The degradation every adapter's mode/votable fields exist for — graceful at the rendering layer only: a readable answer instead of a vote bar that cannot work, at the cost of a round the rating engine has nothing to learn from.

AG-UI, inside assistant-ui's with-ag-ui example

Two anonymous answers streaming side by side in assistant-ui, the vote bar disabled while both slots are still writing
Two concurrent AG-UI text messages in one run, which the stock @assistant-ui/react-ag-ui runtime aggregates into one assistant message with two text parts — rendered as the A and B columns, blind, vote bar disabled until both finish.
The finished pair with the five-way vote bar enabled and both models still anonymous
Both answers done, both still labelled anonymous, and the five-way vote enabled.
After the vote: both columns named with their models and the picked column badged
The vote uses the matchupId and matchupToken read off the chat response's x-arena-matchup header — which the Next proxy forwards and the thin agent's fetch wrapper records, because the stock runtime discards CUSTOM. Only after the vote are the columns named and the pick badged.
A follow-up question answered as turn 2 of the same conversation, below the previous round's reveal
A decisive vote left a winning response, so the follow-up is turn 2 of the same conversation with a fresh blind matchup — continuation follows the vote response's continuable flag; a reload rebuilds the thread from GET /api/arena/conversations/:id.
Arena mode off: one column labelled Single model, with the vote bar replaced by an explanation
Arena mode off → mode: "single" and votable: false; one column, vote bar replaced by an explanation rather than a vote that cannot be cast.

AG-UI, inside CopilotKit's stock chat UI

Two anonymous answers streaming side by side in CopilotKit, vote bar disabled mid-stream
Concurrent AG-UI slot messages (messageId = <matchupId>:A|B) as blind columns in CopilotKit's default chat — vote controls disabled until both finish.
The finished pair with the five-way vote bar enabled and both models still anonymous
Both answers done, identities still hidden, five-way vote enabled.
After the vote: both columns named with their models and the picked column badged
Vote token from server-side x-arena-matchup capture (GET /api/arena/matchup poll; CK 1.63.2 also forwards CUSTOM arena_matchup) → same-origin POST /api/arena/vote — both models named, your-pick badge on the chosen column.
A follow-up question answered as turn 2 of the same conversation, below the previous round's reveal
Decisive vote continues the same conversation — turn 2 below the previous reveal, fresh blind matchup.
Arena mode off: one column with a single-model notice instead of a vote bar
Arena mode off → mode: "single", votable: false; one column and a single-model notice instead of a vote bar.