Skip to Content
Timelines

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.

  1. at is 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 +3d refuses the schedule. It is resolved once, so a reset replays the same instants. An event already due fires at once.
  2. A message, {"channel","body"}, lands on GET /_feed.
  3. A seed merges into state as POST /_rystic/seed does.
  4. A call is {"method","path","body"?,"headers"?}. Its body goes as JSON or a form, whichever the route reads. Its headers may not set Authorization, Content-Type or any other header the simulator sets itself.
  5. A call on a vendor route runs as the actor as names (default member), 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.
  6. A call to another /_ route may name any of the simulator’s own routes outside /_rystic/ and /_feed, without as or headers.
  7. 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

  1. The clock is manual by default, so events fire as you move it. Pacing makes it follow wall time.
  2. A schedule loaded with POST /_rystic/inject, --inject or RYSTIC_INJECT replaces a scenario’s own events. Unless it is empty, it keeps its place when a scenario with events lands later.
  3. A reset re-arms a loaded schedule, which replays from its first event. A scenario’s own events leave with the scenario.
  4. GET /_rystic/inject lists each event with due, fired_at, a vendor-route call’s as, status, a call’s bind and the values it bound, and any error.
  5. A fired call’s request on /_rystic/events and /_rystic/calls carries source: "timeline" and the event’s id as event.
  6. 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:

  1. Anything a client of the service could have done is a call. It runs the real handler as the actor as names, so ids, timestamps, webhooks and dependent state come out as they would for a client’s request.
  2. A seed is 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.
  3. A message changes no state. It lands on GET /_feed for 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}.

  1. 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.
  2. 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 when amt bound one.
  3. 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 /id or /data/0/id. Anything else is refused at load.
  4. 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/Id reads <Order><Id>…</Id></Order>, and /Order/@id reads the root’s id attribute. An XML field binds only as text.
  5. Refused at load. The whole schedule is refused, naming the event, for a ${name} no earlier event binds. Earlier means firing order: by at, then file order on ties. A name bound twice, bind on a message or seed, a field that is not a JSON Pointer, or a ${…} in a seed’s register name is refused too.
  6. Routing. A call whose path holds a reference must still match a route by its path: /v1/payment_intents/${pi}/confirm matches /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.
  7. 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.
  8. A failed binding never fills in an empty value. The binding call may have answered 400 or above, or an answer that is neither JSON nor XML. Its field may be missing, or hold an object, array or null. Then every event that uses the name errors in GET /_rystic/inject, naming why, and sends nothing.
  9. 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.
  10. Reading them back. GET /_rystic/inject shows each call’s bind and, once it fired, the values it bound. The listed call keeps its ${name}; the filled request is on /_rystic/calls. A reset forgets 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.

Last updated on