Concepts
Worlds and seeding
A simulator boots empty unless you give it a starting state: a named world built into the binary, a seed of your own, or both. The examples use the Lob simulator; every simulator takes the same calls.
Named worlds
rystic pull lob-twin
PORT=$(rystic run lob-twin -d --name worlds --port 0 --scenario design_studio --json | jq -r .data.port)
curl -s localhost:$PORT/_rystic/scenarios | jq -c '.scenarios[] | {name, frames, loop}'
# -> {"name":"design_studio","frames":0,"loop":null}
- Without
--scenario(orRYSTIC_SCENARIO), a simulator boots empty:GET /_rystic/inspectanswers{}, or only the records the service always holds, such as Okta’s Everyone group. POST /_rystic/scenarioresets, then loads a world, with no restart. A world loaded this way holds exactly the state the same world booted with--scenarioholds.
curl -s -X POST localhost:$PORT/_rystic/scenario -d '{"name":"design_studio"}'
# answers 204
An unknown name answers 404, naming the worlds the build has:
{"message":"unknown scenario \"design-studio\" (have: design_studio)"}
List the worlds
| Ask | How |
|---|---|
| A running simulator’s worlds | curl -s localhost:$PORT/_rystic/scenarios |
| The same, without starting one | rystic run lob-twin --name menu --port 0 -- --list-scenarios |
| What each world holds | rystic docs lob-twin scenarios prints the installed SCENARIOS.md; Stripe ships none |
| Worlds, registers and operations at once | rystic describe lob-twin (below) |
Discovering routes and scenarios covers the container image and the bare binary.
The shared studio
design_studio is one company, Website Design Contractors, on Lob, Okta, PayPal, Resend, Slack and Stripe. Environments runs them together.
A world’s timeline
A world can carry events that fire as its clock passes them: Lob, Okta, PayPal and Resend’s design_studio do. The clock stays put until you move it (Pacing):
curl -s -X POST localhost:$PORT/_rystic/clock -d '{"advance_seconds":3600}'
curl -s localhost:$PORT/_rystic/inject | jq -c '.events[0] | {id, fired_at, status}'
# -> {"id":"harborview-second-office","fired_at":"2026-03-01T01:00:00Z","status":200}
Timelines has the event format.
Seeding
POST /_rystic/seed lands records directly, without calling the API. It answers 204.
curl -s -X POST localhost:$PORT/_rystic/seed -d '{"state_address": {"adr_home": {"id": "adr_home",
"object": "address", "name": "HOME", "address_line1": "185 BERRY ST",
"address_city": "SAN FRANCISCO", "address_state": "CA", "address_zip": "94107"}}}'
curl -s -u test_x: localhost:$PORT/addresses/adr_home | jq -c '{id, name}'
# -> {"id":"adr_home","name":"HOME"}
Two shapes
- Register map:
{"<register>": {"<key>": {<record>}}}, as above. - World document: the register map under
seed, beside optionalclock,rng_seed,network,credentialsandevents. Other keys are ignored, so expected outcomes can live in the same file. Boot inputs describes each key.
cat > world.json <<'EOF'
{
"seed": {"state_address": {"adr_home": {"id": "adr_home", "object": "address", "name": "HOME",
"address_line1": "185 BERRY ST", "address_city": "SAN FRANCISCO", "address_state": "CA", "address_zip": "94107"}}},
"clock": "2026-07-23T00:00:00Z",
"expect": {"postcards": 1}
}
EOF
curl -s -X POST localhost:$PORT/_rystic/reset
curl -s -X POST localhost:$PORT/_rystic/seed --data-binary @world.json
# answers 204; the clock now reads 2026-07-23
Either shape also boots: rystic run lob-twin --seed world.json, or RYSTIC_SEED for a container.
How a seed lands
- A seed merges into what the simulator holds; it does not reset first. A new key adds a record.
- An existing key merges field by field:
{"adr_home": {"name": "HOME 2"}}changes the name and keeps the address. A nested object, such asmetadata, replaces the old one whole. --scenariowith--seedapplies the world first, then merges the file on top.GET /_rystic/inspectreturns the state as a register map, so a dump posted back after a reset restores it.
What a seed refuses
A refusal answers 400 with {"message", "errors"}, and nothing lands. Only these are refused:
-
An unknown register. The error names every register the simulator has:
{"errors":[{"register":"state_adress","error":"unknown register \"state_adress\"; this twin has: state_address, state_idempotency_key, state_postcard"}],"message":"seed rejected: 1 invalid entries"} -
A record that is not a JSON object:
record "<key>" must be a JSON object. -
A world document carrying a
tapeorloop. Those play only from a built-in scenario. -
An incomplete record, under
RYSTIC_SEED_GATE=reject(below).
Registers
A register holds one kind of record. GET /_rystic/registers lists each one: id, kind, key_fields, seed_via and its fields, each with its type, and required where the model requires it.
curl -s localhost:$PORT/_rystic/registers | jq -c '.registers[] | {id, kind, key_fields}'
# -> {"id":"state_address","kind":"document_collection","key_fields":["id"]}
# -> {"id":"state_idempotency_key","kind":"idempotency_store","key_fields":["idempotency_key"]}
# -> {"id":"state_postcard","kind":"document_collection","key_fields":["id"]}
A register with an owner_field files each record under one caller, as Kalshi’s portfolio registers do (Kalshi → Seeding state).
rystic describe
rystic describe <product> asks the installed binary what it serves, with no simulator running.
rystic describe lob-twin
# -> scenarios (1) — `rystic run lob-twin --scenario <name>`; …
# -> design_studio Website Design Contractors' account: the Portland, ME offi…
# -> registers (3) — seed a keyed one with POST /_rystic/seed {<id>: {<key>: <record>}}:
# -> state_address document_collection (key id, 17 fields)
# -> …
# -> operations (17): DELETE 4, GET 8, POST 5
-jsonadds every register’s typed fields and an example record. Post{"<register>": <example>}to/_rystic/seedas it is.-vlists every operation.- With no product named, it describes every installed simulator. CLI flag reference → describe.
The seed gate
A record missing a field its register marks required is still accepted by default, and served as though whole. The simulator’s log says so. Okta’s password register requires salt and verifier:
rystic pull okta-twin
OKTA=$(rystic run okta-twin -d --name gate --port 0 --json | jq -r .data.port)
curl -s -X POST localhost:$OKTA/_rystic/seed -d '{"state_user_password": {"00u1": {"userId": "00u1"}}}'
# answers 204
rystic logs gate | grep "incomplete seed"
# -> rystic: WARNING — incomplete seed: state_user_password record "00u1" is missing 2 required field(s): salt, verifier. …
rystic stop gate
RYSTIC_SEED_GATE=rejectrefuses such a seed instead:400,seed rejected: 1 incomplete record(s), naming each missing field.RYSTIC_SEED_GATE=offskips the check.- The check runs on the merged record, so a partial update to a complete record passes.
- Set it at boot:
rystic run okta-twin --env RYSTIC_SEED_GATE=reject, or in the container’s environment.
Done with the Lob example: rystic stop worlds.