# Watching and grading a run

Prints state changes as they happen, logs every action to a file and gives a grader one-read account snapshots, all behind an optional admin token.

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.

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

```text
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 `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

```json
{"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:

```json
{"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

---

Source: https://www.rystic.ai/docs/simulators/kalshi/features/grading-a-run · Markdown: https://www.rystic.ai/docs/simulators/kalshi/features/grading-a-run.md
