# Multi-simulator environments

Run several simulators as one project from a rystic.yaml — start and stop them together, move their clocks together, export the stack to Compose.

One `rystic.yaml` names every simulator your code talks to. `rystic up` starts them as one project, `rystic clock` moves all their clocks at once, and `rystic export compose` turns the same file into a Compose stack. Every flag is in the [CLI flag reference](https://www.rystic.ai/docs/cli-reference.md#up); the [Docker builder](https://www.rystic.ai/docs/docker-builder.md) writes the file for you.

## The stack file

```yaml
# rystic.yaml
version: 1
project: design-studio
services:
  stripe: {product: stripe-twin, port: 8080, scenario: design_studio}
  paypal: {product: paypal-twin, port: 8081, scenario: design_studio}
  slack:  {product: slack-twin,  port: 8082, scenario: design_studio}
  resend: {product: resend-twin, port: 8083, scenario: design_studio}
  okta:   {product: okta-twin,   port: 8084, scenario: design_studio}
  lob:    {product: lob-twin,    port: 8085, scenario: design_studio}
```

1. `version: 1` is required. `project` labels every instance for `ps --project`, `clock` and `down`; it defaults to the file's directory name.
2. Each service is one `rystic run -d`, and its name is the instance name.
3. A service key is the `run` flag of the same name, `_` for `-`: `product`, `version`, `port`, `scenario`, `seed`, `runner`, `network`, `seed_rng`, `egress_retain`, `speed`, `clock` and the rest `rystic help up` lists. `version` defaults to the installed build; a product not installed yet is pulled.
4. `env` is a map, one `--env KEY=VALUE` each. `args` is a list, passed after `--`.
5. `seed_rng` and `egress_retain` take strings: quote a number, or the file is refused.
6. The top level also takes `clock` and `speed` for every service: [One clock](#one-clock).

The same keys reach the simulator as they would from `run`:

```yaml
  stripe: {product: stripe-twin, port: 8080, scenario: design_studio, egress_retain: "500", args: ["--events=all"]}
```

```bash
curl -s localhost:8080/_rystic/version | jq .egress_retain
# -> 500
```

A malformed file exits 2 and names the problem:

```
rystic: rystic.yaml: service stripe: unknown key "scenaro" (keys: args, clock, copies, egress_retain, env, …)
rystic: rystic.yaml: service stripe: a stack file never carries a license — `rystic login <file>` activates one for this machine, and every twin finds it
```

## Start, list, stop

In the folder that holds `rystic.yaml`:

```bash
rystic up -d
# -> lob-twin 0.0.8 (model …) → http://localhost:8085  [native lob]
# -> … one line per service
rystic ps --project design-studio
# -> NAME PRODUCT VERSION RUNNER URL MODEL LICENSEE UPTIME STATUS — one row per service, running
rystic down
# -> stopped lob
# -> … one line per service
```

1. `-d` returns once every service serves. Without it, `up` holds the terminal and Ctrl-C is `down`.
2. `up` again prints `stripe: already running (http://localhost:8080)` per service instead of restarting.
3. `rystic up -d stripe slack` starts only those services.
4. `-f <file>` names another stack file; `rystic down --project design-studio` works from any folder.

## One clock

Every `design_studio` world boots at `2026-03-01T00:00:00Z`. Moving the clock moves every simulator in the project:

```bash
rystic clock --advance 1h
# -> NAME    PRODUCT      CLOCK
# -> lob     lob-twin     2026-03-01T01:00:00Z
# -> okta    okta-twin    2026-03-01T01:00:00Z
# -> paypal  paypal-twin  2026-03-01T01:00:00Z
# -> …
```

The PayPal, Okta, Lob and Resend worlds carry a timeline that plays as the clock passes `2026-03-01`. One hour in, Okta has added Hannah Kim to Content and PayPal has opened an order for invoice WDC-1298:

```bash
curl -s localhost:8084/api/v1/users/00uFmE5mnLllb4P4sys9/groups -H 'Authorization: SSWS x' | jq -c '[.[].profile.name]'
# -> ["Everyone","Development","Content"]

curl -s localhost:8081/v2/checkout/orders/ORD00000000000001 -H 'Authorization: Bearer x' | jq -c '{status, invoice: .purchase_units[0].invoice_id}'
# -> {"status":"CREATED","invoice":"WDC-1298"}
```

`GET /_rystic/inject` on each lists its events and when they fired. [Timelines](https://www.rystic.ai/docs/control-plane.md#timelines) has the event shape.

1. `--advance` takes whole seconds: `90s`, `15m`, `48h`; `-1h` moves back. `--set 2026-04-01T09:00:00Z` pins every clock to one instant.
2. It reads the project from `./rystic.yaml`, or `-f <file>`, or `--project <p>`.
3. Records you create are stamped with the new time: a Stripe customer's `created`, a Slack message's `ts`.
4. It posts [`/_rystic/clock`](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) to every running instance at once and prints each clock as it reads back. Nothing running under the project is exit 5.

### Start every simulator at the same instant

Worlds that aren't one company boot at different instants. In this stack GitHub's starts at `2026-02-01`, Linear's at `2026-01-20` and Slack's at `2026-03-01`; a top-level `clock` lines them up:

```yaml
# rystic.yaml
version: 1
project: coding-agent
clock: 2026-03-02T09:00:00Z
services:
  github: {product: github-pulls-twin, port: 8080, scenario: github-pulls_bot_pr_queue}
  linear: {product: linear-twin,       port: 8081, scenario: linear_triage_inbox}
  slack:  {product: slack-twin,        port: 8082, scenario: design_studio}
```

```bash
rystic up -d
rystic clock --advance 24h
# -> NAME    PRODUCT            CLOCK
# -> github  github-pulls-twin  2026-03-03T09:00:00Z
# -> linear  linear-twin        2026-03-03T09:00:00Z
# -> slack   slack-twin         2026-03-03T09:00:00Z

curl -s localhost:8080/repos/tb-owner/tb-repo/issues/11/comments -H 'Authorization: Bearer ghp_x' -d '{"body":"needs a test"}' | jq -r .created_at
# -> 2026-03-03T09:00:00Z
curl -s localhost:8082/api/chat.postMessage -H 'Authorization: Bearer xoxb-x' -d channel=CWDC0000001 -d text=shipped | jq -r '.ts | tonumber | floor | todate'
# -> 2026-03-03T09:00:00Z
```

1. `clock` (RFC3339) stands in for the clock each world names: the world's records keep their dates, its timeline counts from the new start, and `POST /_rystic/reset` returns to it.
2. `speed` is simulated seconds per wall second for every service: `speed: 60` runs an hour a minute, `0` keeps the clock where you put it.
3. A service's own `clock` or `speed` wins over the top level.
4. `POST /_rystic/scenario` loads a world at that world's own clock; `rystic clock --set` brings it back in line.

## One company across simulators

`design_studio` is one company in six simulators: Website Design Contractors (WDC), a web design studio in Portland, ME. Clients, people, invoice numbers and dates agree across all six.

| Simulator | What WDC holds there |
|---|---|
| [Stripe](https://www.rystic.ai/docs/simulators/stripe.md) | 30 clients as customers and 269 charges; every payment intent names its WDC invoice |
| [PayPal](https://www.rystic.ai/docs/simulators/paypal.md) | the orders its PayPal clients paid; `invoice_id` is the WDC invoice number |
| [Resend](https://www.rystic.ai/docs/simulators/resend.md) | 595 emails: invoices, receipts and overdue reminders |
| [Slack](https://www.rystic.ai/docs/simulators/slack.md) | the studio's workspace, with a channel per client project |
| [Okta](https://www.rystic.ai/docs/simulators/okta.md) | 33 active users and 3 deprovisioned ex-staff: the Slack workspace's people, by the same email |
| [Lob](https://www.rystic.ai/docs/simulators/lob.md) | 63 postcards to 39 addresses, tagged `metadata[client]` |

`rystic describe <product>` names each world; `rystic docs <product> scenarios` describes it. Ids are fixed, so these calls work on any machine.

The same invoice in Stripe and Resend:

```bash
curl -s localhost:8080/v1/payment_intents/pi_b8MAu55IELPL0e -u sk_test_x: | jq -c '{description, amount, status}'
# -> {"description":"Invoice WDC-1257: design stage","amount":1200000,"status":"requires_payment_method"}

curl -s localhost:8083/emails/32cad955-6ad1-48d1-aaf1-739f0e5a4fa2 -H 'Authorization: Bearer re_x' | jq -r .text
# -> Hi Deborah,
# -> Here is invoice WDC-1257 for Pickford Law (design stage).
# -> Amount due: $12,000.00. Due date: January 9, 2026.
# -> …
```

The same person in Okta and Slack:

```bash
curl -s -g 'localhost:8084/api/v1/users?search=profile.email%20eq%20%22hannah@wdcontractors.example%22' -H 'Authorization: SSWS x' | jq -c '.[0].profile | {firstName, lastName, title}'
# -> {"firstName":"Hannah","lastName":"Kim","title":"support and maintenance"}

curl -s 'localhost:8082/api/users.lookupByEmail?email=hannah@wdcontractors.example' -H 'Authorization: Bearer xoxb-x' | jq -c '.user | {id, real_name}'
# -> {"id":"UWDC0000027","real_name":"Hannah Kim"}
```

## Export to Compose

```bash
rystic export compose -o ./deploy
# -> wrote .env.example, docker-compose.yaml to ./deploy
cp deploy/.env.example deploy/.env      # then set RYSTIC_LICENSE
docker compose -f deploy/docker-compose.yaml up
```

1. Export needs no Docker. Each `image:` is `registry.rystic.ai/<product>@sha256:…`, looked up over HTTPS with your license file.
2. A service without `version` exports the installed build; with neither, export exits 2.
3. Each service gets `RYSTIC_LICENSE: ${RYSTIC_LICENSE}`, `init: true`, `hostname` set to its name, and its keys as variables: `scenario` → `RYSTIC_SCENARIO`, `clock` → `RYSTIC_CLOCK`, `speed` → `RYSTIC_PACE`, `egress_retain` → `RYSTIC_EGRESS_RETAIN`. `args` become the `command`.
4. Without a license file, export warns and pins by tag; `--no-digest` does that on purpose. On an API key, the container reads `RYSTIC_TOKEN` instead: [Docker](https://www.rystic.ai/docs/docker.md#pull).

`rystic export antithesis -o ./anti --tenant <t> --gcp-project <p>` writes the same compose with `platform: linux/amd64`, a `FROM scratch` `Dockerfile.config` and a README with the push and launch steps. `-o` is required. [Docker](https://www.rystic.ai/docs/docker.md#generate-the-compose-file) covers both.

## Example stacks

Four ready stacks in [rystic-scripts/environments](https://github.com/Rystic-Inc/rystic-scripts/tree/main/environments). Each folder holds the `rystic.yaml`, the `docker-compose.yaml` exported from it, and `.env.example`.

| Stack | Simulators (world) | For |
|---|---|---|
| [stripe-ci](https://github.com/Rystic-Inc/rystic-scripts/tree/main/environments/stripe-ci) | Stripe (`design_studio`) | one simulator behind a CI job |
| [kalshi-bot](https://github.com/Rystic-Inc/rystic-scripts/tree/main/environments/kalshi-bot) | Kalshi (`thin_book`) on 8080, Kalshi (`flash_crash`) on 8081 | one trading bot against a thin book and a flash crash at once |
| [coding-agent](https://github.com/Rystic-Inc/rystic-scripts/tree/main/environments/coding-agent) | GitHub (`github-pulls_bot_pr_queue`), Linear (`linear_triage_inbox`), Slack (`design_studio`) | a coding agent's pull requests, issue queue and team chat |
| [design-studio](https://github.com/Rystic-Inc/rystic-scripts/tree/main/environments/design-studio) | Stripe, PayPal, Slack, Resend, Okta, Lob (`design_studio`) | one company across six APIs |

The files pin a `version` per service; delete it to run your installed build.

## Next

1. [Control plane and plans](https://www.rystic.ai/docs/control-plane.md) — `/_rystic/clock`, `/_rystic/reset`, timelines.
2. [Docker](https://www.rystic.ai/docs/docker.md) — the same simulators as containers.
3. [Integration tests in CI](https://www.rystic.ai/docs/integration-testing.md) — a simulator as a GitHub Actions service.

---

Source: https://www.rystic.ai/docs/environments · Markdown: https://www.rystic.ai/docs/environments.md
