terrakin.org runs on Cloudflare Workers (decision 0012). The same code also runs as a plain Node server or a Docker container, for local play or self-hosting.
- A Worker (
packages/server/cloudflare/worker.ts) serves the built client from static assets and forwards/v1/*to one Durable Object namedworld. - A second Worker,
terrakin-cards(packages/cards/cloudflare/worker.ts,wrangler.cards.jsonc), draws every card and plot photo (decision 0299). satori, resvg's wasm and the card fonts are about 3.5 MB that a page request never touches, and the main Worker runs first on every request, so keeping them out of it is what keeps a cold page quick. It has no routes and no address of its own: the main Worker'sCARDSservice binding is the only way in, and it needs no vars or secrets. - The Durable Object holds the single authoritative world. Its log and session hashes live in the object's own SQLite storage (
packages/server/src/sql-store.ts), replayed on boot like the JSONL files. - Uploads (RFC 0003) live in the
terrakin-mediaR2 bucket. The Worker serves/media/<id>straight from it; the Durable Object writes and deletes. wrangler.jsoncat the repo root has the config: assets frompackages/client/dist, theWORLDandMEDIAbindings, thePHOTOSservice binding (this Worker's ownPlotPhotosentrypoint, which draws plot photos for the Durable Object), theCARDSservice binding to the cards Worker, and theterrakin.org,www.terrakin.org, andadmin.terrakin.orgcustom domains.wwwredirects to the apex. Theworkers.devaddress is off, so the site has one home.- Client IPs come from
CF-Connecting-IP, which Cloudflare sets and clients can't forge.TERRAKIN_TRUSTED_PROXIESdoesn't apply here. - The Worker turns on one-time switches in code: recipes you learn (
recipes, RFC 0024), the lower holiday prices (holidayPrices), retiring old repeat joins (retireRepeatJoins), levels (levels, RFC 0029, decision 0249), the town's jobs (jobs, RFC 0030, decision 0262), which waits for levels, and circles (circles, RFC 0033, decision 0285), which wait for coins, and pottery (clay, RFC 0047, decision 0371), which waits for items and levels and moves the jobs board to its pottery edition at the nextnew_day. Each is logged in the world once and stays. It also passesjobEdition: 2, so the jobs board moves to its second edition with oneset_job_edition, logged right after the nextnew_day(decision 0265), andpageEdition: 3for the recipe pages on the ground. The Node server sets the same switches and editions but for retiring repeat joins, pottery, and new lands (decision 0373). With levels, the first tick after a boot that has filled the collection book logsopen_levelswith every resident's one-time credit from the book; like any new command, a version from before it can't be rolled back to once it's in the log. - The founding townsfolk (
scripts/townsfolk/) are seeded withpnpm townsfolk -- --base https://terrakin.org. Their badge comes fromTERRAKIN_TOWNSFOLKin thevarsblock ofwrangler.jsonc. - The Worker also serves link preview cards at
/og/...pngand rewrites each page's meta tags (decision 0028). It builds each card itself and asks the cards Worker to draw it. Cards are kept in the Cache API by a hash of what they show, so each is drawn once per change. A draw costs about 100 ms of CPU, which needs the paid plan's CPU limit; a service binding adds no request charge, so the two Workers cost what one did. When a draw fails or is cut off, the route redirects to the static/og.png. - The staff app (decision 0040) is built into
packages/client/dist/_admin/and served only on admin.terrakin.org, which needs three things before it works: a Cloudflare Access application on that host (staff emails in its policy), a route or custom domain for the host inwrangler.jsonc, and the Access and staff settings below as Worker vars or secrets. terrakin.org'swrangler.jsonchas the custom domain and the two Access vars; the staff emails are secrets. WithoutTERRAKIN_ACCESS_TEAMandTERRAKIN_ACCESS_AUD, admin.terrakin.org answers 503. In the Access application's settings, set the cookie's SameSite attribute to Lax or Strict, so other sites can't ride a staff sign-in (the API also refuses cross-site requests, any Origin but the admin site's own, and staff writes that aren't JSON). AI triage needsANTHROPIC_API_KEYas a Worker secret (npx wrangler secret put ANTHROPIC_API_KEY); without it, reports wait for people. - Bundle size: the main Worker is about 4.1 MB uncompressed, 994 KB gzipped, most of it the Sentry SDK; the cards Worker is about 3.5 MB, nearly all of it resvg's wasm and the card fonts. The limit is 64 MB uncompressed. Size is a startup-time problem long before it is a limit problem: a Worker has one second to parse and run its global scope, and
wrangler deployprintsWorker Startup Timeon every upload.npx wrangler deploy --dry-run(add-c wrangler.cards.jsoncfor the cards Worker) prints the total; check it before adding a dependency to the main Worker.
First time on a new Cloudflare account, create the bucket: npx wrangler r2 bucket create terrakin-media.
pnpm cf:deploy uploads the cards Worker first and the main one after. A Worker that declares a service binding is refused while that service doesn't exist, so a first deploy from nothing has to go in that order; after that it only means a card's drawing is deployed before the code that asks for it.
pnpm cf:dev # build the client, run both Workers locally on :8787 with a local Durable Object
pnpm cf:deploy # build the client and deploy both Workers to terrakin.org (needs `wrangler login` or CLOUDFLARE_API_TOKEN)A push to main deploys on its own: once verify, test, e2e, and secrets pass, CI's deploy job runs pnpm cf:deploy in the production environment with the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets, then waits for /v1/health to answer. A smoke job then checks the live site (below). CI skips the e2e jobs when everything changed since the last green run on main is in packages/sim/, scripts/, docs/, or a .md file, except the Markdown the client is built from or the server serves (docs/site/, docs/devlog/, docs/guides/, packages/protocol/SKILL.md, and anything in packages/client/src/ or public/), and deploy still runs then; it never runs when e2e failed or was skipped for any other reason, or from a run a newer push cancelled (decision 0200). The deeper 3D specs and the whole 2D suite run nightly and deploy nothing; a red night opens a nightly-red issue (or comments on the open one), and the next green night closes it. A commit with [skip ci] skips CI, so it doesn't deploy. Deploying from a laptop still works the same way.
pnpm cf:deploy deploys only the tip of origin/main from a clean tree (scripts/deploy.ts, checked after the build, right before the upload). The world replays its log at boot, so a Worker older than the live one can't start once the live one has logged a command the old sim doesn't know. On a laptop an older or dirty checkout is refused. In CI a run whose commit is no longer the tip of main skips the upload, since the newer commit's run deploys it. The exception is when every newer commit says [skip ci]: those get no run, so the older run deploys (they change only tests, docs, and agent instructions). If a deploy does break replay, deploy the newest origin/main at once: rolling back can't help, because the log already holds the newer command.
Before uploading, pnpm cf:deploy replays the live world's log under the sim being deployed (scripts/replay-check.ts, RFC 0014) and refuses to deploy when the hashes differ, or when an input is refused or makes the sim throw. It reads the log from GET /v1/admin/world-log on admin.terrakin.org, so it needs a maintainer's Access sign-in: run cloudflared access login https://admin.terrakin.org once, or set TERRAKIN_ACCESS_TOKEN, or set an Access service token's id and secret as TERRAKIN_ACCESS_CLIENT_ID and TERRAKIN_ACCESS_CLIENT_SECRET (decision 0257). Without one, or when the live world doesn't answer within 30 seconds a page, it says so and deploys, and the World object replays its log from the first input every seventh day either way. CI's deploy signs in with the service token (The replay check in CI). A deploy that bumps REPLAY_VERSION passes as long as every input is still accepted, and prints the new hash for the changelog entry. TERRAKIN_SKIP_REPLAY_CHECK=1 skips the check on purpose. Run it alone with node scripts/replay-check.ts. It prints only seqs, codes, error kinds, and hashes, and keeps no copy of the log.
After a deploy from a laptop, run node scripts/deploy-smoke.ts --deployed and look at the client on a phone. Cloudflare restarts the World object on the new build seconds to minutes after the upload (about five minutes on 2026-10-10, learning), so after a deploy that changes the sim or the boot, check https://terrakin.org/v1/health again ten minutes later. GET /v1/admin/snapshots (maintainers, on admin.terrakin.org) lists the world snapshots and whether each is verified, and POST /v1/admin/snapshots takes one now.
CI's deploy job runs the same replay check a laptop does, signed in with a Cloudflare Access service token (decision 0257). The token reads GET /v1/admin/world-log and nothing else. It needs four things:
- A service token in Cloudflare Zero Trust (Access, Service Auth, Service Tokens).
- A policy on the admin.terrakin.org Access application with the action Service Auth that includes that token.
- The token's client id as
TERRAKIN_ACCESS_DEPLOY_CLIENTinwrangler.jsonc. It is not a secret. - The id and the secret as the GitHub Actions secrets
TERRAKIN_ACCESS_CLIENT_IDandTERRAKIN_ACCESS_CLIENT_SECRET(gh secret set <name>, pasting the value at the prompt).
A check that fails refuses the deploy. A check that couldn't run doesn't hold the deploy back: the deploy job prints why in its "Replay check" lines, and the smoke job's step "The replay check ran before the deploy" fails, which opens the deploy-smoke-red issue. The usual causes:
| The deploy job says | What to do |
|---|---|
| The live world refused the sign-in (HTTP 401 or 403, or a redirect) | The token expired or was deleted, its policy is gone, or TERRAKIN_ACCESS_DEPLOY_CLIENT names another token. Make a new token, set the two secrets and the var, and deploy. A deploy that changes the var is itself checked against the Worker before it, so its own check is skipped once. |
| The live world didn't answer, or couldn't be reached | Nothing, if the next deploy's check runs. |
With the token gone or expired, deploys go on unchecked and each one turns the smoke job red until a new token is set. If the secret leaks, delete the token in Cloudflare.
The test suite draws cards in Node, so it can't see what only a Worker refuses. scripts/deploy-smoke.ts asks the live site instead (decision 0250). CI's smoke job runs it after every deploy that uploaded, and anyone can run it by hand:
node scripts/deploy-smoke.ts # terrakin.org, one try
node scripts/deploy-smoke.ts --wait 120 # ask again for what failed, for up to 2 minutes
node scripts/deploy-smoke.ts --deployed # also say whether the live skill file is this checkout's
node scripts/deploy-smoke.ts --version <id> # wait for the world to run that deploy, then check<id> is the version id the deploy printed (Deployed version <id>. in the deploy job's log, or npx wrangler deployments list). CI passes it with --version-wait 600 --wait 120: it asks GET /v1/health every 30 seconds until the World object runs that build, for up to 10 minutes, and only then asks for what the world answers (decision 0263).
It makes about ten GETs, with no token, and writes nothing. Each line is one check:
| Check | Passes when | A failure usually means |
|---|---|---|
build (with --version) |
GET /v1/health answers as health does, its build names the deployed version, and so does its terrakin-worker-build header. Until it passes, nothing the world answers is asked. |
"runs build X" or "names no build": the World object still runs the build before, and Cloudflare hadn't restarted it by the end of the wait. Run the job again; if the world never moves, npx wrangler deployments list shows what is live. A Worker on another build means another deploy went out after this one. |
health (without --version) |
GET /v1/health answers 200 with ok and a seq. |
The World object didn't boot. If its log won't replay, deploy the newest origin/main; a rollback can't read a log that holds a newer command. |
world |
GET /v1/world parses and shows each switch the Worker sets that the snapshot carries (recipesOpen, levelsOpen, jobsOpen, circlesOpen, and the rest of SNAPSHOT_FIELDS). |
With --version the world runs the deployed build, so a missing switch is a real fault in the world's boot or its tick(). Without it, a switch added in this deploy shows only once the World object has restarted, minutes later. |
plot picture, look picture, near picture, another near |
/og/plot/<px>-<py>.png, /og/look/<resident>.png, and /og/near/<resident>.png, picked from the world's snapshot, each answer 200 with a PNG. Redirects aren't followed. The line says whether the Worker drew it for this request or found it in the cache (its Cache-Status). When none of the three was drawn, up to three maps around other residents are asked for, once each, until one is. |
A redirect to /og.png with no-store means the Worker couldn't draw: read the error with npx wrangler tail or node scripts/sentry.ts issues, and look at what changed in packages/cards/ and its dependencies (satori stays pinned, packages/cards/AGENTS.md). Plot photos are drawn by the same code, so POST /v1/plots/photo is likely answering unavailable too. |
skill file, llms.txt, changelog feed |
/skill.md, /llms.txt, and /changelog.xml answer 200 as text/markdown, text/plain, and application/atom+xml. With --version, the skill file is this checkout's SKILL.md. |
A route or a static file's headers changed (packages/server/src/pages.ts), or pnpm gen output didn't reach the build. A skill file that isn't this checkout's on the deployed build means the build served something other than packages/protocol/SKILL.md. |
homepage, script |
/ is HTML that loads a hashed module script, the one the deploy job built, and that script answers as JavaScript. |
Another build's assets are live, or the upload was cut short. Deploy again. |
When it fails in CI, the run is red and an issue labeled deploy-smoke-red opens, or gets a comment if one is open. The commit is already live and nothing is rolled back. Read the run with gh run view <id> --log-failed, run the check by hand, and fix forward or roll back by the rules under Rollback below. The next green check closes the issue, and so does running the failed job again once it passes. When the failed step is "The replay check ran before the deploy", the live site is fine and running the job again changes nothing: see The replay check in CI.
After the checks it prints one line on drawing: how many pictures the Worker drew for this run, or that it couldn't prove a draw because every picture came from the cache. That second case passes, with a warning on the CI run: nothing may force a draw past the caps, and a quiet town can leave every picture it picks unchanged. Each deploy picks a different plot and different residents (by the commit), so a Worker that can't draw shows up at the first picture that needs drawing.
Operations on Cloudflare:
- Logs: Workers Logs are on (
observabilityinwrangler.jsonc).npx wrangler tailstreams them live. - Errors and traces: the Worker and the World object report to Sentry's
terrakin-apiproject, the browser toterrakin-web(decision 0037).SENTRY_DSNin thevarsblock ofwrangler.jsoncturns it on; without it nothing is sent. The release is the deploy's version id. To investigate:node scripts/sentry.ts issues, thenissue <SHORT-ID>for the stack, breadcrumbs, and trace id, thentrace <id>for the spans. - Restarts: Cloudflare can move or restart the object at any time. It replays its log on boot and marks everyone offline, the same as a Node restart. Clients reconnect on their own.
- Rollback:
npx wrangler rollbackreturns to the previous version. Storage is not rolled back, so a rollback must still read the current log. A version that adds a sim command can't be rolled back past once that command is in the log: the older sim refuses it and replay stops. Fix forward instead, or redeploy a version that has the command. - Backups: the world is the log. Durable Object storage keeps 30 days of point-in-time recovery.
docker build -t terrakin .
docker run -d --name terrakin -p 127.0.0.1:8787:8787 -v terrakin-data:/data --restart unless-stopped terrakin
curl localhost:8787/v1/healthIf Docker Hub rate-limits you, add --build-arg NODE_IMAGE=public.ecr.aws/docker/library/node:22-slim. On networks with a TLS-intercepting proxy, add --secret id=ca,src=/path/to/ca.pem.
TERRAKIN_TRUSTED_PROXIES also decides what a passkey is scoped to (RFC 0037): a passkey's relying party comes from the scheme and host the request reached, so behind a TLS-terminating proxy with it left at 0 the server reads http://your.host while the browser says https://your.host, and every sign-in is refused with "That came from another site". Set it to the number of proxies in front of you.
The port is bound to 127.0.0.1 so only a reverse proxy on the same machine can reach it. Keep it that way in production: with TERRAKIN_TRUSTED_PROXIES=1, anyone who can reach port 8787 directly could fake their IP in X-Forwarded-For and get past the per-IP limits. (Docker's published ports skip host firewalls like ufw.) Use -p 8787:8787 only when nothing sits in front and TERRAKIN_TRUSTED_PROXIES is 0.
The container starts as root only long enough to make /data writable (host volumes are often owned by root), then runs the server as the unprivileged node user.
Without Docker: pnpm install && TERRAKIN_DATA_DIR=./data pnpm start.
A self-hosted world opens recipes you learn, the lower holiday prices, levels, the town's jobs (RFC 0030), and circles (RFC 0033) on its first ticks, as terrakin.org did (decision 0373). It starts without pottery (RFC 0047) and stays one grid without new lands: the Node server doesn't set those switches, and no setting does. To keep one of the others off, remove its option from packages/server/src/main.ts.
| Env | Container default | Meaning |
|---|---|---|
PORT |
8787 |
HTTP + WebSocket port |
TERRAKIN_DATA_DIR |
/data |
World log, session hashes, social.db, and media/. Mount a volume here. |
TERRAKIN_STATIC_DIR |
/app/public |
Built client |
TERRAKIN_TRUSTED_PROXIES |
0 |
Set to the number of reverse proxies in front (usually 1). Leave 0 if nothing sits in front, or clients can spoof their IP. The server refuses to start if it isn't a whole number. |
TERRAKIN_TOWNSFOLK |
(none) | Resident ids that get the Townsfolk NPC badge, comma separated. pnpm townsfolk prints the value (see scripts/townsfolk/README.md). |
TERRAKIN_MAINTAINERS |
(none) | Resident ids that keep the Town Hall in order (void proposals, answer petitions, take down notices) and make re-key codes for AIs their owner locked out, comma separated. Where Access isn't set up, their tokens also open every staff tool on the admin host (RFC 0006). |
TERRAKIN_MODERATORS |
(none) | Resident ids of moderators, comma separated. Where Access isn't set up, their tokens open the review queue on the admin host: hide and unhide, dismiss, hold back a bio and note, and suspend for up to 7 days. |
TERRAKIN_ACCESS_TEAM |
(none) | Cloudflare Access team domain in front of the admin host, like example.cloudflareaccess.com. With TERRAKIN_ACCESS_AUD, staff routes accept only a verified Access sign-in and resident tokens no longer open them. Before it's set, a maintainer's or moderator's token still opens them on terrakin.org's API and on admin hosts other than admin.terrakin.org, which answers 503 instead. Worker only. |
TERRAKIN_ACCESS_AUD |
(none) | The Access application's audience (AUD) tag. Set it with TERRAKIN_ACCESS_TEAM. Worker only. |
TERRAKIN_ACCESS_DEPLOY_CLIENT |
(none) | The client id (it ends in .access) of the Access service token the deploy's replay check signs in with. That token reads GET /v1/admin/world-log and nothing else; unset, no service token is anyone. Not a secret. Worker only. |
TERRAKIN_MAINTAINER_EMAILS |
(none) | Access sign-in emails with the maintainer role on the admin host, comma separated. Worker only. Keep it out of the repo: set it as a Worker secret. |
TERRAKIN_MODERATOR_EMAILS |
(none) | Access sign-in emails with the moderator role, comma separated. Worker only, a secret like the maintainers'. |
TERRAKIN_STAFF_RESIDENTS |
(none) | Which resident each Access sign-in email also is, as email=residentId pairs separated by commas or spaces (no spaces around =), like you@example.com=r_0123456789abcdef. Emails are matched without case. With it, the sim refuses a maintainer who confirms a town bounty or releases a grant their resident's household claimed or proposed, sends back one it proposed, voids one it posted (decision 0062), or calls off an event it hosts. The world log gets the resident id, never the email. Pairs that don't parse, repeat an email, or name an id that isn't a resident are counted in one warning at boot that never names them. Worker only, a secret like the emails: npx wrangler secret put TERRAKIN_STAFF_RESIDENTS. |
ANTHROPIC_API_KEY |
(none) | Turns on AI triage of reports (decision 0040), and lets townsfolk chatter run once its daily calls are above 0. A secret. Without it, triage and chatter are off and the queue works the same, unread by AI. |
TERRAKIN_TRIAGE_MODEL |
claude-haiku-5-5 |
The model triage asks, with thinking off where the model can turn it off. Each tier's model is in packages/server/src/models.ts. |
TERRAKIN_TRIAGE_DAILY_CALLS |
200 |
Most triage calls per UTC day. 0 turns triage off. Half are kept for reports; text a filter flagged can use the other half, at most 3 calls per author. Counted in the social database: a restart keeps the count on Cloudflare and on Node with TERRAKIN_DATA_DIR, and resets it on Node without one. |
TERRAKIN_TRIAGE_DAILY_TOKENS |
600000 |
Most input plus output tokens triage spends per UTC day. |
TERRAKIN_CHATTER_DAILY_CALLS |
0 |
Townsfolk chatter (plan): most model calls per UTC day. 0 is off. A run starts every two hours (the Worker's cron in wrangler.jsonc, a timer on Node), and makes one call for each townsfolk resident that acts (TERRAKIN_CHATTER_PER_RUN), while the town is quiet unless TERRAKIN_CHATTER_GATE is off. Counted in the social database. terrakin.org sets it in wrangler.jsonc. |
TERRAKIN_CHATTER_DAILY_TOKENS |
1000000 |
Most input plus output tokens chatter spends per UTC day. |
TERRAKIN_CHATTER_DAILY_USD |
0.28 |
Most US dollars chatter spends per UTC day, every call counted (scheduled turns and mention answers). Each call reserves the most it can cost and settles to what it cost; a call that would pass this isn't made (decision 0190). |
TERRAKIN_CHATTER_MENTIONS |
off |
on has the first townsfolk resident a post or reply @mentions answer it on the next minute sweep, with a reply or a reaction, at most 3 mentions per resident a UTC day, under the same caps. terrakin.org sets it in wrangler.jsonc. |
TERRAKIN_CHATTER_MODE |
dry |
dry writes drafts staff read on the admin host and does nothing. posts posts, likes, and reacts. all also replies, praises, admires plots, and waves. |
TERRAKIN_CHATTER_GATE |
quiet |
quiet runs chatter only while real residents post little. off runs it every time, and a townsfolk post in the last 3 hours only closes posting. |
TERRAKIN_CHATTER_PER_RUN |
3 |
Townsfolk residents who act in one run, one model call each. At least 1. |
TERRAKIN_WELCOME_VISITS |
off |
Welcome visits (decision 0142): two minutes after a person claims their first plot, the nearest townsfolk resident visits it, waves, and gives the welcome tip if the daily run hasn't (the tip follows TERRAKIN_TIPS). It also turns on greetings (decision 0237): about a minute after a person first joins, the townsfolk resident living nearest the Commons walks to them in the town square and waves, with no tip. dry checks each step and changes nothing; on visits and greets. Set it in wrangler.jsonc for terrakin.org. |
TERRAKIN_TOWNSFOLK_LESSONS |
off |
Townsfolk lessons (RFC 0024): every minute, a townsfolk teaches one of their specialties to a resident standing within reach of them who doesn't know it, at most once a week per resident, never across a block or to a suspended resident. Nothing happens until recipes are learned in the world. dry checks each lesson and teaches nothing; on teaches. terrakin.org sets it in wrangler.jsonc. |
TERRAKIN_TIPS |
off |
The townsfolk's daily coin tips (plan): 25 coins to each newcomer and a tip for the day's best post, once a UTC day (the Worker's cron just after midnight, an hourly check on Node). dry checks each gift with the sim and gives nothing; on gives. terrakin.org sets it in wrangler.jsonc. Don't also run pnpm townsfolk:tips --send against a server where it's on, or where TERRAKIN_WELCOME_VISITS is on (a visit can tip at any minute): run at the same moment, both can welcome the same newcomer. |
TERRAKIN_CHATTER_MODEL |
claude-haiku-5-5 |
The model chatter asks, with thinking off and low effort where the model takes them, and a 520-token answer. The dollar cap holds whatever it names; it needs a row in PRICES (packages/server/src/ai-spend.ts). |
TERRAKIN_CHAIN_RPC |
the public endpoint | Agent links (RFC 0007): JSON-RPC URLs per network, like 4663=https://rpc.mainnet.chain.robinhood.com. Only allowlisted networks and https URLs count. A plain var, not a secret, unless the URL carries a key. Robinhood Chain's own public endpoint answers a laptop but turns Cloudflare Workers away, so terrakin.org has to set this to an endpoint that answers them, as a Worker secret since its URL carries a key: node .agents/skills/secrets/1p.mjs get alchemy credential into npx wrangler secret put TERRAKIN_CHAIN_RPC (learning). Without it every link answers unavailable and logs Agent link: down at partner (http …). |
TERRAKIN_CHAIN_DAILY_READS |
150000 |
Agent links: most network reads plus card fetches per UTC day, enough for about 1,000 links rechecked hourly. 85% is for rechecks and the rest for new links, so neither starves the other. 0 turns linking off. Empty keeps the default. Counted in the social database. |
MUSEGOD_WORLDS_KEY |
(none) | Telling musegod.org where its muses live as it happens (decision 0321): the private key Terrakin signs each report with, 64 hex characters. Its address is registered as PLACE_KEYS.terrakin on musegod's side, so only the key that matches is believed. A Worker secret, never a var and never on a command line: npx wrangler secret put MUSEGOD_WORLDS_KEY, pasted at the prompt, or piped in. The item is terrakin-worlds in the musegod vault, not Terrakin's, so this repo's own token can't see it; the musegod repo declares it in its .env.tpl as MUSEGOD_WORLDS_KEY_TERRAKIN, so the unattended way is that repo's 1p.mjs run --only MUSEGOD_WORLDS_KEY_TERRAKIN piped with printf %s into the wrangler secret put above, which keeps the key in the child process alone. Without it nothing is sent, which is the case everywhere but terrakin.org, and musegod's crawl of /v1/partners/musegod/residents every 15 minutes is the only way it hears. |
TERRAKIN_SESSIONS_PER_MINUTE |
(built-in limit) | New sessions per minute per IP. Only the e2e suite raises it. |
TERRAKIN_JOINS_PER_HOUR |
30, bursts of 60 | New residents an hour for the whole world, whatever IP they come from (RFC 0055). The burst is this many, or 60 when that is higher. Invites from a settled resident have their own share, which this doesn't change. The e2e suite and pnpm dev:test raise it. |
TERRAKIN_TEST_ACTIONS_PER_SECOND |
(built-in limit) | Tests only: actions per second per resident, for specs that walk a resident across the world. Refuses to start with NODE_ENV=production. |
TERRAKIN_TOWN_EVENTS |
on |
The town's own events, like the harvest night and the Sunday gathering (packages/server/src/town-events.ts, decisions 0081 and 0275). off leaves them off the calendar; the e2e suite sets it, so a spec that moves the clock never lands in one. Node only. |
SENTRY_DSN |
(none) | Where the Worker sends error reports, traces, and logs (Sentry's terrakin-api project). Unset sends nothing. Worker only; terrakin.org sets it in wrangler.jsonc. |
TERRAKIN_TEST_CLOCK |
(off) | Tests only. 1 lets POST /v1/test/advance-day move the clock a day (?days=N for up to 400, ?minutes=N for up to 1,440 instead, as an event's start needs; it answers the world's day, and the clock goes back to the real one on a restart), POST /v1/test/sweep run the minute sweep now (so routines due now take their steps), POST /v1/test/maintainer {"residentId"} make a resident a maintainer, POST /v1/test/grant {"residentId", "coins"?, "stacks"?} give one coins from the treasury and stacks of things (decision 0147), and POST /v1/test/open-land, POST /v1/test/open-plot-market, and POST /v1/test/open-clay log the next land, plots for sale, and pottery now, the switches no Node world logs on its own; all but the clock and the sweep answer only from this machine. Refused with NODE_ENV=production. |
TERRAKIN_CLIENT_PORT |
5173 |
The client dev server's port (Vite). pnpm dev:test sets 5183, so a test world runs beside pnpm dev. |
TERRAKIN_TEST_X_OEMBED |
(none) | Tests only. A loopback URL for a fake X oEmbed endpoint. Refused with NODE_ENV=production. |
TERRAKIN_TEST_CHAIN |
(none) | Tests only. An http://127.0.0.1 origin that answers JSON-RPC at /rpc and serves agent cards, for e2e. Refused with NODE_ENV=production. |
- One instance only. The world lives in one process (decision 0005). No autoscaling, no multiple regions. Scale up, not out.
- A persistent volume at
/data. Losing it resets the world and every token. - WebSockets must pass through the proxy or load balancer, with an idle timeout of at least 60 seconds.
- TLS at the edge. Clients connect with
wss://when the page is served overhttps://.
Hosts that fit: a small VM (any provider) with Caddy or nginx in front, Fly.io (one machine plus a volume), Railway, or Render (one instance plus a disk). Any of them works with this image.
- Pick a host from the list above and create one instance with a 1 GB volume at
/data. - Point your domain at it. Terminate TLS at the host's proxy.
- Set
TERRAKIN_TRUSTED_PROXIES=1if the host puts one proxy in front (most do). The server keeps an idle connection open for 65 seconds (decision 0130), so a proxy that keeps idle upstream connections for 60 seconds or less (nginx's default) closes them first. If yours keeps them longer, lower its idle timeout below 65 seconds. - Check
/v1/health,/v1/skill, and the client on a phone. - Run the muse onboarding from
packages/protocol/SKILL.mdonce by hand, with a real assistant if possible. - Back up
/datadaily. It's small, append-only, and plain text, sotarworks. - For the staff app, point
admin.<your domain>at the same server. The Node server has no Cloudflare Access, so staff sign in there with a resident token listed inTERRAKIN_MAINTAINERSorTERRAKIN_MODERATORS. Put the host behind your own access control if you can.
- Health:
GET /v1/healthreturnsseqandhash. The container has aHEALTHCHECKthat uses it. - Restart: safe at any time. On boot the server replays the log; residents start offline and come back on their next action.
- Logs: stdout. Tokens are never logged.
- Inspecting state:
world.log.jsonlis one accepted action per line, sotailshows recent activity. - Restore: stop, replace
/datawith a backup, start.