Skip to Content
SimulatorsKalshiWatching and grading a run

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:

SettingThe stream prints
--events=offnothing
--events=alleverything, market data included
classes, such as RYSTIC_EVENTS=privatethose 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 401 and a wrong token gets 403. 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 rystic CLI presents it on its own control calls when RYSTIC_ADMIN_TOKEN is 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 in RYSTIC_ADMIN_TOKEN_HOSTS (comma-separated host or host:port), never to another host.
  • RYSTIC_GATEWAY_TOKEN is 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, HEAD or OPTIONS), 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}}
  • seq is gapless and increasing.
  • wall is when the line was written: for a request, when it finished.
  • received is on request lines only: when the request arrived, before it waited its turn behind other requests and replay steps. wall minus received is how long the simulator took to respond, waiting included.
  • sim_clock is the simulator’s pinned clock, without its one-millisecond per-request ticks.
  • replay appears while a tape is loaded. tape_ms is the head’s tape time and position the next tape event, so an order can be attributed to the replay step it was placed in.
  • kind is request, fill or control. A control line’s data is {"op", "detail"}, as on the activity feed.
  • A fill line is one of your fills. Its data is {"actor", "order_id", "trade_id", "ticker", "is_taker", "count_fp", "yes_price_dollars", "created_time"}, the values GET /portfolio/fills serves. Its sim_clock and replay.tape_ms are when the fill happened: a recorded print’s time, or the paused instant for an order that crossed while replay was paused. Its wall is when the simulator applied it. A fill your own order took is written just before that order’s request line.

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/positions serves. side is yes, no or empty for a closed row. avg_entry_price_dollars is market_exposure_dollars divided by the contracts held, to 6 decimal places, and null when 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_source says which (bid, last or none). It is what the position would sell for now, not a mid. portfolio_value_dollars is cash plus every position at its mark, and is not the Trade API’s portfolio_value, which is cost basis.
  • Fills keep the Trade API’s order, and gain replay when they happened while a tape was loaded. In it, sim_ms is the simulator’s pinned clock and tape_ms the 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_time then order_id. fees_paid_dollars sums every fill’s fee_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_seed and initial_clock
  • seed: a digest chained over every seed applied since the last reset, with its count
  • inject: 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
Last updated on