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}
version: 1is required.projectlabels every instance forps --project,clockanddown; it defaults to the file’s directory name.- Each service is one
rystic run -d, and its name is the instance name. - A service key is the
runflag of the same name,_for-:product,version,port,scenario,seed,runner,network,seed_rng,egress_retain,speed,clockand the restrystic help uplists.versiondefaults to the installed build; a product not installed yet is pulled. envis a map, one--env KEY=VALUEeach.argsis a list, passed after--.seed_rngandegress_retaintake strings: quote a number, or the file is refused.- The top level also takes
clockandspeedfor 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
-dreturns once every service serves. Without it,upholds the terminal and Ctrl-C isdown.upagain printsstripe: already running (http://localhost:8080)per service instead of restarting.rystic up -d stripe slackstarts only those services.-f <file>names another stack file;rystic down --project design-studioworks 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.
--advancetakes whole seconds:90s,15m,48h;-1hmoves back.--set 2026-04-01T09:00:00Zpins every clock to one instant.- It reads the project from
./rystic.yaml, or-f <file>, or--project <p>. - Records you create are stamped with the new time: a Stripe customer’s
created, a Slack message’sts. - It posts
/_rystic/clockto 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
clock(RFC3339) stands in for the clock each world names: the world’s records keep their dates, its timeline counts from the new start, andPOST /_rystic/resetreturns to it.speedis simulated seconds per wall second for every service:speed: 60runs an hour a minute,0keeps the clock where you put it.- A service’s own
clockorspeedwins over the top level. POST /_rystic/scenarioloads a world at that world’s own clock;rystic clock --setbrings 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 | 30 clients as customers and 269 charges; every payment intent names its WDC invoice |
| PayPal | the orders its PayPal clients paid; invoice_id is the WDC invoice number |
| Resend | 595 emails: invoices, receipts and overdue reminders |
| Slack | the studio’s workspace, with a channel per client project |
| Okta | 33 active users and 3 deprovisioned ex-staff: the Slack workspace’s people, by the same email |
| Lob | 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:
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
- Export needs no Docker. Each
image:isregistry.rystic.ai/<product>@sha256:…, looked up over HTTPS with your license file. - A service without
versionexports the installed build; with neither, export exits 2. - Each service gets
RYSTIC_LICENSE: ${RYSTIC_LICENSE},init: true,hostnameset to its name, and its keys as variables:scenario→RYSTIC_SCENARIO,clock→RYSTIC_CLOCK,speed→RYSTIC_PACE,egress_retain→RYSTIC_EGRESS_RETAIN.argsbecome thecommand. - Without a license file, export warns and pins by tag;
--no-digestdoes that on purpose. On an API key, the container readsRYSTIC_TOKENinstead: 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.
| Stack | Simulators (world) | For |
|---|---|---|
| stripe-ci | Stripe (design_studio) | one simulator behind a CI job |
| 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 | 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
- Control plane and plans —
/_rystic/clock,/_rystic/reset, timelines. - Docker — the same simulators as containers.
- Integration tests in CI — a simulator as a GitHub Actions service.