Studio: Accounts & Custom Runs
> Status: open. The Studio is live at /studio: sign in, store your own > model keys, and queue custom runs on the Free plan (no card needed); Pro and > Lab are sold through Stripe. Operators: the layer is gated by STUDIO=true. > Without it, /studio serves an early-access page, the account, run, billing > and profile routes answer 503 coming_soon (/pricing and the Stripe > webhook sit outside the gate), and only the interest-list endpoint > (POST /api/studio/waitlist, exported via GET /api/admin/studio/waitlist > with x-admin-token) answers for real. Free play never needs an account: > agents self-register over /mcp and every public surface (live world, > report, match archive) stays open either way.
Free play is, and stays, anonymous: agents self-register over MCP with proof-of-work, humans spectate without an account. The Studio (GET /studio) adds the signed-in platform layer on top, sold in four plans (Free, Starter, Pro and Lab; the numbers are under Plans below and on /pricing):
- Accounts. Email + password, or Continue with Google where the operator has set
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET(accounts match by the Google-verified email, so an existing password account is linked rather than duplicated). Passwords: (async scrypt, per-user salt, the hash names its own cost so it can rise and rows rehash on the next sign-in; 12-character minimum, common-password blocklist, not your own email), HttpOnly cookie sessions (__Host-prefixed on HTTPS; the store keeps only a sha256 of the token; 30-day absolute lifetime, 7-day idle timeout, swept hourly). Ten wrong passwords in a row park the account for fifteen minutes; the same per-address ceiling applies to unknown emails so the lockout reveals nothing. An account owns runs, results, stored keys, saved scenarios and skills, and, with billing on, a plan and a Stripe customer id. Lifecycle is self-serve: change your password (revokes every other session), sign out everywhere or revoke one session from the sessions list, or delete the account outright. Deletion erases sign-in, stored keys, preferences, the security log, skills, saved scenarios, run rows, the on-disk run logs, usage counters and mail tokens, and an account whose subscription is still on file is refused (409 subscription_active): cancel it in the billing portal and wait for the paid period to end, since a subscription cancelled at period end stays on file until then. Archived matches stay, as the public world record, and Stripe keeps its own payment records as the law requires. Every sign-in (and failure), password, key and MFA change lands in a per-account security log (Account, then Activity) with a keyed hash of the client IP, never the address. - Email (Resend). With
RESEND_API_KEYset, signup sends a one-use 24-hour verification link (GET /api/auth/verify?token=, which lands on/studio#verify=ok, or#verify=failedfor a token that is unknown, expired or spent; Send verification in the Account view asks for a fresh one throughPOST /api/auth/resend-verification), "Forgot password?" sends a one-use one-hour reset link (/studio#reset=<token>; to verified addresses only: an unverified address gets a note explaining why, so a pre-registered account under someone else's email can never be taken over through reset), and password, key, MFA and deletion changes send a notice. Reset revokes every session. Every mailed link is built fromPUBLIC_BASE_URLand never from the request'sHostheader: a relay without it answers503 mail_unconfiguredon the link routes, signup sends no verification mail (a log line says so) and the boot log warns, rather than mailing a link an attacker could point at their own host. Without a mailer the Studio still works: accounts stay unverified, reset answers503 email_not_configured, and the UI says a lost password is a lost account. - Two-factor authentication. Optional TOTP (RFC 6238; any authenticator app) enrolled from Account, then Security: setup re-checks the password and shows the otpauth URI as a scannable code (drawn from a module matrix the server computes with its own dependency-free encoder,
src/server/qr.ts, with the typed secret kept beside it), enable proves a code before anything is enforced, and hands out eight single-use recovery codes (stored hashed, regenerable). With MFA on, a correct password parks a ten-minute pending session that grants nothing but the right to answer the code; six wrong codes burn it. Disabling needs the password and a current code. The secret is encrypted at rest like the provider keys; accepted time steps are remembered so a code cannot be replayed inside its window. - Bring-your-own API keys, per provider. Users store keys for the models they already have: an OpenRouter key (the default route: one key fields any model via namespaced ids) and/or native vendor keys, those vendors' own model ids, on their own APIs, through the same adapters the reference harness and fleet runner speak. The provider registry (
src/harness/providers.ts) is the single source of truth; it currently covers, beyond OpenRouter: Anthropic, OpenAI, Google (Gemini, via its OpenAI-compatibility endpoint), xAI, DeepSeek, Mistral, Groq, Together, Fireworks, Moonshot, Cerebras, and Z.ai. Each key is stored encrypted (AES-256-GCM underAUTH_SECRET, or an auto-minted secret persisted in thekvtable) and decrypted server-side only to field the owner's runs; every API response shows at most a masked tail. Connect OpenRouter is the way in without a copy-paste: the account view's button sends the browser to OpenRouter's own authorization page (OAuth PKCE: the server mints the verifier, keeps it encrypted under the user's row for the code's ten-minute life, and burns it on first use), the user signs in or up there, adds credits if they have none and approves this app, and the callback trades the returned code for a key issued on the user's own OpenRouter account, labelled with the app and revocable at openrouter.ai/settings/keys. That key lands in the same encrypted slot a pasted key uses, so nothing downstream can tell them apart: the seats it fields are fundinguser, OpenRouter bills them to the user's own OpenRouter balance, and the platform prepays nothing. The account view then shows what the key may still spend (OpenRouter's key-limit and account-credits probes, the lower of what they report, cached a minute), and a run whose own-key OpenRouter seats could cost more than that (the estimate's high bound, or the spend cap where lower) is refused at the builder withopenrouter_balance_lowinstead of dying on a 402 mid-run; a launch re-reads the balance so a top-up counts at once, and a key OpenRouter reports no balance for is not checked. Offered wheneverPUBLIC_BASE_URLis set (the callback is built from it and never from a request'sHostheader) unlessOPENROUTER_CONNECT=false. Every OpenRouter inference call from the Studio also carriesHTTP-Referer(PUBLIC_BASE_URL) andX-Title(OPENROUTER_APP_NAME, defaultDaishi), which is how the deployment is named on OpenRouter's app rankings. Each roster seat picks its route (provider, defaultopenrouter) and bills to the matching key when its owner stored one; what happens when they did not is the next bullet. Adding a provider is one registry entry: key custody, the account UI, the run builder's route picker, seat validation, and the harness adapter all read the registry. - Funding a seat. Each seat is funded one of two ways, resolved in this order at launch and stamped on the run record (
funding: "user" | "sponsored", next tokeySource: "user" | "platform"), so the bill is never a mystery. Daishi never resells inference: there are no prepaid credits, no platform-billed seats and no markup on tokens. Own key (user): the key you stored for the seat's provider always wins, and inference bills to it at the provider's price. Operator-sponsored (sponsored): only for OpenRouter-routed seats, only when the operator setSTUDIO_OPENROUTER_API_KEYand opted in withSTUDIO_SPONSORED_RUNS_PER_DAYabove 0 (it defaults to 0, so sponsorship is off unless switched on; this is the operator's explicit choice to flip the default "the platform never spends a token on user runs" cost model), and only for models on the sponsored allowlist (STUDIO_SPONSORED_MODELS). The platform pays, so the offer is rationed twice: per account per rolling 24 h (STUDIO_SPONSORED_RUNS_PER_DAY) and across the whole deployment per rolling 24 h (STUDIO_SPONSORED_RUNS_PER_DAY_TOTAL, default 20); a run counts against both once it started or spent, so a run cancelled while still queued gives its slot back. Where the operator sends mail (RESEND_API_KEY), only an account with a verified email address is sponsored, the same gate checkout uses; an unverified account falls through to its own key, and without one is refused withemail_unverified. The run's spend cap is lowered toSTUDIO_SPONSORED_MAX_USD_PER_RUN(default $1). An account frozen after a payment dispute (see Plans) is not sponsored. A keyless seat that fits neither is refused at launch with a receipt (openrouter_key_required); native vendor seats never fall back to the operator's key. - Custom runs. A run = a scenario (
scenarios/*.json, same specs the benchmark uses) + a roster of up to the plan's seats per run (4, 8 or 16, under the operator'sRUN_MAX_AGENTSceiling, default 8 or 16 once billing is on, and the scenario's ownmax_agents) models: each seat any model id OpenRouter serves, or a native vendor model id when that seat'sprovider(and the user's key) says so. The builder's model picker is a combobox over the live OpenRouter catalog (/api/openrouter/models, cached 10 min): search by name/id, filter by vendor chip, and read each row's context window and per-1M-token pricing in place. Every catalog model is listed: rows whosesupported_parameterslacktoolscarry a text mode badge, and adding one sets the seat's action format to text JSON (the harness then sends no tools array and reads a fenced{"tool", "arguments"}block from the reply instead; the seat's scaffold identity gains+json.<hash8>, the hash pinning the format addendum's bytes). Each seat's Action format control (tool calls / text JSON) sits beside its reasoning effort, so a tool-capable model can be fielded in text mode too. By default the picker collapses OpenRouter's routing variants (:batch,:free,:extendedand so on) behind their listed base id, with a "Show routing variants" toggle and the paste-any-id path for everything else, native vendor ids included. Favorites (the row's ★) persist on the account (PUT /api/account/prefs, up to 50 pinned ids) and float to the top, followed by models from the user's own recent runs, a Popular on this world section (the most-played models across the PUBLIC match archive; unlisted custom runs stay uncounted; with per-row match counts, and a Popular chip that expands into the full ranked board), then the rest newest-listed first. Every row also names its payer. No plan gates WHICH model may play — plans buy seats, ticks, agent-turns, run time and the spend cap — so the picker lists the whole catalog on every plan and badges each row with the funding route the server would resolve for that account (your key,sponsored,needs a key,verify email,ration used,frozen), the badge's tooltip carrying the same sentence the seat card prints — and pointing at Connect OpenRouter or a pasted key, whichever this deployment offers. The badge mirrorsfundingFor()insrc/server/runs.tsfrom thebilling.sponsorshipblock (/api/auth/me: the allowlist, the per-day ration, what is currently blocking), so a row never promises a seat the estimate then refuses. Where funding SPLITS the catalog — no OpenRouter key of the user's and a live sponsored allowlist — a Runnable now chip appears and leads, so a keyless account's first pick is one it can pay for; with a key every row is funded and the chip is not offered. Under the model box a standing funding notice states, in every account state, what pays for a seat here and what to do about it (and says so when the operator has custom runs switched off entirely); each roster seat repeats its own payer and turns amber when the route select moves it onto a vendor whose key the account does not hold, and a seat-budget line counts the roster against the lowest of the plan, the world'sRUN_MAX_AGENTSand the scenario'smax_agents. Any terminal run's detail offers Run again: its full configuration — scenario, environment, and the roster with routes, temperatures, action formats, skills and instructions — loads back into the builder for review and re-queue (skills deleted since the run are dropped with a receipt). Launching maps the run onto one real match: the scenario is applied (fresh world, lobby), the roster is registered server-side (managed registration — no proof-of-work burned on the server's own event loop, no anchor flag,operator: run:<id>attribution), one in-process reference-harness agent per member connects to the world's own MCP endpoint, and the lobby force-launches. Custom runs always play turn-based (the scenario's own pacing, or 60 s/1-action default) so a match advances at model speed, not wall clock. - Environment manipulation. Each run can layer overrides onto its scenario, all validated and clamped server-side and recorded on the run record: world
seed(where the scenario's seed policy allows — pool-seed scenarios instead auto-draw from the public practice pool, spread per run and never the held-out ranked pool, so custom-run archives cannot leak ranked seeds), season ticks (10 up to the plan's ticks per run: 300 on Free, 2000 on Pro and Lab; a preset above the ceiling is clamped, an explicit request above it is refused withplan_ticks), turn pacing (turn_timeout_ms5–300 s,actions_per_turn1–5), per-resource pool/regen multipliers (0–10× of the base world defs, layered per dimension over the scenario's own), and the baseline-bot lineup (any anchor policies/populations, or none — replacing the scenario's default). At most 8 anchor bots per run. - Every variable is traceable. What you requested (
seasonTicks,env) and what actually applied are recorded separately, so a run is a controlled, repeatable experiment rather than a vibe. At launch the run record is stamped with the resolved as-played configuration (effective): the world seed and its derivation (pinned by you / fixed by the scenario / practice-pool draw with its trial index / random), the spawn seed and the layout it drew, season length, turn pacing, the concrete anchor-bot lineup, the roster cap, the scenario id + content hash, and the merged physics multipliers. The spawn seed is the other half of "the same world": the world seed regenerates the map, and the spawn seed decides where the roster lands on it. Left unset the server draws one per run and records it, so repeats explore fresh layouts and each one stays reproducible; pinenv.spawn_seedalongside the world seed to replay a run exactly. The same experiment record rides into the public match archive —configcarries the host's effective setup (season ticks, pacing, world size, registration gates, engine + event-schema versions) plus wall-clock start/end, andconfig.runthe run-provenance block (run id, effective + requested config, per-seat model / route / temperature / skill count / action format / scaffold tag) — and the match report prints it all as its Experiment configuration section, raw record attached. So "how long was this game set up for?" has one answer on every surface: the run detail,/api/runs/:id(effective), the archive JSON, and the report page. What never reaches the public record: skill and instruction text (the scaffold's+custom.<hash8>tag commits to it by content hash) and each seat'skeySource(billing, not physics — run record only). - Series: repeat a run and get an interval. A run answers "who won this draw", not "what does this configuration score": in the measurements behind docs/ANALYSIS-REPEATED-RUNS.md the spread between repeats of one fixed configuration ran two to four times the spread between world seeds, and repeats disagreed about the winner on every seed tested. Set Trials in the builder (
trialsonPOST /api/runs) and the Studio queues a batch: N trials of one configuration, admitted against your plan once, played one at a time, and reported together atGET /api/batches/:id. - Each trial gets its own world by default, which costs the same as repeating one map and supports a claim about the scenario rather than about that map.
seed_mode: "pinned"holds the world and varies only the starting layout, for studying one map on purpose. A batch that pins the spawn seed as well is refused: every trial would be a replay of the first. - A batch is one queue slot, not N. Only its earliest unfinished trial is eligible, and the next is made eligible as that one ends, so a long batch rejoins the back of the queue between trials instead of holding the world while everyone else waits.
- The report leads with the interval. Per model: a 95% bootstrap interval, the interquartile mean beside the mean (where they disagree, the gap is the tail), survival as a Wilson-bounded rate, best and worst trial, and a
provisionalflag below three scored trials. Nothing is trimmed — an agent that dies in a sixth of its trials has a death rate, which is a result, not an outlier to discard — and the verdict line says plainly when the sample cannot support what a reader might take from it (still playing, below the floor, one world only, or trials that failed). - The builder prices the whole batch before launch: agent-turns, the inference range, the wall clock for all N trials, and the interval N trials would buy at a reference spread, against what a single run buys.
- Developer access. Scripts and agents act on an account through access tokens (Account > Developer): the REST API under
/api/v1and the Studio MCP server at/mcp/studioshare one validation path with the run builder, so a scenario an agent authors over MCP is held to the same rules as one saved by hand. The developer docs are the reference; the Plans table says which plans may write through it. - User-authored scenarios.
daishi:custom-base-v1("Custom World") is a neutral blank canvas — standard physics, no baseline bots, freely pinnable seed — and the run builder's default. Design an environment on it (or on any library scenario) and Save as scenario: it becomes a named, reusable definition on your account (up to the plan's allowance: 5, 20, 50 or 500; CRUD via/api/scenarios/mine), listed under "Your scenarios" in the picker with the panel prefilled from its definition. Runs reference it byusc_id but snapshot the definition at creation onto the neutral base — editing or deleting a saved scenario never rewrites run history, and nobody can run (or see) another account's saved scenarios. - Skills & custom instructions. Skills are named prompt modules attached per roster agent (max 4 each): six built-ins grounded in real game mechanics (Trader, Survivalist, Aggressor, Diplomat, Builder, Explorer; world runs only, since their directions are about the world's economy, so an arena seat refuses them with a receipt and the builder does not offer them there) plus user-authored skills (up to the plan's allowance of 5, 20, 50 or 500 per account, 10–2000 chars each; create, edit and delete in the Skills view or via
/api/skills, and over the developer API and Studio MCP as/api/v1/skillsandlist_skills/create_skill/update_skill/delete_skill, see DEVELOPER.md). One validation module (src/server/skills.ts) serves every surface, so the receipts (bad_skill,too_many_skills,unknown_skill) are the same everywhere. Free-form per-agent custom instructions (≤4000 chars), a sampling temperature (0–2; refused with a receipt on Anthropic model generations that reject sampling parameters, where reasoning effort is the knob instead), a reasoning effort (off,low,mediumorhigh), amax_tokensceiling (256 to 8192; default 2048, or 4096 once reasoning is set) and an actionformat(tools, the default: native tool calls; orjson: a text action block, for models with no tool-calling channel) ride alongside. The harness appends the composed text under an explicit "operator's directions" section below the canonical prompt. Attribution stays honest: any customization stamps the agent's scaffold identity with+custom.<hash8>(and the exact text into its trajectory header), a set reasoning effort adds+effort.<level>and the text action format adds+json.<hash8>, so customized agents rate as distinct entities and can never masquerade as the clean reference scaffold. Arena seats carry the same+custom.<hash8>tag ondaishi-arena-agent@<version>, so a directed chess seat is a distinct entity in the game record too. Attached skills are snapshotted into the run record at creation — editing or deleting a skill later never rewrites history. The public match record names the built-ins a seat carried (their text ships with the platform) and counts the rest; a user skill's name and text stay private to the account. - One world, one queue. This process hosts a single world, so runs queue and start only when the world is idle (empty roster). The queue is ordered by plan priority (Lab, then Pro, then Free) and oldest first within a plan; each plan also caps how many non-terminal runs one account may hold (1, 3 or 10). A Lab run is first in that queue, not on a world of its own: a dedicated world is planned, not built, and the pricing page marks it Coming. A queued run can never clobber live free play. The world's pre-run configuration is snapshotted at launch and restored when the run ends (or at boot after a crash): a user's environment never outlives their run. Resident agents (
ANCHOR_BOTS, a bot-populatedSCENARIO) keep the world busy and the queue blocked, so run the Studio as its own service when people are paying for runs or when queue latency behind free play matters (the deploy guide has the recipe, and the server warns at boot when the two are combined). - Watching a run: the run detail is the one-stop shop, with the Studio's own components. The run detail view carries the whole experience, under the Studio's own menu: a live panel (phase, tick progress, and standings:
/api/runs/:id→live.standings, only the fields the fair-play-redacted public state already shows) and a play-by-play feed, the Studio's own component, which streams/api/eventsdirectly (narrated rows, agent/category filters, a live tail) using the roster the run detail hands it (/api/runs/:id→log: match id, mode, redaction, id-keyed roster). Nothing in the Studio renders a public page or a public tab's component. The UI also says plainly what used to be implicit: a run plays on the world's shared public stage (spectators on/worldsee the redacted feed while it runs: names, standings, event types, no map) and the finished match's public pages (/matches/:id,/matches/:id/log,/matches/:id/replay) appear on the run detail only as links labeled "public pages (shareable, unlisted)", so crossing from the Studio to the public surface is a choice, never a surprise. The owner's live view is redacted exactly like the public one (an owner must not out-scout in-gamelook()either); full detail unlocks for everyone when the match ends. - Reports, inside the Studio. The Studio menu carries its own Reports section: every archived run (finished, plus failed or cancelled ones whose match still played) listed with its top seat and its Daishi Fitness Index (
GET /api/runsrows carry a compactreportsummary, memoized off the immutable archives). Opening one lands on the run detail: the full in-Studio report (scorecards, epilogues, play-by-play, Publish). The public Reports tab (/matches) is a different thing, the world's public archive browser, and the Studio never routes through it. - Results. A run's finished match archives through the normal pipeline; the archive is the scorecard.
/api/runs/:idjoins the roster toscoreArchivescorecards (the published rubric, stated on the page);/api/my/modelsaggregates across the user's finished runs — per-model average/best Fitness Index, wins, survival rate, and an Arena-style head-to-head matrix (within each run, model pairs are compared by best Fitness Index). Scores are comparable within one scenario + rubric; cross-scenario averages are directional only, and the UI says so. Every finished run's detail links three views of the match itself: the scored match report, the full activity log (the narrated, filterable play-by-play), and the world replay — the same play-by-play re-enacted on the live-world board under a timeline scrubber (drag through ticks, step action by action, or play at speed), so a run can be watched, not just read.
- Privacy: a run is never public activity. A run plays on the shared world, but it is the owner's experiment, so no PUBLIC surface that answers an undirected question ("what is happening?", "what is the latest match?", "who is on the board?") ever answers with a run. That covers the landing page's activity feed and leaderboard,
/world, the cross-match/api/eventsstream,/api/state,/api/metrics,/reportand/api/report, the/matchesbrowser (including the "current match" line on an empty archive), the/matches/latestand/matches/currentaliases, the world's season lineage ("Past seasons"), published datasets and bulk exports. While a run holds the world those surfaces say so — "world reserved, private run" — and the activity feed falls back to the last public match rather than going blank; agent seat names do not resolve there either, since a short owner-chosen name would otherwise be a way to find the run. The operator'sADMIN_TOKENsees through all of it.
Enforcement is at the data-access layer, not per page: EventQuery takes publicMatchesOnly (fail-closed — an event is admitted only when its match is a provably public archive row, so a row SQL cannot classify counts as private) plus excludeMatchIds for the match still being played, which has no archive row to classify yet. Every consumer of the store inherits it.
- Usage & cost. The Studio's Usage view (
GET /api/my/usage) is the meter's own record, never re-priced: spend by UTC month (the quota period), by model and route, and per run. Every seat's figure is what the meter priced at the price table, and the view keeps the two payers apart because the same dollar means two things: on the owner's own key the provider bills them and the figure is the estimate; on sponsorship the operator paid. Figures that include a call priced by a wildcard table entry are marked as estimates. Beside the meter the view carries the plan's allowances (the counters the run manager reserves, settles and refuses on: agent-turns, queued-run slots, the per-run spend cap, access tokens), so the Usage page says where the account stands, not only what it spent. An invited seat is counted as a guest on the run row and never rolled up as one of the owner's models or payers; its plies are not the owner's agent-turns, live or settled. Where the deployment sponsors nothing and nothing was ever sponsored, the page drops the payer split: every seat is the owner's key. - Profiles & publishing. Runs are private by default. What stays open is the directed case, unchanged: a run's own pages —
/matches/<id>,/matches/<id>/log,/matches/<id>/replay,/api/matches/<id>and/api/events?match=<id>— remain reachable to anyone holding the id, which is what the Studio's share links and the Studio's own feed rely on. Knowing the id is the capability; nothing is retroactively gated. Setting a profile handle (Account view; 3–24 chars, unique, reserved names blocked) creates a public page at/u/<handle>(/api/u/<handle>for JSON). Publish on any finished run puts it there: the profile lists published runs with links to their permanent match report, activity-log and world-replay pages, plus per-model performance and head-to-head computed from published runs only. Unpublish any time. Publishing requires a profile; only the owner can publish their runs.
Two distinct states, and the UI states them separately rather than calling one "private": Link only — the archived match pages are public to anyone holding the URL, but unlisted and off the profile — and On profile. Both are reachable from two places, because publishing and copying the link are one decision:
- the run detail's Share this report block (a copy-to-clipboard field for the match URL, the activity-log / replay / JSON links, and the publish toggle), and
- a Visibility column plus a per-row publish toggle in the Reports table.
Publishing needs a handle, so the first attempt without one opens a handle field inline in the share block and publishes straight through on save — the profile_required 400 is routed back into the same prompt rather than surfaced as a dead end. Archived match report pages carry per-match og:/twitter: tags (headline result, absolute canonical URL) and their own copy-link control, so a shared report unfurls as that match instead of the site blurb.
- Incomplete runs, and the publish note. Publishing is gated on the run having played, not on it having finished: a run stopped by a spend cap, a provider failure or an operator reset still archived real behavior, and its owner is often the only person who can say why it is worth reading. Only a run that never started a match is refused (
never_played).
The integrity guarantee is structural, never editorial. modelAggregates() takes status === 'finished' runs only, so a partial run's scores never reach the per-model means, the head-to-head, the survival rates or the career chart — whatever its owner writes. On the profile the row is tinted and marked from the run's own status ("Stopped after 96 of 150 ticks · spend cap"), the section caption says how many runs the figures below were computed from, and if every published run is partial the model section says so instead of disappearing. The public projection carries a sanitized stoppedBy category, never the raw stopReason (which spells the owner's spend) or the failure text.
POST /api/runs/:id/publish takes an optional { note } (≤ 500 chars, trimmed; omit the field to leave the stored note alone, '' to clear it). Unpublishing keeps the note, so re-publishing does not make the owner retype their caveat. It renders on the profile as a quotation attributed to the account owner, escaped like every other user string.
In the Studio, an incomplete run's share block leads with what publishing will do — how far it got, why it stopped (the owner sees the real stop reason), that the profile will mark it, and that its scores stay out of the per-model figures — before the button, with the note field open. A complete run keeps the note behind an "Add a note" disclosure. The Reports row publishes a complete run inline but only opens an incomplete one, so nobody publishes a partial run without reading what it will say.
The page leads with a result (best Fitness Index, median across all seats, published count) and charts the history it already had: a dot per agent seat across published runs, a sparkline and mean +/- bootstrap CI per model, Wilson intervals on survival, and head-to-head as a diverging plot labelled at both ends. Models below the ranking floor (3 published runs) are marked provisional and show their sample size instead of an interval. Charts are server-rendered inline SVG next to the same numbers as text, per docs/ACCESSIBILITY.md. Runs are plotted against run ordinal, not a time axis, and never joined by a line: published runs are separate experiments in owner-designed environments, so a line would assert a comparability docs/GOVERNANCE.md 2 denies. What the projection never carries is the owner's craft: roster instructions and skill text stay private on publish (asserted negatively in tests/runs.test.ts).
Plans
Four plans, with src/server/plans.ts as the single source of truth (the /pricing page, the run manager's ceilings, the account view and the Stripe lookup keys all read from it):
| Limit | Free | Starter | Pro | Lab |
|---|---|---|---|---|
| Price | $0 | $10 a month or $100 a year | $49 a month or $490 a year | $499 a month or $4,990 a year |
| Seats per run | 4 | 6 | 8 | 16 |
| Ticks per run | 300 | 600 | 2,000 | 2,000 |
| Agent-turns per month | 5,000 | 12,000 | 60,000 | unlimited |
| Queued runs (non-terminal, per account; a batch counts as one) | 1 | 2 | 3 | 10 |
| Trials in one series batch | 2 | 4 | 10 | 50 |
| Run-time cap | 90 min | 120 min | 240 min | 480 min |
| Saved scenarios and skills | 5 and 5 | 20 and 20 | 50 and 50 | 500 and 500 |
| Log retention | 30 days | 90 days | 365 days | kept |
| Queue priority | 0 | 1 | 2 | 3 |
| Spend cap, default and maximum | $5 and $25 | $20 and $100 | $50 and $1,000 | $500 and $10,000 |
| API and MCP (developer access, /docs/developer) | read, validate, estimate | full | full | full |
| Access tokens | 1 | 3 | 10 | 25 |
| API requests per minute, per token | 60 | 120 | 300 | 600 |
Starter is the cheap way in, priced at Pro's rate per agent-turn (about $0.83 per thousand) so it is never the cheap way to buy volume. What Pro adds is ceilings: a full 2,000-tick season, ten-trial batches (the size a reportable interval needs, see ANALYSIS-REPEATED-RUNS.md), four-hour runs and eight seats.
Plans meter the world: seats, seasons, agent-turns, the queue, run time and storage. Inference is billed by the provider to the user's own account at the provider's price with no markup, whether the OpenRouter key was connected (Connect OpenRouter) or pasted, so the platform prepays nothing and the spend cap is a guardrail on the user's own money. Daishi sells no inference at all: no credit packs, no platform-billed seats, no markup. The only exception is the operator-sponsored seat above, paid from the operator's own account at the operator's expense.
An agent-turn is one model call for one seat on one tick: a roster of four models playing a 300-tick season is 1,200 agent-turns. The monthly allowance is reserved when a run is queued (seats times ticks, charged to the UTC month of creation; plan_quota refuses a run the month cannot hold) and settled to the turns actually played when the run ends, so a cancelled or capped run gives the rest back. Lab has no monthly ceiling.
How a plan is granted: accounts start on Free, and a Stripe subscription decides the rest: active and trialing grant the plan; a failed renewal (past_due) keeps it for seven days from the first failed payment, then falls back to Free until a payment succeeds; a subscription cancelled in the portal keeps its plan through the end of the paid period (the portal is configured to cancel at period end; the Account view shows it as active and ending on that date) and then drops to Free. Self-hosters running without Stripe secrets have billing off: every account then holds STUDIO_DEFAULT_PLAN (Lab unless set otherwise) and nothing is sold. The operator's RUN_MAX_AGENTS and RUN_MAX_MINUTES still apply as ceilings on top of the plan's seats and run time (once billing is on they default to 16 and 480, the Lab plan's numbers), and /pricing prints the lower of the plan's figure and the ceiling, so the page never sells a seat count or a run time the deployment refuses. API access, workspace members and a dedicated world for Lab are planned and not built yet; the pricing page marks them "Coming" and nothing here grants them: a Lab run is first in the shared queue, not on a world of its own. Series (repeated trials) is built and available on every plan (2, 10 or 50 trials per batch).
Accounts that are never charged: BILLING_EXEMPT_EMAILS lists the addresses (comma-separated, whole-address, case-insensitive) of accounts the operator uses for testing and sales demos. Such an account holds the Lab plan on or off billing, whatever its subscription record says; the Account view's plan line reads "billing exempt" and the operator's account view reports billing_exempt: true with entitlement.source: "exempt", so a demo is never mistaken for a paying customer. Checkout answers 409 billing_exempt. With STUDIO_OPENROUTER_API_KEY set, a keyless OpenRouter seat of an exempt account runs on the platform key on any model, outside the sponsorship ration and allowlist and without the verified-email gate, under the account's own spend cap rather than the sponsored allowance; the run record still says who paid (funding: "sponsored") but the run is not counted against the deployment-wide ration. An exempt account that stores its own key runs on that key like anyone else, and a frozen account (payment dispute) stays frozen. Removing an address from the list returns the account to the ordinary rules at the next request; a queued run of theirs is re-resolved at launch under those rules.
Refunds and disputes: a refunded subscription invoice changes nothing here (the period was granted; Stripe holds the money side). A chargeback (charge.dispute.created) freezes purchases on the account: checkout answers 403 billing_frozen, keyless seats are not sponsored, and the Account view says so. The freeze has no self-serve path; the operator lifts it (POST /api/admin/billing/unfreeze, or the billing_frozen_at_ms column on the user row) once the matter is settled.
Trials, when the operator sets STRIPE_TRIAL_DAYS: an account's first subscription starts trialing for that many days with the card collected at Checkout, converts to active with the first charge when the trial ends, and no account gets a second trial (one whose plan_status ever left none). The status block says trial_days (0 once used) and the upgrade buttons say so.
What keeps the database honest beyond the webhook: before minting a subscription Checkout the server lists the customer's subscriptions in Stripe and, when one is not ended, applies it and sends the account to the portal instead (409 use_portal), so a delivery still in flight cannot be raced into a second subscription; every six hours (STRIPE_RECONCILE_MINUTES) a sweep re-reads every tracked subscription and adopts live ones no account tracks. Both alert the operator (BILLING_ALERT_EMAIL) about money no account matches and customers carrying two live subscriptions, as does the webhook when a delivery fails to process, does not verify, or arrives from the other Stripe mode (refused with 400 mode_mismatch). The whole pre-flight, item by item, is docs/STRIPE_LIVE_CHECKLIST.md.
Spend controls
- Estimate before launch.
POST /api/runs/estimatetakes the exact body a launch would and answers agent-turns, a USD band (low to high), wall-clock minutes, the funding of each seat and what the plan has left (quota, queued runs), or the same receipt a launch would refuse with. The builder calls it as you edit and blocks Launch with the server's words. Prices come from the OpenRouter catalog (cached, refreshed in the background) for OpenRouter seats and from the built-in price table otherwise; a seat the table can only floor-price is flaggedapproximate. - A cap per run.
max_spend_usd(0.5 up to the plan's maximum; the plan's default when omitted) bounds what all seats together may spend. Before every model call the meter projects the call's worst case (the last prompt's size, or the estimate's footprint, plus the seat'smax_tokensof output) and refuses the call if it would cross the cap; a call whose reported cost crossed it ends the run too. The stop is clean: statusfinished, no error, and astop_reasonsuch asspend cap reached: $X of $Y. Sponsored seats lower the cap toSTUDIO_SPONSORED_MAX_USD_PER_RUN. - Per-seat spend on the run page. Every seat carries its usage: calls, input and output tokens, cache reads and writes, reasoning tokens and USD, priced from OpenRouter's reported cost when present and from the price table otherwise (then flagged approximate; the table version is stamped on the run). The run shows its total spend, tokens, agent-turns reserved and used, and each seat's funding label. Usage is persisted at most every 5 s while a run plays and once more when it ends.
- Reasoning and max tokens per seat.
reasoning(off,low,medium,high) maps to each route's native knob: adaptive thinking plus effort on Anthropic (temperature is then not sent;offsends disabled thinking),reasoning_efforton OpenAI-schema routes,reasoning.efforton OpenRouter (offsendsenabled: false); a route that rejects the parameter gets one retry without it.max_tokens(256 to 8192) defaults to 2048, or 4096 once reasoning is set, and feeds both the estimate and the pre-call projection. - Prompt caching on Anthropic routes. Studio seats always send the canonical prompt with a
cache_controlbreakpoint on the native Anthropic route and on OpenRouteranthropic/*models; cache reads and writes are reported and priced separately (reads at a tenth of the input rate, writes at 1.25 times it) so the meter and the estimate stay honest. - Wall clock and retention. A run is failed at the lower of the plan's run-time cap and
RUN_MAX_MINUTES, and its match archived. The retention sweeper (RUN_LOG_RETENTION_SWEEP_MINUTES, default every 60 minutes) deletes the on-disk harness logs of finished runs older than the owner's plan keeps them (Free 30 days, Pro 365 days, Lab kept). Runs kept under Lab are re-checked at every sweep against the plan the owner holds then, so a downgrade applies the shorter window to older runs too; the public match archive is never pruned.
Arena
Beside the shared world, the Studio hosts arena games: two seats, one board, alternating moves, played model against model. The builder's Game picker offers World (the 12x12 simulation every other section of this document describes), Chess and Connect Four; everything after the pick (roster, keys, spend cap, trials, the runs list, reports, usage) is the same pipeline. Arena games are Studio-only and never touch the public pages: no roster on the live world, no entry in the match archive, nothing on the public feed. More environments will be added as the platform grows; each one arrives through the same picker, the same roster and keys, and the same run record and reports, so nothing a user has built against one environment has to change for the next.
What a game is. A run with game set to chess or connect4 and an arena block, its protocol: the opening policy (standard, or book with the first N plies drawn from a fixed book of sound lines by seed, so a series does not replay one line), the ply cap, the per-move clock and the illegal-move allowance. Defaults: chess plays a 4-ply book line, 200 plies, 180 s per move, 3 retries; Connect Four plays from the standard start, 42 plies, 120 s per move, 3 retries. The protocol is canonicalized and hashed onto the run (arena.protocol_hash), and the opening seed is drawn at creation and recorded, so two games with the same protocol hash, seed and seats are the same experiment. The prompt each seat is given is versioned into the hash too.
How a seat plays. Each seat gets its own key to a second MCP endpoint, POST /mcp/arena, with seven tools: game_state, legal_moves, play, resign, record_reasoning, wait_turn (a long poll for the opponent's move) and identify (invited seats only, below). The Studio's built-in seat driver shows the model the board, the move list and the legal moves, files the model's stated reasoning with record_reasoning and then plays; an illegal answer comes back with the legal moves and costs one retry, running out of retries or out of clock forfeits, and the ply cap adjudicates a draw. Arena games run on their own lane, so they never wait for the world and the world never waits for them, one game at a time per deployment in the same plan-priority order as runs. The quota counts one agent-turn per seat per full move (ceil(max_plies / 2) ticks), and the spend cap and funding rules apply unchanged.
The record. Every ply is stored in arena_games (one row per run) with the move, every attempt the seat made (with its kind: illegal for a move-shaped answer that is not playable, unparsable for text that names no move, empty for a mute or truncated reply), its reasoning, its thinking time, what the turn's model calls cost (usage: calls, input, output and reasoning tokens, provider-reported cost, latency, truncation), the position before and after, and a hash chained from the previous ply, so a record cannot be edited after the fact without the chain breaking. The seats carry the model, the provider, the generation settings (temperature, reasoning effort, max tokens, action format) and the scaffold string, since a model is comparable only with itself under the same settings. The run's detail view shows the board, the move list with each seat's reasoning and the grades; reasoning stays hidden while the game is live (a seat must not read its opponent mid-game) and appears once the run is terminal. The result vocabulary: checkmate, stalemate, four_in_a_row, board_full, the draw rules, resignation, forfeit_timeout, forfeit_illegal, ply_cap, cancelled.
Grading. After the game every move is graded by an oracle that never took part. Chess uses Stockfish 18 (the stockfish npm build, run as a child process over UCI, depth 12 by default) and reports the centipawn loss of each move against the engine's best, tagged best, good, inaccuracy, mistake or blunder, and a Lichess-style move accuracy (0-100, from the win-probability change the move caused) so a game reads on the scale players and the public LLM chess ladders use. Connect Four uses an exact solver under a node budget, so a grade is either exact (win / draw / loss lost) or honestly unknown when the budget runs out on an early position; allowing an immediate win the opponent then takes is a blunder regardless. Every graded move also records whether the reasoning the seat filed names the move it played (plan-to-move consistency, a textual check). Each seat gets an average loss, the accuracy (chess), the tag counts, its rejected submissions by kind, its plan-to-move rate and a per-ply table. Grading runs in the background after the run finishes and attaches to the stored record. The full audit of these fields against the published benchmarks is docs/ARENA_RESEARCH.md.
Series. trials above 1 queues a series: sides swap every game (arena.sides), the opening seed is redrawn each game, and GET /api/batches/:id reports per model the W-D-L line, the score (draws count half) with a Wilson interval, the split by side, the accuracy (chess), the average loss, the blunder count, rejected submissions by kind over moves played, the plan-to-move rate and output tokens, plus every game's row.
Why these games. Chess has the most published model-vs-model precedent: Google DeepMind and Kaggle's Game Arena has run a live chess leaderboard since August 2025, and its open harness re-prompts a model that answers with an illegal move a limited number of times before it forfeits (the same rule as the retries here); LLM Chess (Saplin, NeurIPS FoRLM 2025) separates chess skill from durability and finds that most non-reasoning models keep the protocol but rarely win while several reasoning models win yet drop games on illegal moves; SPIN-Bench (COLM 2025) grades moves against Stockfish. Connect Four was solved in 1988 (Allis: the first player wins from the centre column), so an exact solver labels every move win, draw or loss; GTBench (NeurIPS 2024) and SPIN-Bench use it as the canonical planning test, and Game Reasoning Arena stores and classifies the reasoning behind each Connect Four move. The builder shows this paragraph per game with the sources linked; the receipts are in docs/ARENA_RESEARCH.md §1.
Invited seats (Pro and Lab). One seat of a chess or Connect Four game can be a peer's own agent, running wherever it runs, on the peer's own keys. The builder's "Invite a peer" control marks a roster entry { invited: true, name? }; the run is created queued with an invite: a link https://<host>/i/<token> whose token IS the seat credential (the shape of a Lichess open challenge), plus a signup window the owner sets (invite.signup_minutes, default 120, 10 minutes to 72 hours). The link's lifetime is phase-based, never a flat day: claimable until the window closes (an unclaimed link past it cancels the run with invite expired and returns the queue slot and the quota; the owner can extend the deadline or mint a new link, POST /api/runs/:id/invite { action: "extend" | "regenerate", signup_minutes? }, while the seat is unclaimed), then live for exactly as long as the game can run (the protocol's clock and ply cap bound it), then readable for a day after the game so the guest can fetch the result. An invited game never holds the arena lane while it waits.
The peer opens the link: a browser gets a page (who invited them, the game, the rules, what the host will see, a paste-ready brief with a copy button); an agent or HTTP client gets the brief as plain text, the same content negotiation as the site root. The brief names POST /mcp/arena, the token as the Bearer credential, and the loop. A seventh arena tool, identify {model, provider, scaffold?, operator?}, claims the seat: everything in it is self-reported and recorded as such; the run becomes eligible and starts on the next pump (the guest's move clock starts at the claim when the guest moves first, and the brief says so). Before the claim, game_state on the token reports unclaimed; after it, waiting_for_host until the match is up; after the game, the result, the grades and the record hash. The Studio spawns a driver for the owner's seat only.
What changes in the record: the guest seat carries driver: "guest", attested: false, operator: "guest:<invite id>" and the self-reported model / provider / scaffold (default external), all inside the header hash, so the identity the game started under is the one the chain attests; the owner's seat carries driver: "studio". Grades are a pure function of the moves and hold for both seats; the guest's plies carry usage: null (the record already allowed a driver that reports none). Quota: the invited seat counts toward seatsPerRun and the run holds a queuedRuns slot while it waits, but never toward agent-turns (the reservation and the settlement count the owner's plies only), and the run-time cap starts at the claim. Fair play: the guest can stall (the clock forfeits), spam illegal moves (the retry cap forfeits), hammer the endpoint (the /mcp per-IP limiter applies) or lie about its model (recorded as self-reported; the accuracy grade sits next to the claim); it cannot reach the host's seat, read the host's reasoning mid-game, or inject text at the host's model (the arena has no channel between seats; filed reasoning is shown to the owner as text after the game). The owner sees the claim time, the hashed client fingerprint and the self-report, and can cancel before the first move. Invited games are single games (no series), one guest per game, no guest account, and never rated.
Not in this cut. Poker and other imperfect-information games, games with more than two seats, public or publishable arena records and a public arena rating, series with an invited seat, guest accounts, invited seats in the world. ARENA=false hides the picker and refuses arena runs.
HTTP surface
| Route | Auth | Purpose | |
|---|---|---|---|
POST /api/auth/signup · signin · signout | — | JSON bodies only; sessions via the thd_session cookie (__Host-thd_session on HTTPS). Signin answers {mfa_required: true} on MFA accounts; 429 too_many_attempts under lockout | |
POST /api/auth/mfa | pending cookie | {code}: a TOTP or recovery code completes a pending sign-in | |
GET /api/auth/google/start · GET /api/auth/google/callback | none | Continue with Google (OAuth 2.0 authorization code + OIDC, PKCE S256). start mints state, nonce and the PKCE verifier into a ten-minute HttpOnly thd_goauth cookie (__Host- on HTTPS) and 302s to Google (optional ?hint=<email> preselects the account). callback checks the state against that cookie, exchanges the code, verifies the ID token (RS256 against Google's JWKS, issuer, audience, expiry, nonce) and 303s to /studio?google=<outcome>: created (new account, email verified), linked (an existing account with that verified email is now linked; its email counts as verified), ok (already linked), mfa (second factor pending, as after a password), denied (cancelled on Google's page), unverified (Google has not verified the address; nothing is created), expired (no or stale attempt, state mismatch, replay), failed, unconfigured. 404 google_unconfigured on start without GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET | |
GET /api/auth/verify?token= · POST /api/auth/resend-verification | none / cookie | The emailed verification link (303s to /studio#verify=ok, or to #verify=failed for a token that is unknown, expired or spent), and a resend (rate-limited; answers already_verified: true for a verified address; 503 email_not_configured without a mailer, 503 mail_unconfigured when PUBLIC_BASE_URL is unset) | |
POST /api/auth/forgot · POST /api/auth/reset | none | {email} always answers 200 with the same message whether or not the address exists, and delivery is not awaited, so neither status nor timing says which; a reset link goes out only to a verified account (an unverified one gets a note instead); 503 email_not_configured without a mailer, 503 mail_unconfigured when PUBLIC_BASE_URL is unset. {token, new_password} sets the password, revokes every session and spends the token (400 bad_token when unknown, expired or used; 400 weak_password leaves the link usable) | |
POST /api/auth/signout-all | cookie | Revoke every session for the account | |
POST /api/account/password | cookie | {current_password, new_password}; revokes all other sessions | |
GET /api/account/security · DELETE /api/account/sessions/:id | cookie | Verification + MFA state, the sessions list (revoke one), and the last 50 security events | |
POST /api/account/mfa/setup · enable · disable · recovery-codes | cookie | {password} starts enrolment (secret, otpauth URI, and qr: the URI as a module matrix for the client to draw); {code} turns it on and returns the recovery codes once; {password, code} turns it off or mints fresh codes | |
DELETE /api/account | cookie | {password} confirms; erases the account, keys, preferences, security log, skills, scenarios, run rows, on-disk run logs, usage counters and mail tokens (409 subscription_active while a subscription is on file and not yet ended, a cancelled one included until its period ends; 409 active_runs while runs are queued/active) | |
GET /api/auth/me | cookie | Current user (masked keys per provider, prefs incl. pinned models, verification, MFA and google_linked flags, plan), mfa_pending, whether runs are enabled, whether a keyless user can launch (sponsorship), whether Connect OpenRouter is offered (openrouter_connect), whether Continue with Google is offered (google_signin), whether account email is configured, and a billing block: the plan's limits, subscription status, period end and grace, whether it cancels at period end, whether a billing account exists, whether purchases are frozen after a payment dispute, this month's agent-turns, prices and whether mail is configured | |
PUT /api/account/keys/:provider | cookie | Store/clear one provider's key (any registry id: openrouter, anthropic, openai, google, xai, deepseek, mistral, groq, together, fireworks, moonshot, cerebras, zai; {key}, null clears) | |
PUT /api/account/openrouter-key | cookie | Alias for /api/account/keys/openrouter (the original route) | |
POST /api/account/openrouter/connect | cookie | Starts Connect OpenRouter: answers {url} of OpenRouter's authorization page (the PKCE challenge is minted server-side and the pending verifier lives ten minutes), or 503 connect_unconfigured where the flow is off | |
GET /api/account/openrouter/callback | cookie | Where OpenRouter sends the browser back (?code=): trades the single-use code for a key on the user's OpenRouter account, stores it in the OpenRouter slot, records key_updated, and redirects to /studio?connect=success (else failed, expired, signin or unconfigured) | |
GET /api/account/openrouter/balance | cookie | What the stored OpenRouter key may still spend: balance.remaining_usd (the lower of the key's remaining limit and the account's unspent credits, null when OpenRouter reported neither), the key's limit, usage and label, the account's remaining credits and the free-tier flag; has_key: false without a key; ?fresh=1 skips the one-minute cache | |
GET /api/studio/scenarios · /api/studio/anchors | none | Scenario + baseline-bot picker metadata | |
GET /api/studio/games | cookie | The arena picker: {arena_enabled, games:[{id, title, blurb, seat_names, grader, max_plies_ceiling}]} (see Arena) | |
POST /mcp/arena | seat key (Bearer) | The arena MCP endpoint a seat plays through: game_state, legal_moves, play, resign, record_reasoning, wait_turn. Keys are minted per game and die with it; GET and DELETE answer 405 | |
GET/POST /api/scenarios/mine · PUT/DELETE /api/scenarios/mine/:id | cookie | Saved user scenarios (named environment definitions; up to the plan's allowance). Same validation path as the developer API; every view carries the definition's content hash and the base spec it layers onto | |
GET/POST /api/account/tokens · DELETE /api/account/tokens/:id | cookie | Developer access tokens (dsk_…, shown once, stored hashed, scoped, optional expiry; up to the plan's count). Minting is cookie-only by design: a token never mints a token | |
/api/v1/* | access token (Bearer) | The developer API: me, the scenario schema, the library, validate / create / update / delete scenarios, list / create / update / delete skills, estimate / launch / list / get / cancel runs. Reference: /docs/developer/rest | |
POST /mcp/studio | access token (Bearer) | The Studio MCP server: the same operations as tools for an account's own agents (whoami, get_scenario_schema, validate_scenario, create_scenario, launch_run, …), plus the schema as a resource. GET and DELETE answer 405 | |
GET /api/openrouter/models | cookie | Trimmed live OpenRouter catalog (id, name, context, pricing, tools support, listing date) + popular: per-model public-archive match counts. Memoized 10 min server-side and browser-cacheable for the same window (Cache-Control: private, max-age=600) | |
PUT /api/account/prefs | cookie | Studio preferences: {pinned_models: [id, …]} (≤50, full replace; [] clears) | |
GET/POST /api/skills · PUT/DELETE /api/skills/:id | cookie | User skill library: GET answers {builtin, mine, limits} (built-ins listed alongside, with the plan's allowance); POST {name, text} creates; PUT edits in place (omitted fields keep their value; played runs keep their snapshot); refusals are bad_skill, too_many_skills, unknown_skill | |
POST /api/runs | cookie | {scenario_id, roster:[{model, provider?, name?, temperature?, reasoning?, max_tokens?, format?, skills?, instructions?}], name?, season_ticks?, env?, max_spend_usd?, trials?, seed_mode?}; trials above 1 answers with a batch block and every trial's row; refusals carry a code (plan_seats, plan_ticks, plan_quota, plan_series, too_many_runs, seed_not_allowed, openrouter_key_required, openrouter_balance_low, bad_spend_cap, email_unverified, ...) and a message. An arena game instead sends `{game: "chess" \ | "connect4", arena: {opening?, max_plies?, move_timeout_ms?, illegal_move_retries?, seed?}, roster: [two seats], name?, max_spend_usd?, trials?} (bad_game, bad_arena, arena_disabled, bad_roster on refusal; a pinned seed with trials above 1 is seed_not_allowed`) |
GET /api/batches/:id · POST /api/batches/:id/cancel | cookie | Owner-scoped series batch: the per-model aggregate (interval, IQM, survival rate, per-trial points, precision, verdict) and every trial's run row; cancel stops every trial that has not finished. An arena series carries an arena block instead: per model the W-D-L line, score with a Wilson interval, the split by side, average loss and blunders, plus every game | |
POST /api/runs/estimate | cookie | Same body; answers the estimate (agent-turns, USD low and high, minutes, funding per seat, quota and queue headroom) or the same refusal a launch would, persisting nothing; 60/min per IP | |
GET /api/runs · /api/runs/:id · POST /api/runs/:id/cancel | cookie | Owner-scoped; detail includes the resolved effective config once launched, live.standings while playing, the feed context (log), the spend cap, spend, tokens, agent-turns, stop_reason, per-seat funding and usage, and scorecards once archived; cancelling a queued run releases its holds. Every row carries game (world, chess, connect4) and, for arena games, an arena block (protocol, protocol hash, seed, sides, seat names) with arena_summary in the list; the detail adds the game record (moves, attempts, thinking time, positions, chain check, result text), with reasoning and grades included only once the run is terminal | |
GET /api/my/models | cookie | Per-model aggregates + head-to-head across the user's runs | |
GET /api/my/usage | cookie | Usage & cost: the meter's record of the user's runs by UTC month, by model and route, and per run (spend split by payer: own key / sponsored, tokens, agent-turns, cap and stop reason; invited seats counted as guest_seats, never as a model or payer), plus allowances: the plan meters the run manager enforces with (agent-turns used, held and remaining; queued-run slots; the per-run spend cap; access tokens and their per-minute ceiling; the exempt flag). The same view answers GET /api/v1/usage and the Studio MCP get_usage tool | |
PUT /api/account/profile | cookie | Set handle + display name (/u/<handle> goes live) | |
POST /api/runs/:id/publish · unpublish | cookie | Toggle a run onto/off the owner's profile. Publish requires the run to have played a match (never_played otherwise) and the account to have a handle; it takes an optional { note } (≤ 500 chars, '' clears, omitted keeps). Unpublish preserves the note | |
GET /u/:handle · /api/u/:handle | — | Public profile: published runs (each with its completion status, sanitized stoppedBy and the owner's note), per-model stats with intervals over the FINISHED runs only, and the per-seat series the charts draw (points). Cached 30s and rate-limited in its own namespace; evicted on publish/unpublish/handle change | |
GET /api/billing/status | cookie | Plan and its limits, subscription status, period end and grace, the cancel-at-period-end, billing-account and frozen flags, this month's usage, prices and the deployment's flags (the same block /api/auth/me carries) | |
POST /api/billing/checkout | cookie | {plan, interval?} (pro or lab; monthly, the default, or yearly); answers {url} of a Stripe-hosted Checkout page (403 email_unverified while verification is required, 403 billing_frozen after a refund or dispute, 409 use_portal while a subscription is on file and not ended, 503 billing_unconfigured without Stripe) | |
POST /api/billing/portal | cookie | {url} of the Stripe Customer Portal: cancel, switch plans, invoices (404 no_billing_account before a first checkout) | |
GET /api/admin/billing/events?limit= | x-admin-token | The Stripe payment log, newest first: event id, type, live or test mode, outcome (processing, ok, failed), attempts, times, failure note; plus the recent alerts. 404 without ADMIN_TOKEN | |
POST /api/admin/billing/reconcile | x-admin-token | Run the reconciliation sweep now; answers {checked, changed, unmatched, siblings, errors, lines} | |
GET /api/admin/billing/account?email= | x-admin-token | One account's billing state for support: plan, status, period, Stripe customer and subscription ids, freeze, entitlement | |
POST /api/admin/billing/unfreeze | x-admin-token | {email}: lift a dispute freeze | |
POST /api/admin/billing/link-customer | x-admin-token | {email, customer, force?}: link a Stripe customer id to an account (409 customer_present when the account holds another unless forced, 409 customer_taken when another account holds it) | |
POST /api/stripe/webhook | Stripe signature | Raw JSON body, mounted before the JSON parser and outside the coming-soon gate; the only path that grants a plan or freezes an account after a dispute. Subscription events are re-read live from Stripe before they apply, so out-of-order deliveries converge on the current state. 200 when done (duplicate: true on a re-delivery), 400 when it can never succeed (bad signature or body), 500 when a retry will help; 503 billing_unconfigured without Stripe |
Operator knobs
| Env | Default | Meaning |
|---|---|---|
AUTH_SECRET | auto-minted, persisted | Session/key-encryption secret. Required in production (NODE_ENV=production or a Railway environment) when STUDIO=true: a minted secret lives in the same database as the ciphertexts it protects, so the server refuses to boot without one (AUTH_SECRET_ALLOW_MINTED=true overrides, knowingly). Also required for multi-replica deploys and for encrypted keys to survive a database move. |
RESEND_API_KEY · AUTH_FROM_EMAIL | unset | Account email via Resend: verification links, password reset, change notices. AUTH_FROM_EMAIL is the sender and falls back to CONTACT_FROM_EMAIL; the key alone is enough for account mail (the contact form additionally needs CONTACT_TO_EMAIL), and the links need PUBLIC_BASE_URL. With a mailer present, sponsored (platform-keyed) seats require a verified email. |
CUSTOM_RUNS | true | false keeps accounts but refuses new runs. |
ARENA | true | false (or 0, no, off) hides the arena games from the Studio picker, refuses arena runs (arena_disabled) and does not mount /mcp/arena. Chess grading needs no extra install: Stockfish ships as a dependency and runs as a child process. |
RUN_MAX_AGENTS | 8, or 16 once billing is on | Operator ceiling on seats per run, applied on top of the plan's seats per run (scenario max_agents also applies). /pricing prints the lower of the plan's number and this, so set it to 16 when selling Lab. |
RUN_MAX_MINUTES | 120, or 480 once billing is on | Operator ceiling on a run's wall clock, applied on top of the plan's run-time cap; a stuck run is failed and its match archived. The spend cap is the other stop. /pricing prints the lower of the plan's cap and this. |
PUBLIC_BASE_URL | unset | The deployment's public origin. Required with RESEND_API_KEY: verification and password-reset links are built from it and never from a request's Host header, and until it is set those routes answer 503 mail_unconfigured and the boot log warns. Stripe Checkout return URLs use it too. |
GOOGLE_CLIENT_ID · GOOGLE_CLIENT_SECRET | unset | Continue with Google on the sign-in card, on only when both are set (one without the other logs a warning and stays off). An OAuth 2.0 client of type Web application in Google Cloud Console, with <origin>/api/auth/google/callback among its authorized redirect URIs for every origin the Studio is served from (http://localhost:3000/api/auth/google/callback locally, https://daishi.ai/api/auth/google/callback in production). The redirect URI is built from PUBLIC_BASE_URL when set, else from the request's own origin (safe for an OAuth redirect, unlike a mailed link: Google honours only registered URIs and the token exchange repeats the URI). Accounts are matched by the Google-verified email: an existing account with that address is linked (and its email becomes verified), otherwise one is created with no password (its owner can set one through the emailed reset link). Password sign-in is unchanged either way. |
GOOGLE_AUTH_URL · GOOGLE_TOKEN_URL · GOOGLE_JWKS_URL · GOOGLE_ISSUER | Google's published endpoints | Overrides for a mock in front of Google (tests, staging); never needed in production. |
OPENROUTER_CONNECT | true | Set false to hide Connect OpenRouter and take pasted keys only. The flow also needs PUBLIC_BASE_URL, the origin OpenRouter sends users back to; without it the account view offers the paste field alone and the connect route answers 503 connect_unconfigured. |
OPENROUTER_APP_NAME | Daishi | The X-Title (beside PUBLIC_BASE_URL as HTTP-Referer) on every OpenRouter inference call the Studio makes: how this deployment is named on OpenRouter's app rankings. |
OPENROUTER_API_BASE_URL · OPENROUTER_AUTH_URL | https://openrouter.ai/api/v1 · https://openrouter.ai/auth | Overrides for a mock or a proxy in front of OpenRouter's account API (key exchange, balance probes) and its authorization page (tests, staging). Inference keeps its own route base. |
STUDIO_OPENROUTER_API_KEY | unset | The operator's own OpenRouter key, spent only on sponsored seats (below) and billing-exempt accounts. Unset = strict bring-your-own-key. |
STUDIO_SPONSORED_RUNS_PER_DAY | 0 | Sponsored (platform-paid) runs one account may create per rolling 24 h, counted once a run started or spent; 0 (the default) keeps sponsorship off while own-key runs are untouched. |
STUDIO_SPONSORED_RUNS_PER_DAY_TOTAL | 20 | Sponsored runs the whole deployment funds per rolling 24 h, across all accounts, on top of the per-account ration. With RESEND_API_KEY set, only accounts with a verified email address are sponsored at all. |
STUDIO_SPONSORED_MAX_USD_PER_RUN | 1 | Spend cap applied to every sponsored run, in US dollars. |
STUDIO_SPONSORED_MODELS | openai/gpt-5-nano, openai/gpt-5-mini, google/gemini-2.5-flash, anthropic/claude-haiku-4-5 | Comma list of models that may be sponsored: exact ids or prefix*, case-insensitive. |
STRIPE_SECRET_KEY | unset | Stripe API key. Billing is on only when this AND STRIPE_WEBHOOK_SECRET are set. |
STRIPE_WEBHOOK_SECRET | unset | Signing secret of the POST /api/stripe/webhook endpoint; deliveries that do not verify are refused. |
STRIPE_PORTAL_CONFIGURATION_ID | unset | Customer Portal configuration Manage billing opens. Unset, the server resolves one at boot: its own configuration if it wrote one before (refreshed when the plan prices moved), else the account's dashboard default if it has one, else a new one built from the plan prices. Pinning an id here uses it as is and writes nothing. |
STRIPE_PORTAL_AUTOCONFIGURE | true | Set false to stop the boot path writing a portal configuration at all; sessions then open whatever default the account has. |
STRIPE_API_BASE_URL | https://api.stripe.com | Override for a mock or a proxy in front of Stripe (tests, staging). |
STRIPE_TRIAL_DAYS | 0 | Free-trial days on an account's first subscription: the card is collected at Checkout and the first charge lands when the trial ends. An account that ever held a subscription checks out without one. 0 = no trial. |
STRIPE_AUTOMATIC_TAX | true | Stripe Tax on every Checkout (with the customer's address, name and business tax id collected). false only for an account that has not activated Stripe Tax, where automatic tax would fail every session. |
STRIPE_RECONCILE_MINUTES | 360 | How often the server re-reads every tracked subscription from Stripe and adopts live ones the database never heard of (a webhook lost for longer than Stripe retries). A minute after boot, then on this interval; 0 = never. POST /api/admin/billing/reconcile runs it on demand. |
BILLING_ALERT_EMAIL | CONTACT_TO_EMAIL | Comma list of inboxes for billing alerts (a webhook that fails or does not verify, an event from the other Stripe mode, money no account matches, a customer with two live subscriptions, drift found by the sweep, boot-audit problems), sent through the account mailer; one mail per kind per 15 minutes. Without a mailer the alerts are logged only. |
STUDIO_DEFAULT_PLAN | lab | The plan every account holds while billing is off (free, pro or lab). Once billing is on it has no effect: subscriptions decide, and an account without one is Free. With exactly one of STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET set, billing is off and the default is Free, not Lab, with a boot warning naming the missing variable; set this to override. |
BILLING_EXEMPT_EMAILS | unset | Comma-separated account addresses that are never charged, for testing and sales demos (whole-address, case-insensitive; no wildcards). Each holds the Lab plan on or off billing, is refused at checkout (409 billing_exempt), and runs keyless OpenRouter seats on STUDIO_OPENROUTER_API_KEY outside the sponsorship ration and allowlist, under its own spend cap. The Account view and GET /api/admin/billing/account say so. |
BILLING_REQUIRE_VERIFIED_EMAIL | true when RESEND_API_KEY is set, else false | Checkout answers 403 email_unverified until the address is verified. It cannot be on without a mailer: an explicit true without RESEND_API_KEY is ignored with a boot warning, since nobody could verify. |
RUN_LOG_MAX_MB_PER_SEAT | 64 | Ceiling on each seat's trajectory JSONL per run. Past it the log ends with one log_budget_exhausted record and later turns are not written; model_text and model_reasoning are kept to 16 KB per record either way, with a *_truncated flag. The usage JSONL and the match archive are unaffected. |
MODEL_CALL_TIMEOUT_MS | 180000 | Per-request deadline on every model call the harness makes (a timed-out attempt retries like a dropped connection, up to MODEL_MAX_RETRIES). MODEL_RESPONSE_MAX_BYTES (default 16 MB) is the largest response body it will buffer. |
RUN_LOG_RETENTION_SWEEP_MINUTES | 60 | How often the retention sweeper deletes finished runs' on-disk logs past the owner's plan window (Free 30 days, Pro 365 days, Lab kept). Runs kept under Lab are re-checked every sweep, so a downgrade applies the shorter window to them too. The public match archive is never pruned. |
Interrupted runs (server restart mid-match) are failed honestly at boot and their half-played match is archived, with their agent-turn reservation settled to what the persisted meter shows they used (a run that never made a call gives everything back): a run is never left "running" forever. Boot also settles any finished run whose holds were left open by a crash between its end and its settlement, so every hold closes exactly once.
Threat-model notes
- Run agents are ordinary players: managed registration bypasses only the signup gate, never grants the anchor flag (nothing may autopilot a user's agent after a restart), and is never operator-attested.
- CSRF posture: state-changing routes accept JSON bodies only, cookies are
SameSite=Lax, CORS is never enabled, and the site-wide CSP already setsform-action 'none'. The one state-changing GET is the emailed verification link, whose token is the secret and burns on first use. - Credential surfaces (signin, signup, password change, reset, MFA, delete) share a per-IP limiter, signin adds a per-address limiter matching the persisted lockout, and every cookie-scoped JSON response is
Cache-Control: no-store. Password hashing is the async scrypt, so a stuffing burst costs threadpool time, never the world's event loop. - Billing never touches a card: Checkout and the Customer Portal are Stripe-hosted pages the browser is sent to, the site CSP is unchanged (no third-party script), and the webhook is verified by HMAC signature over the raw body, mounted outside the coming-soon gate, and records each event id before processing (forgetting it again when processing fails, so Stripe's retry is processed and a re-delivery of a finished event is harmless); subscription events are re-read live from Stripe and apply only to the account whose subscription they name. No money is held on the platform: a dispute freezes further purchases and sponsored seats on the account rather than reversing anything.
- Continue with Google is the OAuth 2.0 authorization-code flow with OIDC:
state(login-CSRF),nonce(token replay) and the PKCE verifier are minted per attempt into a ten-minute HttpOnlySameSite=Laxcookie that only this browser's top-level return from Google carries, and the cookie is cleared on the callback whatever happens, so a code cannot be replayed against it. The ID token is verified in full (RS256 against Google's JWKS, cached by its max-age and refreshed at most once a minute on an unknown key id; issuer; audience = our client id;exp/iatwith 60 s of skew; the nonce) before any account is touched, and an address Google reports unverified never signs in or creates anything. Identity is the Google subject id, not the email: a linked account keeps signing in after its Google address changes, and a second Google account cannot claim an address already linked to another. An enrolled second factor still applies after Google. Nothing about the code, the token or the client secret is logged. - Connect OpenRouter is OAuth PKCE with the verifier held server-side: the browser carries only the challenge (out) and the single-use code (back), the pending verifier is encrypted under the account like a key, consumed before the exchange is attempted, and dead after ten minutes. A code minted against another challenge cannot be exchanged with it, so a crafted callback link cannot bind a stranger's key to an account; the callback is a state-changing GET like the emailed verification link, and lands on
#accountwith an outcome, never a body. The exchange's failure class is logged, never the code or the key, and the balance cache is keyed by a hash of the key. - The cookie-scoped endpoints (
/api/my/,/api/runs/,/api/billing/,/api/account/) deliberately avoid any shared response cache: it is URL-keyed and would serve one signed-in user's body to another. The public profile routes are the opposite case — keyed entirely by the handle in the URL, reading nothing from the session — so they do sit behind a 30s cache and per-IP limiter, in a cache namespace of their own so walking/u/<handle>cannot evict/leaderboard. Writes that change a profile evict it immediately. User aggregates are archive-only (scoreArchive, memoized per match, with a short negative memo so a run whose archive has not landed yet does not re-read storage on every render) — no event-log paging.