Spawn 6 support. Spawn 6 is not a new version of the thing this server talked to — it is a
different write path, and 1.x could not reach it at all. A 6.0 world's code is a git repository, and
the whole-document PUT /game-specs that every 1.x push used answers 409 world_is_git against one
before it reads the body. Every spawn_push aimed at a 6.0 world failed, and nothing it reported
was reaching the tree.
2.0 detects which engine a world is on and routes itself. The tool names did not change, so an
agent prompt written for 1.x keeps working on both — but the lanes underneath are genuinely
different, and the breaking changes below are the places where that shows.
It also gains a body. A room boots for a player and never for a door, so spawn_exec,
spawn_logs and spawn_rooms read nothing until someone is standing in the world. In 1.x the only
way to arrange that was spawn_play_open — a headed Chromium with a working WebGPU adapter.
spawn_client_join puts the agent's own body in the room instead: no browser, no GPU, a few
seconds.
Worlds on a pre-6.0 engine are unaffected. Every document-lane behaviour in this list is
unchanged from 1.8.0.
Breaking changes
Only on engine 6.0+ worlds; the document lane behaves exactly as it did.
spawn_initclones instead of scaffolding. There is no spec document to scaffold from — the
tree arrives whole or not at all. A project directory that is not empty and is not already this
world's clone is refused rather than cloned over, where 1.x would have written agame.json
that could never be pushed.spawn_pushrequiresmessage. The commit's first line lands in the creator's chat under
your name, so it is a required argument on this lane, not an optional label. Calls without one
fail.spawn_pushrefusesforce. It meant "whole-replace over the base-version rail", and there
is no safe equivalent: on a git world it would mean a force-push, and the server never
force-pushes or merges.spawn_latestrefusesmode,version,updateSlugandapplyLocal. They are document
concepts. Silently ignoring "give me the published live snapshot" and handing back the dev head
would look like it worked, so it is an error instead. With no arguments it isgit pull --rebase.spawn_validateis a local pre-flight, not an authority. This lane has no server-side
validator — the push itself is the validator. "Clean" now means only that the failures visible
without the engine are absent.spawn_statusdrops the version rail.baseVersion,hasSpecRail,conflictReceiptsand
hasGameJsonare absent on a 6.0 world; branch, HEAD, ahead/behind and uncommitted files take
their place. Anything parsing those keys must handle their absence.- This server's own files moved out of the world tree. A 6.0 world tracks
.spawn/— its
engine pin, its skills index, its per-cell cost files — so the docs and caches written there in
1.x now live under.git/spawn-mcp/. If you read.spawn/guide.mdor.spawn/tome-api.mdby
path, read.git/spawn-mcp/on a 6.0 clone (spawn_initreportsdocsDir, and a clone also
carries its ownAGENTS.mdat the root). Screenshots moved with them. - Lane-sensitive tools now make an engine-detection call (cached per process, and persisted).
A world whose engine cannot be read from the API is now an error where 1.x would have gone ahead
and pushed. PassengineVersionexplicitly to proceed without detection. - New optional dependency: Bun, required by the four
spawn_client_*tools
and nothing else. Everything that worked in 1.8.0 still works without it.
Added
-
Both write lanes, detected per world. Every tool that reads or writes a world routes itself:
tool pre-6.0 (document lane) 6.0+ (git lane) spawn_initscaffold game.json/world//scripts/, pull the specclone the repo into projectDirspawn_pushcompile + PUT /game-specsstage, commit, git push, report the rooms' verdictsspawn_latestpull a saved spec, sync scripts, .theirsreceiptsgit pull --rebasespawn_validateserver-side schema validation local tree pre-flight spawn_statusbase version, receipts, head vs published branch, HEAD, ahead/behind, uncommitted spawn_docsguide + tome API + skills same, and the world's era + semver spawn_exec/spawn_logs/spawn_roomsunchanged unchanged — identical on both lanes -
engineVersion, an optional argument on every lane-sensitive tool. Omitted, the world's
engine is read from the API and cached. Passed — as a semver (6.0.0,5.4) or an era name
(6.0,document) — it is checked against the real pin, and a disagreement fails the call
without writing anything. It is an assertion, not an override, because the failure it exists to
prevent is a document-lane push aimed at a git world, or agame.jsonscaffolded over a live
clone.spawn_exec/spawn_logs/spawn_roomsdeliberately do not take it: those endpoints
are identical on both lanes, so the parameter would be a knob that does nothing.The API answers the engine question in three places now, and all three are used:
GET /api/agent/v1/meand/worldscarryengine: { semver, era, git }per world, and
GET /api/sdk/v1/{id}/agent/docscarriesengineVersion+era. Detection readsworldsfirst
(a few hundred bytes) and falls back todocs, which is authoritative for any variant the token
can reach. A409 world_is_gitfrom the document lane drops the cached era, so a world migrated
to 6.0 mid-session is picked up on the next call rather than retried into the wall. -
spawn_client_*— the agent's own body in the world.tool what it does spawn_client_joinstands your body in the world as a real player — boots the room, no browser, no GPU spawn_client_statuswhich sessions are standing, and how much ttl is left spawn_client_leavedespawns the body; the room folds when the last one goes spawn_clientany other client verb — where,players,inputs,move,look,witness,crossing,screenshot,runThis is the cheap way to a live room, and it is also how an agent plays the game it is
building: the body wears your name, stands in the room's census beside the creator and Savi, and
the world's own player hooks fire for it like anyone's.spawn_execandspawn_logsnow name the
join in their no-live-room errors instead of sending you to open a browser.A body and a browser answer different questions, and the build loop now says so in that order: a
body makes the room readable and lets you act in it; the browser is the only thing that tells you
whether the frame is any good. Join to query, look to judge.Four tools rather than one per verb, on purpose: the client is served by the stack rather than
shipped here ("the client it runs is the one the stack you join serves"), so its verb list can
change under us. Join, leave and status carry real schemas because they are the lifecycle an agent
has to get right; everything else goes through one passthrough that cannot drift. -
spawn_pushtakes amessageand abodyon the git lane. The commit's first line is not a
log entry: it lands in the creator's chat and their changes list under the agent's name, beside
what they and Savi said. The tool asks for one plain sentence about what changed for the player.
bodycarries the how — and since no agent account can wake Savi any more, that body is the only
channel to her an agent has. -
A local pre-flight for the git lane (
spawn_validate). The push is the validator on this
lane, and it is live in every open room the moment it lands, with no dev/live split to absorb a
broken tree. Sospawn_validatechecks locally what the push law names explicitly: every script
and template is ESM-parsed (vm.SourceTextModulein a child process — a real parse that accepts
import/export, and never evaluates the module),.scenefiles are checked against their
# spawn-scene v2 yaml <cellKey>header and the cell key their filename implies, image bytes are
checked against their extension, and binaries underassets/are caught before
law.git.asset-kindrefuses them.spawn_pushruns the same check over the changed files and
blocks on failure. -
depthonspawn_init— how much history a 6.0 clone takes (default 20,0for all of it).
Fixed
-
The play client could not open a 6.0 world.
agent/docsreturnsplayUrlabsolute on the git
lane and relative on the document lane, and four call sites prefixed it with the API origin
unconditionally — producinghttps://www.spawn.cohttps://www.spawn.co/@user/world. All of them
now go through one helper that leaves an absolute URL alone. -
spawn_savireported an opaque 403. The studio-chat door is now closed by account class: a
standalone agent account (sak_from/signup) can never wake Savi, linked or not, while a
human's own token still can. Retrying and re-wording both fail forever, so the tool names the
reason and points at the channel that does work — the commit body. -
spawn_pushechoed a commit subject that had not landed. When a push sends a commit that
already existed (usually one a refused push left behind), it reports the real HEAD subject and
says nothing new was committed, rather than echoing back themessageargument. -
spawn_latestreported a no-op pull as a change.git rev-parse --shortpicks its length
from the repo's object count, so the same commit abbreviates to 7 characters in a fresh clone and
8 once a fetch has brought more objects in. Comparing those strings made an unchanged HEAD read as
a change —changed: truebeside0 file(s) moved. Comparisons now use the full sha and only the
display fields are abbreviated. Caught by re-verifying against the live world a few days after the
clone, which is exactly how long it took the abbreviation to grow. -
A pull that moves HEAD without changing the tree now says so. A world commits its own
bookkeeping (re-measuring cell costs, for instance), and those can land as real commits whose net
diff is empty. "Rebased onto origin — 0 file(s) moved" was accurate but read like a bug; it now
says the tree is byte-identical and there is nothing to re-read.
Security
-
The git credential never lands anywhere durable. The helper is passed per-invocation with
-c, so a clone this server makes carries no credential in.git/configand none in
git remote -v. The helper body references$SPAWN_TOKEN, expanded by the shell git runs it in,
so the token is never an argv element; it reaches the child through its environment only. Git
output is scrubbed of the token, and of any credentials riding in an echoed URL, before it is
returned.GIT_TERMINAL_PROMPT=0is set because an MCP server has no terminal: a git that decided
to prompt would hang the tool call rather than fail it. The username is validated against a strict
handle pattern rather than escaped, because it is interpolated into a shell function body. -
This server's own files stay out of the world tree. They live under
.git/spawn-mcp/, which
is never tracked, never pushed, and never touched by a pull;.envis hidden via
.git/info/excluderather than the world's own.gitignore, so a clone carries no change nobody
asked for. Excluding.spawn/wholesale — the first attempt — was worse than the problem: it
would have madegit add -Asilently skip legitimate new world files there, and the push would
have landed missing exactly the work nobody thought to check. A guard now refuses to push when
this session's own artifacts (.env,.theirsreceipts, screenshots, a document-lane
game.json) are sitting in the tree, because a 6.0 push isgit add -A. -
The play client's origin is pinned, like the API's.
--originanywhere on a client line
redirects it to another stack, and the agent token travels to whatever that names — the same
threatsrc/config.tspins the API origin against, reached through a different door. It is
refused in the verb and in every argument, andSPAWN_ORIGINis set explicitly to this server's
resolved API origin so the client and the API always talk to the same host. Verbs are validated
against a plain-word pattern and always run asspawn client <verb>, so the blast radius is the
client namespace — neverspawn upload,spawn fetch, or a flag smuggled into the front of the
line. Arguments go throughexecFilewith no shell, and the client runs with its cwd in this
server's own cache directory, never in the game project (bun xresolves a package into its cwd,
and a world's tree is not the place for that).
Requirements
- git on
PATH, for 6.0 worlds. Pre-6.0 worlds do not need it. - Bun for the four
spawn_client_*tools, and nothing else. It is not a
preference: the client's session shell is literally spawned asbun <entry>, so under Node the
CLI gets as far asshell process failed to spawn (no pid). The client package needs no
install —bun x @spawnco/clientis the fallback, and a globalbun add -g @spawnco/clientis
used when present.
| New env var | Default | Purpose |
|---|---|---|
SPAWN_GIT_TIMEOUT_MS |
180000 |
Abort a git command that hangs |
SPAWN_CLIENT_TIMEOUT_MS |
120000 |
Abort a client command that hangs |
SPAWN_BUN_BIN |
resolved | Path to the Bun binary, when it is somewhere unusual |
SPAWN_CLIENT_ENTRY |
resolved | Path to @spawnco/client's bin/spawn.mjs, to pin a copy |
Notes
-
Clones are shallow by default (
depth: 20;depth: 0for everything). Measured against a
live world,main's full history was 18,500 objects and 80 MB and the fetch did not finish at
all; the same world's tip was ~320 objects and a few seconds. Nothing this server does needs deep
history — it edits the tip, commits, and pushes, all of which work from a shallow clone. The
refs/notes/spawnrefspec is only configured on a full clone: notes point at commits all through
history, so on a shallow clone they cannot resolve, and configuring the refspec anyway breaks
every latergit pullwith "remote did not send all necessary objects". -
Worlds move fast. On the world this was built against,
origin/mainadvanced between a fetch
and a push seconds later, so a non-fast-forward rejection is ordinary rather than exceptional:
spawn_pushnames it as such and points atspawn_latest, andspawn_statusreports how far
behind you are. The server has a second form of it —law.git.head-moved, a remote rejection
when the world moves while the push is being read — and both are reported the same way, because
the fix is the same: pull, then push. -
Client sessions are detached and outlive the tool call, which is what makes
join-once-then-query work. They self-expire atttl(default 600s) — the safety net against a
forgotten body standing in someone's world — and this server deliberately does not kill them on
shutdown, since a session is meant to survive an MCP restart. -
Bun resolution finds the real binary rather than the
bun/spawnshim onPATH, because on
Windows those are.cmdfiles that cannot be exec'd without a shell.
Known upstream bugs
Both are in Spawn's packaged client, not in this server; both are reported here because the errors
are otherwise very hard to read. Each is tracked as an issue carrying the repro, the exact cause and
a suggested patch, because the package's declared tracker (earth-kiln/main) is private:
#5 and
#6.
spawn client rundoes not work on Windows
(#6). The session shell validates
scriptPathas POSIX-absolute, so aC:\…path is refused — and-efails identically, because it writes the
source to a temp file and passes that same path. Play scripts are therefore unavailable on Windows
entirely.spawn_clientdetects the signature and says it is the client's bug rather than
reporting it as the script's fault. Every other verb works:where,players,inputs,move,
look,witness,crossing,screenshot.@spawnco/client0.2.0 passes a bare Windows path toimport()when run under Node
(ERR_UNSUPPORTED_ESM_URL_SCHEME: Received protocol 'c:'), which stops every client verb before
it starts (#5). Under Bun it does not
bite, so this server's Bun requirement routes around it.
Verified against a live world
@wfbcargo/simplecity, engine 6.0.0:
- Git lane: clone,
git pull --rebase, the local pre-flight over 210 scripts and 9 scenes, a
refused non-fast-forward push, the rebase, and an accepted push whose receipt read
live · 1 room · 4 players · 1 op. - Engine detection: era and semver from both
worldsanddocs; adocumentclaim about a 6.0
world refused; a patch-version disagreement warned but allowed. - Play client: joined as a real player and stood in the room beside Savi and the creator;
agent/roomslisted the room andagent/execanswered200with a live read of 2,628 entities
with no browser open anywhere; walked the body by input; readwhere/players/inputs;
departed cleanly. Joining by world id works as well as by@user/worldaddress, so the tools use
SPAWN_VARIANT_IDdirectly. - Document lane, which this account owns no world on, is covered by tests against a loopback
stand-in for the API.
357 tests, 91 suites.
Upgrading from 1.8.0
Nothing to do for a pre-6.0 world. For a 6.0 world:
- Install git (and Bun, if you want
spawn_client_*). - Run
spawn_initin an empty directory holding only your.env— it clones the world there.
An existing 1.x project directory full ofgame.jsonandscripts/is not a 6.0 clone and will
be refused; start a fresh one. - Read
AGENTS.mdat the clone root and.git/spawn-mcp/tome-api.mdbefore writing code. - Pass
messagetospawn_pushfrom now on.