Skip to main content
Glama
README.md
# spawn-mcp

Local [Model Context Protocol](https://modelcontextprotocol.io) server for the [Spawn](https://www.spawn.co) Games agent API — on **both** of Spawn's engines: pre-6.0 worlds, which are a spec document, and 6.0+ worlds, which are a git repository. It also carries two ways to be *in* the game: a **body** in the world as a real player (no browser), and a **Playwright Chromium play client** so the LLM can open the live game, screenshot it, drive input, and debug without asking you to look.

> **New here?** Read **[GETTING-STARTED.md](GETTING-STARTED.md)** instead: a plain-language walkthrough from install to your first game, no MCP experience assumed. The rest of this file is the technical reference.

## Install

```bash
git clone https://github.com/wfbcargo/wfbcargo_spawn_mcp.git
cd wfbcargo_spawn_mcp
npm install
npm run build
npm run setup        # one-time: downloads Chromium (~150MB) for the play client
```

`npm run setup` is separate on purpose, so `npm install` never downloads a browser behind your back. The API tools work fine without it; only the `spawn_play_*` tools need Chromium. Equivalent: `npx playwright install chromium`.

### What else you need

| | For | Needed when |
|---|---|---|
| **Node 18+** | everything | always |
| **git** on `PATH` | `spawn_init`, `spawn_push`, `spawn_latest`, `spawn_status` | the world is on engine **6.0+** — its code *is* a git repo |
| **Chromium** (`npm run setup`) | `spawn_play_*` | you want to *see* the world |
| **[Bun](https://bun.sh)** | `spawn_client_*` | you want a body in the world (the client's session shell is spawned as `bun`) |

None of the last three are needed for a pre-6.0 world you only push to. Nothing here is bundled: each tool says what is missing, and why, if you reach for it without.

## Cursor config

```json
{
  "mcpServers": {
    "spawn": {
      "command": "node",
      "args": ["/absolute/path/to/wfbcargo_spawn_mcp/dist/index.js"],
      "env": {
        "SPAWN_PROJECT_DIR": "/absolute/path/to/your-spawn-game"
      }
    }
  }
}
```

See `mcp.example.json`. On Windows use forward slashes (`C:/Users/you/...`).

| Env var | Default | Purpose |
|---------|---------|---------|
| `SPAWN_PROJECT_DIR` | process cwd | Game project holding `game.json` / `.env` |
| `SPAWN_PLAY_HEADED` | `1` | `0` forces headless (see the warning below); games will not render |
| `SPAWN_TEAM` | unset | `1` enables team mode: adds `spawn_team_*` and the session latch |
| `SPAWN_TEAM_DIR` | shared `.git/spawn-team` | Ledger location, for agents that are not worktrees of one repo |
| `SPAWN_ASSET_BANK` | `~/.spawn-mcp/assets` | Cross-project asset catalog directory |
| `SPAWN_HTTP_TIMEOUT_MS` | `60000` | Abort API calls that hang |
| `SPAWN_GIT_TIMEOUT_MS` | `180000` | Abort a git command that hangs (6.0 worlds) |
| `SPAWN_API_URL` | pinned in `src/config.ts` | Dev override only; must be `https` (or localhost) |
| `PLAYWRIGHT_BROWSERS_PATH` | Playwright default | Override where Chromium is installed |

## Loop

```
edit → spawn_validate → spawn_push        (PUT on pre-6.0; commit + git push on 6.0+)
     → spawn_client_join (once)           a body in the world: boots the room, no browser
     → spawn_play_open   (once)           your eyes: headed Chromium
     → spawn_play_screenshot / spawn_play_input
     → spawn_logs / spawn_play_console / spawn_exec if broken
     → fix → push → screenshot again
```

`spawn_client_join` and `spawn_play_open` answer different questions. A **body** makes the room live
so `spawn_exec` / `spawn_logs` / `spawn_rooms` read anything at all, and lets you play the game —
walk, look, press what the world declares. The **browser** is the only thing that tells you whether
the frame is any good. Join to query, look to judge.

Spawn is WebGPU/canvas, so accessibility snapshots won't see the world. Screenshots are the ground truth.

### Play client (a body in the world)

| Tool | Purpose |
|------|---------|
| `spawn_client_join` | Stand your own body in the world as a real player — boots the room, no browser, no GPU |
| `spawn_client_status` | Which sessions are standing, and how much ttl is left |
| `spawn_client_leave` | Despawn the body; the room folds when the last one goes |
| `spawn_client` | Any other client verb — `where`, `players`, `inputs`, `move`, `look`, `witness`, `crossing`, `screenshot`, `run` |

A room **boots for a player and never for a door**: `spawn_exec`, `spawn_logs` and `spawn_rooms`
read nothing until a body stands. Before this the only way to boot one was `spawn_play_open`, which
needs a headed Chromium with a working WebGPU adapter. A join needs neither and takes a few seconds.

Sessions are detached and outlive the tool call, so join once and query freely. They **self-expire
at `ttl`** (default 600s) — that is what stops a forgotten body standing in someone's world — so
raise it for long work and call `spawn_client_leave` when done.

**Requires [Bun](https://bun.sh).** The client's session shell is literally spawned as `bun`, so
Node alone gets as far as `shell process failed to spawn (no pid)`. The client package itself needs
no install: `bun x @spawnco/client` is the fallback, and a global `bun add -g @spawnco/client` is
used when present.

| Env var | Purpose |
|---------|---------|
| `SPAWN_BUN_BIN` | Path to the Bun binary, when it is somewhere unusual |
| `SPAWN_CLIENT_ENTRY` | Path to `@spawnco/client`'s `bin/spawn.mjs`, to pin a copy |
| `SPAWN_CLIENT_TIMEOUT_MS` | Abort a client command that hangs (default `120000`) |

> **Known upstream bug: `spawn client run` does not work on Windows**
> ([#6](https://github.com/wfbcargo/wfbcargo_spawn_mcp/issues/6)). The session shell validates
> `scriptPath` as POSIX-absolute, so a `C:\…` path is refused — and `-e` fails identically, because
> it writes the source to a temp file and passes that path. `spawn_client` detects this and says so
> rather than reporting it as your script's fault. Every other verb works on Windows.
>
> Running the client under **Node** on Windows fails earlier still, for a related path bug
> ([#5](https://github.com/wfbcargo/wfbcargo_spawn_mcp/issues/5)) — which is part of why these tools
> require Bun. Both issues carry the cause and a suggested patch; neither is fixable from here.

### Play browser rules

Three things that cost real debugging time if you learn them the hard way:

- **Headed only.** Headless Chromium gets no WebGPU adapter (`requestAdapter()` returns `null`, SwiftShader flags included), so Spawn refuses to start and every screenshot is its *"One graphics fix away"* gate rather than your game. `spawn_play_open` probes this and reports `webgpu: "ok" | "unavailable"` with an explanation. Leave `SPAWN_PLAY_HEADED` unset, and only use `headed: false` to reach a non-Spawn page.
- **`spawn_play_eval` cannot touch your game UI.** `ui.js` renders into a *cross-origin sandboxed iframe*, so `document.querySelector` in the top frame finds none of your buttons and reaching into the frame throws. Click UI with `spawn_play_input` coordinates: screenshot, read the button's position off the image, click it. (`spawn_play_eval` also takes an expression, not a function body: wrap statements in an IIFE.)
- **`spawn_exec` needs a live room and cannot read your database.** Rooms exist only while a player is connected, so call `spawn_play_open` first or you get a 5xx (the error says so). The endpoint is read-only server-side and refuses `api.sql` outright, even `SELECT`, so there is no way to query the game's SQLite from this server. Verify persistence through replicated state instead.

## Two engines, two lanes

Spawn worlds come in two eras, and they are not two versions of one protocol — they are two
different write paths. Every tool that reads or writes a world detects which one it is on and
routes itself, so the tool names below are the same on both.

| | pre-6.0 — the **document lane** | 6.0+ — the **git lane** |
|---|---|---|
| the world is | a compiled `GameSpec` document | a git repository |
| you save with | `PUT /game-specs` | `git push` |
| the tree | `game.json` + `world/` + `scripts/` | `world.config.yaml`, `places/<place>/cells/x<cx>z<cz>.scene`, `templates/*.js`, `scripts/**` |
| `spawn_init` | scaffold the project, pull the spec | **clone the repo** into `projectDir` |
| `spawn_push` | compile + PUT | **commit + push**, and report the rooms' verdicts |
| `spawn_latest` | pull a spec, sync scripts, `.theirs` receipts | **`git pull --rebase`** |
| `spawn_validate` | server-side schema check | **local tree pre-flight** — there is no server validator |
| `spawn_status` | base version, receipts, head vs published | branch, HEAD, ahead/behind, uncommitted |
| `spawn_exec` / `spawn_logs` / `spawn_rooms` | — | identical on both lanes |

On a 6.0 world the document `PUT` does not degrade or fall back: it answers `409 world_is_git`
before it reads the body. That is what a pre-1.9 `spawn_push` was hitting.

### `engineVersion`

Every lane-sensitive tool takes an optional `engineVersion`. Omit it and the engine is read from
the API and cached. Pass it — a semver (`6.0.0`, `5.4`) or an era name (`6.0`, `document`) — and it
is **checked** against the world's real pin; a disagreement fails the call without writing
anything.

```
engineVersion omitted     → detect from the API, cache it
engineVersion "6.0"       → git lane, and verify the world really is on it
engineVersion "document"  → document lane, same verification
mismatch                  → error, nothing written
```

It is an assertion, not an override, because the failure it prevents is a document-lane push aimed
at a git world — or a `game.json` scaffolded over a live clone. `spawn_exec`, `spawn_logs` and
`spawn_rooms` do not take it: those endpoints are identical on both lanes.

The API answers the engine question in three places, and all three are used —
`GET /api/agent/v1/me` and `/api/agent/v1/worlds` carry `engine: { semver, era, git }` per world,
and `GET /api/sdk/v1/{id}/agent/docs` carries `engineVersion` + `era`.

### Working a 6.0 world

- **`spawn_init` clones.** The agent token is the git password, passed per-command and never
  written to `.git/config`. `.env` is hidden through `.git/info/exclude`, and this server's own
  files (docs, caches, screenshots) live under `.git/spawn-mcp/` — never in the tree, because a
  6.0 world *tracks* `.spawn/` itself.
- **Clones are shallow** (`depth: 20`, `depth: 0` for the full history). A live world's `main` ran
  to 18,500 objects and 80 MB and would not finish fetching; its tip is a few hundred objects and
  a few seconds. Editing, committing and pushing all work from a shallow clone. `git log
  --notes=spawn` needs `depth: 0`.
- **`spawn_push` needs a `message`, and it is not a log line.** Its first line lands in the
  creator's chat and their changes list under your name — one plain sentence about what changed
  for the player. `body` carries the how, and is the only channel to Savi an agent has.
- **Read `AGENTS.md` at the clone root**; it is that world's own grammar. Then read
  `.git/spawn-mcp/tome-api.md` in full before writing code — every shape in it is exact, and a
  push in another shape is refused naming the row, the line and the field.
- **The push is the validator, and it is live.** There is no dev/live split to absorb a broken
  tree. `spawn_validate` runs a local pre-flight first (scripts ESM-parse, scene headers and cell
  keys agree, image bytes match their extension, no binaries under `assets/`), and `spawn_push`
  runs it again over the changed files and refuses on failure — but clean locally does not mean the
  push will land.
- **Worlds move fast.** Savi, `exec` and other clones commit to the same repo, so a
  non-fast-forward rejection is ordinary. Pull with `spawn_latest`, then push again.

## First connection

1. Spawn gear → **Build with a coding agent** → fresh `sbk_…` key (~5 min, once).
2. **`spawn_bootstrap`** → token lands in project `.env` (masked in tool output). Use a distinct `name` per agent.
3. **`spawn_me`**, then **`spawn_create_game`** (or list + **`spawn_set_variant`**).
4. **`spawn_init`** — scaffolds a pre-6.0 project, or clones a 6.0 world's repo. Then read the
   guide and the Tome API reference it saved (`.spawn/` on the document lane,
   `.git/spawn-mcp/` on the git lane; a 6.0 clone also carries `AGENTS.md` at its root).
5. **`spawn_play_open`**: agent joins as its own browser client (creator can still keep their tab open).

## Multi-agent

Same creator account needs **no crew setup**. Each agent gets its own key (settings → build with your own agent) and can push the same game concurrently, the same model Savi's background builders use.

1. **Publish in the Spawn UI** before unleashing agents. Published (`mode=live`) stays stable for players while agents mutate dev head. There is no agent publish API; agents only *read* live via `spawn_latest` / `spawn_status`.
2. **One project dir (or worktree) per agent.** A shared `SPAWN_PROJECT_DIR` will thrash `game.json`, scripts, and `.spawn/base-version`. Same `SPAWN_VARIANT_ID` for everyone. Credentials resolve from the project's own `.env` first, so a git worktree each (`.env` and `.spawn/` are untracked) is what makes them separate connections. A key in the MCP config env is only a fallback for projects that carry none.
3. Start with **2 to 3 agents**, partition script/area ownership, treat **409 `version_conflict`** as normal: `spawn_latest` → merge `.theirs` → push.
4. Label bootstraps (`terrain-agent`, …) and call **`spawn_savi`** after meaningful pushes — with `task` to hand Savi a slice outright, since it fans work out across its own sub-agents (up to 8) with no key, worktree, or GPU cost on your side. Cheapest capacity available, and `spawn_savi_status` tells you how much of it is idle; see [Delegating to Savi](#delegating-to-savi).

```
spawn_status                 # head vs published, local base, .theirs, credential source
spawn_latest                 # pull head (conflict recovery)
spawn_latest mode=live       # inspect published (no local write)
spawn_latest mode=live applyLocal=true   # reset local to published snapshot
```

### Delegating to Savi

`spawn_savi` writes into the creator's studio chat, and Savi acts on what it reads there. Pass `task` and it can pick the work up and fan it out across **its own sub-agents — up to 8**, none of which cost you a bootstrap key, a worktree, a checkout, or a headed Chromium holding a GPU context. Compared to standing up another `sbk_` agent it is free capacity, and it is the widest parallelism reachable from this server.

```
spawn_savi
  message:   "Pushed v12: parkour course in the north canyon."
  task:      "Give the canyon a night pass — lighting, ambient audio, and whatever set dressing sells it."
  subAgents: 4                                  # optional; omit to let Savi split it as far as it splits
  keepOff:   ["scripts/player/**", "world.terrain"]
```

Two rules make it work:

- **Delegate broad, not prescriptive.** "Give the canyon a night pass" splits across sub-agents; a numbered list of edits does not. The decomposition is the part Savi's fan-out is good at, so a narrow task spends 8 agents on a job for one.
- **Never wait on it.** The channel is one-way — no reply, no acknowledgement, no completion event. Declare your boundary with `keepOff` instead of asking for one, keep building your own area, and notice the work arriving as a head-version bump you did not cause (`spawn_status`), then `spawn_latest` to take it.

#### Seeing the fleet

`spawn_savi_status` answers the question that makes the rest of this actionable: **how many of the 8 lanes are burning right now.**

```
spawn_savi_status
  → Savi's fleet: 4/8 wisps burning — 4 lanes free
    workingOn: ["Sent wisp to rebuild the nine set pieces out of real geometry", …]
    advice: "4 of 8 lanes are idle. That is 4 sub-agents you are not using…"
```

The wisps are the little flames along the top of the play page — one per sub-agent, eight slots. No endpoint reports them, so this reads the play client you already have open (`spawn_play_open`): the studio broadcasts its state to that page, and the server keeps the latest broadcast plus a direct count of the flames on screen. Two readings of the same thing, so the count survives either one changing shape.

Use it in both directions. **Before** a handoff it sizes the slice — eight idle lanes is a bigger ask than one. **After** one, a new wisp lighting up is uptake, which is as close to an acknowledgement as this channel gets. It still reports no author: a burning wisp means Savi is busy and on what, never that it is busy on *your* task.

Idle lanes are the finding worth acting on. They are parallelism nobody had to key, check out, or supervise, and an agent building serially past four of them is choosing the slow route to the same place.

### Team mode

Opt-in bookkeeping for the above. Set `SPAWN_TEAM=1` in the first session's MCP config and run `spawn_team_init` in each agent's worktree. That writes a roster into the repo's shared `.git/spawn-team/`, which every worktree finds with no configuration and nobody can commit by accident, so later sessions pick the mode up on their own and need no extra config.

| Tool | Purpose |
|------|---------|
| `spawn_team_init` | Create the ledger if absent, register this worktree under a label |
| `spawn_team_status` | Every agent, how far behind head each rail is, who has unresolved receipts, open claims, recent pushes |
| `spawn_team_claim` / `spawn_team_release` | Take or give up ownership of `game.json` key paths and `scripts/` globs |
| `spawn_team_add` | Stand up a new agent's worktree: variant, own token, scaffold, roster entry |
| `spawn_team_brief` | Ready-to-paste opening prompt for one builder, or the whole team |

Four behaviours change while it is on:

- **One session drives one agent.** `spawn_push`, `spawn_latest applyLocal`, `spawn_revoke`, and `spawn_play_open` latch to the first project directory they see and refuse a second one. Identity, the version rail, and the single Chromium session all belong to one directory, so driving two from one session pushes one agent's work onto the other's rail and points its screenshots at the wrong client. Read-only tools stay free to inspect any worktree, and `spawn_bootstrap` / `spawn_init` stay free so a new worktree can be provisioned from anywhere.
- **A globally configured `SPAWN_PROJECT_DIR` is refused**, with an explanation, rather than used. It would resolve every session to one `.env`, so every agent would push as the same connection while appearing to work in its own worktree. An explicit `projectDir` argument is never refused.
- **Pushes serialise and rebase.** `spawn_push` takes a ledger-wide lock, and from inside it "behind head" can only mean a teammate landed a push since your last sync, so it pulls first. A clean rebase costs you nothing and the 409 never happens. A rebase that collides stops the push with your work intact and the conflicts named, because that needs a decision no server should make. `force: true` skips the rebase, since it is a deliberate whole-replace.
- **Claims warn on push.** Changes are diffed against the base rails, so what gets checked is exactly your own edits, and touching another agent's claim is reported alongside the successful push. Advisory by design: a stale claim must never become a hostage situation.

Claim `game.json` key paths (`entities.player`, `world.terrain`) and script globs (`scripts/hud/**`). Everything except `scripts/**` is claimed by key path, because `spawn_init` puts the whole spec in `game.json`; a `world/foo.json`-style pattern is rejected rather than silently never matching.

Solo, none of this exists: the tool list stays at 35, nothing latches, and pushes take no lock.

Adding an agent is two calls plus one command you run yourself:

```
spawn_team_add label="terrain" worktreePath="../game-terrain" branch="terrain"
  → returns: git worktree add -b terrain ../game-terrain     # run it; this server never executes git
spawn_team_add label="terrain" worktreePath="../game-terrain" bootstrapKey="sbk_…"
  → writes its variant, trades the key for its OWN token, scaffolds, registers it
spawn_team_brief label="terrain"
  → the opening prompt to paste into a session started in that worktree
```

Mint the `sbk_` key just before the second call: they are single-use and expire in about five minutes. `spawn_team_brief` with no label briefs the whole team at once.

The fuller design, including what is deliberately not built and why, is in [TEAM-MODE.md](TEAM-MODE.md).

### What a pull merges

`spawn_latest` three-way merges against the last-seen upstream, tracked in `.spawn/base-scripts.json` and `.spawn/base-game.json`. Disjoint edits compose; only genuine overlap conflicts.

| Content | Merged | Conflict lands as |
|---------|--------|-------------------|
| `scripts/**` | per file, by content | `<file>.theirs` beside it |
| `game.json` | per key path | `game.json.theirs` (upstream's whole spec) |
| `world/*.json` | **no** | nothing, see below |

A conflict always keeps **your** value and names what collided (a path, or a dotted key like `entities.player.hp`). `spawn_push` refuses to run until every receipt is resolved and deleted.

`world/*.json` overlays are the gap. They are deep-merged onto `game.json` at compile time and never reconciled, so a stale overlay re-applies over freshly pulled content and pushes back up. Disjoint overlays are fine; two agents writing the same key are not, and nothing will warn you.

Projects created before this rail existed have no `.spawn/base-game.json`. Their first pull keeps the old whole-replace behaviour, copies the previous `game.json` to `.spawn/replaced-game.json` if that drops anything, and establishes the rail. `spawn_status` reports `hasSpecRail`.

## Tools

### API
| Tool | Purpose |
|------|---------|
| `spawn_bootstrap` | Trade `sbk_…` → durable token in `.env` |
| `spawn_me` | Whoami |
| `spawn_list_games` / `spawn_create_game` / `spawn_set_variant` | Pick a game |
| `spawn_getting_started` | Whole workflow + what this project already has (no credentials needed) |
| `spawn_init` | Scaffold project + docs (pre-6.0), or clone the world's repo (6.0+) |
| `spawn_docs` | Guide, tome API, skills index — and the world's engine era + semver |
| `spawn_skills` / `spawn_skill` | Browse the skill menu / load a set of skills by id |
| `spawn_latest` | Pull head / published / version / updateSlug (+ script sync); `git pull --rebase` on 6.0+ |
| `spawn_validate` / `spawn_push` | Schema check (pre-6.0) or local tree pre-flight (6.0+) / live push — PUT or commit+push |
| `spawn_exec` / `spawn_logs` / `spawn_rooms` | Live world inspect (needs a live room; no SQL) |
| `spawn_savi` | Context for Savi, or hand it a task to fan out across its sub-agents |
| `spawn_savi_status` | How many of Savi's 8 sub-agents are running, and on what (needs a play session) |
| `spawn_revoke` / `spawn_status` | Disconnect / local + engine + remote health (versions, or git divergence) |

### Asset bank
| Tool | Purpose |
|------|---------|
| `spawn_asset_sync` | Pull every game on your account and harvest its live spec (slow; the authoritative fill) |
| `spawn_asset_scan` | Harvest `cdn/` paths from local projects into the cross-project bank |
| `spawn_asset_search` | Find an asset you already used, before inventing a new name (`facets`, `category`, `minGames`) |
| `spawn_asset_note` | Name, categorize, describe, mark good/bad, point a bad name at its replacement |
| `spawn_asset_preview` | Check existence on the CDN; render images inline so the model can see them |

### Local audit
| Tool | Purpose |
|------|---------|
| `spawn_audit_scan` | List exported functions and say which are auditable without a live room |
| `spawn_audit_math` | Sweep pure functions across declared input domains and check invariants |
| `spawn_audit_ui` | Count which of the 21 Interface In Game UI surfaces the project has |

### Play browser
| Tool | Purpose |
|------|---------|
| `spawn_play_open` | Launch Chromium on the play URL (headed; screenshot by default) |
| `spawn_play_screenshot` | See the world after a push (jpeg by default; `format:"png"` for flat art) |
| `spawn_play_input` | Keys/mouse (WASD, click, drag, type); the only way to click game UI |
| `spawn_play_reload` | Hard reload if the client didn't reshape |
| `spawn_play_console` | Page console / pageerror |
| `spawn_play_eval` | Top-frame page JS only; cannot see or click game UI |
| `spawn_play_status` / `spawn_play_close` | Session health (incl. a one-line fleet read) / teardown |

Also: **`spawn_session`** prompt with the full loop (including multi-agent). `spawn_getting_started` returns the same text as a tool call, because most clients never surface prompts to the model.

## Social baseline and drop-in games

Spawn owns a social layer that games do not rebuild: text and voice chat, parties of friends, and
doors that carry a player (and the party they came with) from one world into another. A game can
still break it — a chat box of its own, a HUD under the platform's rail, a team shuffle that splits a
party, an arrival hook that drops someone who came through a portal into the middle of a running
match. So every game is held to a **social baseline**, and it ships in the server rather than in a
skill: it is a requirement on every build, and `spawn_getting_started` is the one text every agent in
every client actually reads.

| The baseline | On 6.0 | On pre-6.0 |
|---|---|---|
| Chat is Spawn's; keep UI off the right-edge rail | no chat of your own; `player.displayName` on nameplates | same |
| Parties arrive together and stay together | `player.party` (read-only), `instance: party` | no party field in the docs |
| A door out, and arrival from any door | `ctx.cross(entity, link)`, `onArrive`, `onRefuse` | `enterPlace`, within the world only |
| Invites are links | `window.publicUrl + "/room:" + name` | `window.publicUrl + "?room=" + name` |
| Leaving never stalls a round or loses progress | `onLeave`, `player.state` saved at the moment it is earned | `onPlayerDisconnected` |

Two parts of it are deliberately stated as unknown rather than written down. The 6.0 `chat` skill,
which describes `scripts/chat.js` and the voice keys, is not served by the skills endpoint, so the
guide tells agents not to write that file from a guess. And no fetched doc says *how* a party follows
a member through a door, so the guide asks games to receive parties together rather than claiming a
mechanism. The reasoning is in [`.wiki/decisions/0002`](.wiki/decisions/0002-social-baseline-in-server-drop-in-as-skill.md).

**Drop-in games** are the optional half: the shape that suits the weekly [Spawn Jam](https://www.spawn.co/jam)
and most open briefs — playable within three seconds, one verb in one small space, safe to leave and
come back to, solo-viable but better with a party, cheap to run. `spawn_getting_started` nudges
towards it only when the creator has not fixed a design. The depth lives in a Claude skill,
[`.claude/skills/drop-in-games/`](.claude/skills/drop-in-games/SKILL.md): six tests a concept must
pass, eight archetypes (round-based party, course, king of the hill, social deduction, create and vote,
idle garden, hangout, co-op survival) with the Roblox, Fortnite Creative, Rec Room, .io and Jackbox
games that proved them, a design order, the 6.0 moves that make each cheap, jam-specific advice, and a
review checklist. The sourced research behind it sits beside it in `reference/research.md`. It is a
lens, not a rule: a creator with a vision gets their game.

## Art and UI

The most common quality gap in an agent build is visual, and it has two causes worth knowing.

**The engine's craft lives in skills, not in the API reference.** There are ~60 of them, and the visual cluster (`drawn-art`, `game-ui`, `looks`, `custom-materials`, `fx`, `3d-sprites`, `world-composition`, `match-a-reference`) is where textures, HUDs, colour grade, and shader surfaces are actually explained. An agent that skips them writes untextured primitives and default DOM.

Rather than rely on a prompt telling the model to go and read them, the endpoints are shaped to pull skills in: `spawn_skill` takes `ids: [...]` so the natural call carries the whole set (mechanic *and* look), `spawn_push` and `spawn_play_screenshot` say in their own descriptions that a plain-looking result is a missing skill rather than a missing feature, and a wrong id answers with the full menu, so guessing is cheaper than looking up.

**Naming is creating.** A `cdn/` asset is generated on first fetch of its path and cached there forever, so the path *is* the asset: reference `/cdn/moodboard-<slug>/<category>-<name>.<ext>` and that model, texture, or clip comes into being. Agents create art by naming it, and the name is the prompt. Two consequences the tools are built around: a path cannot be re-rolled (a bad result means picking a different name, permanently), and a bare `/cdn/<name>.<ext>` with no moodboard folder shares one global namespace with every other game. See **[the asset bank](#asset-bank)** below and [ASSET-BANK.md](ASSET-BANK.md).

```
spawn_skill ids=["game-ui","drawn-art","looks"]   # load a set; a bad id returns the menu
spawn_skills                                      # all 60, id + name + description
spawn_skills search="ui"                          # filter over id, name, description
spawn_skills detail="brief"                       # id + name only (the full index is ~9k tokens)
```

The index is read from `.spawn/skills.json` when `spawn_init` / `spawn_docs` has already saved it, so browsing costs no network call; `refresh: true` re-fetches.

## Asset bank

Because a path *is* an asset, the same path in two games is the same asset — cross-project sharing costs nothing and needs no tooling. What Spawn has no API for is a **catalog**: the guide says so outright ("there is no catalog"). So a name that produced something great is unrecoverable knowledge the moment you forget how you spelled it, and a name that produced something bad is permanently bad, because you cannot re-roll a path.

The bank is the local record of that judgment, kept in `~/.spawn-mcp/assets/` — user-level, not per-repo, since its whole value is crossing games.

```
spawn_asset_sync                                           # every game on your account (start here)
spawn_asset_scan dirs=["../game-one","../game-two"]        # local-only: harvest what the checkouts cite
spawn_asset_note path="cdn/…/texture-packed-earth.png" name="dirt" category="terrain" verdict="good"
spawn_asset_search query="dirt"                            # before inventing a name
spawn_asset_search category="terrain"                      # or by your own grouping
spawn_asset_search facets=true                             # what categories/kinds/families exist
spawn_asset_search minGames=2                              # proven: reused across games
spawn_asset_preview path="dirt"                            # a name works anywhere a path does
```

**Start with `spawn_asset_sync`.** There is no asset API on Spawn (`/api/agent/v1/assets` and friends are `404`), so the only account-wide record is what your games have pushed. The sync lists every game you own, fetches each one's current spec, and harvests it — which sees things a local scan cannot: games with no checkout on this machine, and assets a teammate or Savi pushed that never reached your disk. On the account this was built against a local scan of three projects found **169** assets and the sync found **408**. It is slow by design (one spec fetch per game, four at a time; six games ≈ 3s), so it is a tool you run deliberately.

Every asset tool reports a `syncAdvice` line when the bank is empty, never synced, or over a week stale — not just search. A stale bank answers "no match", the model coins a fresh path, and an asset that already exists under a good name gets regenerated under a second one. Since a path cannot be re-rolled, those two names can never be merged.

**Name your assets.** A name is a short unique handle, and every tool that takes a path takes a name instead — `spawn_asset_preview path="knight"` rather than 60 characters of style family and hyphenation. Alongside it, `category` is your own grouping ("enemies", "ui-icons"), kept separate from the filename's `prefix` (`model-`, `texture-`) so a rescan can never overwrite a judgement.

**Results report how many *games* use an asset, not how many directories.** In team mode one game is several worktrees, so counting directories would report a three-agent team as three games. Each use records the variant id from that project's own `.env`, and the count collapses on it. Reuse across games is the best evidence an asset actually worked, so it feeds ranking and `minGames` filters on it.

**One file per style family**, plus `_meta.json`, with the `root` namespace split by prefix (`root-effect.json`, `root-sfx.json`) since it is usually the biggest group. At a measured 821 bytes/asset a 10k-asset bank is ~7.8 MB and parses in ~19 ms, so this is not about search speed — it keeps a one-field note from rewriting the whole catalog, and keeps each file openable.

`spawn_asset_preview` checks the storage host directly rather than the `/cdn/` cook route. Storage answers a plain 200/404 and never generates, so a 404 honestly means "not created yet" instead of "not allowed to ask" — and for images it hands the bytes back inline, so the model judges the art instead of guessing from the filename. Models and audio report existence only; put those in the world and use `spawn_play_screenshot`.

Paths are classified as `moodboard` (the documented namespaced form), `root` (a bare global name, shared with every other Spawn game), `custom`, or `ingested` (opaque `public.<base64>` uploads, which carry no naming guidance and rank last).

**A namespace warning is loud only while the name can still change.** A path that is already generated — or already referenced by a game — cannot be re-rolled, so telling you to rename it is not advice; those collapse to one counted line per kind. A path storage has never seen gets the full recommendation, because that is the only moment it can be acted on.

The design, including what is deliberately not built, is in [ASSET-BANK.md](ASSET-BANK.md).

## Local audit

Reviewing a build is slow because every question gets asked through the same instrument: a
headed browser, a screenshot, and a judgement call. Plenty of those questions are arithmetic,
and arithmetic does not need a browser.

Game scripts are plain JS, and the engine injects `objectApi` as a **parameter** rather than an
import — so a function that does not take `api` cannot reach the engine and runs fine in Node.
That is the whole basis for these two tools. Neither needs credentials, a push, a live room or
Chromium.

```
spawn_audit_scan                     # what can be checked locally, and what needs a room
spawn_audit_math                     # run audit/math.json
spawn_audit_math checks=[…]          # try one rule without saving it first
```

`spawn_audit_scan` classifies by signature: no `api` parameter and no engine-only `require`
means the function is pure. On a real 77-script game that is 185 of 271 exported functions.

`spawn_audit_math` reads **`audit/math.json` in the game project**, because per-game invariants
are not knowledge a generic server can hold. This server owns the runner; the game owns the
assertions — a test runner, and tests.

```json
{
  "checks": [
    {
      "id": "wave-bodies-all-fit",
      "module": "scripts/battle-system.js",
      "export": "planWave",
      "args": [
        { "name": "tier", "range": [1, 12] },
        { "name": "waveInTier", "range": [1, 5] },
        { "name": "popMult", "values": [1, 1.5, 2, 3] }
      ],
      "select": "dropped",
      "assert": { "finite": true, "max": 0 }
    }
  ]
}
```

Domains are `range` (with optional `step`), `values`, or `const`. Assertions cover `finite`
(NaN and Infinity), `integer`, `min`/`max`, the four monotonicity forms (`increasingIn`,
`nondecreasingIn`, `decreasingIn`, `nonincreasingIn`, naming an argument), and `expr` for
anything else. `select` pulls a field out of an object result. Failures report the **exact
arguments** that produced them, so a finding is a line you can paste into a REPL.

On the game this was built against, six checks over 1,020 calls run in **72 ms** and pin a
formation that does not fit its zone to one wave: `T3.3`.

Two deliberate refusals. A sweep that exceeds its call budget reports `CAPPED from N` rather
than truncating quietly, because a bounded sweep reported as a full one reads as "covered
everything". And the engine-only builtins (`fx`, `geom`, `three`, `tsl`, `vibe`,
`room-routing`, `primitives`) are refused rather than stubbed — a stub lets a check pass
against behaviour that never ran, which is worse than a check that declines to run.

`module.exports = { … }` helpers are loaded and scanned alongside `export function` ones. Both
systems are in use, and in practice the pure math lives in the CommonJS half.

`spawn_audit_ui` counts something different: not "is this function correct" but "does a
pause overlay exist at all." It walks the project's scripts and scenes (git lane) or
`game.json` plus any folded-out scripts (document lane) and looks for each of
[Interface In Game](https://interfaceingame.com)'s 21 named UI surface types —
`main-menu`, `inventory`, `skill-tree`, and so on — scoring each `found` (the surface's
name turned up in a path or identifier) or `missing` (no evidence anywhere).

```
spawn_audit_ui                       # check audit/ui.json's `expect`, or the 6-surface baseline
spawn_audit_ui expect=[…]            # try a different surface set without writing the file
```

**`found` means "the name showed up," not "looks right" — or even "is built."** A static
text scan cannot see a rendered screen, so it doesn't try to grade one: `found` is as
much as a name match can honestly claim. `spawn_play_screenshot` is the only thing that
can look, and every report says so.

Like `audit/math.json`, `audit/ui.json` lives in the **game** project, because which
surfaces a given game actually needs is not knowledge a generic server can hold:

```json
{
  "expect": ["main-menu", "in-game", "settings", "overlay", "game-over", "inventory", "map"],
  "ignore": ["credits"],
  "genre": "rpg",
  "theme": "fantasy"
}
```

`expect` replaces the 6-surface baseline (`main-menu`, `in-game`, `settings`, `overlay`,
`game-over`, `loading`) when present; `ignore` drops surfaces from the report entirely,
so a puzzle game with no skill tree isn't scored against having one. `genre` and `theme`
are validated against interfaceingame.com's own filter vocabulary and appended to every
reference link; an unknown surface, genre, or theme fails with the full menu rather than
scoring silently.

Every `missing` finding names the craft skill to load (`spawn_skill`) and an
`interfaceingame.com/screenshots/?elements=…` link. That link is handed back as a URL
for a **human** to open — this tool never fetches, crawls, or caches the site itself.
Its terms of use prohibit scraping, the screenshots are the games' own copyright to
begin with, and a filename this tool can't render would be worthless as reference
anyway; the 21-surface vocabulary is the useful part, and that much is hardcoded here.

Three places wire this in rather than leaving it a report someone has to remember to
run. `spawn_push`'s own description points here for the question a screenshot can't
answer — not how a surface looks, but whether it exists at all. `spawn_team_brief` runs
the same check per worktree and names any `missing` surfaces in the brief it hands a
builder. And the savi-conductor skill checks it on its slower Maintain cadence, turning
each `missing` surface into a `ready` backlog entry that a later tick dispatches to Savi.

## Development

```bash
npm run typecheck   # tsc over src/ + test/
npm test            # node:test suite (no browser needed)
npm run check       # both
npm run build       # emit dist/
```

Tests cover the parts that silently corrupt a project when they regress: the spec compiler, the script path guards, and the three-way pull/merge in `syncPulledScripts`. CI runs them on Node 20/22 across Linux and Windows.

Release notes live in [CHANGELOG.md](CHANGELOG.md). Versions are tagged `v<major>.<minor>.<patch>`.

> The `test` script lists test files explicitly rather than globbing, because `node --test` only expands globs itself on Node 21+, and Windows shells don't expand them either. **Add new `test/*.test.ts` files to that script or they won't run.**

## Security

- `SPAWN_AGENT_KEY` lives in the game project's `.env` only.
- Tools never echo the full token; `spawn_bootstrap` and `spawn_status` return a masked prefix.
- `.env` and `.spawn/` are gitignored by init/bootstrap on the document lane. In a 6.0 clone
  `.env` goes in `.git/info/exclude` instead — the world's own `.gitignore` is never touched —
  and this server's files live under `.git/spawn-mcp/`, because a 6.0 world tracks `.spawn/`
  itself. `spawn_push` refuses to run while any of this session's artifacts are sitting in the
  tree, since a 6.0 push is `git add -A`.
- **The git credential is never written down.** The helper is passed per-invocation with `-c`
  (nothing in `.git/config`, nothing in `git remote -v`) and its body references `$SPAWN_TOKEN`,
  expanded by the shell git runs it in — so the token is never an argv element and reaches the
  child through its environment only. Git output is scrubbed of the token before it is
  returned, and `GIT_TERMINAL_PROMPT=0` makes a credential failure fail rather than hang.
- The play browser is a normal player client. It runs in a fresh, credential-free context and does not inject the agent key into the page.

### Trust model

This server hands an LLM real capabilities on your machine. Worth knowing before you run it:

- **Filesystem writes.** Every tool takes a `projectDir` and writes `.env`, `.gitignore`, `game.json`, `scripts/**`, and `.spawn/**` under it. There is no sandbox beyond the path you pass.
- **Git execution (6.0 worlds).** `spawn_init` / `spawn_latest` / `spawn_push` run `git` in that
  directory — clone, fetch, rebase, `add -A`, commit, push. A push to a 6.0 world is live in
  every open room within about a second; there is no staging step and no dev/live split.
- **Code execution.** `spawn_exec` runs JS in your live room; `spawn_play_eval` runs JS in the play page; `spawn_play_open` will navigate to any URL it's given.
- **Untrusted text flows back to the model.** `spawn_logs`, `spawn_exec`, and `spawn_play_console` return server- and player-influenced content, and `spawn_audit_ui` renders paths and text out of the game project itself (`game.json`, scripts, scenes — a cloned repo may not be yours). Treat it as data, not instructions.
- **The API origin is pinned** to `https://www.spawn.co` in [`src/config.ts`](src/config.ts). It is deliberately *not* read from the project `.env` and *not* a tool argument, so neither a cloned game repo nor the model can redirect your bearer token. Only the `SPAWN_API_URL` process env, set by whoever wrote the MCP config, can override it, and only to an `https` origin (or localhost).

## License

MIT. See [LICENSE](LICENSE). Unofficial community project; not affiliated with or endorsed by Spawn.

TDQS

A3.7/5.0

Scored across 40 tools

Disambiguation4/5

The 40 tools are mostly distinguishable by domain prefix and explicit descriptions, but several clusters can be confused: spawn_exec vs spawn_play_eval vs spawn_client (read-only JS vs browser JS vs client verbs), and spawn_logs vs spawn_play_console (room script logs vs browser console). Status tools also overlap across domains (spawn_status, spawn_play_status, spawn_client_status, spawn_savi_status). Descriptions mitigate but do not fully remove ambiguity.

Naming Consistency4/5

All names use a consistent spawn_ prefix and snake_case, making grouping predictable. However, the token pattern is not uniformly verb_noun: some are domain_action (spawn_play_open, spawn_asset_sync), others are verb_noun (spawn_create_game, spawn_list_games), and a few are bare nouns or phrases (spawn_me, spawn_getting_started). This is mostly consistent with minor deviations.

Tool Count2/5

40 tools is well above the 3–15 sweet spot and exceeds the 25-tool threshold for 'too many', even for a complex development platform. The surface covers many sub-domains (auth, project, play, client, audit, asset, Savi), each adding tools that could have been consolidated under fewer multiplexed commands. The count feels heavy and increases selection cost.

Completeness4/5

The surface covers the major lifecycle stages: auth, game creation/selection, project provisioning, push/pull/validate, live-room interaction, play-browser control, skills, audits, asset bank, and Savi delegation. However, a referenced tool (spawn_team_status) is absent, and there is no explicit game/variant deletion or team-management surface, leaving minor dead ends. Otherwise coverage is strong.

Maintenance

ActivityMaintained
ResponsivenessWithin a week