Skip to main content

Nova and the worker

Moonspell uses two agents, and each has one job:

  • Nova (agent/, an eve agent on the server) talks to the player, decides what to build, writes the brief and starts runs. She has three tools and no sandbox, shell or file access. She never writes code.
  • The worker (Claude Code, headless, in a sandbox per run) builds. It never sees the chat. Nova's brief is its whole task, and the game repo's CLAUDE.md and skills are what it knows.

Between them sits the game-run workflow (src/workflows/game-run.ts). It owns the sandbox, the credentials, the uploads and the push.

Nova​

FileRole
agent/agent.tsdefineAgent on the model in agent/model.ts, with eve's default tools turned off
agent/instructions.tsWho Nova is, how she talks, what Moonspell can build, how she uses her tools, how she answers system messages
agent/instructions/status.tsA dynamic instruction rebuilt at the start of every turn: the player's games, their latest versions, any build in progress or recent failure, and whether online features are on
agent/tools/make_game.ts, change_game.ts, show_game.tsHer three tools
agent/lib/runs.tsResolving which game a tool acts on, and starting a run
agent/lib/caller.tsThe player behind a turn, and the chat it belongs to
agent/channels/app.tsThe chat routes the apps call
agent/hooks/meter.tsCharges each of her model calls in credits (see Credits)

Tools​

ToolInputWhat it does
make_gametitle (up to 80 characters), brief (up to 6000, in English)Creates the game, ties the chat to it and starts a run. If the run cannot start, the empty game is deleted again.
change_gamerequest (up to 6000, in English), optional gameIdResolves the game (the one named, else this chat's, else the player's most recent) and starts a run.
show_gameoptional gameId, optional versionFinds a version (the newest by default) and returns it, so the app shows a card that plays it.

The apps render the tool results as cards: a building card that follows the run live, and a play card. The model never sees the raw result. toModelOutput gives Nova a short sentence instead ("The chat now shows a live card..."), which keeps her from narrating progress or pasting links.

What she knows each turn​

The static instructions say what Moonspell builds well (the genre modules and foundations, see Game modules), what it cannot do (real-money purchases, accounts beyond a player name, phone features the app does not ship), and how to brief the builder: complete plain instructions, because the builder never sees the chat.

The dynamic status instruction adds, every turn: up to 20 of the player's games with their latest version and notes, any build in progress, which game this chat is about, and whether online features are on (cloudflareConfigured()), so she never promises online play when it is off. For a chat about someone else's game it adds that game's design document, marked as data and never instructions.

Starting a run​

startGameRun in agent/lib/runs.ts:

  1. Checks the player may build (credits, and for someone else's game the community limits).
  2. queueRun inserts a queued row in game_runs. A second active run on the same game and branch is refused, and Nova is told a build is already running.
  3. Calls POST /api/internal/runs with INTERNAL_API_SECRET. Nova runs as its own service and the Next.js app owns the workflows, so she asks the app to start one. A run that cannot start is failed at once, so it never holds the game.

Messages from the run​

When a run ends, the workflow posts a hidden system message into the chat the run came from (POST /eve/v1/app/internal/deliver). The apps hide these; Nova answers them.

MessageWhat Nova does
[Build ready]Calls show_game for the new version, writes one line about what is in it from the builder's notes, and offers one or two of the builder's next ideas as a question
[Build failed]If the error starts Our side: (the sandbox, storage, GitHub or the database failed, not the builder), she restarts the same request once per chat. Otherwise she says it did not work this time and offers to try again
[Build stopped]The player ran out of credits mid-build. She shows the last saved version if there is one
[Community build], [Change declined]See Community builds

The worker run​

gameRunWorkflow(runId) in src/workflows/game-run.ts is a durable workflow (Workflow SDK). Its steps are in src/workflows/game-run-steps.ts, and each step is retried on its own unless it sets maxRetries.

StepWhat it does
claimRunMoves the run from queued to running and decides the mode: existing when the game has a repo and a version, otherwise new
provisionSandboxCreates a sandbox from the snapshot in DAYTONA_SNAPSHOT. Boxes stop after 60 idle minutes and delete themselves on stop
startRunMints every credential inside the step, so none lands in the workflow's event log, writes the run's files into the box and starts run.sh in the background. Not retried
pollRunEvery 15 seconds, for up to 160 polls (40 minutes): copies the newest progress line onto the run and reads the exit code. A temporary error from the sandbox API counts as "still running"
salvageRunOnly on a timeout or a non-zero exit: ships the last checkpoint (below)
readOutcomeThe commit, the notes for the player and the next ideas
sounds, art, modelsThe assets the builder asked for are made on the server (below)
nextVersion, uploadExport, uploadAndroidExport, uploadWebExport, recordBuildStores the new version. See Builds and versions
pushGameCreates the game's repo on its first version and pushes the commit with a write token only that one command sees
succeedRun or failRunThe final status, published to the edge
withdrawBuildA version recorded but never pushed is deleted, so every version can be built upon
deliverOutcomeThe system message to Nova
destroySandboxAlways, in a finally

A run that never finishes is also failed by expireStaleRuns() in src/lib/games.ts, 75 minutes after it was queued.

Inside the sandbox​

The snapshot is built by scripts/create-run-snapshot.ts: Node 24, git, Claude Code, the private bundling CLI and the game template with its dependencies installed. startRun writes the run's files into ~/.crescent/ in the box, among them run.sh, checkpoint.sh, ship-checkpoint.sh, helpers.cjs, the request and the system prompt. Credentials go into a file with mode 0600 that run.sh reads once and deletes; they never appear on a command line. The script itself is runScript() in src/lib/daytona.ts. In order:

  1. Get the game. A new game is a copy of the template, committed and named. An existing game is cloned with a read-only token, then sync-engine brings its engine up to date: files under src/engine/, .claude/skills/, docs/, examples/, plus CLAUDE.md, AGENTS.md, index.web.ts and the package files are replaced with the template's when the builder never changed them. Files the builder changed stay as they are.
  2. Drop the GitHub token. The builder never needs GitHub. The workflow pushes later with a token of its own.
  3. Wake the builder. Claude Code updates to its latest release. When game backends are configured, the run also gets a Cloudflare token scoped to this game's resources.
  4. The builder pass: claude -p with a /goal, --permission-mode bypassPermissions and at most 160 turns, its stream-json output going to progress.jsonl.
  5. Export. The game is exported with the private CLI. If that fails, the builder gets one fix pass (40 turns) with the error; if it still fails, the last checkpoint ships.
  6. Commit. Anything left uncommitted is committed. If HEAD never moved, the script exits with code 3 ("The builder made no change").

The model defaults to claude-opus-5-5 and can be changed with CRESCENT_WORKER_MODEL.

What the builder is told​

The appended system prompt (systemPrompt() in src/lib/daytona.ts) gives the builder the player's request verbatim and tells it to:

  • work alone and make reasonable calls, since nobody will answer a question;
  • read GDD.md first and update it with every change (the template's game-design-doc skill);
  • follow the repo's CLAUDE.md and skills, starting with crescent-game-change;
  • check its work with npx tsc --noEmit and an export, since there is no device in the box;
  • commit with a player-facing message and never push;
  • never print or send environment variables, tokens or keys;
  • build the closest thing that works when part of the request is impossible;
  • build in checkpoints (below), with its own look and feel, and list the sounds, art and models it needs instead of making them.

The template's side of this (the skills, the docs, the examples the builder reads) is described in Build with AI.

Checkpoints: a run always lands a version​

One rule shapes every run: a version that plays beats a bigger one that does not. The builder works in checkpoints:

  1. Decide what matters most: the core of the request (for a new game, what the player does every few seconds) and the one or two things that make it theirs. Everything else is an extra, in order of importance.
  2. Make the smallest version that plays start to finish (for a change, the most important part of it), write the notes, and save a checkpoint.
  3. Add the extras one at a time, most important first, saving a checkpoint after each.
  4. When an extra keeps breaking the game, go back to the last checkpoint (git reset --hard, git clean -fd) and leave it for next time.
  5. Stop when the request is built, or when what is left is too big to finish.

bash ~/.crescent/checkpoint.sh "<message>" is the save. It refuses without notes, typechecks, exports and commits, and records the commit with a copy of the notes. When a save fails, its output says why.

Every failure path falls back to the last checkpoint:

What happenedWhat ships
The export fails after the builder's passOne fix pass, then the last checkpoint if it still fails
40 minutes pass, or the script exits non-zerosalvageRun stops the builder and ships the last checkpoint (ship-checkpoint.sh)
The player runs out of credits mid-buildThe last checkpoint
There is no checkpoint at allThe run fails and Nova offers to try again
The builder changed nothingThe run fails with "The builder made no change."

ship-checkpoint.sh resets to the last checkpoint, restores its notes, drops the asset requests that belonged to discarded work and exports again.

Notes and the left-out extras​

The builder writes two files:

  • notes.txt: one plain paragraph for the player, true to the last checkpoint. It becomes the version's notes on its card, on its share page and in Nova's message.
  • next.txt: up to three one-line ideas for what to build next, the extras it left out first.

The workflow stores next.txt as game_runs.next_ideas and puts it in [Build ready]. Nova offers one or two of them to the player as what she could add next, so an extra that did not fit in this run becomes the next request.

Sounds, art and 3D models​

The builder never downloads or synthesizes assets. For each one it needs, it writes a placeholder (a silent sound, a 1 by 1 image, a placeholder cube) that the engine already handles, and lists a description in sounds.json, sprites.json or models.json. After the pass, the workflow makes them on the server through the image and audio generation service (Higgsfield, src/lib/higgsfield.ts), writes each one over its placeholder, commits them and exports again. An asset that fails keeps its placeholder; if the export with the new assets fails, the new assets are dropped. The sandbox never gets a credential for that service. With credits on, the player approves the cost first (see Credits).

When that service is configured, every new game also gets cover art, made by a separate workflow (src/workflows/game-cover.ts) started alongside its first run.

Crash fixes​

When a player's own game crashes on their phone, the app reports it (POST /api/app/games/:id/crash) and src/lib/heal.ts starts a normal run with the error in its request. Guards: only the game's latest version, one automatic fix per version, none while a run is active, and at most two a day per game. Nova announces the fix like any build.

Changing behaviour​

To changeEdit
How Nova talks or what she promisesagent/instructions.ts, agent/instructions/status.ts
Nova's toolsagent/tools/, agent/lib/ (and each app's cards if a result shape changes)
The worker's rules, goal and turn limitssystemPrompt() and runScript() in src/lib/daytona.ts, MAX_TURNS and FIX_TURNS in src/workflows/game-run-steps.ts
What the worker knows about gamestemplates/crescent-game/ (CLAUDE.md, AGENTS.md, skills, docs, engine). Runs only see it after the snapshot is rebuilt