Skip to Content
Running a shared simulator

Guides

Running a shared simulator

For one simulator that many clients or agents call: a shared dev box, a CI service, or a simulator behind your own proxy. The clients get the vendor API. You keep the control plane (reset, seed, clock, inspect) and a log of every change.

Set it up

export RYSTIC_ADMIN_TOKEN=$(openssl rand -hex 16)   # keep this; clients never need it
export RYSTIC_ACTION_LOG=$PWD/actions.jsonl
rystic run stripe-twin -d --name shared --port 8080 --scenario design_studio
stripe-twin 0.0.11 (model 92e15804) → http://localhost:8080  [native shared]

Clients get http://<this host>:8080 and a key of their own. The native runner, rystic run’s default, hands both variables to the simulator.

The admin token

With RYSTIC_ADMIN_TOKEN set, every control route needs it in an X-Rystic-Admin-Token header. The vendor API does not.

curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/_rystic/reset
# -> 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/_rystic/reset -H 'X-Rystic-Admin-Token: wrong'
# -> 403
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/_rystic/reset -H "X-Rystic-Admin-Token: $RYSTIC_ADMIN_TOKEN"
# -> 204
curl -s localhost:8080/v1/customers -u sk_test_agent: | jq .object
# -> "list"
RouteWith the token set
/_rystic/*: every route in Routes by plan, the state UI includedneeds the token
a simulator’s own control routes: Kalshi’s /_replay/*, /_settle and /_account/snapshot, Stripe’s /_dispute, Resend’s /_bounce, Lob’s /_tracking_eventneeds the token
GET /_rystic/versionopen: the readiness probe
/_rystic/files/…open: the signature is the credential (File URLs)
the vendor APIopen
  1. No token answers 401 with the body Unauthorized; a wrong one answers 403.
  2. A browser sends the token as a rystic_admin_token cookie, for the state UI at /_rystic/ui/.
  3. Unset, every control route is open.

From the CLI

rystic reads RYSTIC_ADMIN_TOKEN from its own environment:

  1. rystic run hands it to the simulator it starts.
  2. The CLI adds the header to its own calls to the simulator, such as rystic tail and run --seed-rng, on localhost and loopback addresses.
  3. For a simulator on another host, list that host in RYSTIC_ADMIN_TOKEN_HOSTS, comma-separated host or host:port. The CLI sends the token to no other host.
export RYSTIC_ADMIN_TOKEN=…  RYSTIC_ADMIN_TOKEN_HOSTS=sims.internal:8080
rystic tail --addr http://sims.internal:8080
# -> 00:00:00.000 CTL reset

Without the host in the list, or without the token:

rystic: cannot reach twin at http://sims.internal:8080: 401 Unauthorized from http://sims.internal:8080/_rystic/events

Export the token. rystic run --env RYSTIC_ADMIN_TOKEN=… hands it to the simulator only, so the CLI’s own calls, such as --seed-rng, are refused.

The action log

RYSTIC_ACTION_LOG=<path> appends one JSON line per state change to that file. After the setup, the three resets above and one write:

curl -s localhost:8080/v1/customers -u sk_test_agent: -d email=ada@example.com > /dev/null
jq -c '{seq, kind, op: .data.op, path: .data.path, status: .data.status}' actions.jsonl
# -> {"seq":1,"kind":"control","op":"reset","path":null,"status":null}
# -> {"seq":2,"kind":"control","op":"scenario","path":null,"status":null}
# -> {"seq":3,"kind":"request","op":null,"path":"/_rystic/reset","status":401}
# -> {"seq":4,"kind":"request","op":null,"path":"/_rystic/reset","status":403}
# -> {"seq":5,"kind":"control","op":"reset","path":null,"status":null}
# -> {"seq":6,"kind":"request","op":null,"path":"/_rystic/reset","status":204}
# -> {"seq":7,"kind":"request","op":null,"path":"/v1/customers","status":200}

What it logs:

  1. Every request but GET, HEAD and OPTIONS, on the vendor API and the control plane, refused ones included.
  2. Every control operation, the boot’s reset and scenario included, as a control line. On Kalshi, a playing tape adds a control line with op replay per step.
  3. No reads.

A request line:

{"seq":7,"kind":"request","wall":"2026-10-13T16:29:49.720243Z","received":"2026-10-13T16:29:49.719984Z","sim_clock":"2026-01-01T00:00:00Z",
 "data":{"method":"POST","path":"/v1/customers","template":"/v1/customers","actor":"member","status":200,
         "request":{"email":["ada@example.com"]},"response":{"id":"cus_…","object":"customer",…}}}
FieldWhat it is
seq1, 2, 3… for this process
kindrequest or control
wallwall time the line was written, UTC
receivedrequest lines: wall time the request arrived
sim_clockthe simulator’s clock
data.method, data.path, data.query, data.statusthe request and the status it got
data.template, data.actorvendor routes: the route it matched and the actor that sent it
data.request, data.responsethe bodies: a form as its fields, JSON as JSON, anything else as text; past 64 KiB, cut and marked …[truncated]
data.op, data.detailcontrol lines: the operation and its arguments
  1. Headers are never copied, so neither the admin token nor Authorization reaches the file.
  2. The caller’s API key is replaced by [redacted] wherever it appears in a body or query. On Stripe, that is a key sent as Authorization: Bearer; a key sent with curl -u is not redacted.
  3. A /_rystic/* request body (a seed, a schedule, credential bindings) is logged as {"omitted_bytes":N,"sha256":"…"}, not copied.
  4. The file is created readable by you only. A restart appends to it, with seq starting again at 1.
  5. A path the simulator can’t open stops it at boot with panic: rystic: RYSTIC_ACTION_LOG: open …: no such file or directory; rystic run -d exits 7.

Read it over HTTP

GET /_rystic/actions serves this process’s lines as application/x-ndjson. It needs the admin token when one is set.

curl -s -D - "localhost:8080/_rystic/actions?since=1&limit=1" -H "X-Rystic-Admin-Token: $RYSTIC_ADMIN_TOKEN"
# -> X-Rystic-More: true
# -> X-Rystic-Next-Since: 2
# -> {"data":{"detail":{"frames":0,"name":"design_studio"},"op":"scenario"},"kind":"control","seq":2,…}
  1. since is the last seq you have, default 0; pass X-Rystic-Next-Since as the next one. X-Rystic-More: true means another page waits.
  2. limit is 1–10000, default 1000.
  3. With no action log, it answers 404 {"message":"the action log is off; set RYSTIC_ACTION_LOG to a file path"}.

Behind your own proxy

RYSTIC_GATEWAY_TOKEN makes every request carry X-Rystic-Gateway-Token; the CLI page has what it does to rystic run and the image’s health check. With both tokens set, the proxy adds the gateway header to everything, and you add the admin header on top:

Request carries/_rystic/versionvendor APIcontrol routes
nothing403403403
the gateway token200served401
the admin token only403403403
both200servedserved
RYSTIC_GATEWAY_TOKEN=… RYSTIC_ADMIN_TOKEN=… \
  ~/.rystic/twins/stripe-twin/0.0.11/stripe-twin --addr 127.0.0.1:8080

The binary needs its API key (RYSTIC_TOKEN) or license (RYSTIC_LICENSE): Environment variables.

Listen address

Started withListens on
rystic run --port 8080every interface, *:8080
the binary, --addr 127.0.0.1:8080that address only
the binary, RYSTIC_ADDR=127.0.0.1:8080that address only; --addr wins over it
  1. rystic run listens on every interface, even with --env RYSTIC_ADDR=…. Anyone who can reach the port reaches the control plane unless the admin token is set.
  2. To listen on one interface, run the binary with --addr. Its log names the address: rystic: stripe-twin 0.0.11 listening on 127.0.0.1:8080 (model …).
  3. Notion’s file and upload URLs name the host the client sent in Host, and https when the request carries X-Forwarded-Proto: https. A proxy in front must pass the original Host through.

Many clients, one simulator

Every caller acts as member until you say otherwise. Two ways to give each client its own actor:

  1. X-Rystic-Actor: <label> on the request. It wins over everything else.
  2. A credential binding: the client changes nothing but its key.
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/_rystic/credentials \
  -H "X-Rystic-Admin-Token: $RYSTIC_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"bindings":{"sk_test_agent_a":"agent_a","sk_test_agent_b":"agent_b"}}'
# -> 204
curl -s localhost:8080/v1/customers -H 'Authorization: Bearer sk_test_agent_a' > /dev/null
curl -s localhost:8080/v1/customers -H 'Authorization: Bearer sk_test_agent_b' > /dev/null
curl -s localhost:8080/_rystic/calls -H "X-Rystic-Admin-Token: $RYSTIC_ADMIN_TOKEN" \
  | jq -c '.calls[-2:][] | {actor, method, path, status}'
# -> {"actor":"agent_a","method":"GET","path":"/v1/customers","status":200}
# -> {"actor":"agent_b","method":"GET","path":"/v1/customers","status":200}
  1. A posted set of bindings replaces the last one. A reset clears them; a key with no binding acts as member.
  2. On Stripe, a binding matches a key sent as Authorization: Bearer, the way Stripe’s libraries send it. curl -u sk_test_agent_a: acts as member.
  3. GET /_rystic/calls lists every request since the last reset, reads included, with its actor and principal: the user the actor is in the loaded world, empty when it is none. The action log’s data.actor names the same actor.
  4. What an actor sees is each simulator’s own rule: Slack identities, Kalshi’s multiple bots, which also reads KALSHI-ACCESS-KEY.

File URLs

The Notion simulator serves the files it stores from itself. An uploaded file, once attached to a page, reads back with a signed URL under /_rystic/files/:

{"type":"file","file":{"url":"http://localhost:8080/_rystic/files/notes.txt?expires=1769907600003&key=…&resource=state_file_upload&signature=…","expiry_time":"2026-02-01T01:00:00.003Z"}}
  1. It expires one hour after the read that returned it, on the simulator’s clock: expiry_time says when. Every read returns a fresh one.
  2. It needs no admin token. Anyone holding the URL can fetch the file until it expires.
  3. GET and HEAD only; anything else answers 405.
  4. An expired URL answers 403 {"message":"file URL expired at 2026-02-01T01:00:00.004Z"}; an edited one, 403 {"message":"file URL signature does not match"}.
  5. Moving the clock past expiry_time with POST /_rystic/clock expires it at once.

Notion → file uploads has the upload calls.

Last updated on