Skip to Content
SimulatorsKalshiScripted events

Simulators

Scripted events

A schedule lists events that fire when the simulator clock reaches them. So a scenario can carry an outside feed (a score, a weather reading) and an ending (a settlement) alongside the market. Each event has a time and one payload:

PayloadWhen the event fires, it
messagelands on GET /_feed
seedmerges into state, as POST /_rystic/seed would
callcalls one of the simulator’s own routes outside /_rystic/, such as settling a market when its game ends
{"id":"end","at":"2026-08-22T01:50:07.240Z","call":{"method":"POST","path":"/_settle","body":{"ticker":"KXMLBGAME-26AUG211840STLPHI-PHI","result":"yes"}}}

A call on a Trade API route runs as the actor as names (member by default), through the same guards and handler as your own requests. Its WebSocket frames go out on schedule. as and headers belong to Trade API routes only. An event’s at is an RFC 3339 time, or +<duration> (+90s, +15m) from the clock when the schedule loads.

Load a schedule with POST /_rystic/inject, or at boot with --inject <file> or RYSTIC_INJECT=<file> (rystic run --inject <file> sets it). A file holds either the {"events":[…]} body the route takes or one event per line (JSONL). The empty_book scenario holds its clock still at 2026-07-25T14:30:00Z, so a schedule fires when you move the clock:

export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name events --scenario empty_book --json | jq -r .data.url)
curl -s -X POST "$RYSTIC_URL/_rystic/inject" -d '{"events":[{"id":"td","at":"2026-07-25T14:31:12Z","message":{"channel":"play_by_play","body":{"text":"TD KC, 7-3"}}},{"id":"wx","at":"2026-07-25T14:32:00Z","message":{"channel":"weather","body":{"hpa":1004.2}}}]}'
curl -s "$RYSTIC_URL/_feed?since=0"
# -> {"events":[]}
curl -s -X POST "$RYSTIC_URL/_rystic/clock" -d '{"time":"2026-07-25T14:31:30Z"}'
curl -s "$RYSTIC_URL/_feed?since=0"
# -> {"events":[{"seq":1,"time":"2026-07-25T14:31:12Z","channel":"play_by_play","body":{"text":"TD KC, 7-3"}}]}
curl -s -X POST "$RYSTIC_URL/_rystic/clock" -d '{"advance_seconds":60}'
curl -s "$RYSTIC_URL/_feed?since=1"
# -> {"events":[{"seq":2,"time":"2026-07-25T14:32:00Z","channel":"weather","body":{"hpa":1004.2}}]}

When events fire

  • Each event fires once, when the clock is pinned at or past its at. A replaying tape, POST /_rystic/clock or a scenario’s clock moves the clock, and so does pacing. A jump fires every event it passes, in order. The one-millisecond tick per read doesn’t count.
  • RYSTIC_PACE=<speed> moves the clock by speed seconds per second. rystic run --inject sets 1 unless --speed says otherwise, and --tape and --live never set it. Pacing waits while a /_replay tape or a live feed drives the clock. btc15m_tape drives the clock itself, so don’t pace beside it: set --speed 0.
  • A replaying tape fires each event on its own step, after the tape events before its at and before the rest, at any speed. Events past the tape’s last event never fire, unless a checkpoint past it carries the clock there. Then those due by the stop fire before the pause. Fractional timestamps keep nanosecond precision, and replacing the schedule during playback reschedules pending events.
  • at is the clock’s time, so a rebase replay shifts the tape away from it. To write at in tape time, load the file with the replay, as script events on tape time describes.
  • POST /_rystic/reset re-arms the whole schedule and empties the feed. seq starts again at 1, and an offset at replays at the same instant.

Reading the feed and the schedule

GET /_feed?since=<seq> serves only fired messages, never seeds or what is still pending, and only the ones after seq. It sits outside /_rystic/ and the modeled rate limit, so a UI can poll it.

GET /_rystic/inject lists every event with these fields:

FieldWhat it holds
duethe instant the event is due
fired_atwhen it fired
asa Trade API call’s actor
statusa call’s status
errorwhy the store refused a seed, or the call’s 4xx or 5xx

An error doesn’t stop the schedule: later events still fire. A call must name a route the simulator serves, and anything else refuses the whole schedule. A Trade API call’s entry in GET /_rystic/calls carries source: "timeline" and the event’s id as event.

A new POST /_rystic/inject replaces the schedule, and fires at once what the clock has already passed. An id is an event’s identity. An event the old schedule already fired stays fired in the new one, keeping its fired_at, its feed line and what it bound. So re-posting an overlapping rolling window never fires an event twice. To fire an edited event again, give it a new id.

Last updated on