Reference
Control plane and plans
Beside the vendor’s API, every simulator serves a control plane under /_rystic/, plus /_replay/ on Kalshi. The routes are the same on every simulator; your plan decides which of them answer. Plans and API keys covers prices and credentials. This page lists every route, what each plan boots, the network profile format, and how to find out what a running simulator can do.
Each control route belongs to one feature:
| Feature | Unlocks | Free | Pro | Enterprise |
|---|---|---|---|---|
| none | version, routes, reset | yes | yes | yes |
state_view | inspect, registers, egress, ws, events | no | yes | yes |
state_set | seed, clock, seed-rng, egress-retain; boot --seed | no | yes | yes |
scenarios | scenarios, scenario; boot --scenario naming another world | no | yes | yes |
faults | network, ws-drop; boot RYSTIC_NETWORK | no | yes | yes |
ui | /_rystic/ui | no | yes | yes |
replay | /_replay/* (Kalshi tapes and live feed) | no | yes | yes |
A Free or Pro simulator learns its plan from the lease Rystic signs when it starts and renews as it checks in every 30 seconds; a renewal that changes the plan applies from the next request, with no restart. GET /_rystic/version shows the grant under lease.entitlements.features: empty on Free, ["*"] (every feature) on Pro. An Enterprise build runs on a license file, holds no lease, and serves every route.
A route your plan lacks answers 403 before it reads the method or the body:
{"message":"/_rystic/seed needs the \"state_set\" feature, which this plan does not include; upgrade at https://www.rystic.ai/pricing","feature":"state_set","upgrade_url":"https://www.rystic.ai/pricing"}
A path no route names answers 404 with unknown control endpoint <path> on every plan, so a typo still reads as a typo.
Routes by plan
| Route | Method | Feature | Free | Pro | Enterprise | What it does |
|---|---|---|---|---|---|---|
/_rystic/version | GET | none | yes | yes | yes | Identity and health: product, version, commit, model_hash, contract, edition (metered on Free and Pro, empty on Enterprise), network state, egress_retain, initial_clock. Adds lease (session, expiry, entitlements, any notice) on Free and Pro, and licensee and license_expires on Enterprise. A stopping simulator keeps answering here with stopping and the reason. |
/_rystic/routes | GET | none | yes | yes | yes | Every vendor API route this build serves, as {"method","template"} rows; an alias row names its target. The control plane is not in it: this table is its list. |
/_rystic/reset | POST | none | yes | yes | yes | Back to the plan’s baseline, answering 204: Free re-applies its world; Pro and Enterprise wipe to empty. See What each plan boots. |
/_rystic/inspect | GET | state_view | 403 | yes | yes | The entire state, every register, as JSON. |
/_rystic/registers | GET | state_view | 403 | yes | yes | Every seedable register: id, kind, key_fields, fields, seed_via, and owner_field for owner-scoped ones. |
/_rystic/egress | GET | state_view | 403 | yes | yes | The webhooks or WebSocket frames your requests produced, in order, as {events, total, dropped}. ?tail=N returns the last N. Webhooks are never delivered, so assert on them here; Kalshi’s WebSocket frames also go out to the connections subscribed to them. |
/_rystic/ws | GET | state_view | 403 | yes | yes | Open WebSocket connections and their subscriptions (Kalshi’s feed). |
/_rystic/events | GET | state_view | 403 | yes | yes | A Server-Sent Events stream of requests, control calls and effects. Resume with Last-Event-ID or ?cursor=. rystic tail reads it. |
/_rystic/seed | POST | state_set | 403 | yes | yes | Merge a register map, {"<register>":{"<key>":{"<field>":…}}}, into state; 204. 400 lists invalid or incomplete records. Takes the map, not a scenario file’s wrapper. |
/_rystic/clock | POST | state_set | 403 | yes | yes | {"time":"<RFC3339>"} sets the clock, {"advance_seconds":N} moves it; 204. The clock does not follow wall time. The one thing that moves it on its own is a playing Kalshi tape (btc15m_tape, or one started with /_replay/start or run --tape): it re-pins the clock to the tape’s time with every batch, over any time you set. |
/_rystic/seed-rng | POST | state_set | 403 | yes | yes | {"seed":N} pins the random stream, so ids and draws repeat run to run; 204. |
/_rystic/egress-retain | POST | state_set | 403 | yes | yes | {"retain":N} bounds the egress log (0 is unbounded); 204. Frames already dropped stay dropped. |
/_rystic/scenarios | GET | scenarios | 403 | yes | yes | The named worlds built into this binary: {"scenarios":[{"name","summary","frames","loop"}]}. |
/_rystic/scenario | POST | scenarios | 403 | yes | yes | {"name":"<world>"} resets, then applies the world; 204. An unknown name answers 404 listing the ones that exist. |
/_rystic/network | GET, POST | faults | 403 | yes | yes | GET returns {enabled, source, profile}. POST sets latency and rejection per endpoint; 204. See Network profiles. |
/_rystic/ws-drop | POST | faults | 403 | yes | yes | Cut WebSocket connections: {"all":true}, {"connections":[id,…]} or {"tickers":["…"]}, plus "graceful":true to send a 1001 close frame first instead of just cutting the socket; answers {"dropped":[<connection id>,…]}, the ids it cut ([] when none matched). |
/_rystic/ui and below | GET | ui | 403 | yes | yes | The state UI, in a browser. |
/_replay/* | GET, POST | replay | 403 | yes | yes | Kalshi’s tape player and live feed: markets, status, stream (GET); prepare, start, play, pause, seek, feed (POST); watch (both). Kalshi → Replay. |
/_rystic/activity | GET | none | loopback | loopback | loopback | Activity counters for the CLI on the same machine; any other caller gets 403. |
- A route that changes state takes only the method listed; any other answers
405with anAllowheader, so a browser prefetch ofGET /_rystic/resetwipes nothing. Read routes take any method. - Network profiles never shape the control plane, the WebSocket feed or the replay routes.
- Each simulator’s page names its own worlds and registers.
Boot inputs
What a simulator starts with is gated like the routes. On Free, a boot input the plan lacks stops the simulator before it serves, with the same sentence a route refusal carries, and rystic run exits non-zero. Enterprise has no gate.
| Input | Needs | On Free |
|---|---|---|
no --scenario, or --scenario / RYSTIC_SCENARIO naming the plan’s world | nothing | boots that world |
--scenario / RYSTIC_SCENARIO naming any other world | scenarios | the boot scenario "tight_book" needs the "scenarios" feature, which this plan does not include; upgrade at https://www.rystic.ai/pricing |
the binary’s own --seed <file> (a bare binary or container command) | state_set | --seed needs the "state_set" feature, … |
RYSTIC_SEED, which rystic run --seed sets | state_set | RYSTIC_SEED needs the "state_set" feature, … |
RYSTIC_NETWORK, any value including off (run --network sets it) | faults | RYSTIC_NETWORK needs the "faults" feature, … |
run --seed-rng N | state_set | the CLI posts /_rystic/seed-rng after start; Free refuses it and the RNG stays unpinned, without an error from run |
run --tape, --copies, --live | replay | the start fails with exit 7: …/_replay/prepare answered 403 Forbidden (or …start answered 403) |
RYSTIC_EGRESS_RETAIN, RYSTIC_EVENTS / --events and RYSTIC_SEED_GATE are not gated.
A boot seed file (--seed, RYSTIC_SEED) is either a bare register map, exactly what POST /_rystic/seed takes, or a scenario file, marked by its top-level seed key: {"seed":{…},"clock":"<RFC3339>","rng_seed":N,"network":{…}}. clock, rng_seed and network are optional, and any other key is ignored. network takes the POST /_rystic/network body (enabled and/or profile, not a bare profile) and installs it as if you had posted it, so it outlasts reset.
What each plan boots
| Simulator | Free boots, and reset restores | Pro and Enterprise, no --scenario | Pro and Enterprise reset |
|---|---|---|---|
github-pulls-twin | github-pulls_open_queue: four open pull requests on tb-owner/tb-repo | empty | empty |
kalshi-twin | btc15m_tape: a recorded KXBTC15M order book, replayed on the exchange clock and looping | empty: no markets until you load a world | empty |
lob-twin | design_studio | empty | empty |
paypal-twin | design_studio | empty | empty |
resend-twin | design_studio | empty | empty |
slack-twin | design_studio: auth.test answers as the bot studiobot | empty: auth.test answers invalid_auth | empty |
stripe-twin | design_studio | empty | empty |
design_studio is one company, Website Design Contractors, on every simulator that carries it: its Stripe customers, PayPal orders, Resend emails, Lob postcards and Slack channels name the same clients and invoices.
- Free has no route to another world, so
POST /_rystic/resetre-applies the plan’s world.POST /_rystic/scenarioanswers403, even for that world. - Pro and Enterprise boot empty unless
--scenario,RYSTIC_SCENARIO,--seedorRYSTIC_SEEDsays otherwise. Name the Free world,--scenario design_studio(orgithub-pulls_open_queue,btc15m_tape), and the same command line boots the same state on every plan. reseton Pro and Enterprise wipes to empty. It does not re-apply the boot--scenario;POST /_rystic/scenarioloads a world again.- On every plan,
resetclears every register, restarts id sequences and the egressseqat 1, and reseeds the RNG from the last pinned seed. resetpins the clock toinitial_clock(shown onGET /_rystic/version), not to a clock the boot world set. Stripe’sdesign_studiostarts at 2026-03-01; after a Pro or Enterprise reset the clock readsinitial_clockuntil you load the world again. Free’s reset re-applies its world, and the world’s clock with it.resetstops a scenario’s tape, Kalshi’sbtc15m_tapeamong them (on Free, the re-applied world starts it again). A tape started withPOST /_replay/startorrun --tapeis not a scenario’s: it keeps playing through a reset and goes on moving its market.POST /_replay/pausestops it.- A network profile a scenario installed goes with
reset. One you posted to/_rystic/network, or loaded fromRYSTIC_NETWORKor a boot seed file, stays;{"enabled":false}switches it off. - Restarting the process discards everything; state lives in memory.
Network profiles and fault injection
Pro and Enterprise. A network profile adds latency and refuses a fraction of requests, per endpoint, inside the simulator’s own HTTP path. Shaping is off by default and costs nothing until enabled.
Kalshi ships a measured profile for nine endpoints, off by default: RYSTIC_NETWORK=on or POST /_rystic/network with {"enabled":true} turns it on. No other simulator ships one, so there {"enabled":true} alone answers 400 cannot enable shaping: no network profile installed; post a profile.
The profile
POST /_rystic/network
{
"enabled": true, // optional; at least one of enabled / profile
"profile": { // optional; replaces the active profile whole
"id": "any-name",
"default": SHAPE, // optional; any endpoint not listed below
"endpoints": {
"POST /v1/charges": SHAPE // "METHOD template", exactly as GET /_rystic/routes lists it
}
}
}
SHAPE = { "in": DELAY, "out": DELAY, "reject": REJECT } // each optional
DELAY = { "base_ms": 120, "jitter_ms": 40, "correlation": 0.3 }
| { "quantiles_ms": { "p50": 90, "p90": 180, "p99": 400 }, "correlation": 0.3 }
REJECT = { "rate": 0.25, "status": 429, "body": { … } } // or "body_text": "…"; "content_type" overrides
- Keys are
METHOD /templateexactly asGET /_rystic/routesreturns them:POST /v1/charges,GET /v1/customers/{customer}. An endpoint without a key usesdefault, or nothing. indelays before the handler runs;outdelays after state has changed, before the client sees the answer.base_ms±jitter_ms, or aquantiles_mstable (p50required,p90andp99optional);correlationin[0,1)makes consecutive delays drift together instead of jumping independently.rejectanswersrate(0 to 1) of requests withstatusand exactly thebody(JSON) orbody_textyou give,application/jsonortext/plainunlesscontent_typesays otherwise. The handler never runs and no state moves. Delays still apply. It sets no other headers, so there is noRetry-After.- Deterministic. Delays and rejections draw from their own seeded stream: the same
seed-rngand the same profile refuse the same requests in the same order on every run, and shaping never shifts the ids or values the vendor routes draw. - Lifetime. A posted profile lasts until you post another or restart, and
resetdoes not remove it;{"enabled":false}switches it off and keeps it. A profile a scenario carries (Kalshi’srate_limited,brownout_503, …) ends at the nextresetor scenario.
Worked example: throttle Stripe charges with 429s
Start Stripe on a free port and keep the port in PORT (pull first, so run prints nothing but the JSON):
rystic pull stripe-twin
PORT=$(rystic run stripe-twin -d --name faults --port 0 --scenario design_studio --json | jq -r .data.port)
Refuse half of POST /v1/charges with a Stripe-shaped rate-limit error, and add 120 ± 40 ms on the way back:
curl -s -X POST "localhost:$PORT/_rystic/network" -H 'content-type: application/json' -d '{
"enabled": true,
"profile": {
"id": "charges-throttled",
"endpoints": {
"POST /v1/charges": {
"reject": {
"rate": 0.5,
"status": 429,
"body": {"error": {"type": "invalid_request_error", "code": "rate_limit", "message": "Request rate limit exceeded."}}
},
"out": {"base_ms": 120, "jitter_ms": 40}
}
}
}
}'
# answers 204
curl -s "localhost:$PORT/_rystic/network" | jq -c '{enabled, source}'
# {"enabled":true,"source":"control"}
Send ten charges and count the answers:
for i in $(seq 10); do
curl -s -o /dev/null -w '%{http_code}\n' "localhost:$PORT/v1/charges" -u sk_test_twin: \
-d amount=2000 -d currency=usd -d source=tok_visa
done | sort | uniq -c
# 6 200
# 4 429 (a rate of 0.5 is a draw per request, not every other one; the same six pass on every run)
Only the 200s created charges. Point your client at localhost:$PORT and check that it backs off and retries. Then switch shaping off, since reset keeps a posted profile, and wipe the charges:
curl -s -X POST "localhost:$PORT/_rystic/network" -d '{"enabled":false}' # shaping off; the profile stays installed
curl -s -X POST "localhost:$PORT/_rystic/reset" # wipes the state; shaping stays off
rystic stop faults
A 503 brownout is the same shape with an HTML page: "reject": {"rate": 1, "status": 503, "body_text": "<html>…</html>", "content_type": "text/html"}.
At boot
rystic run <product> --network <value>, or RYSTIC_NETWORK=<value> for a container or the binary:
| Value | Effect |
|---|---|
off | shaping off; a built-in profile stays installed, inactive |
on | turns on the built-in profile (Kalshi); does nothing on a simulator without one |
| a file path | loads the file. A bare profile, one with an endpoints key and neither profile nor enabled, is installed and enabled. Any other file is read as the POST body above: {"profile":{…}} without "enabled":true installs with shaping off, and a profile with a default but no endpoints key refuses the boot with network body must set enabled and/or profile (add "endpoints":{}, or wrap it as {"enabled":true,"profile":{…}}) |
On Free, any value refuses the boot.
Discovering routes and scenarios
| Ask | How | Plan |
|---|---|---|
| Which vendor routes does this build serve? | curl -s localhost:$PORT/_rystic/routes | jq -r '.routes[] | "\(.method) \(.template)"' | every plan |
| What does my plan unlock? | curl -s localhost:$PORT/_rystic/version | jq .lease.entitlements | Free and Pro (Enterprise: everything) |
| Which worlds does this build ship? | curl -s localhost:$PORT/_rystic/scenarios | Pro, Enterprise |
| The same, with no simulator running and no key | the binary’s --list-scenarios flag, below | every plan |
| What does each world contain? | rystic docs <product> scenarios prints the installed archive’s SCENARIOS.md (every simulator but Stripe ships one) | every plan |
| What can I seed? | curl -s localhost:$PORT/_rystic/registers | Pro, Enterprise |
| How do I drive this simulator? | rystic docs <product>: the installed HOWTO.md plus the live routes of each running instance, else the site’s page | every plan |
| Where do the docs mention X? | rystic docs search <terms> | every plan |
| What features does the installed build declare? | rystic features [-v] <product> (-v adds the control-plane URLs) | every plan |
| Which flags does a command take? | rystic help <command> (on stdout; add 2>&1 on an older CLI) | every plan |
--list-scenarios reads the menu from the binary itself, before any license or lease check:
"$HOME/.rystic/twins/kalshi-twin/$(cat ~/.rystic/twins/kalshi-twin/current)/kalshi-twin" --list-scenarios
# 47 scenarios (run one with --scenario <name>):
#
# arb_closes a live 3-leg arb that re-prices away 3 seconds in — get all legs or none [tape: 1 frames]
# …
docker run --rm registry.rystic.ai/stripe-twin:<version> rystic-twin --list-scenarios
rystic docs works offline from the site’s pages as of your CLI’s release, built into it, so a page newer than your CLI is not there; rystic docs -online reads the live set. Every page is also Markdown at its URL plus .md, and llms.txt indexes them.
Environment variables
Every simulator binary reads these, and the image passes them through. rystic run sets the first four from its flags. The CLI’s own variables are on the CLI page.
| Variable | Default | Meaning |
|---|---|---|
RYSTIC_ADDR | :8080 | listen address; rystic run sets it from --port |
RYSTIC_SCENARIO | unset | boot world; --scenario wins. Gated: Boot inputs |
RYSTIC_SEED | unset | boot seed or scenario file, applied after --seed. Needs state_set |
RYSTIC_NETWORK | unset | off, on, or a profile file (At boot). Needs faults |
RYSTIC_EGRESS_RETAIN | 100000 | egress frames kept before the oldest drop; 0 is unbounded |
RYSTIC_EVENTS | private,writes,control | events printed to stdout: on, off, all, or a list of private, public, writes, reads, control; --events wins |
RYSTIC_SEED_GATE | warn | reject refuses a seed with missing required fields instead of serving it with a warning; off skips the check |
RYSTIC_GATEWAY_TOKEN | unset | when set, every request, vendor and control alike, must carry X-Rystic-Gateway-Token with this value or gets 403; for a simulator behind your own proxy on a network agents can reach. rystic run’s readiness probe and the image’s HEALTHCHECK send no header, so use it with the bare binary, or a container with a health check of its own, not rystic run (run -d times out, exit 7) |
RYSTIC_PRESERVE_SERVICE_URLS | unset | 1 keeps the vendor’s hostnames in URLs the simulator returns, for a gateway that routes those hosts to it |
RYSTIC_TOKEN | unset | Free and Pro: the API key the simulator registers with |
RYSTIC_REGISTER_WAIT | 10m | how long a Free or Pro simulator retries reaching Rystic at start; 0 tries once |
RYSTIC_LEASE_CACHE | your config directory’s rystic/leases | where the last lease is kept, which allows one offline start |
RYSTIC_LEASE_URL | https://lease.rystic.ai | the lease server |
RYSTIC_LEASE_VERBOSE | unset | true prints a line per heartbeat; rystic run -v sets it |
RYSTIC_LICENSE | ./rystic-license.json | Enterprise: a license file path, or the license itself, JSON or base64 |
Kalshi adds its replay, live-feed and key variables: Kalshi → Flags and environment.
The binary also takes these flags, with one or two leading hyphens, as --flag value or --flag=value:
| Flag | Meaning |
|---|---|
--scenario <name> | boot world |
--seed <file> | boot seed or scenario file |
--addr <host:port> | listen address |
--events <scope> | stdout event scope |
--list-scenarios | print the worlds and exit |
--version | print the identity and exit |
--health[=<addr>] | probe a running simulator and exit 0 if it answers; the image’s HEALTHCHECK |