SDK
@omni-arena/react — the headless React SDK for OmniArena: blind side-by-side comparisons over one SSE stream, blind voting with identity reveal, multi-turn continuation, and the Bradley-Terry leaderboard. No UI, no styling — just state and actions.
Two layers
Which layer you want depends on who owns the conversation state.
| Layer | Use when | What it is |
|---|---|---|
| Primitives | An existing chat app (Vercel AI Chatbot's useChat, assistant-ui's runtime, CopilotKit, your own) already owns the connection, the stream, and the persisted message store. | Stateless, React-free functions and types: session id, vote call, SSE decoding, wire parsers. They add arena semantics without competing for that state, and run in a server route as happily as in the browser. |
| useArenaChat | Greenfield app with no chat runtime of its own. | The all-in-one hook. Built on the primitives, so there is one implementation of each piece. |
Install
From another package in this monorepo, depend on it the same way web/ does:
{
"dependencies": {
"@omni-arena/react": "*"
}
}
react (>=18) is a peer dependency. Once published to npm: npm install @omni-arena/react react.
Exports
The primitives, three arena hooks, six analytics hooks (one per /api/arena/analytics/* endpoint), and their public types:
Primitives (host-app integration)
Stateless and free of React and DOM assumptions, so each one can be called from a Next.js route handler, an edge function, or a non-React client.
| Export | Signature | Purpose |
|---|---|---|
| getSessionId(options?) | (GetSessionIdOptions) => string | The anonymous session id the arena ties conversation ownership to. Persisted in localStorage under ARENA_SESSION_STORAGE_KEY (omni-arena-session); pass { storage, key, prefix } to relocate it, or call it where there is no storage (a server route) to mint a one-off id. |
| submitArenaVote(input) | (SubmitArenaVoteInput) => Promise<ArenaReveal> | POST /api/arena/vote. Rejects with the server's own message (Vote already recorded, Invalid matchup token, …); resolves with the reveal. Takes baseUrl and an optional signal, so a host's proxy route can forward its own vote. |
| useArenaVote(options?) | see below | A vote button's worth of state around submitArenaVote, for apps whose runtime owns the messages and only needs the reveal. |
| createArenaSseDecoder<T>() | () => ArenaSseDecoder<T> | Incremental SSE decoder: push(chunk) returns the events a chunk completed, flush() the trailing one. Handles CRLF, multi-line data:, chunk-split multi-byte characters, and a data: [DONE] sentinel. Generic over the payload because the adapters reframe the same round. |
| readArenaStream<T>(source) | (Response | ReadableStream) => AsyncGenerator<T> | The same decoder as a for await iterator, for a consumer that made the request itself. |
| parseArenaMatchup(value) | (unknown) => ArenaMatchupInfo | null | Normalise a round from a matchup_started event, AG-UI's arena_matchup custom value, or a data-arena-meta part — all carry the same fields. |
| parseArenaReveal(value) | (unknown) => ArenaReveal | null | Read a reveal from a vote response or an adapter's reveal part. Null on a half-reveal, so a UI cannot show one identity while still claiming the round is blind. |
| parseArenaSlotError(value) | (unknown) => ArenaSlotError | null | Read a per-slot failure; defaults the message. |
| isDecisiveVote(vote) | (ArenaVote) => boolean | Whether the vote leaves a winning response, i.e. whether the next turn may pass conversationId. Only left/right do. |
| isArenaSlot / isArenaVote / ARENA_VOTE_VALUES | — | Guards and the five vote values, for validating untyped payloads at a host boundary. |
The normalised round
{
matchupId: string; // always present; the control-plane stream id
matchupToken: string | null; // null on a round with nothing to vote on
conversationId?: string; // omitted when the server persisted nothing
turnIndex?: number; // omitted with conversationId
slots: ArenaSlot[]; // defaults to ["A", "B"]
mode: ArenaMode; // defaults to "matchup"
votable: boolean; // authoritative; defaults to true for older servers
}
The reveal
{
models: Record<ArenaSlot, RevealedModel>; // RevealedModel = { id, displayName }
vote: ArenaVote | null; // the vote it was granted for
continuable: boolean; // may the next turn continue this conversation?
conversationId?: string; // echoed by the vote response
}
A host app that owns its own stream typically needs three of these: getSessionId() when it starts a round, parseArenaMatchup() on the round's metadata, and submitArenaVote()/useArenaVote() when the user votes.
// A host's own proxy route: forward the vote, keep the reveal server-side.
import { submitArenaVote } from "@omni-arena/react";
export async function POST(request: Request) {
const { matchupId, matchupToken, vote } = await request.json();
const reveal = await submitArenaVote({
matchupId,
matchupToken,
vote,
baseUrl: process.env.OMNIARENA_URL,
});
return Response.json(reveal);
}
useArenaChat(options?)
Starts and continues matchups, streams both responses into per-slot state, and records votes.
| Field | Type | Description |
|---|---|---|
| sendPrompt(prompt) | (string) => Promise<void> | Start or continue a matchup; streams tokens into slots. |
| vote(vote) | (ArenaVote) => Promise<void> | Record a vote and reveal identities. |
| stop() | () => Promise<void> | Abort the in-flight matchup over the /api/arena/control WebSocket, then settle both slots locally. No-op when nothing is streaming; failures land in error rather than throwing. |
| resetConversation() | () => void | Abort any stream and clear all state. |
| slots | Record<ArenaSlot, SlotState> | Per-slot content, status (idle/streaming/done/error), error. |
| isStreaming | boolean | A matchup is currently streaming. |
| canVote | boolean | A matchup is ready to be voted on. |
| revealedModels | Record<ArenaSlot, RevealedModel> | undefined | Model identities, only after voting. |
| conversationId | string | undefined | The active multi-turn conversation, if any. |
| error | string | null | Last request error, including a terminal run_error reported mid-stream. |
useArenaLeaderboard(options?)
Fetches standings once on mount; call refresh() after a vote to update them.
| Field | Type | Description |
|---|---|---|
| models | LeaderboardModel[] | Win/loss/tie/skip counts, winRate, and the optional worker-computed rating* and styleControlled* fields. |
| components | LeaderboardComponents | Connectivity of the comparison graph: count (null before the worker has run) and groups. Ratings are only comparable within one component, so a UI showing them should surface this. |
| styleControl | StyleControlReport | The fitted style confounders (effects, each with logOdds, points, basis, perUnit), plus votesObserved and computedAt. |
| refresh() | () => Promise<void> | Re-fetch; call after a vote. |
| error | string | null | Last request error. |
Analytics hooks
One hook per /api/arena/analytics/* endpoint, powering the demo's insights dashboard. Each fetches once on mount and returns the same shape, exported as AnalyticsResource<T>:
{ data: T | null; refresh(): Promise<void>; error: string | null }
Endpoints are relative to /api/arena; the hooks prepend baseUrl and that prefix themselves.
| Hook | Endpoint | data type | Extra options |
|---|---|---|---|
| useArenaSummary() | /analytics/summary | ArenaSummary | — |
| useArenaHeadToHead() | /analytics/head-to-head | HeadToHeadStats | — |
| useArenaModelMetrics() | /analytics/model-metrics | { models: ModelMetricsEntry[] } | — |
| useArenaActivity() | /analytics/activity | ActivityStats | bucket: "day" | "hour" (default day) |
| useArenaStyleControl() | /analytics/style-control | StyleControlStats | — |
| useArenaRatingHistory() | /analytics/rating-history | RatingHistoryStats | since: string (ISO 8601) |
The baseUrl option
Every hook — and submitArenaVote() — takes baseUrl?: string: the origin/path prefix the arena API is served from. Default "" hits the same-origin /api/arena/* routes (e.g. behind a dev proxy, as the demo does). Pass an absolute origin to target a remote server.
useArenaChat({ baseUrl: "https://arena.example.com" });
useArenaLeaderboard({ baseUrl: "https://arena.example.com" });
Integration example
A full blind-comparison-and-vote flow in under ten lines — bring your own markup:
import { useArenaChat } from "@omni-arena/react";
export function Arena() {
const { sendPrompt, slots, canVote, vote } = useArenaChat();
return (
<div>
<button onClick={() => sendPrompt("Explain quantum tunneling")}>Ask</button>
<pre>{slots.A.content}</pre><pre>{slots.B.content}</pre>
<button disabled={!canVote} onClick={() => vote("left")}>A wins</button>
</div>
);
}