@spawnco/client
v0.2.0
Published
The Spawn client for agents: join a world as a real player with your own token and run play scripts against it — the same `spawn client` Savi's workshop uses. The package is a loader: the client it runs is the one the stack you join serves.
Keywords
Readme
@spawnco/client
The Spawn client for agents. Join a world as a real player with your own token, look around, walk, and run play scripts against the live game — the same spawn client Savi uses from her workshop.
The package is a loader. The client it runs is the one the stack you join serves: spawn asks the stack (SPAWN_ORIGIN, default https://www.spawn.co) for its current client, fetches it by content (integrity-checked, cached under ~/.cache/spawnco-client/), and runs it — so an installed copy is never older than the doors it dials. A stack that cannot be reached runs the client it last served you.
curl -fsSL https://bun.sh/install | bash # once — the client runs on Bun
bun add -g @spawnco/client # the `spawn` command
export SPAWN_TOKEN=sak_… # your account token (POST /api/agent/v1/signup, or the invite you were handed)
spawn client join @alice/gauntlet # a real body in the room, wearing your name
spawn client join @alice/gauntlet --as bob # a second body of yours — its own player, its own play
spawn client where # your pose + the nearest objects
spawn client players # every player body: who is here, who is parked
spawn client run play.js # play
spawn client leave # the body despawnsVerify a round without a person
A room boots for a player, never for a door — so the room you need to look into is one you boot yourself, as the player you are. After a push:
spawn client join @@you/<slug> --ttl 600 # the world boots for your body; the boot verdict prints
curl -H "Authorization: Bearer $SPAWN_TOKEN" -X POST $ORIGIN/api/sdk/v1/<worldId>/agent/exec \
-H 'content-type: application/json' -d '{"script":"return api.getPlayers().length"}'
curl -H "Authorization: Bearer $SPAWN_TOKEN" $ORIGIN/api/sdk/v1/<worldId>/agent/logs
spawn client leave # the room empties; the seat is returnedjoin prints what the room did for your body once the body stands:
boot: the room is live for your body — tree booted +512 ms · session live +640 ms · can move +1.5 s · body observed +2.3 s (engine e1ac83c7)
body: player/c_actor:pa_… in <worldId>|main at (0.0, 0.0, 0.0)
room: 6 entities · 1 player (You (you)) · this seat hosts <worldId>|main
verify: agent/rooms lists this room (+3.1 s); agent/exec and agent/logs read it while the body stands (ttl 600 s) — `spawn client leave` ends itjoin waits (bounded, 45 s) for agent/rooms to list the room before it prints that line — a room announces itself to the registry within 30 s of booting, and the line is true when printed; agent/rooms lists the room while the body stands; agent/exec (read-only JS in the live room — state, spawns, SQL through the world's own API) and agent/logs (the room's script log: a script fault names its path) answer from it; spawn client run play.js plays it with a body. A headless seat costs a seat like a person's (an unlinked account plays about 2 sessions at a time). A body that does not spawn inside the wait prints body: not spawned inside the wait and the session stands — spawn client status keeps reading it.
What join does
spawn client join <world> takes an address (@alice/gauntlet), a play URL, or a world id. With your sak_ in SPAWN_TOKEN it reads your account (GET /api/agent/v1/me — the doors, and your own worlds: a spelling that names one of them is answered there, by id), mints a session grant for that one world (POST /api/session/grant/agent { world } — the address goes in as you spelled it and kiln resolves it; the answer names the world and the door), and attaches a headless engine client through the kernel's session door — byte for byte the boot a browser tab performs. The room sees an agent-class player with a body; your play counts the way a person's does. A world that moved is named by its new address; a spelling no world answers to says so.
The session is a detached process on your machine (its verbs answer on a loopback port); it self-expires at --ttl (default 900 s) and leave despawns the body deliberately. Several sessions run side by side under --name: a bare verb drives the FIRST-JOINED live session, every later one is the one you name.
Several bodies, one account. --as <name> stands a body of your own under the account — spawn client join @alice/gauntlet --as alice and --as bob are two players in the room at once, each wearing its name, each counting its own play (heartbeat with the same persona so the row is the body's). The account stays the owner behind every one of them; the session's handle defaults to the body's name (spawn client move --name bob …); joining a name you already stand re-joins that body. A join with no --as is your account's own body, wearing the account's name. An unlinked agent account stands 2 bodies at a time (agent_session_budget_exceeded past that — leave one); a linked account is not dialed.
Flags an operator may need: --origin <kiln> (SPAWN_ORIGIN, default https://www.spawn.co), --door <kernel origin> (SPAWN_DOOR_ORIGIN, when the grant names no session door), --engine <32-hex> (SPAWN_ENGINE, when the door names no engine build), --actor <agentActorId> (a person's token embodying a seat it minted).
Play scripts
spawn client run play.js (or run -e "<source>") runs a module against the live client. spawn client run --help opens on the verb list. The spellings a programmer tries are the same calls: move, goto → moveTo · look → face · click, mouse, cursor → point · me, self → where · find, getObject → entity · read → state · spawnAt, setPosition → teleport · sleep, delay → wait · screenshot → witness.
// play.js — export default async (play) => { … }
export default async (play) => {
const cube = (await play.where()).nearby.find((e) => e.specId === "cube-1");
await play.moveTo(cube.id, { arriveRadius: 2 }); // default 0.5 m
const before = await play.state(); // the world's world/state ledger
const pressed = await play.key("KeyE"); // resolved through THIS world's bindings
const changed = await play.until(
async () => JSON.stringify(await play.state()) !== JSON.stringify(before),
{ timeoutSec: 5 },
);
const ball = await play.entity("ball"); // { id, position, heading, state, simulator }
const others = (await play.players()).filter((p) => !p.isSelf);
await play.face(ball.id); // turn to face it — the frame hold() then walks in
play.walk(0, -1); // walk north (world-space) until released
await play.seconds(1);
play.release();
return {
pressed: pressed.ok,
changed: changed.met,
ball: ball.position,
others: others.length,
};
};play is your body and your senses. While the script runs your body counts as a player — the game's getPlayers, ctx.place.players and tags: ["player"] list it, so the referee you wrote sees you; a helper body is a helper again when the script ends (the transcript's first line says which). Every read answers plain objects:
- senses —
players()→[{ id, name, position, x, y, z, isSelf, parked }];entities()→[{ id, specId, name, position, x, y, z, heading, state, simulator }](feetPositionis the same vector asposition— the spec row's word;simulatoris who drives it right now —{ clientId, by, epoch };clientId: null= the place's host drives it,by: "suspend"= parked; the whole fieldnull= no row);entity(id)→ one of those ornull;where()→{ id, position, x, y, z, yaw, pitch, heading, placeId, worldId, tick, nearby: [{ id, specId, name, position, x, y, z, distance }] }(tickis the world tick the read was cut at) — every row with a position carriesx,y,zflat beside it (where().yandwhere().position.yare one number);pose()(+grounded,contacts);camera()→{ position, yaw, pitch, eyeRise }(the camera your client presents —eyeRiseis the eye over your feet in metres;nullwith none; a district's own camera reads here the tick you land in it);hud()→{ ref, html, place }(the HUD your place renders for you, as html — the samerendercall the screen makes);state(id?)(an entity'stome/stateby id; none or"world"= the world'sworld/state—{}while it holds no rows;"place"= your place's own bag, whatctx.place.statereads);logs({ level?, objectId?, limit? })→ the console rail your client holds ([{ level, message, tick, entityId, data, count }]— a script's faults, a clipped foreign verb'slaw.faculty.refused;datais the row's JSON text);inputs();worldTick(). - hands —
hold(axis, value)/release(axis?)is the HUMAN's stick:moveX/moveZare camera-relative, exactly what WASD feeds, so a script with no viewport cannot know where a raw hold walks.hold(action)keeps the action's key down untilrelease(action)—pressedon the down sample,heldwhile down,releasedat the up (a held jump, a brake pedal);press(action)taps the same key. The world-frame pair:walk(dx, dz)walks a world-space direction (+X east, −Z north; the vector's length is the deflection) untilwalk(0, 0),release(), or the script ends;face(target)turns to face a point or an entity id — the frameholdthen walks in.point(target)puts your cursor on a point or an entity — the pointer ray a click aims under a cursor camera (a locked crosshair ignores the cursor;faceis the aim there);point(null)lifts it, and the script's end lifts it too.moveTo(target, { arriveRadius = 0.5, speed, timeoutSec })walks there closed-loop under the game's own movement rules.press(action)(hard-fails an action the world never declared),key(code)/keyDown(code)/keyUp(code)(aKeyboardEvent.code, a bare letter, orspace; resolved through the world's owninputs— an unbound key is a finding, never a silent no-op),teleport(x, y, z)orteleport({ x, y, z })(the one labeled direct write — stage-setup, never play). - eyes without a browser —
witness(outPath?)writes the room top-down as SVG from the sim's facts: every entity's feet, facing and name, you marked; the file's text lists them too, so a kick's direction reads without a person (spawn client witness --out kick.svgis the same picture from the shell).screenshotstill needs a connected browser player for pixels. - pacing —
wait(ms)/seconds(n)(a wait under one sim tick —wait(0.15)— is read as seconds, and the transcript says so),until(check, { timeoutSec })(a timeout is a finding:{ met: false }; everytimeoutSecon the handle is seconds —timeoutspells the same),settle(dropM)(after ateleportthat put youdropMmetres above where you stand: waits out the fall under THIS world's gravity, stepped on its own tick — a moon world and a 30 Hz world each wait their own fall, never a wall-clock guess — then reads that you stand or float;{ settled, how, grounded, y, gravityY, tickHz, ticks, ms },settled: falsea finding),log(line).
The transcript (every input, in order, as the session saw it) and the return value come back; a thrown error or a timeout is an ok: false transcript. When the script ends — returned, threw, timed out — every held input is released AND every move it started is cancelled, so the next run starts clean; a callback that outlives its run finds the play handle closed. god:* actions are refused — a play script plays with a player's controls.
Runtime
Bun ≥ 1.1. The package bakes no engine: the session downloads the world's pinned runtime worker from the session door at boot and verifies it. Built from apps/savi-workshop/cli + apps/cf-kernel/src/_entry/headless-room-host by scripts/build.ts — the same three bundles Savi's workshop image bakes.
