# Running a shared simulator

One simulator for many clients or agents — lock the control plane with an admin token, log every change, and expose it on a network.

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

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

```bash
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](https://www.rystic.ai/docs/control-plane.md#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](#file-urls)) |
| the vendor API | open |

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.

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

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

```json
{"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](#many-clients-one-simulator) 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 |

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.

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

```bash
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](https://www.rystic.ai/docs/control-plane.md#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 |

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.

```bash
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](https://www.rystic.ai/docs/simulators/slack.md#identities), [Kalshi's multiple bots](https://www.rystic.ai/docs/simulators/kalshi.md#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/`:

```json
{"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`](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) expires it at once.

[Notion → file uploads](https://www.rystic.ai/docs/simulators/notion.md#moves-templates-async-tasks-and-file-uploads) has the upload calls.

---

Source: https://www.rystic.ai/docs/shared-simulators · Markdown: https://www.rystic.ai/docs/shared-simulators.md
