# Control plane and plans

Every /_rystic route and the plan that serves it, what each plan boots and resets to, network fault profiles, and how to discover a simulator's routes and worlds.

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](https://www.rystic.ai/docs/licensing.md#plans) 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:

```json
{"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](#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](#network-profiles-and-fault-injection). |
| `/_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](https://www.rystic.ai/docs/simulators/kalshi.md#replay-a-captured-market-day). |
| `/_rystic/activity` | `GET` | none | loopback | loopback | loopback | Activity 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.

| 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.

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

```text
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):

```bash
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:

```bash
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:

```bash
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 `200`s 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:

```bash
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:

```bash
"$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](https://www.rystic.ai/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](https://www.rystic.ai/docs/cli.md#files-and-environment).

| Variable | Default | Meaning |
|---|---|---|
| `RYSTIC_ADDR` | `:8080` | listen address; `rystic run` sets it from `--port` |
| `RYSTIC_SCENARIO` | unset | boot world; `--scenario` wins. Gated: [Boot inputs](#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](#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](https://www.rystic.ai/docs/licensing.md#offline-and-outages) |
| `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](https://www.rystic.ai/docs/simulators/kalshi.md#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` |

---

Source: https://www.rystic.ai/docs/control-plane · Markdown: https://www.rystic.ai/docs/control-plane.md
