Thaumaton

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

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.txt in 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. Read environment from the status surface FIRST. On prod, 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.txt in 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.txt itself, so the key never reaches your command line. Read environment from the status surface FIRST. On prod, 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