apps/creator
The creator runner (Technical specs §9.8): the platform's own model making games with their owners, in a conversation, the in-game way to create beside Claude Code. It claims a turn of a creation from the platform API (POST /creator/claim, a long poll behind FOI_CREATOR_SECRET, straight to the API, never through the stack's proxy), runs a tool-calling model over the MCP server's tools in its own process (createMcpServer over the SDK's in-memory transport) as the job's own session, and tells the API how it ended. The session is the turn's alone: a grant scoped job:<id> that reaches its one draft, its builds and playtests, the catalogue, the levels, the guides and the games, nothing else (the API's JOB_ROUTES, deny by default), so a brief that tries to talk the model into publishing, opening rooms or touching another draft gets nowhere. How owners use it is in Creating games and the queue, the allowance and the budget in Platform.
| File | What |
|---|---|
runner.ts | runJob: one turn of a creation's conversation, the loop of the model's calls over the tools from what the owner said to its answer (a game built, a question, a proposal), within the turn's caps (30 calls of the model, 600,000 tokens in and 40,000 out, 8,000 a call; what is left of the creation's dollars, 20 minutes; its builds the API counts). The first turn starts from the brief, a later one from the model's own history (the API keeps it, its tools' long answers cut) and the owner's new messages in quotes. It fails when the same errors come back twice, and when the provider or the API cannot be reached (system: given back on the first turn). Before each call a priced model is bounded: the conversation's tokens read at most (a token for every three characters) and the most it may answer must fit what is left of the job's dollars, so the answer is cut shorter, or the job stops before a call that could pass its cap. Heartbeats every 3 s carry the step and the spend (the ledger's) and abort the turn under way on a cancel, as a 401 from the API does; at each change of step the long answers of the steps before the last are cut. Its outcome carries the transcript (the conversation after the system prompt, 200 messages and 8,000 characters each at most), which the finish hands the API for the moderators. |
tools.ts | creationTools: the MCP server in this process, the tools a creation's session may call (catalogue_search, catalogue_get, levels_list, level_get, draft_create, draft_get, level_spec_set, game_set, mission_upsert, mission_remove, draft_build, draft_playtest, games_search) as functions in the chat-completions shape, and read_resource (the guides, whole or one section, foi://guide/<name>/<heading>; the bundled examples; every template's level spec schema; never the game document's schema), its texts cut at 24,000 characters. |
card.ts | The creator card, the system prompt's same bytes every time (so the provider caches them): the game document and the mission language distilled (every verb, event, condition and action with the fields that matter), an example that passes, where games are made (a city drawn freely among them), the tone, how a turn ends (the answer to the owner); then the MCP server's LOOP steps 1 to 6 and its RULES; the brief and every later message (followUp) come after it in quotes, as the owner's words, not instructions. card.test.ts holds it to the mission schema, the catalogue and 12 KB. |
openrouter.ts | openRouterModel: OpenRouter's chat completions with the tools, one call at a time (parallel_tool_calls: false), what each call cost (OpenRouter's usage accounting, always on), no provider that keeps prompts (provider.data_collection: 'deny'), the models after the first as fallbacks; a 402 fails at once, a busy provider is tried again, every refusal no fault of the brief (the creation given back). fetchPrices: the models' prices from OpenRouter's models endpoint, read once at start, the dearest of the fallbacks (the runner does not start on a model OpenRouter does not list). |
claude.ts | claudeModel (FOI_CREATOR=claude): Claude on Anthropic's Messages API with the platform's ANTHROPIC_API_KEY, the conversation turned into Claude's shape (the system prompt apart, tool_use and tool_result blocks), one tool call at a time (disable_parallel_tool_use), the tools, the card and the conversation so far cached (cache_control), the models after the first as fallbacks; each call's cost worked out from Anthropic's token counts at the model's list price (CLAUDE_PRICES: cache writes and reads included, Haiku 5.5 dearer over 100,000 tokens; FOI_CREATOR_PRICE for a model the list does not know), bounded at the dearest rate a token could cost (claudePrice); a busy model tried again, every refusal no fault of the brief. checkClaudeModels: at start, Anthropic takes the key and has each model. |
evals.ts | The evals: twenty fixed briefs (Riverbend, small grid towns, regions; 1 to 3 missions), each made by each candidate model on the platform in this process and measured (built without an error with the missions asked for, moderation passed, the calls, the cost, the time), summed up against the target (nine in ten built at $0.05 a creation or less on average), written as Markdown and JSON. |
eval.ts | npm run creator:eval: the evals by hand, never in CI. It refuses to start without OPENROUTER_EVAL_KEY (a key of its own, never the live OPENROUTER_API_KEY), and for the claude-… candidates without ANTHROPIC_EVAL_KEY (never the live ANTHROPIC_API_KEY), prints the worst case first (each creation's cap: $8 for twenty briefs and four models) and spends only with --yes; FOI_EVAL_MODELS names the candidates (the Gemini Flash, GPT mini, DeepSeek and Qwen bands by default), --briefs N the first N, --out the report's folder (reports/creator-eval), --fake the scripted model at no cost. |
local-platform.ts | localPlatform: the platform API in this process (buildApp on a memory store, its build queue, creation on), reached by an injecting fetch, with its runners' client and calls as an account: what the evals and the integration tests run creations on. |
fake-model.ts | fakeModel (FOI_CREATOR=fake): a scripted model that plays a known-good sequence making a small game on test-mission-basics (the brief's first words its title, 1 to 3 missions), or revises a draft, each turn FOI_CREATOR_FAKE_DELAY_MS long and two tenths of a cent, its games' blurb FOI_CREATOR_FAKE_BLURB when set (a blocked word, for the moderation to refuse): what the tests and the end-to-end tests run. |
model.ts | The chat-completions shapes the models speak, a model's Price (USD a token in and out, and a call), and ModelError (system: no fault of the brief). |
client.ts | creatorClient: the API's private routes with the runners' secret: claim, heartbeat, finish. |
config.ts | loadConfig: the environment (below). |
index.ts | The process: on OpenRouter the models' prices read first, on Claude the key and the models checked; then FOI_CREATOR_CONCURRENCY slots, each claiming, running and finishing creations; stopped, it fails those under way as no fault of their briefs. |
Configuration
Run on its own, these are in the root .env.example (FOI_CREATOR_FAKE_DELAY_MS, FOI_CREATOR_FAKE_BLURB, FOI_API_URL, FOI_WEB_URL, and the evals' OPENROUTER_EVAL_KEY, ANTHROPIC_EVAL_KEY and FOI_EVAL_MODELS); the ones the Compose stack sets too (FOI_CREATOR, FOI_CREATOR_SECRET, FOI_CREATOR_CONCURRENCY, FOI_CREATOR_MODELS, FOI_CREATOR_PRICE, OPENROUTER_API_KEY, ANTHROPIC_API_KEY) in deploy/.env.example.
| Variable | Default | What |
|---|---|---|
FOI_CREATOR | off | fake (the scripted model), openrouter or claude; off runs nothing. |
FOI_CREATOR_SECRET | The runners' secret the API shares (16 characters at least). | |
FOI_API_URL | http://localhost:8080 | The platform API, straight. |
FOI_WEB_URL | http://localhost:5173 | The web client, which the tools' links point at. |
FOI_CREATOR_CONCURRENCY | 1 | How many creations this process runs at once (an owner never has two running). |
FOI_CREATOR_FAKE_DELAY_MS | 400 | How long each turn of the scripted model takes. |
FOI_CREATOR_FAKE_BLURB | its own | The blurb the scripted model writes (the end-to-end tests' blocked word). |
OPENROUTER_API_KEY | The platform's OpenRouter key, for openrouter (never a creator's own: no bring-your-own). | |
ANTHROPIC_API_KEY | The platform's Anthropic key, for claude (never a creator's own). | |
FOI_CREATOR_MODELS | google/gemini-2.5-flash,openai/gpt-4.1-mini | The models in order, the first asked, the others its fallbacks, until the evals choose; their prices are OpenRouter's, read at start. With claude, Anthropic's ids (claude-haiku-5-5 by default), at Anthropic's list prices. |
FOI_CREATOR_PRICE | With claude, the price of a model the list does not know: dollars a million tokens in and out (1,5). |
Run it with npm run dev:creator (the scripted model; npm run dev:platform starts it beside the API, which then needs FOI_CREATOR=fake and the same secret, as dev:platform sets them). The Compose stack runs it as the creator service, off unless FOI_CREATOR is set (see Deploying).
Tests
runner.integration.test.ts runs the runner against the platform API in the same process (local-platform.ts): a brief made into a built game with the scripted model, its steps, its result and the owner's feed; a revision of the same draft; a cancel mid-run (stopped within a turn, the draft kept, the creation spent); the runner's own stops (the same errors twice, a model that gives up, a provider that will not bill, its calls spent, tools and resources it may not use); the deadlock test (one runner slot, one build slot, a Claude Code build queued ahead: the creation's builds go first); a priced model's calls bounded by the dollars left; the transcript a finish keeps; and a game whose blocked word the moderation refuses. evals.integration.test.ts runs the evals with the scripted model (and one that gives up, one that writes a blocked word) and their report. openrouter.test.ts and claude.test.ts run the OpenRouter and Claude clients against a mocked fetch only, on recorded answers' shapes (no key exists for the tests, and none is ever used), card.test.ts the card's drift test, config.test.ts the configuration. The API's own side (the allowance, the budget, the session's scope, the admins' queue, the moderation of briefs and of what the model wrote, the transcripts) is apps/api/src/creations.test.ts.