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"
| Route | With the token set |
|---|---|
/_rystic/*: every route in Routes by plan, the state UI included | needs the token |
a simulator’s own control routes: Kalshi’s /_replay/*, /_settle and /_account/snapshot, Stripe’s /_dispute, Resend’s /_bounce, Lob’s /_tracking_event | needs the token |
GET /_rystic/version | open: the readiness probe |
/_rystic/files/… | open: the signature is the credential (File URLs) |
| the vendor API | open |
- No token answers
401with the bodyUnauthorized; a wrong one answers403. - A browser sends the token as a
rystic_admin_tokencookie, for the state UI at/_rystic/ui/. - Unset, every control route is open.
From the CLI
rystic reads RYSTIC_ADMIN_TOKEN from its own environment:
rystic runhands it to the simulator it starts.- The CLI adds the header to its own calls to the simulator, such as
rystic tailandrun --seed-rng, onlocalhostand loopback addresses. - For a simulator on another host, list that host in
RYSTIC_ADMIN_TOKEN_HOSTS, comma-separatedhostorhost: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:
- Every request but
GET,HEADandOPTIONS, on the vendor API and the control plane, refused ones included. - Every control operation, the boot’s
resetandscenarioincluded, as acontrolline. On Kalshi, a playing tape adds acontrolline withopreplayper step. - 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",…}}}
| Field | What it is |
|---|---|
seq | 1, 2, 3… for this process |
kind | request or control |
wall | wall time the line was written, UTC |
received | request lines: wall time the request arrived |
sim_clock | the simulator’s clock |
data.method, data.path, data.query, data.status | the request and the status it got |
data.template, data.actor | vendor routes: the route it matched and the actor that sent it |
data.request, data.response | the bodies: a form as its fields, JSON as JSON, anything else as text; past 64 KiB, cut and marked …[truncated] |
data.op, data.detail | control lines: the operation and its arguments |
- Headers are never copied, so neither the admin token nor
Authorizationreaches the file. - The caller’s API key is replaced by
[redacted]wherever it appears in a body or query. On Stripe, that is a key sent asAuthorization: Bearer; a key sent withcurl -uis not redacted. - A
/_rystic/*request body (a seed, a schedule, credential bindings) is logged as{"omitted_bytes":N,"sha256":"…"}, not copied. - The file is created readable by you only. A restart appends to it, with
seqstarting again at1. - 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 -dexits 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,…}
sinceis the lastseqyou have, default0; passX-Rystic-Next-Sinceas the next one.X-Rystic-More: truemeans another page waits.limitis1–10000, default1000.- 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/version | vendor API | control routes |
|---|---|---|---|
| nothing | 403 | 403 | 403 |
| the gateway token | 200 | served | 401 |
| the admin token only | 403 | 403 | 403 |
| both | 200 | served | served |
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 with | Listens on |
|---|---|
rystic run --port 8080 | every interface, *:8080 |
the binary, --addr 127.0.0.1:8080 | that address only |
the binary, RYSTIC_ADDR=127.0.0.1:8080 | that address only; --addr wins over it |
rystic runlistens on every interface, even with--env RYSTIC_ADDR=…. Anyone who can reach the port reaches the control plane unless the admin token is set.- 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 …). - Notion’s file and upload URLs name the host the client sent in
Host, andhttpswhen the request carriesX-Forwarded-Proto: https. A proxy in front must pass the originalHostthrough.
Many clients, one simulator
Every caller acts as member until you say otherwise. Two ways to give each client its own actor:
X-Rystic-Actor: <label>on the request. It wins over everything else.- 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}
- A posted set of bindings replaces the last one. A
resetclears them; a key with no binding acts asmember. - 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 asmember. GET /_rystic/callslists every request since the last reset, reads included, with itsactorandprincipal: the user the actor is in the loaded world, empty when it is none. The action log’sdata.actornames the same actor.- 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"}}
- It expires one hour after the read that returned it, on the simulator’s clock:
expiry_timesays when. Every read returns a fresh one. - It needs no admin token. Anyone holding the URL can fetch the file until it expires.
GETandHEADonly; anything else answers405.- 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"}. - Moving the clock past
expiry_timewithPOST /_rystic/clockexpires it at once.
Notion → file uploads has the upload calls.