# Nova and the worker

> How Nova, the chat agent in agent/, hands a brief to the worker run, how the worker builds in checkpoints so a run always lands a version, and how left-out extras come back as suggestions.

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

| File | Role |
|---|---|
| `agent/agent.ts` | `defineAgent` on the model in `agent/model.ts`, with eve's default tools turned off |
| `agent/instructions.ts` | Who Nova is, how she talks, what Moonspell can build, how she uses her tools, how she answers system messages |
| `agent/instructions/status.ts` | A 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.ts` | Her three tools |
| `agent/lib/runs.ts` | Resolving which game a tool acts on, and starting a run |
| `agent/lib/caller.ts` | The player behind a turn, and the chat it belongs to |
| `agent/channels/app.ts` | The chat routes the apps call |
| `agent/hooks/meter.ts` | Charges each of her model calls in credits (see [Credits](https://docs.moonspell.app/moonspell/credits.md)) |

### Tools

| Tool | Input | What it does |
|---|---|---|
| `make_game` | `title` (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_game` | `request` (up to 6000, in English), optional `gameId` | Resolves the game (the one named, else this chat's, else the player's most recent) and starts a run. |
| `show_game` | optional `gameId`, optional `version` | Finds 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](https://docs.moonspell.app/modules/overview.md)), 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.

| Message | What 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](https://docs.moonspell.app/moonspell/community-builds.md) |

## 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`.

| Step | What it does |
|---|---|
| `claimRun` | Moves the run from `queued` to `running` and decides the mode: `existing` when the game has a repo and a version, otherwise `new` |
| `provisionSandbox` | Creates a sandbox from the snapshot in `DAYTONA_SNAPSHOT`. Boxes stop after 60 idle minutes and delete themselves on stop |
| `startRun` | Mints 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 |
| `pollRun` | Every 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" |
| `salvageRun` | Only on a timeout or a non-zero exit: ships the last checkpoint (below) |
| `readOutcome` | The commit, the notes for the player and the next ideas |
| sounds, art, models | The assets the builder asked for are made on the server (below) |
| `nextVersion`, `uploadExport`, `uploadAndroidExport`, `uploadWebExport`, `recordBuild` | Stores the new version. See [Builds and versions](https://docs.moonspell.app/moonspell/builds.md) |
| `pushGame` | Creates the game's repo on its first version and pushes the commit with a write token only that one command sees |
| `succeedRun` or `failRun` | The final status, published to the edge |
| `withdrawBuild` | A version recorded but never pushed is deleted, so every version can be built upon |
| `deliverOutcome` | The system message to Nova |
| `destroySandbox` | Always, 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](https://docs.moonspell.app/ai/overview.md).

## 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 happened | What ships |
|---|---|
| The export fails after the builder's pass | One fix pass, then the last checkpoint if it still fails |
| 40 minutes pass, or the script exits non-zero | `salvageRun` stops the builder and ships the last checkpoint (`ship-checkpoint.sh`) |
| The player runs out of credits mid-build | The last checkpoint |
| There is no checkpoint at all | The run fails and Nova offers to try again |
| The builder changed nothing | The 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](https://docs.moonspell.app/moonspell/credits.md)).

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 change | Edit |
|---|---|
| How Nova talks or what she promises | `agent/instructions.ts`, `agent/instructions/status.ts` |
| Nova's tools | `agent/tools/`, `agent/lib/` (and each app's cards if a result shape changes) |
| The worker's rules, goal and turn limits | `systemPrompt()` and `runScript()` in `src/lib/daytona.ts`, `MAX_TURNS` and `FIX_TURNS` in `src/workflows/game-run-steps.ts` |
| What the worker knows about games | `templates/crescent-game/` (`CLAUDE.md`, `AGENTS.md`, skills, docs, engine). Runs only see it after the snapshot is rebuilt |
