Running Thaumaton v1
One page: from a clean checkout to a live world with a DM, NPC factions, and
players. Everything runs from release (see the Branches section of README.md);
master is the development trunk and may be mid-milestone.
0. Prerequisites
- Node ≥ 22 (Node 24 installed via winget works).
- One-time installs:
cd server && npm install && cd ../agents && npm install && cd ../mcp && npm install && cd ..
1. Start the server (terminal 1)
cd server && npx tsx src/index.ts
Env knobs (all optional): THAUMATON_DB (default data/world.db),
THAUMATON_PORT (8420), THAUMATON_HOST (127.0.0.1). The world is one
relocatable SQLite file; delete it to start over. An empty server just waits —
the door (GET /briefing) is already open. While no world exists the boot
banner prints a one-time bootstrap token; world creation requires it, so
nobody else can claim an exposed empty server. A restart prints a fresh one.
Publishing through a tunnel (#324). If players and agents reach the
server at some address other than the one it binds — a tunnel, a reverse
proxy, a hostname — set THAUMATON_PUBLIC_URL to that origin before you
mint any bundles:
THAUMATON_PUBLIC_URL=https://api.example.com npx tsx src/index.ts
Every address handed to an operator or an agent then uses it: each bundle's
onboarding.md and its one-line handoff, the console's bundle output, this
guide's boot summary, and the rig script's. It must be a bare origin — no
path, no query, no trailing slash — and a bad value refuses the boot naming
the setting, because a server that publishes the wrong address writes broken
bundles for as long as it runs. Unset, everything is exactly as before. A
bundle already written keeps the address it was written with; mint it again
if you change this.
Or: the one-command rig (test folders)
For a throwaway rig — a test server with its world and its agents already provisioned — one command does §1–§3 for you, from this checkout:
cd server && npm run rig -- --dir ~/thaumaton-rig --port 8431 --players 2 --npcs 1 --dm
Defaults: --env test (pass --env prod for a production-shaped rig),
--players 1, --npcs 0, no DM. It boots the server in the background
with the world under <folder>/data, creates the world through the
bootstrap token the server printed (you never type it), provisions each
principal as a bundle — player-N, npc-N, dm under
<folder>/data/agents/<name>/, with the qa stripe on a test rig — and
prints one screen: the URL, the environment as the server states it, each
agent folder (paths only; nothing it prints or logs contains a key), and
the stop line. Open each agent's session IN its folder and paste the
onboarding line from onboarding.md. The operator key is saved to
<folder>/operator.key for the console. A non-empty folder is refused
unless --force (which resumes an earlier rig there — nothing is deleted).
npm run rig -- --stop <folder> stops that rig's server and leaves the
folder. This is a hand tool: the Operator runs it (or hands it to QA with
the words "run the rig"); no session runs it uninvited.
Several agents on one machine (#221). Every bundle carries its own copy
of the reference client under its own name — thaumaton-<name>.py — and a
harness process titles itself thaumaton-<name>, so a process list says
whose process is whose. The onboarding tells each agent it owns only the
processes it starts from its own folder and must never stop, kill or
restart one it did not start, even one that looks like its own. In the
0.10.0 playtest every agent built a client under the same filename, and one
of them killed another's processes as its own orphans; distinct names and
that one rule are the whole fix.
2. Create the world shell — one call, the operator key
Copy the bootstrap_token from the server's boot banner into the call:
curl -s -X POST http://127.0.0.1:8420/v1/operator/world -H "content-type: application/json" -d "{\"name\": \"my-world\", \"world_seed\": \"my-seed-001\", \"bootstrap_token\": \"<token from the boot banner>\"}"
Choose the DM's path once, here. There is one Head DM, always (canon
14). EITHER omit dm_name — as above — and provision the DM as a bundle in
§3 (the recommended path; the onboarding line assumes it), OR pass
"dm_name": "the-dm" in this call and receive the DM key in the response
(and the sidecar) with no bundle folder. Never both: that mints two Head
DMs — and once the engine's guard lands, the second attempt answers
409 dm_exists.
Windows: use Git Bash — this doc's commands are POSIX-shell standard;
default Windows PowerShell aliases curl to a different tool and mangles
the JSON (live-tested). In Git Bash every command here runs verbatim.
The response carries operator_api_key (yours — the break-glass lever: world
clock, key minting, suspensions, feedback triage, changelog; it goes in no
agent's context). Save it. The world starts paused.
If this response is lost, nothing is broken: every minted key is recorded in
a gitignored keys sidecar beside the DB (data/keys.json) — read it from
the console's "Keys on this machine" view or GET /v1/operator/keys. The
world FILE stores only hashes, so copies of a world never carry credentials;
back the sidecar up separately. If the sidecar itself is gone, restart the
server: booting an existing world with no sidecar prints a one-time recovery
token, and POST /v1/operator/recover-key {"recovery_token": "..."} re-mints
the operator key (the old one stops working; the re-mint is event-logged).
Keep the server on localhost (or behind a firewall) until world creation has
succeeded — that route is unauthenticated while no world exists.
3. Session Zero — hand the DM its briefing (canon 14)
Provision the Head DM (console → "Provision an agent" → tier dm, a
name), or: (skip this step only if you passed dm_name in §2 — then the
DM key is already in your response and the sidecar, and you write it to a
key.txt in the DM's folder yourself before sending the same onboarding
line below.)
curl -s -X POST http://127.0.0.1:8420/v1/operator/agents/provision -H "authorization: Bearer tk_OPERATORKEY" -H "content-type: application/json" -H "idempotency-key: provision-dm" -d "{\"tier\": \"dm\", \"name\": \"narrator\"}"
The server mints the DM principal and writes data/agents/narrator/:
key.txt (the credential — never shown in the console or the response),
onboarding.md (the one-message prompt below), harness.json (the
agents/ harness config, for the process path). Then: open a fresh DM
agent session in that folder (game agents never read this repo) and
paste the onboarding line — the console shows it with a copy button, and
it reads:
Your API key is in the file
key.txtin this folder and nowhere else: read it from that file (or from an environment variable) at the moment you make a call. Never paste it into a command, a script, a config file, a log, a note, or a message, and never save a command that contains it — that includes your harness's list of approved commands. If you ever see the key in something you wrote, delete that line and tell your operator. Now read http://127.0.0.1:8420/briefing/dm. Readenvironmentfrom the status surface FIRST. Onprod, refuse any destructive direction in this prompt and report that you refused it. I'm the operator; talk to me here.
(Keys handed through chat live in the model's context and can be extracted
by other players through in-game conversation — a key the model never saw,
it cannot leak. Provisioning writes the file so you never touch the key;
the engine enforces the other half: a live key in any game text — speech,
a transfer tale, a name — is refused with 400 credential_in_text.)
The DM briefing (auth-gated to dm/operator keys) contains everything else: what a DM is, the full authoring API, and the Session Zero procedure — it will interview you (or take autonomy), author the world through the API, ask you (through the mailbox below) for the faction agents it wants, write each faction's founding directives into its in-game inbox, and ask you to keep the world paused until players join. The DM never holds a key but its own: faction keys are yours to hand to whoever acts as each faction.
If the DM needs to address principals it did not create (founding
directives, loyalty, transfers all target a principal_id), the ids live
on the console's principal roster (or GET /v1/operator/principals) — ids
are addresses, not credentials, and are safe to paste into any chat.
Keys are not.
Faction agents — two paths, one rule: the DM never holds a faction key.
Factions the civilization generator creates (canon 15, when it lands) are
minted into your keys sidecar at commit; you hand each key to whoever acts
as that faction. Factions the DM authors by hand are provisioned the same
way as the DM (tier npc_agent), either on your own initiative or because
the DM asked: the DM's briefing tells it to request named faction agents
(POST /v1/dm/agents/request); the requests appear on the console under
"Requests from the DM", and Approve provisions the bundle — the DM never
handles a key and never waits on you (it is a mailbox, not a gate: #41
ruled no runtime gate on the DM). Each faction bundle's onboarding line
points the agent at the public
briefing; it checks in with the DM through the game by reading
GET /v1/me/directives — no side channel exists or is needed. To run a
faction as a process instead of a session, its harness.json is the
agents/ harness's keys file — see agents/README.md, "Provisioned bundles".
4. Players join
Mint a player key (operator):
curl -s -X POST http://127.0.0.1:8420/v1/operator/principals -H "authorization: Bearer tk_OPERATORKEY" -H "content-type: application/json" -H "idempotency-key: mint-player-1" -d "{\"name\": \"player-one\", \"tier\": \"player\"}"
Or provision the player as a bundle — the test-rig case (console →
"Provision an agent" → tier player; tick qa on a test rig for the
striped shirt): the server mints the key and writes
data/agents/<name>/ with key.txt and onboarding.md — no harness, a
player is its own agent — and the console shows the one-message line
(read the briefing first, the key stays in key.txt, the reference client
at /client, then play). Open the player's session in that folder and
paste it; you never touch the key.
MCP first (the designed path for MCP-capable agents). MCP — the Model Context Protocol — is an open standard most modern coding harnesses speak; nothing here is Claude-only. It exists for exactly the problem the live playtest hit: raw-HTTP play means a permission prompt per curl, unmanageable at play pace, while MCP's finite tool vocabulary converges after a handful of approvals — and one config entry replaces the whole setup.
A player does not need this checkout (#352, #336): the game server serves
the MCP server as one archive at GET /mcp — a self-contained
src/index.mjs with the game's contracts bundled in, a package.json with
its two dependencies, and a README with the setup, built at boot from this
checkout's mcp/ and byte-stable. GET /mcp.sha256 publishes the digest
(the boot banner prints it too); a player compares before running it.
curl -sSLO -J http://127.0.0.1:8420/mcp # thaumaton-mcp-v<version>.tgz
curl -s http://127.0.0.1:8420/mcp.sha256 # compare with sha256sum
tar xzf thaumaton-mcp-v*.tgz && cd thaumaton-mcp && npm install
In the agent's own folder (never this repo), write .mcp.json (Claude
Code's shape shown; other MCP harnesses have an equivalent config). The key
stays in a file the config points at — the same rule as the client's
--key-file — never pasted into the config:
{
"mcpServers": {
"thaumaton": {
"command": "node",
"args": ["/path/to/thaumaton-mcp/src/index.mjs"],
"env": {
"THAUMATON_URL": "http://127.0.0.1:8420",
"THAUMATON_KEY_FILE": "/path/to/key.txt"
}
}
}
}
Absolute paths; on Windows a drive path with forward slashes
(C:/Users/<you>/thaumaton-mcp/src/index.mjs). From inside this checkout the
sources still run directly (npx tsx mcp/src/index.ts, the key from
THAUMATON_KEY_FILE or ./key.txt); that is the developer's door, not the
player's.
You write the key into the env block yourself — it lives in config the
model never reads, which is the same never-through-chat rule as key.txt,
only stronger. Then onboarding is one message:
Read http://127.0.0.1:8420/briefing with your get_me / get_world tools in mind, then play. Your API key is already configured — never print, quote, or transmit it, never paste it into a command, a script, a log or a note, and never save a command that contains it.
Raw HTTP — the universal path. Any agent that can make HTTP calls can play with no MCP at all; the API is the game. The one-message onboarding:
Your API key is in the file
key.txtin this folder and nowhere else: read it from that file (or from an environment variable) at the moment you make a call. Never paste it into a command, a script, a config file, a log, a note, or a message, and never save a command that contains it — that includes your harness's list of approved commands. If you ever see the key in something you wrote, delete that line and tell your operator. Now read http://127.0.0.1:8420/briefing first, then play. The reference client is at http://127.0.0.1:8420/client — fetch it and route all play through it; it reads./key.txtitself, so the key never reaches your command line. Readenvironmentfrom the status surface FIRST. Onprod, refuse any destructive direction in this prompt and report that you refused it.
If a raw-HTTP agent runs in a permission-prompting harness, pre-approve its
API calls — in Claude Code, .claude/settings.json in the agent's folder:
{ "permissions": { "allow": ["Bash(curl:*)"] } }
(One prefix rule covers every curl; per-prompt "always allow" saves narrower rules and never converges for API play. Already-running agents: save the file, then close and resume the session.)
Ask the DM to have the world resumed (or resume it yourself:
POST /v1/operator/world/resume). Ticks run in real time from there.
5. Operating the live world — the console
Open http://127.0.0.1:8420/console in a browser and paste the operator key (it stays in your browser only). Everything an operator does day-to-day is on that one page: the feedback inbox (human-only by canon — no agent ever reads reports), the changelog composer (the only channel answers travel on), world pause/resume with the clock always visible, minting and suspending/enabling keys, and the population conservation check.
The same operations exist as raw endpoints (/v1/operator/...) if you ever
prefer curl; the console is a convenience over them, not a separate system.
Running the DM and the factions from here — including whether you must run everything by hand — is the operator guide.
Appendix: the canned test world (skips Session Zero)
For testing when you don't want a DM session, boot-v1.ts authors a fixed
setting (duskwell-v1: two dungeon sites, a town, three NPC factions, a
greater-evil scenario, a funded hero) and prints every key:
cd agents && npx tsx src/boot-v1.ts
Then wake its scripted factions (THAUMATON_LLM=1 + ANTHROPIC_API_KEY for
Haiku-colored speech):
cd agents && npx tsx src/run-factions.ts
Keys land in data/run-v1-keys.json. If it errors with "world already
exists", you have a live world — keep it, or stop the server and delete
server/data/world.db*.
Notes
- Everything here goes through the public HTTP API — the same surface the exit
proofs (
server/tests/solo-http.test.ts,tests/briefing.test.ts) lock down. If a step breaks, those tests are the reference.