# Timelines

Schedule calls, seeds and messages on a simulator's clock, hand one call's answer to the next with response bindings, and read back what fired.

A [timeline](https://www.rystic.ai/docs/concepts/glossary.md#timeline) is a schedule of events a [simulator](https://www.rystic.ai/docs/concepts/glossary.md#simulator) fires on its [clock](https://www.rystic.ai/docs/concepts/glossary.md#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`:

```text
{"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](https://www.rystic.ai/docs/concepts/glossary.md#egress-log) 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](https://www.rystic.ai/docs/concepts/reset-and-determinism.md#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](https://www.rystic.ai/docs/simulators/kalshi/features/tape-replay.md) covers replay, and [Kalshi → Scripted events](https://www.rystic.ai/docs/simulators/kalshi/features/scripted-events.md#when-events-fire) 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](https://www.rystic.ai/docs/simulators/kalshi/features/tape-replay.md#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](https://www.rystic.ai/docs/simulators/kalshi/features/websocket-feed.md).
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:

```bash
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:

```bash
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:

```bash
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.

---

Source: https://www.rystic.ai/docs/timelines · Markdown: https://www.rystic.ai/docs/timelines.md
