Simulators
Watching and grading a run
A verifier needs to see what an agent did and score the result, without touching the simulator’s filesystem or letting the agent read the answer. This page covers the activity stream you watch while a bot runs and the action log a harness collects afterward. It also covers the three read routes a grader uses, and the admin token that locks them away from the agent.
export RYSTIC_URL=$(rystic run kalshi-twin -d --port 0 --name grade --scenario empty_book --runner native --env RYSTIC_ACTION_LOG=/tmp/kalshi-actions.jsonl --env RYSTIC_ADMIN_TOKEN=change-me --json | jq -r .data.url)
curl -s -o /dev/null -w '%{http_code}\n' "$RYSTIC_URL/_rystic/inspect"
# -> 401
curl -s -o /dev/null -w '%{http_code}\n' -H 'X-Rystic-Admin-Token: wrong' "$RYSTIC_URL/_rystic/inspect"
# -> 403
curl -s -X POST "$RYSTIC_URL/trade-api/v2/portfolio/events/orders" -H 'Content-Type: application/json' -d '{"ticker":"KXDEMO-26-A","client_order_id":"q1","side":"bid","count":"1","price":"0.10","time_in_force":"good_till_canceled","self_trade_prevention_type":"taker_at_cross"}' -o /dev/null
curl -s -H 'X-Rystic-Admin-Token: change-me' "$RYSTIC_URL/_account/snapshot?actor=member" | jq -c '{actor, cash: .cash.balance_dollars, open_orders: (.open_orders | length), portfolio_value_dollars}'
# -> {"actor":"member","cash":"10000.0000","open_orders":1,"portfolio_value_dollars":"10000.0000"}
curl -s -H 'X-Rystic-Admin-Token: change-me' "$RYSTIC_URL/_rystic/actions" | jq -c 'select(.kind == "request") | {seq, kind, status: .data.status}'
# -> {"seq":3,"kind":"request","status":201}
Watching state change in the terminal
The simulator prints state events to its stdout as they happen: fills, order updates, position changes and one line per mutating request.
03:57:06.302 FX fill owner=member action=buy count_fp=10.00 is_taker=true market_ticker=KXBTC... post_position_fp=10.00 yes_price_dollars=0.4700
03:57:06.302 FX market_position owner=member market_ticker=KXBTC... position_fp=10.00 realized_pnl_dollars=0.0000
03:57:06.302 REQ POST /trade-api/v2/portfolio/events/orders -> 201 (3ms)
Repeated replay and tape control operations coalesce to one ×N line, and public market-data floods are off by default. Pick what prints with --events, or with RYSTIC_EVENTS in the environment:
| Setting | The stream prints |
|---|---|
--events=off | nothing |
--events=all | everything, market data included |
classes, such as RYSTIC_EVENTS=private | those classes, comma-separated, of private, public, writes, reads and control |
rystic tail --addr localhost:8080 reads the same feed from another terminal, from GET /_rystic/events as Server-Sent Events.
Locking the control plane away from an agent
When an agent shares the simulator’s network, set RYSTIC_ADMIN_TOKEN. Every control route then needs that value in an X-Rystic-Admin-Token header, or in a rystic_admin_token cookie for the state UI in a browser. Two routes stay open: GET /_rystic/version, the readiness probe, which carries no tape or account state, and GET /_feed, which serves only scripted messages that have already fired.
- A request without the token gets a bare
401and a wrong token gets403. The Trade API itself needs no admin token. - The token is stripped from every request before a handler sees it and is never logged.
- The
rysticCLI presents it on its own control calls whenRYSTIC_ADMIN_TOKENis set in its environment, and passes it to the simulators it starts. It sends the token only to loopback addresses and to any hosts listed inRYSTIC_ADMIN_TOKEN_HOSTS(comma-separatedhostorhost:port), never to another host. RYSTIC_GATEWAY_TOKENis a separate, outer lock: it gates every request, Trade API included, and the two can be combined.
The action log
Set RYSTIC_ACTION_LOG=/path/actions.jsonl and the simulator appends one JSON object per line as it happens, so a crash loses at most the line in flight. It records:
- every request that changes state (any method but
GET,HEADorOPTIONS), with its request and response bodies - every fill you get, as it applies
- every control operation, including each scripted event that fires and each replay step
{"seq":42,"received":"2026-10-02T03:41:07.497Z","wall":"2026-10-02T03:41:07.512Z","sim_clock":"2026-09-20T23:14:05.5Z","kind":"request",
"data":{"method":"POST","path":"/trade-api/v2/portfolio/events/orders","status":201,"actor":"member",
"template":"/trade-api/v2/portfolio/events/orders","request":{},"response":{"order":{}}},
"replay":{"tape_ms":1790000000500,"position":1234,"rebase_offset_ms":0}}
seqis gapless and increasing.wallis when the line was written: for a request, when it finished.receivedis on request lines only: when the request arrived, before it waited its turn behind other requests and replay steps.wallminusreceivedis how long the simulator took to respond, waiting included.sim_clockis the simulator’s pinned clock, without its one-millisecond per-request ticks.replayappears while a tape is loaded.tape_msis the head’s tape time andpositionthe next tape event, so an order can be attributed to the replay step it was placed in.kindisrequest,fillorcontrol. A control line’sdatais{"op", "detail"}, as on the activity feed.- A
fillline is one of your fills. Itsdatais{"actor", "order_id", "trade_id", "ticker", "is_taker", "count_fp", "yes_price_dollars", "created_time"}, the valuesGET /portfolio/fillsserves. Itssim_clockandreplay.tape_msare when the fill happened: a recorded print’s time, or the paused instant for an order that crossed while replay was paused. Itswallis when the simulator applied it. A fill your own order took is written just before that order’srequestline.
Bodies are capped at 64 KiB, and a longer body is cut off and marked …[truncated]. A request to /_rystic/* logs its body as {"omitted_bytes": n, "sha256": "…"}, because seeds, schedules and credential bindings can carry secrets, and the digest is still enough to match which seed or schedule file was loaded. Headers are never logged, and the caller’s credential is redacted wherever it appears in a body or query, plain, URL-encoded or form-encoded.
Reading a run for a grader
Three read routes give a verifier or a rollout bundle what it needs without filesystem access. All three need the admin token when RYSTIC_ADMIN_TOKEN is set, so an agent that does not hold it cannot reach them.
GET /_account/snapshot?actor=member is one account in one read. It runs the same reads the Trade API serves for that actor (balance, positions, open orders, fills, settlements), so it never disagrees with them, and adds what a grader would otherwise compute:
{"actor":"member","sim_clock":"…","replay":{"sim_ms":0,"tape_ms":0},
"cash":{"balance_dollars":"99.4600","by_shard":[{"exchange_index":0,"balance":"99.4600"}]},
"positions":[{"ticker":"…","position_fp":"2.00","market_exposure_dollars":"1.020000",
"side":"yes","contracts_fp":"2.00","avg_entry_price_dollars":"0.510000",
"mark_price_dollars":"0.5000","mark_source":"bid","market_value_dollars":"1.0000"}],
"positions_value_dollars":"1.0000","portfolio_value_dollars":"100.4600",
"open_orders":[],"fills":[],
"fees_paid_dollars":"0.020000","settlements":[]}
- Positions are sorted by ticker and carry every field
GET /portfolio/positionsserves.sideisyes,noor empty for a closed row.avg_entry_price_dollarsismarket_exposure_dollarsdivided by the contracts held, to 6 decimal places, andnullwhen none are held. - The mark is the best bid on the side you hold, else that side’s last trade price, else zero, and
mark_sourcesays which (bid,lastornone). It is what the position would sell for now, not a mid.portfolio_value_dollarsis cash plus every position at its mark, and is not the Trade API’sportfolio_value, which is cost basis. - Fills keep the Trade API’s order, and gain
replaywhen they happened while a tape was loaded. In it,sim_msis the simulator’s pinned clock andtape_msthe recorded time under it, the same axis the action log stamps. A fill the agent took while replay was paused carries the paused instant, and a fill a recorded print gave it carries that print’s time. - Open orders are every resting order, sorted by
created_timethenorder_id.fees_paid_dollarssums every fill’sfee_cost. - Balances are 4-decimal-place dollar strings, as the Trade API serves them, and per-contract prices and fees have 6.
GET /_rystic/actions?since=<seq>&limit=<n> serves the action log’s lines from this run, byte for byte, as NDJSON: every line whose seq is above since, at most limit (default 1000, at most 10000). X-Rystic-Next-Since is the last seq served, and X-Rystic-More: true means another page follows. A log file reused across runs serves only this run’s lines. With RYSTIC_ACTION_LOG unset it returns 404.
GET /_rystic/config names the run exactly:
- the contract, product, version, edition and model hash
rng_seedandinitial_clockseed: a digest chained over every seed applied since the last reset, with its countinject: the loaded schedule’s event count and digest- the active scenario
replay: the loaded session (mode, markets, window, rebase offset and speed) and where its tapes came from. That’s the archive name, the cut manifest’s SHA-256 when there is one, and each tape’s window and files.admin_token: only whether the token is set, never its value