Guides
Timelines
A timeline is a schedule of events a simulator fires on its clock. It comes from a scenario’s own events, from POST /_rystic/inject, or from a file loaded at boot with --inject or RYSTIC_INJECT.
The schedule
POST /_rystic/inject replaces the schedule and answers 204:
{"events":[{"id","at","as"?,"message"|"seed"|"call","bind"?}]}
An inject file holds that body, or one event per line.
atis an RFC3339 simulated time, or+<duration>from the clock when the schedule loads. A duration takes hours, minutes and seconds (+72h); a day unit such as+3drefuses the schedule. It is resolved once, so aresetreplays the same instants. An event already due fires at once.- A
message,{"channel","body"}, lands onGET /_feed. - A
seedmerges into state asPOST /_rystic/seeddoes. - A
callis{"method","path","body"?,"headers"?}. Its body goes as JSON or a form, whichever the route reads. Its headers may not setAuthorization,Content-Typeor any other header the simulator sets itself. - A
callon a vendor route runs as the actorasnames (defaultmember), presenting the credential bound to it. It goes through the same checks, handler and egress as a client’s request, so its webhooks and events land as a client’s would. - A
callto another/_route may name any of the simulator’s own routes outside/_rystic/and/_feed, withoutasorheaders. - A call that fails has its answer recorded, and later events still fire.
The whole schedule is refused for an unknown route. It is also refused for an as the scenario holds no user or account for, on a simulator that has them. On a simulator that checks credentials, an as with no bound credential refuses it too.
How it runs
- The clock is manual by default, so events fire as you move it. Pacing makes it follow wall time.
- A schedule loaded with
POST /_rystic/inject,--injectorRYSTIC_INJECTreplaces a scenario’s own events. Unless it is empty, it keeps its place when a scenario with events lands later. - A
resetre-arms a loaded schedule, which replays from its first event. A scenario’s own events leave with the scenario. GET /_rystic/injectlists each event withdue,fired_at, a vendor-route call’sas,status, a call’sbindand the values itbound, and anyerror.- A fired call’s request on
/_rystic/eventsand/_rystic/callscarriessource: "timeline"and the event’s id asevent. - Scheduled calls are not billed as activity.
Kalshi: tape time
A playing Kalshi tape or live feed re-pins the clock to each event’s own time as it applies it, over any time you set. Kalshi → Replay a captured market day covers replay, and Kalshi → Scripted events covers pacing beside it.
A Kalshi timeline can run on tape time. With --tape, --inject defaults to $RYSTIC_INJECT, for rystic up tape services too: Kalshi → Script events on tape time.
Call or seed
Pick each event’s kind by who makes the change in the real service:
- Anything a client of the service could have done is a
call. It runs the real handler as the actorasnames, so ids, timestamps, webhooks and dependent state come out as they would for a client’s request. - A
seedis for a change no client makes: a market moving, a bank opening a dispute, data synced from another system. It writes the records as given and runs no handler, so it mints nothing and, on every simulator but Kalshi, sends no webhook or event. Kalshi’s WebSocket frames for a seed are in Kalshi → WebSocket feed. - A
messagechanges no state. It lands onGET /_feedfor a UI to read.
Response bindings
A call event may add bind, {"<name>": "<JSON Pointer>"}, to save fields of its answer under names. "bind": {"pi": "/id"} saves the answer’s id as pi, and a later event uses it as ${pi}.
- Where a name goes. In a later call’s path (query included), header values and body, and in a later seed event’s record keys and values. A seed’s register names cannot be bound, and a message’s body lands on the feed as written.
- What a name holds. A string, number or boolean. In a path, a header value, a JSON key or a longer string, the value goes in as text. A JSON string that is exactly one
${name}takes the value itself, so"amount": "${amt}"sends a number whenamtbound one. - Names and pointers. A name starts with a letter or underscore, then may hold letters, digits,
.and_. The field is a non-empty RFC 6901 JSON Pointer into the answer, such as/idor/data/0/id. Anything else is refused at load. - XML answers. An XML answer is read through its JSON view: the root element is the first key, child elements go by name and attributes by
@name. So/Order/Idreads<Order><Id>…</Id></Order>, and/Order/@idreads the root’sidattribute. An XML field binds only as text. - Refused at load. The whole schedule is refused, naming the event, for a
${name}no earlier event binds. Earlier means firing order: byat, then file order on ties. A name bound twice,bindon amessageorseed, a field that is not a JSON Pointer, or a${…}in a seed’s register name is refused too. - Routing. A call whose path holds a reference must still match a route by its path:
/v1/payment_intents/${pi}/confirmmatches/v1/payment_intents/{intent}/confirm. Once its path or header values are filled it is routed again, so a bound value may select the operation. A filled call that reaches no route a schedule may call errors and sends nothing. - Filled seeds. A filled seed is checked again as a posted one is. Under
RYSTIC_SEED_GATE=reject, a filled URL on a vendor host the simulator does not declare errors and lands nothing. - A failed binding never fills in an empty value. The binding call may have answered
400or above, or an answer that is neither JSON nor XML. Its field may be missing, or hold an object, array ornull. Then every event that uses the name errors inGET /_rystic/inject, naming why, and sends nothing. - Errors carry down a chain. An event that errors binds nothing either. A binding call that succeeded without the field shows that error on its own row too.
- Reading them back.
GET /_rystic/injectshows each call’sbindand, once it fired, the values itbound. The listed call keeps its${name}; the filled request is on/_rystic/calls. Aresetforgets every binding, and a schedule that replays binds afresh.
Bindings need control-plane contract 22 or later, the contract that GET /_rystic/version reports.
Worked example: confirm the PaymentIntent a timeline created
Start Stripe with design_studio, which sets the clock to 2026-03-01 and leaves it manual:
rystic pull stripe-twin
PORT=$(rystic run stripe-twin -d --name timeline --port 0 --scenario design_studio --json | jq -r .data.port)
Each call runs as member and presents the credential bound to it, so bind a key to member first. Then, one minute in, create a PaymentIntent and bind its id as pi. Five minutes in, confirm it by ${pi}. The single quotes keep the shell from expanding ${pi} itself:
curl -s -X POST "localhost:$PORT/_rystic/credentials" -d '{"bindings":{"sk_test_twin":"member"}}'
curl -s -X POST "localhost:$PORT/_rystic/inject" -H 'content-type: application/json' -d '{"events":[{"id":"create","at":"+1m","call":{"method":"POST","path":"/v1/payment_intents","body":{"amount":2000,"currency":"usd","payment_method":"pm_card_visa"}},"bind":{"pi":"/id"}},{"id":"confirm","at":"+5m","call":{"method":"POST","path":"/v1/payment_intents/${pi}/confirm","body":{"return_url":"https://example.com/done"}}}]}'
Move the clock past both events, then read what each bound, the requests they made and the webhooks they caused:
curl -s -X POST "localhost:$PORT/_rystic/clock" -d '{"advance_seconds":300}'
curl -s "localhost:$PORT/_rystic/inject" | jq -c '.events[] | {id, status, bound}'
# -> {"id":"create","status":200,"bound":{"pi":"pi_000000000000000000000001"}}
# -> {"id":"confirm","status":200,"bound":null}
curl -s "localhost:$PORT/_rystic/calls" | jq -r '.calls[] | "\(.event) \(.method) \(.path)"'
# -> create POST /v1/payment_intents
# -> confirm POST /v1/payment_intents/pi_000000000000000000000001/confirm
curl -s "localhost:$PORT/_rystic/egress" | jq -r '.events[].type'
# -> payment_intent.created
# -> charge.succeeded
# -> payment_intent.succeeded
rystic stop timeline
The confirm ran Stripe’s own handler, so the charge and its webhooks came out as a client’s confirm would. A seed writing the PaymentIntent as succeeded would have sent none of them.