Skip to Content
Multi-simulator environments

Guides

Multi-simulator environments

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; the Docker builder writes the file for you.

The stack file

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

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

  stripe: {product: stripe-twin, port: 8080, scenario: design_studio, egress_retain: "500", args: ["--events=all"]}
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:

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:

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:

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

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

SimulatorWhat WDC holds there
Stripe30 clients as customers and 269 charges; every payment intent names its WDC invoice
PayPalthe orders its PayPal clients paid; invoice_id is the WDC invoice number
Resend595 emails: invoices, receipts and overdue reminders
Slackthe studio’s workspace, with a channel per client project
Okta33 active users and 3 deprovisioned ex-staff: the Slack workspace’s people, by the same email
Lob63 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:

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:

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

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.

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

Example stacks

Four ready stacks in rystic-scripts/environments . Each folder holds the rystic.yaml, the docker-compose.yaml exported from it, and .env.example.

StackSimulators (world)For
stripe-ci Stripe (design_studio)one simulator behind a CI job
kalshi-bot Kalshi (thin_book) on 8080, Kalshi (flash_crash) on 8081one trading bot against a thin book and a flash crash at once
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 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 — /_rystic/clock, /_rystic/reset, timelines.
  2. Docker — the same simulators as containers.
  3. Integration tests in CI — a simulator as a GitHub Actions service.
Last updated on