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

Setup

Self-hosted as a single container per deployment: one Fastify process serves the API and the built web UI on one port. docker compose up brings up Postgres, the rating worker, and the app in one command; an npm path stays available for development.

Quick start · Docker (single-tenant self-host)

One container serves the API and the web UI on one port, so chat data never leaves adopter infra. The app service waits for Postgres, then its entrypoint runs migrations, seeds the lineup, and starts the server.
cp .env.example .env      # set GOOGLE_API_KEY and MATCHUP_TOKEN_SECRET
docker compose up

Open http://localhost:3001 — web UI and /api/... share the same origin and port (PORT, default 3001). The multi-stage Dockerfile builds all workspaces, ships the migration .sql files next to dist, and prunes dev deps into a slim runtime. The server serves web/dist via @fastify/static only when the bundle exists (production/Docker), with an SPA fallback to index.html for GET routes outside the API prefixes /api, /health, /v1, /models, /chat/completions, /completions, /embeddings — an unmatched path under one of those gets a JSON 404, because HTML at 200 for GET /v1/models makes an OpenAI client report a mimetype error instead of a missing route. Override the bundle location with WEB_DIST_DIR.

Quick start · npm (development)

Postgres + worker in Docker, Node/Vite dev servers on the host for hot reload. The bundle is absent, so Vite serves the UI on :5173 and same-origin static serving stays off.
cp .env.example .env                    # configure the seeded provider
npm install
docker compose up -d postgres worker    # Postgres 16 + rating worker
npm run db:migrate --workspace server
npm run db:seed --workspace server
npm run dev                             # server :3001 · web :5173

Open http://localhost:5173; Vite proxies /api and /health to :3001.

Workspaces

An npm workspace monorepo. The root workspaces array is in build order — server, packages/react-sdk, then web — so the SDK builds before the demo that depends on it.

WorkspacePathNotes
@omni-arena/serverserver/Fastify API. New dep @fastify/websocket powers the /api/arena/control control plane.
@omni-arena/reactpackages/react-sdk/Published headless React SDK — chat, vote, leaderboard, and analytics hooks plus their framework-free helpers. The demo consumes it. See SDK.
@omni-arena/webweb/Vite + React demo; imports @omni-arena/react (workspace *).
omniarena-ratingworker/Python rating worker (not an npm workspace).
e2e/, examples/*, and integrations/* are not workspaces either: each has its own package.json and lockfile and is installed on demand by its own script, so a plain root npm install stays fast and none of them can break the published build. See Third-party UI integrations.

Environment variables

VariableRequiredPurpose
GOOGLE_API_KEYGoogle modelsGemini API key (aistudio.google.com/apikey)
OPENAI_API_KEY / OPENAI_BASE_URLOpenAI modelsOpenAI bearer token; optional compatible base URL
OLLAMA_BASE_URLnoDefaults to http://localhost:11434; provider is always registered
VLLM_BASE_URL / VLLM_API_KEYvLLM modelsOpenAI-compatible endpoint + optional bearer token
HOST_PROXY_URL / HOST_PROXY_TOKENhost-proxy modelsHost-owned OpenAI-compatible endpoint + optional proxy auth
HARNESS_VERSIONno (v1)Version label persisted on every matchup
REFIT_INTERVAL_SECONDSno (300)Rating worker loop interval between refits
FULL_REFIT_EVERYno (12)Refits between from-scratch (cold) fits in loop mode; 0 warm-starts indefinitely
RATING_RIDGEno (0.01)Rating worker ridge-prior strength (log-odds scale)
STYLE_RIDGEno (0.05)Ridge-prior strength for the style-controlled fit
LOG_LEVEL / LOG_FORMATno (INFO / text)Rating worker log level and format (text or json)
MATCHMAKERno (smart)smart (under-sampled / high-variance pairs) or random (uniform)
DATABASE_URLyesPostgres connection. npm dev: host localhost. Docker Compose: app/worker override the host to postgres
MATCHUP_TOKEN_SECRETproductionHMAC secret, min 16 chars; insecure dev default otherwise
PORTno (3001)Port the app listens on / publishes; web UI + API share it
WEB_ORIGINno (:5173)Allowed CORS origin (moot when the single container serves both same-origin)
WEB_DIST_DIRnoOverride the built web bundle dir; default ../../web/dist is correct for the repo and image
ARENA_MOCK_PROVIDERnoSet 1/true to register the deterministic mock provider for demos/examples/e2e (no LLM key). Off by default.
ARENA_TRIGGERno (always)When the arena engages: always, manual, or sampled. Any other value fails at boot. See Trigger and exposure.
ARENA_SAMPLE_RATEARENA_TRIGGER=sampledEngagement probability in [0, 1] (default 0). Ignored for always/manual. Out-of-range values fail at boot. See Trigger and exposure.
ARENA_EXPOSUREno (blind)What the user sees when engaged: blind (both answers streamed, votable) or shadow (only incumbent streamed; challenger persisted silently; not votable). Requires ARENA_DEFAULT_MODEL when shadow. See Trigger and exposure.
ARENA_DEFAULT_MODELARENA_TRIGGER ≠ always, or ARENA_EXPOSURE=shadowEnabled model for single rounds and as the shadow incumbent: UUID, provider_model_id slug, display_name, or provider:provider_model_id. Resolved at boot. See Trigger and exposure.
ARENA_JOIN_WINDOW_MSno (2000)Rendezvous window for slot join: how long the first of two sibling requests waits for its pair. 0 disables joining (a joinKey is then ignored); max 60000.
ARENA_JOIN_MAX_PENDINGno (256)Cap on unpaired join scopes held in memory; over it a join is refused with join_unavailable rather than queued. Min 1, max 100000.
ARENA_JOIN_MAX_QUEUED_EVENTSno (4096)Per-connection event backlog on a joined matchup: events that may queue for one slot whose client reads slower than the model produces. Over it that one connection fails instead of growing without bound. Min 16, max 1000000.
.env.example lists every variable the server and the rating worker read; the table above expands on them with bounds and semantics. The default seed contains Google models, so it needs GOOGLE_API_KEY; change provider + provider_model_id in the seed for OpenAI, Ollama, vLLM, or host proxy. The root .env loads regardless of workspace cwd.
All ARENA_JOIN_* values are coerced and range-checked at boot (server/src/arena/join.ts), so a non-numeric or out-of-range value throws before the server listens. Compose's app service loads the repo-root .env wholesale; its worker service instead forwards each variable the rating worker reads explicitly (see Rating worker).

Host-proxy contract

Expose OpenAI-compatible streaming chat completions at <HOST_PROXY_URL>/chat/completions. OmniArena sends model, the server-derived linear messages, stream: true, and x-omni-arena-proxy: 1. The host keeps the upstream provider credential.

Trigger and exposure

By default every request to POST /api/arena/chat is a blind A/B matchup. Two orthogonal axes relax that: trigger (ARENA_TRIGGERwhen) and exposure (ARENA_EXPOSUREwhat the user sees).

ARENA_TRIGGERWhat a request getsVotable
always (default)An engaged round of two models chosen by the MATCHMAKER strategyonly when ARENA_EXPOSURE=blind
manualA single round served by ARENA_DEFAULT_MODEL, unless the request opts inonly opted-in blind rounds
sampledAn engaged round with probability ARENA_SAMPLE_RATE, otherwise singleonly engaged blind rounds
ARENA_EXPOSUREEngaged behaviourWire mode
blind (default)Both answers streamed anonymously; user can votematchup
shadowOnly the incumbent (ARENA_DEFAULT_MODEL) is streamed; a challenger ≠ incumbent runs silently; both responses + matchups.mode='shadow' persisted; no vote tokenshadow
triggerengaged?exposure=blindexposure=shadow
alwaysyesmatchupshadow
sampledhitmatchupshadow
sampledmisssinglesingle
manualopted-inmatchupshadow
manualnotsinglesingle
Opting in, under manual. Either signal turns that one request into an engaged round: body arena: true, or header x-arena: on (case-insensitive, and only on counts — 1/true/yes do not). The header works on every protocol; the body field rides in each protocol's extension slot. The signals are OR-ed. Under always and sampled both are ignored. Closed enums fail at boot on typo. ARENA_DEFAULT_MODEL is required when trigger ≠ always, or when exposure is shadow.
ARENA_DEFAULT_MODEL accepts a UUID or a human identifier. Resolved at boot against the enabled roster and stored as a models.id UUID. Accepted forms (preference order): models.id UUID, provider:provider_model_id, provider_model_id slug, or display_name. A mistyped identifier throws before the server listens.
What a single round is: one slot, one model, nothing recorded. mode: "single", slots: ["A"], votable: false, no matchupToken/conversationId/turnIndex.
What a shadow round is: incumbent on A (streamed), challenger on B (silent). mode: "shadow", slots: ["A"], votable: false, no vote token. Both responses persisted with matchups.mode='shadow'. POST /api/arena/vote returns 403 Shadow matchups are not votable. Auto-judge pickup is deferred.
# Shadow canary against a fixed incumbent
ARENA_TRIGGER=always
ARENA_EXPOSURE=shadow
ARENA_DEFAULT_MODEL=gemini-3-flash-preview
# Client sees mode:shadow, votable:false, slots:["A"], no token
# Both answers land in Postgres with matchups.mode='shadow'

Database

TaskCommandNotes
Migratenpm run db:migrate --workspace serverApplies unapplied migrations/*.sql in filename order, each in a transaction. Add NNN_description.sql; never edit applied files.
Seednpm run db:seed --workspace serverDisables every existing model, then upserts the lineup by (provider, provider_model_id) and re-enables those rows — so editing server/src/db/seed.ts and re-running is how you retire a model too. Currently three Google models (Gemini 3.1 Flash-Lite, 3 Flash, 3.5 Flash).
Seed (mock)npm run db:seed:mock --workspace serverDisables every other model, seeds two mock models (Alpha/Beta). Pair with ARENA_MOCK_PROVIDER=1.
Seed (demo data)npm run db:seed:demo --workspace serverSynthetic voting history so the insights dashboard is not empty. See below.

Demo data

Every chart on the insights dashboard is computed from recorded votes, so a fresh install renders nothing but empty states. server/src/db/seed.demo.ts fabricates a plausible voting record against whichever models are currently enabled.

Opt-in, never automatic. Neither the container entrypoint nor docker compose up invokes it, so a fresh deployment starts with a genuinely empty arena and the dashboard shows empty states until real votes arrive.
# npm dev path (host)
npm run db:seed:demo --workspace server
npm run db:seed:demo --workspace server -- --reset --matchups 400 --days 30

# Docker path — run the compiled script directly. `tsx` is a devDependency and
# is pruned from the runtime image, so the npm script is not available there.
docker compose exec app node server/dist/db/seed.demo.js --reset
FlagDefaultEffect
--matchups240Number of matchups to generate.
--days14Window to spread them over, ramping toward the present.
--resetoffDelete previously seeded demo data first.
--seed20260724PRNG seed; the same value reproduces the same arena.

Each model's personality is derived from its namedisplay_name plus provider_model_id, lowercased — so the seeded arena matches the intuition a reader already has about the roster. Markers must match a whole name segment, since gemini ends in mini.

Name containsReads asGets
lite · mini · nano · small · tiny · 8b · haikuthe fast, cheap variantlowest latency, terse answers, weakest ratings
pro · ultra · opus · max · large · 70b · thinkingthe flagshipslowest, longest answers, strongest ratings
a version numberrecencynewer versions rate higher; older ones in the same class pad their answers more
neither marker, no versionthe middle of the rostermid-tier defaults, ranked by declaration order
With the default Gemini lineup that yields Gemini 3.5 Flash on top, Gemini 3 Flash second but padding its answers, and Gemini 3.1 Flash-Lite fastest by a wide margin and clearly last on quality — so the "does faster win?" scatter has a real answer.
Rao-Kupper votes style confounding many small sessions backfilled rating history
Votes are drawn from the same Rao-Kupper model the worker fits, with deliberate style confounding — verbose, heavily formatted answers win more than their latent strength justifies, so the padded runner-up closes most of the raw gap and gives it all back under style control. Strength outweighs what padding can buy, so the better model still leads the raw leaderboard and style control widens its lead rather than reordering it. Votes are spread across many small anonymous sessions because the worker's anomaly screen excludes sessions resembling vote-stuffing or a one-sided bot. model_ratings, model_style_ratings, model_rating_history, and style_control_coefficients are backfilled (a win-rate approximation on the Elo-like scale, one history checkpoint per day up to twelve) so the rating and style charts work before the worker has run; a real refit overwrites them.
What --reset does and does not cover. Demo conversations carry a demo- session prefix and --reset deletes only those matchups and conversations, so votes recorded through the real UI survive. The four worker-owned tables are the exception: rows for the currently enabled models are deleted and rewritten on every run, --reset or not. Seeded matchups also carry harness_version = 'demo', the easiest way to tell them from real traffic in SQL.

Mock provider

A deterministic stub provider (server/src/providers/mock.ts) streams fixed per-model tokens with no network, so a full round-trip (stream → vote → reveal) runs with no LLM key. Enable with ARENA_MOCK_PROVIDER=1 (registers the mock provider; off by default so it never shadows real providers) and seed with db:seed:mock. Both examples and the e2e suite use it.
docker compose up -d postgres
ARENA_MOCK_PROVIDER=1 npm run db:migrate --workspace server
ARENA_MOCK_PROVIDER=1 npm run db:seed:mock --workspace server
ARENA_MOCK_PROVIDER=1 npm run dev --workspace server   # :3001

Rating worker

The Bradley-Terry rating worker lives in worker/ (Python, NumPy/SciPy). It screens anomalous voting sessions, aggregates votes in-database, fits ratings with Fisher-information CIs, and upserts model_ratings. --style adds the heavier style-controlled pass into model_style_ratings.

cd worker
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# One-shot (needs migrations applied + DATABASE_URL):
DATABASE_URL=postgres://omni_arena:omni_arena@localhost:5432/omni_arena \
  python -m omniarena_rating

# Add the heavier style-controlled pass on raw votes:
python -m omniarena_rating --style

# Periodic loop with warm-started refits (add --style for the style pass):
python -m omniarena_rating --loop --interval 300 --ridge 0.01

# Same loop, but never force a from-scratch refit (default: every 12th):
python -m omniarena_rating --loop --full-refit-every 0

# Skip the pre-fit anomaly screen (keep every session):
python -m omniarena_rating --no-anomaly-filter

python -m pytest          # pure-Python tests, no database
The pre-fit anomaly screen drops spam/malicious sessions via p-value tests before any fit (--no-anomaly-filter disables it). In loop mode most refits are warm-started, but every FULL_REFIT_EVERY refits (--full-refit-every, default 12 — hourly at the default interval) the warm state is discarded for a from-scratch fit, which is then cross-checked against a warm-started fit of the same aggregates; 0 disables the forced pass. See the rating methodology. docker compose up -d also starts a worker service running the loop against Postgres; its image's default command is python -m omniarena_rating --loop --style, so every refit runs both passes. Ratings appear after the first successful refit; until then the leaderboard rating fields are null. Override the service's command: for a one-shot or default-only run.
Compose worker serviceWhat that gives you
--loop --style by default (the image CMD)A stock docker compose up fills model_ratings, model_rating_history, model_style_ratings, and style_control_coefficients — the dashboard's style panels included — with no extra steps
Every worker knob forwardedREFIT_INTERVAL_SECONDS, FULL_REFIT_EVERY, RATING_RIDGE, STYLE_RIDGE, LOG_LEVEL, LOG_FORMAT come from the repo-root .env, each defaulting to the worker's own default
No env_file (unlike app)Deliberate: the worker needs no provider keys or app secrets, so the list above plus a DATABASE_URL on the compose postgres hostname is the whole of what reaches the container
The worker fits pairwise comparisons, so it needs voted matchups. Where most rounds are non-votable single rounds — which persist no matchup — there is nothing to aggregate and the rating fields stay null however often the loop runs. See Rating methodology → what the engine cannot rate.

Commands

CommandWhat it does
npm run devServer (tsx watch) + web (Vite), concurrently
npm testnpm run test --workspaces — server (Vitest + pg-mem), react-sdk (Vitest + jsdom hook tests), web (no tests)
npm test --workspace @omni-arena/reactJust the SDK hook tests
python -m pytestRating worker numerical tests (run in worker/)
npm run buildAll workspaces in order: server tsc → react-sdk tsc → web bundle
npm run typecheckAll workspaces
npm run e2eBuilds both example apps, boots OmniArena (pg-mem + mock provider) + example servers, drives the full arena flow in headless Chromium (Playwright)
Tests need no running Postgres or API keys. The hook tests moved from web/ into the SDK and now sit beside the framework-free modules they cover (protocol, session, stream, vote, one file per hook); they stub fetch with real SSE streams and stub localStorage (Node 24's experimental global shadows jsdom's).
Two cross-cutting server suites. blindness.test.ts asserts that no model identity reaches the wire in any of the five protocols, on a single connection and on both siblings of a joined matchup — cases generated from the protocol registry, so a new adapter is covered by default. arena/join.test.ts covers slot join: simultaneous siblings elect exactly one leader, a third claim is refused, the pending set stays bounded, and slot B still finishes and is persisted after its own consumer disconnects.
Gotcha: run tests per workspace, not a bare vitest at the repo root. The jsdom environment is set per workspace (each vitest.config.ts) and there is no root Vitest config, so a root-level vitest uses the default Node env and the SDK hook test fails with ReferenceError: document is not defined. Use npm test (delegates to npm run test --workspaces) or npm test --workspace <name> — both pass.

Reference examples

ExampleStackAdapter
examples/vercel-ai-chatbotNext.js 16 App Router + @ai-sdk/react?protocol=vercel-ai
examples/assistant-uiVite + React + assistant-ui?protocol=vercel-ai
Both talk to a running server (use the mock provider for a zero-key run). See the integration guide for how each adapter maps to these apps.

Third-party UI integrations

Beyond the purpose-built examples, integrations/ wires the arena into real upstream chat UIs at pinned revisions. Each directory is self-contained — its own package.json, lockfile, tests, and README — and outside the root workspace, so a plain root install neither installs nor builds them.

DirectoryUpstreamProtocolHow to run it (from that directory)
vercel-ai-chatbotvercel/ai-chatbot templatevercel-ainpm install · npm run setup · npm test
assistant-uiassistant-ui monorepo, examples/with-ag-uiag-uinpm install · npm run setup · npm test
open-webuiOpen WebUI container image (v0.10.2)openainpm install · docker compose up -d · npm run arena (blocks), then npm test
Pinned clone + overlay. The two Node integrations share one model: upstream.json records the exact upstream commit, .upstream/ holds the gitignored clone, overlay/ holds the arena-layer sources copied in verbatim, and scripts/overlay.mjs applies anchored patches to upstream's own files — each anchor must match exactly once, so a moved upstream line fails setup loudly instead of producing a half-integrated app. Open WebUI runs from its published image and reaches the arena through a small OpenAI-compatible bridge (bridge/). All three run key-free against the mock provider; see the integration guide for what each protocol can and cannot carry.

End-to-end tests

npm run e2e (orchestrated by e2e/run.mjs) is deterministic and CI-friendly — no real keys or external LLM calls. It installs harness + example deps, builds each example (a smoke check), installs Playwright Chromium, then boots the OmniArena harness (pg-mem + mock) and both example servers and drives the full flow. protocol.spec.ts asserts the raw vercel-ai/ag-ui wire streams (Vercel path is votable, leaderboard updates); examples.spec.ts drives both UIs through stream → vote → reveal. Both share the expected roster and per-model output fingerprints from arena-fixtures.ts.

Continuous integration

.github/workflows/ci.yml runs on pushes to main and every pull request, cancelling in-flight runs for the same ref. No job needs a secret — every suite uses the mock provider and in-memory pg-mem — so CI works unchanged on forks. See CONTRIBUTING.md for the contribution workflow.
JobSteps
Nodenpm cibuild --workspacestypecheck --workspaces → the three workspace test suites. Build runs first because web typechecks against the SDK's emitted declaration files.
Pythonpip install -r worker/requirements.txtpython -m pytest in worker/
End-to-endnpm ci at the root and in e2e/playwright install --with-deps chromiumnpm run e2e; Playwright artifacts uploaded on failure
CI pins Node 22 and Python 3.11. Locally the floor is lower — root engines asks for Node >=20, the worker for Python >=3.10 — so a green local run on Node 20 is possible while CI exercises 22.