Skip to Content
ConceptsWorlds and seeding

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}
  1. Without --scenario (or RYSTIC_SCENARIO), a simulator boots empty: GET /_rystic/inspect answers {}, or only the records the service always holds, such as Okta’s Everyone group.
  2. POST /_rystic/scenario resets, then loads a world, with no restart. A world loaded this way holds exactly the state the same world booted with --scenario holds.
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

AskHow
A running simulator’s worldscurl -s localhost:$PORT/_rystic/scenarios
The same, without starting onerystic run lob-twin --name menu --port 0 -- --list-scenarios
What each world holdsrystic docs lob-twin scenarios prints the installed SCENARIOS.md; Stripe ships none
Worlds, registers and operations at oncerystic 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

  1. Register map: {"<register>": {"<key>": {<record>}}}, as above.
  2. World document: the register map under seed, beside optional clock, rng_seed, network, credentials and events. 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

  1. A seed merges into what the simulator holds; it does not reset first. A new key adds a record.
  2. An existing key merges field by field: {"adr_home": {"name": "HOME 2"}} changes the name and keeps the address. A nested object, such as metadata, replaces the old one whole.
  3. --scenario with --seed applies the world first, then merges the file on top.
  4. GET /_rystic/inspect returns 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:

  1. 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"}
    
  2. A record that is not a JSON object: record "<key>" must be a JSON object.

  3. A world document carrying a tape or loop. Those play only from a built-in scenario.

  4. 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
  1. -json adds every register’s typed fields and an example record. Post {"<register>": <example>} to /_rystic/seed as it is.
  2. -v lists every operation.
  3. 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
  1. RYSTIC_SEED_GATE=reject refuses such a seed instead: 400, seed rejected: 1 incomplete record(s), naming each missing field.
  2. RYSTIC_SEED_GATE=off skips the check.
  3. The check runs on the merged record, so a partial update to a complete record passes.
  4. 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.

Last updated on