Skip to Content
Control plane and plans

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:

FeatureUnlocksFreeProEnterprise
noneversion, routes, resetyesyesyes
state_viewinspect, registers, egress, ws, eventsnoyesyes
state_setseed, clock, seed-rng, egress-retain; boot --seednoyesyes
scenariosscenarios, scenario; boot --scenario naming another worldnoyesyes
faultsnetwork, ws-drop; boot RYSTIC_NETWORKnoyesyes
ui/_rystic/uinoyesyes
replay/_replay/* (Kalshi tapes and live feed)noyesyes

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

RouteMethodFeatureFreeProEnterpriseWhat it does
/_rystic/versionGETnoneyesyesyesIdentity 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/routesGETnoneyesyesyesEvery 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/resetPOSTnoneyesyesyesBack to the plan’s baseline, answering 204: Free re-applies its world; Pro and Enterprise wipe to empty. See What each plan boots.
/_rystic/inspectGETstate_view403yesyesThe entire state, every register, as JSON.
/_rystic/registersGETstate_view403yesyesEvery seedable register: id, kind, key_fields, fields, seed_via, and owner_field for owner-scoped ones.
/_rystic/egressGETstate_view403yesyesThe 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/wsGETstate_view403yesyesOpen WebSocket connections and their subscriptions (Kalshi’s feed).
/_rystic/eventsGETstate_view403yesyesA Server-Sent Events stream of requests, control calls and effects. Resume with Last-Event-ID or ?cursor=. rystic tail reads it.
/_rystic/seedPOSTstate_set403yesyesMerge 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/clockPOSTstate_set403yesyes{"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-rngPOSTstate_set403yesyes{"seed":N} pins the random stream, so ids and draws repeat run to run; 204.
/_rystic/egress-retainPOSTstate_set403yesyes{"retain":N} bounds the egress log (0 is unbounded); 204. Frames already dropped stay dropped.
/_rystic/scenariosGETscenarios403yesyesThe named worlds built into this binary: {"scenarios":[{"name","summary","frames","loop"}]}.
/_rystic/scenarioPOSTscenarios403yesyes{"name":"<world>"} resets, then applies the world; 204. An unknown name answers 404 listing the ones that exist.
/_rystic/networkGET, POSTfaults403yesyesGET returns {enabled, source, profile}. POST sets latency and rejection per endpoint; 204. See Network profiles.
/_rystic/ws-dropPOSTfaults403yesyesCut 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 belowGETui403yesyesThe state UI, in a browser.
/_replay/*GET, POSTreplay403yesyesKalshi’s tape player and live feed: markets, status, stream (GET); prepare, start, play, pause, seek, feed (POST); watch (both). Kalshi → Replay.
/_rystic/activityGETnoneloopbackloopbackloopbackActivity counters for the CLI on the same machine; any other caller gets 403.
  1. A route that changes state takes only the method listed; any other answers 405 with an Allow header, so a browser prefetch of GET /_rystic/reset wipes nothing. Read routes take any method.
  2. Network profiles never shape the control plane, the WebSocket feed or the replay routes.
  3. 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.

InputNeedsOn Free
no --scenario, or --scenario / RYSTIC_SCENARIO naming the plan’s worldnothingboots that world
--scenario / RYSTIC_SCENARIO naming any other worldscenariosthe 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 setsstate_setRYSTIC_SEED needs the "state_set" feature, …
RYSTIC_NETWORK, any value including off (run --network sets it)faultsRYSTIC_NETWORK needs the "faults" feature, …
run --seed-rng Nstate_setthe CLI posts /_rystic/seed-rng after start; Free refuses it and the RNG stays unpinned, without an error from run
run --tape, --copies, --livereplaythe 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

SimulatorFree boots, and reset restoresPro and Enterprise, no --scenarioPro and Enterprise reset
github-pulls-twingithub-pulls_open_queue: four open pull requests on tb-owner/tb-repoemptyempty
kalshi-twinbtc15m_tape: a recorded KXBTC15M order book, replayed on the exchange clock and loopingempty: no markets until you load a worldempty
lob-twindesign_studioemptyempty
paypal-twindesign_studioemptyempty
resend-twindesign_studioemptyempty
slack-twindesign_studio: auth.test answers as the bot studiobotempty: auth.test answers invalid_authempty
stripe-twindesign_studioemptyempty

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.

  1. Free has no route to another world, so POST /_rystic/reset re-applies the plan’s world. POST /_rystic/scenario answers 403, even for that world.
  2. Pro and Enterprise boot empty unless --scenario, RYSTIC_SCENARIO, --seed or RYSTIC_SEED says otherwise. Name the Free world, --scenario design_studio (or github-pulls_open_queue, btc15m_tape), and the same command line boots the same state on every plan.
  3. reset on Pro and Enterprise wipes to empty. It does not re-apply the boot --scenario; POST /_rystic/scenario loads a world again.
  4. On every plan, reset clears every register, restarts id sequences and the egress seq at 1, and reseeds the RNG from the last pinned seed.
  5. reset pins the clock to initial_clock (shown on GET /_rystic/version), not to a clock the boot world set. Stripe’s design_studio starts at 2026-03-01; after a Pro or Enterprise reset the clock reads initial_clock until you load the world again. Free’s reset re-applies its world, and the world’s clock with it.
  6. reset stops a scenario’s tape, Kalshi’s btc15m_tape among them (on Free, the re-applied world starts it again). A tape started with POST /_replay/start or run --tape is not a scenario’s: it keeps playing through a reset and goes on moving its market. POST /_replay/pause stops it.
  7. A network profile a scenario installed goes with reset. One you posted to /_rystic/network, or loaded from RYSTIC_NETWORK or a boot seed file, stays; {"enabled":false} switches it off.
  8. 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
  1. Keys are METHOD /template exactly as GET /_rystic/routes returns them: POST /v1/charges, GET /v1/customers/{customer}. An endpoint without a key uses default, or nothing.
  2. in delays before the handler runs; out delays after state has changed, before the client sees the answer. base_ms ± jitter_ms, or a quantiles_ms table (p50 required, p90 and p99 optional); correlation in [0,1) makes consecutive delays drift together instead of jumping independently.
  3. reject answers rate (0 to 1) of requests with status and exactly the body (JSON) or body_text you give, application/json or text/plain unless content_type says otherwise. The handler never runs and no state moves. Delays still apply. It sets no other headers, so there is no Retry-After.
  4. Deterministic. Delays and rejections draw from their own seeded stream: the same seed-rng and 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.
  5. Lifetime. A posted profile lasts until you post another or restart, and reset does not remove it; {"enabled":false} switches it off and keeps it. A profile a scenario carries (Kalshi’s rate_limited, brownout_503, …) ends at the next reset or 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:

ValueEffect
offshaping off; a built-in profile stays installed, inactive
onturns on the built-in profile (Kalshi); does nothing on a simulator without one
a file pathloads 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

AskHowPlan
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.entitlementsFree and Pro (Enterprise: everything)
Which worlds does this build ship?curl -s localhost:$PORT/_rystic/scenariosPro, Enterprise
The same, with no simulator running and no keythe binary’s --list-scenarios flag, belowevery 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/registersPro, 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 pageevery 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.

VariableDefaultMeaning
RYSTIC_ADDR:8080listen address; rystic run sets it from --port
RYSTIC_SCENARIOunsetboot world; --scenario wins. Gated: Boot inputs
RYSTIC_SEEDunsetboot seed or scenario file, applied after --seed. Needs state_set
RYSTIC_NETWORKunsetoff, on, or a profile file (At boot). Needs faults
RYSTIC_EGRESS_RETAIN100000egress frames kept before the oldest drop; 0 is unbounded
RYSTIC_EVENTSprivate,writes,controlevents printed to stdout: on, off, all, or a list of private, public, writes, reads, control; --events wins
RYSTIC_SEED_GATEwarnreject refuses a seed with missing required fields instead of serving it with a warning; off skips the check
RYSTIC_GATEWAY_TOKENunsetwhen 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_URLSunset1 keeps the vendor’s hostnames in URLs the simulator returns, for a gateway that routes those hosts to it
RYSTIC_TOKENunsetFree and Pro: the API key the simulator registers with
RYSTIC_REGISTER_WAIT10mhow long a Free or Pro simulator retries reaching Rystic at start; 0 tries once
RYSTIC_LEASE_CACHEyour config directory’s rystic/leaseswhere the last lease is kept, which allows one offline start
RYSTIC_LEASE_URLhttps://lease.rystic.aithe lease server
RYSTIC_LEASE_VERBOSEunsettrue prints a line per heartbeat; rystic run -v sets it
RYSTIC_LICENSE./rystic-license.jsonEnterprise: 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:

FlagMeaning
--scenario <name>boot world
--seed <file>boot seed or scenario file
--addr <host:port>listen address
--events <scope>stdout event scope
--list-scenariosprint the worlds and exit
--versionprint the identity and exit
--health[=<addr>]probe a running simulator and exit 0 if it answers; the image’s HEALTHCHECK
Last updated on