Deploying Worlds

Worlds is self-hosted: your laptop, your CI runner, or a box in your VPC. There is nothing to sign up for: Worlds needs no Sparta-hosted data plane and sends no telemetry. What else leaves the machine is yours to know — your model provider (and, with passk --judge or worlds judge, the judged worlds' customer records and the agent's replies — with worlds judge --transcript, the agent's transcript — which go to the model provider the judge calls), the webhook listener you configure, seed import and pnpm drift (which talk to real Stripe), and wherever you install from. This page is the three shapes that cover everyone, and the two settings that matter: where the server binds and whether the admin API wants a token.

The rule

bind host WORLDS_ADMIN_TOKEN admin auth
127.0.0.1 / localhost / ::1 unset off — anyone on this machine can drive worlds
loopback set required
anything else (0.0.0.0, a LAN/VPC address) unset required — the first start mints a token, prints it (that start only) and saves it to <data-dir>/operator-token (0600); every later start loads the file and prints its path, never the token
anything else set required
loopback, with WORLDS_TRUST_PROXY=1 / --trust-proxy unset required — declaring a proxy declares exposure; a token is minted, printed and kept as above

WORLDS_AUTH=required / --auth required forces it on; WORLDS_AUTH=off forces it off (the server prints a WARNING: line when that exposes it). Health (GET /admin/health) is always public and reports "auth": "off" | "required".

Two kinds of token (details in admin-api.md): the operator token (server-wide, the only thing that can create worlds) and world tokens (admin_token in every POST /admin/worlds response; drive that one world, including destroying it; mint/revoke more under /admin/worlds/:id/admin-tokens). A suite built on @worlds/client / worlds_client.py creates a world per test, so it needs the operator token; hand a world token only to a consumer of a world that already exists. Keep the operator token in a secret manager, and never give either to the agent — it gets the world's sk_twin_… key and base_url, nothing more (passk strips WORLDS_ADMIN_TOKEN from the agent's environment).

What this protects against

Admin auth off on a loopback bind is a convenience for trusted local processes, not a boundary: every process on the machine — the agent under test included, if it can open a socket — can reach /admin/*, create, reset or destroy worlds, and read a diff. Stripping WORLDS_ADMIN_TOKEN from the agent's environment (passk does) is hygiene — it keeps a credential out of a transcript — not isolation. The envelope Worlds supports is a disposable local or CI runtime, fresh worlds, and a trusted harness (program plan, decision D5); a profile that isolates a hostile agent from the admin API is a separate design, not something a token or a bind address gives you.

Shape 1 — laptop

pnpm dev                      # or: worlds serve
worlds world create --seed saas-billing-small

Loopback, no token, nothing to configure. worlds demo, worlds init, pnpm test, pnpm showcase all work like this.

Shape 2 — CI runner

The short form is one step: worlds run -- <your suite> or passk --server auto start the server inside the command on the runner, with an ephemeral token, and stop it after ("One terminal", below) — the workflow worlds init writes is that. The two-step form is the setup action (action/, program plan P5), for a suite that is more than one command: uses: Sparta-AI/twinlab/action@<ref> with install-from: https://twinlab-site.vercel.app/dl/sparta_worlds-<version>.tgz (a tarball path or a directory holding bin/worlds.js work too) starts the server for the job on the action runtime's own Node — never setup-node's — with an ephemeral operator token, a free port and a temp data directory, waits for /admin/health, masks the token and exports WORLDS_URL, WORLDS_ADMIN_TOKEN and WORLDS_PUBLIC_URL to every later step (url, admin-token, data-dir, evidence-dir, log and pid as outputs); its post step writes the evidence — the worlds still alive at teardown, each one's dump and request log, the server log — into evidence-dir, a job summary with the server log's tail, and stops the server (SIGTERM, five seconds, SIGKILL). Point passk's --evidence and an upload-artifact step with if: always() at evidence-dir and a failing run's evidence rides along with the server's. Inputs port, data-dir, seeds-dir, scenarios-dir and public-url are serve's; the version input is refused until the runtime has a public home (D1). examples/github-action/agent-tests.yml is the two-step form from a checkout of this repository (uses: ./action, the tarball staged in the job), worlds init --action <ref> writes it for a customer's repository, and this repository's ci.yml runs the action from the staged tarball on every push. The action is dependency-free (main.js, post.js), so a runner needs nothing installed first. If you run a server of your own instead, set WORLDS_ADMIN_TOKEN for both the server step and the test step — the CLI, @worlds/client, and worlds_client.py all read it.

Shape 3 — shared server in a VPC (or a container)

export WORLDS_ADMIN_TOKEN="$(openssl rand -hex 24)"   # or read it from your secret manager: the SAME value on both sides
docker run -d -p 4242:4242 -e WORLDS_ADMIN_TOKEN -v worlds-data:/data worlds
# the harness side, with the same variable:
curl -sf -X POST http://127.0.0.1:4242/admin/worlds \
  -H "authorization: Bearer $WORLDS_ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"seed":"saas-billing-small"}'          # → base_url, api_key for the agent, admin_token for this world

This is the shape CI runs (.github/workflows/ci.yml, job docker): the same literal (ci-smoke-operator-token) on both sides, an anonymous create refused with 401, the authenticated create returning base_url and a world token. Without Docker:

WORLDS_HOST=0.0.0.0 WORLDS_ADMIN_TOKEN=... WORLDS_DATA_DIR=/var/lib/worlds pnpm --filter @worlds/server start

The operator token is global authority, not tenant isolation: it creates worlds and reaches every world on the server — whoever can create a world can list and destroy every other. A world token scopes a consumer to one world; there is no project or team scope. Treat one server as one trust domain: give each team or job its own worlds and hand them the admin_token from the create response (or mint extra ones, and rotate by minting a new one and revoking the old), and give teams that must not see each other's worlds separate servers. Scoped creation and world leases are Stage S of the program plan, on demand.

The data directory (/data in the container, WORLDS_DATA_DIR otherwise) holds one SQLite file per world, and each file records the schema version that wrote it. World files are disposable and are not migrated: after upgrading Worlds, a directory written by another version lists those worlds under unreadable in GET /admin/worlds (the startup banner counts them; worlds world ls prints each with the reason) and refuses them everywhere else with a 409 — destroy them (worlds world destroy <id>) and create fresh ones. A file under worlds/ that is not a readable world file at all (not SQLite, truncated, zero bytes) is listed and refused the same way; the boot only peeks each file's header, so neither kind keeps the server from starting.

Run one server process per data directory: coordination is in-process (the per-world lock, the open-world cache, the in-flight counter), nothing on disk stops a second process, and two servers on one directory would each believe they own every world. The storage is disposable, not an audit log: world files are written with synchronous = OFF under WAL, so a crash can lose the last writes, and a file is never migrated across a schema version. Keep evidence by exporting it, not by keeping the volume — worlds world export (a seed file), GET …/dump, the diff JSON, and passk --json / --report (--evidence <dir> keeps the dump and request log of a run passk could not grade). Retention today: there is no TTL; a world stays on disk until DELETE /admin/worlds/:id; once opened it stays open in the process's cache until it is destroyed or the server exits; and GET /admin/worlds opens every world file in the directory to read it — a directory that accumulates thousands of retained worlds makes listing slow and holds a handle per world. Destroy worlds when a run is done (passk does; a suite's finally should). Leases, idle close, listing without opening and quotas are Stage S.

TLS: terminate at a reverse proxy

Worlds speaks plain HTTP. Put any TLS-terminating proxy in front and keep the Worlds port off the public network. Caddy (automatic certificates):

worlds.internal.example.com {
    reverse_proxy 127.0.0.1:4242
}

nginx:

server {
    listen 443 ssl;
    server_name worlds.internal.example.com;
    ssl_certificate     /etc/ssl/worlds.crt;
    ssl_certificate_key /etc/ssl/worlds.key;
    location / {
        proxy_pass http://127.0.0.1:4242;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;      # only read under WORLDS_TRUST_PROXY=1
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

With a proxy on the same host, bind Worlds to 127.0.0.1 and tell it the origin your agents dial, one of two ways. Recommended: pin itWORLDS_PUBLIC_URL=https://worlds.internal.example.com; every base_url is that origin and no header is trusted. Or derive it — forward X-Forwarded-Host and X-Forwarded-Proto (above) and set WORLDS_TRUST_PROXY=1; Worlds reads the first hop of each chain (the client-facing origin). Admin auth follows the bind, the token and the proxy flag — never the pinned URL: trusting a proxy counts as exposed and turns it on (the first start mints an operator token if none is set), while a pinned URL alone leaves a loopback bind unauthenticated. So on the recommended path set WORLDS_ADMIN_TOKEN (or WORLDS_AUTH=required) yourself before putting nginx in front; nothing is minted for you. Point clients at https://worlds.internal.example.com (WORLDS_URL). The proof that base_url is dialable is a Stripe SDK call through the proxy, not a health check:

WORLDS_URL=https://worlds.internal.example.com WORLDS_ADMIN_TOKEN=… worlds world create --seed saas-billing-small
const stripe = new Stripe(apiKey, { host: "worlds.internal.example.com", port: 443, protocol: "https" });
await stripe.customers.retrieve("cus_seed_dana");   // a 200 through the proxy, from the world just created — the proof that base_url is dialable

Compatibility

Both clients speak an admin protocol number and read the server's from GET /admin/health. createWorld() and listWorlds() run that check — one health call per client — and refuse another protocol with IncompatibleServerError (the message says which side to upgrade). A TwinWorld built directly from a world token — the consumer-of-an-existing-world shape this page recommends for teams that do not create worlds — makes no handshake: it must treat a non-empty diff.unknown_resources (a resource group the server sent that this client does not know) as "do not grade" — fail the run and upgrade client and server together, never assert "nothing else changed" over a diff with groups you cannot read. The Python client (worlds_client.py) behaves the same.

Checklist

  • Server bound to loopback behind a proxy, or 0.0.0.0 only inside a private network
  • WORLDS_ADMIN_TOKEN from a secret manager (16+ chars), or the auto-provisioned file kept on a volume
  • Jobs that create worlds hold the operator token from a secret manager; consumers of an existing world hold that world's token
  • Agents hold only base_url + api_key
  • GET /admin/health returns "auth": "required"
  • One server process per data directory
  • A Stripe SDK call through the proxy returned a world object (not just a 200 from /admin/health)

Platforms

Linux and macOS are where Worlds is developed and tested: the suite runs on macOS and CI runs it on ubuntu-latest. Windows is untested. The server should run there — Node 24+, no native addons — but nothing checks that it does, and worlds passk has one platform-specific piece: when a run times out or is interrupted, it kills the agent and everything the agent spawned. On Linux and macOS the agent is started as its own process group and the whole group is killed at once (kill(-pid)); on Windows passk calls taskkill /T /F on the agent, which walks the tree as best it can — best effort, not a guarantee. If your agent spawns helpers on Windows, expect to clean them up yourself.

Because the agent leads its own group, a signal the terminal sends to passk's job never reaches the agent — only passk can end it. So passk handles every signal that would otherwise end it: Ctrl-C (SIGINT, exit 130), a runner's SIGTERM (143), the terminal hanging up (SIGHUP, 129: a closed SSH session, a killed terminal window) and Ctrl-\ (SIGQUIT, 131). Each kills the agent's tree, destroys the current world, writes what was collected and exits 128 + the signal. A hangup is handled as a whole: when the terminal dies, its stdout and stderr die with it — every later write fails, EIO on a tty, EPIPE on a pipe (the same when a 2>&1 | tee is killed) — and passk holds that death instead of exiting on it: the lines it can no longer deliver go nowhere, the agent's own output (relayed to passk's stderr) is still consumed so the agent never blocks on a pipe nobody drains, the cleanup finishes, the exit code is still 128 + the signal. The hold is runPassk's own, so a program that calls it without the CLI (pnpm shift) gets the same policy. (With no passk in flight the CLI keeps a filter's manners and exits 0 quietly on EPIPE or EIO: worlds world ls | head -3.) The first lifecycle signal decides; a later one of the four is ignored while the cleanup runs — a second Ctrl-C changes nothing and Ctrl-\ is not an escape hatch — and any other signal keeps its default action, so a stuck cleanup (a server that never answers the DELETE) ends one of two ways: the 10 s hard deadline, exit 128 + the first signal with the world possibly left behind, or a SIGKILL from outside. Three caveats. An agent that starts its own session or group (setsid, a daemonizing library, detached: true of its own) has left passk's group and escapes the group kill — that is inherent to process groups, and such a process is yours to stop. nohup does not make passk outlive its terminal: Node resets the signal dispositions it inherits, so a SIGHUP that nohup set to ignored arrives anyway and is handled as above — running passk past its terminal takes setsid or a terminal multiplexer (tmux, screen). And on Windows all of this is best effort: there are no process groups and taskkill is what there is; SIGHUP is dispatched when the console window closes, but Windows terminates the process about 10 s later — the edge of the hard deadline — and SIGQUIT can be listened for but never fires (process.on accepts both; libuv defines them).

Building the release candidate

For contributors; nothing here publishes. The checkout runs TypeScript under tsx with no build step, and the release candidate is the one place code is emitted:

pnpm release:stage    # release/sparta_worlds (the runtime: one package, `sparta_worlds`, bin `worlds`) and release/client (@worlds/client)
pnpm release:smoke    # stage → npm pack → install the tarballs in throwaway projects outside the checkout — npm local,
                      #   npm global, a project already on zod@4, pnpm add — npx worlds serve, world create, a vitest
                      #   file over @worlds/client → the same with npm's registry unreachable

release/ is git-ignored. The runtime is a single installable tarball with nothing under node_modules/ and nothing bundled: packages/cli/src emitted to dist/cli/, packages/server/src emitted to server/dist/ inside the same package, the seed and scenario libraries at server/seeds/ and server/scenarios/ (beside the server's dist/, the first place core/paths.ts looks), the rubrics beside the CLI, Node ≥ 24. The CLI reaches the server through Node's package-internal alias — "imports": { "#server": "./server/dist/index.js" } in the staged manifest, "#server": "@worlds/server" in the checkout's packages/cli/package.json — so the emitted CLI is the checked CLI byte for byte and no @worlds/* name is ever looked up in a registry. The runtime manifest has no main and no exports, on purpose: worlds is a command, not a library. node_modules/twinlab/server/dist/index.js resolves today because nothing forbids the path, and nothing promises it — the library is @worlds/client. (The first cut shipped the server as a bundleDependencies entry; npm turns those into empty placeholder directories on a global or a nested install, and yarn resolves them against the registry — DECISIONS.md, Infra and CI, 2026-09-06.) The client is emitted JS plus .d.ts, zero dependencies, Node ≥ 18 — global fetch is its only requirement, so the server's floor is not the client's. One version, the root package.json's, is stamped into both manifests. Two stagings of the same checkout are byte-identical, trees and tarballs: file modes are normalized (644; 755 for the shim and directories) so the umask never reaches the tarball, and npm pins every entry's mtime.

Both manifests carry "private": true when staged by default, so npm publish refuses them by construction (the refusal is libnpmpublish's, on the real call; npm publish --dry-run does not exercise it and still contacts the registry, so it cannot demonstrate the gate) — pnpm release:stage -- --publishable omits private, and that is how the tarballs served from the site are cut: the served sparta_worlds-<version>.tgz is the publishable package, byte for byte the file npm publish takes ("Cutting a release", below). Their license is SEE LICENSE IN LICENSE.txt: the evaluation terms (owner decision D2, 2026-09-09 — evaluation use, not for redistribution, no warranty), one file copied verbatim from scripts/LICENSE.evaluation.txt into both packages, and the same line under the download on the quickstart. The runtime package is named sparta_worlds (owner decision D1, 2026-09-09): worlds is taken on npmjs by an unrelated package (0.0.0, June 2023), and a customer only ever sees the name in the tarball URL — the command inside stays worlds, because npx -p <url> names the package and worlds its bin. @worlds/client keeps its name for now (it is installed from a URL too; the unregistered @twinlab scope is not decided), so no registry command appears on any page.

What the artifacts never carry, enforced by packages/server/test/stage-release.test.ts on the staged trees and again by the smoke on the packed tarballs: research/, docs/ (the strategy document, the published results), any test/, fixtures/ or golden/ directory or *.test.* file, scripts/, examples/, .ts sources (only .js and .d.ts ship), the server's test harness, tsx, and anything under a node_modules/. Comments are stripped from the runtime emit (they cite reviews and repo paths) and kept in the client's .d.ts, whose JSDoc is its API documentation.

CI ran the smoke in the packed job and kept the tarballs as a workflow artifact for 14 days, until GitHub Actions was switched off for the repository (2026-09-15); a local release/ is the only place a candidate exists until a release is cut.

Cutting a release

One version everywhere, then the served tarballs, then the publish:

  1. Bump version in the four manifests (the root, packages/cli, packages/server, packages/client-ts) and the version the quickstart's commands carry (commands.test.ts pins them to each other); the unversioned /dl/ redirects in site/vercel.json; the get-started page's sample caption if the sample was re-run.
  2. pnpm release:stage -- --publishable, then npm pack each staged package into site/public/dl/ with a .sha256 sidecar beside it (shasum -a 256). The served tarball is the publishable package: no private field, the evaluation license inside.
  3. pnpm release:smoke and pnpm release:smoke -- --from-dir site/public/dl (every quickstart command, verbatim, the registry spec standing for the served tarball), pnpm verify, pnpm site:build; the PR.
  4. Publish, the owner's step: from an npm account that owns sparta_worlds, npm publish site/public/dl/sparta_worlds-<version>.tgz --access public. The same file the site serves, so npx -p sparta_worlds@<version> and npx -p https://twinlab-site.vercel.app/dl/sparta_worlds-<version>.tgz run the same bytes. Until that command has run, the commands on the page name a version the registry does not hold. The client (@worlds/client) stays served as a tarball: its scope is not settled.

One terminal: worlds run and --server auto

worlds run [--seed <name>] [--evidence <dir>] [--port <n>] [--timeout <sec>] [--keep] [--no-preload] -- <command…> starts a server in its own process on a free port (or the one asked for, refused with port 4242 is in use by pid N when it is taken), mints an ephemeral operator token and runs auth as required so the token the command receives is real, runs the command with WORLDS_URL, WORLDS_ADMIN_TOKEN and WORLDS_PUBLIC_URL in its environment (with --seed, one world's WORLDS_BASE_URL, WORLDS_API_KEY and WORLDS_WORLD_ID too), and when the command fails exports its evidence — the world list, every world's dump and request log, the run's own log — to --evidence <dir> or a temp directory named on stderr; then the server is closed, the data directory removed unless --keep (--keep-worlds on passk and try keeps it too, with the worlds' files in it; worlds serve --data-dir <dir> reopens them), and the command's exit code returned (124 on --timeout, 128 + the signal when Ctrl-C or a runner's SIGTERM ended it; both reach the command's whole tree). passk --server auto and try --server auto are the same server around themselves, which is what worlds init's workflow and harness use: a CI job needs no start step, no health loop, no teardown and no token. The setup action (P5) is a thin caller of this command.

The result contract

Every passk, try, selftest and doctor result carries one of five statuses with a reason and a next action — status, reason, next_action and exit_code in the JSON, two lines (status:, next:) at the end of the text, a line in the report's header — and the exit code is the status's: 0 passed; 1 policy_failed (the world's records failed the key or an expectation, or the agent exited non-zero after reaching the twin; the worst task's replay line is the next action); 2 configuration_incomplete (the agent command is still init's placeholder — node agent.js with no agent.js here — or cannot be started; a bad flag, tasks file or rubric); 3 setup_failed (no server at --url, a seed the server does not hold, a world it could not prepare); 4 inconclusive (a keyed run — a tasks file or a rubric — whose request log saw no agent traffic in any world, so the agent never reached the twin; an agent that called a route the twin does not implement or sent a parameter it refuses, so the world's state is not the agent's outcome; a server without before-images; a grading that threw; a run a signal cut short, whose exit stays 128 + the signal). Precedence when several apply: setup, then configuration, then inconclusive, then policy failed. worlds run uses the same codes for its own failures (3 for the port, the directories or a server that never answered; 2 for a seed it cannot create or a command it cannot spawn) and otherwise returns the command's. pnpm shift and the demo kit read the same codes and are untouched by them.

The registry door, and the pinned tarball

A customer never installs the runtime: they run it from the registry at a version, npx -p sparta_worlds@<version> worlds serve (the whole path is quickstart.md). npm fetches the package, installs its third-party ranges beside it in the npx cache, and runs the worlds bin from there — no package.json in the customer's directory, and never a bare npx worlds, which is a stranger's package (owner decision D1). The version is in every command on purpose: the same command gives the same bytes on every machine. The same release is served from the site as https://twinlab-site.vercel.app/dl/sparta_worlds-<version>.tgz with its sha256 beside it, for a machine that must never touch the registry; npm keys its cache by that URL, so a rolling /dl/sparta_worlds.tgz would keep handing some machines the copy they fetched first, and the site serves the unversioned name only as a redirect for humans. What worlds init writes into the harness and the workflow is the CLI's own package and version (WORLDS_RUNTIME: sparta_worlds@<version>), which starts the server inside the CI job with npx -y -p "$WORLDS_RUNTIME" worlds …-y so a runner never waits on the install prompt; worlds init --tarball <url> (or --runtime <spec>) pins the tarball URL or another version instead.