Skill Tracer
Skills › Compound Engineering › ce-polish › live-stream-contract.md

live-stream-contract.md

253 lines · skills/ce-polish/references/live-stream-contract.md at 3449230

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Live stream contract (`live/1`)
2
3The wire contract between a riffrec live page, the `scripts/live-endpoint.js` helper, and the coding agent. This is the skill's own copy of the contract; the page's copy ships with the riffrec package and the two are kept identical by that package's fixtures. When they disagree, the endpoint rejects the page's `schema_version` and the page shows its incompatible-endpoint state instead of parsing best-effort.
4
5Mirrors riffrec `docs/live-stream-contract.md` @ 55be284 (PR #29; `src/live/contract.ts`, `src/live/tools.ts`, and `src/live/realtime/persona.ts` are the typed source of truth). Adding an optional payload field or a new event type is not a breaking change; consumers ignore fields they do not know but reject event types they do not know.
6

Live stream contract (live/1)

The wire contract between a riffrec live page, the scripts/live-endpoint.js helper, and the coding agent. This is the skill's own copy of the contract; the page's copy ships with the riffrec package and the two are kept identical by that package's fixtures. When they disagree, the endpoint rejects the page's schema_version and the page shows its incompatible-endpoint state instead of parsing best-effort.

Mirrors riffrec docs/live-stream-contract.md @ 55be284 (PR #29; src/live/contract.ts, src/live/tools.ts, and src/live/realtime/persona.ts are the typed source of truth). Adding an optional payload field or a new event type is not a breaking change; consumers ignore fields they do not know but reject event types they do not know.

Raw lines 1 to 6
Runs code scripts/live-endpoint.js :3
7## Envelope
8
9Every page -> endpoint message is one envelope:
11```json
12{ "schema_version": "live/1", "session_id": "<page-minted id>", "seq": 12, "t": 1726000000000, "type": "unit", "payload": { } }
13```
15- `seq` is a per-session monotonic integer starting at 1. The endpoint deduplicates on `(session_id, seq)`, applies envelopes strictly in `seq` order (one that arrives ahead of a gap waits, unacknowledged, until the gap closes), and acknowledges the highest contiguous `seq`; after an outage the page replays from the last acknowledged `seq`.
16- `type` is one of the four riffrec capture events (`click`, `navigation`, `network_request`, `console_error`) or `transcript`, `unit`, `unit_update`, `unit_withdraw`, `annotation`, `checkpoint`, `answer`, `frame`, `mic`, `mode`, `stream_state`.
17- `frame` envelopes are posted alone, never in a batch with other events.
18- An unsupported `schema_version` is answered `409 { "expected_schema_version": "live/1" }`; any other invalid envelope is `400 { "reason": <not_object | missing_session_id | session_mismatch | missing_seq | invalid_seq | invalid_t | unknown_type | invalid_payload>, "seq" }`. A storage failure while applying answers `500 { "error": "storage_failed", "acked_seq" }`; each envelope is applied, stored, and saved as one transaction, so a failure rolls that envelope back (no wake or SSE notice escapes before the commit), everything acknowledged before it stays acknowledged, and the rest is retriable. Payloads are checked against the shapes above (the same rules as riffrec's `validateEnvelope`) before anything in the body is acknowledged; a rejected body acknowledges nothing.

Envelope

Every page -> endpoint message is one envelope:

{ "schema_version": "live/1", "session_id": "<page-minted id>", "seq": 12, "t": 1726000000000, "type": "unit", "payload": { } }
  • seq is a per-session monotonic integer starting at 1. The endpoint deduplicates on (session_id, seq), applies envelopes strictly in seq order (one that arrives ahead of a gap waits, unacknowledged, until the gap closes), and acknowledges the highest contiguous seq; after an outage the page replays from the last acknowledged seq.
  • type is one of the four riffrec capture events (click, navigation, network_request, console_error) or transcript, unit, unit_update, unit_withdraw, annotation, checkpoint, answer, frame, mic, mode, stream_state.
  • frame envelopes are posted alone, never in a batch with other events.
  • An unsupported schema_version is answered 409 { "expected_schema_version": "live/1" }; any other invalid envelope is 400 { "reason": <not_object | missing_session_id | session_mismatch | missing_seq | invalid_seq | invalid_t | unknown_type | invalid_payload>, "seq" }. A storage failure while applying answers 500 { "error": "storage_failed", "acked_seq" }; each envelope is applied, stored, and saved as one transaction, so a failure rolls that envelope back (no wake or SSE notice escapes before the commit), everything acknowledged before it stays acknowledged, and the rest is retriable. Payloads are checked against the shapes above (the same rules as riffrec's validateEnvelope) before anything in the body is acknowledged; a rejected body acknowledges nothing.
Raw lines 7 to 19
20## Payload shapes
22| type | payload |
23|---|---|
24| `unit` | `{ id, statement, transcript_excerpt, anchors[], evidence { frame_ids[], annotation_ids[], transcript_span, telemetry_window?, audio_clip_id? }, status, confirmed? }`; status follows `initial` → `triaging` → `accepted` or `needs_info`, then `applied` or `blocked`, and `withdrawn` when the riffer retracts it; `confirmed { element, change }` is present after the final confirmation pass |
25| `anchor` (inside units and annotations) | `{ route, selector, component?, rect, t }` |
26| `annotation` | `{ id, kind: "stroke" \| "pin", points[], bbox, anchor, text?, unit_id?, composite_frame_id? }` |
27| `transcript` | `{ id, role: "riffer" \| "interviewer", text, t_start, t_end, final }` |
28| `unit_update` | `{ unit_id, statement?, anchors_add?, confirmed? }`; `statement`/`anchors_add` apply only while the unit is still `initial` and unreleased (KTD5), `confirmed` at any time |
29| `unit_withdraw` | `{ unit_id, reason? }` |
30| `checkpoint` | `{ id, trigger, mode }`. riffrec's `CheckpointTrigger` is `silence \| page_change \| send \| answer \| mode_change \| final`; the page sends only `silence`, `page_change`, `send`, `final` (the overlay's Done control, after the confirmation pass and before `/session/end`), and this endpoint answers `400 invalid_payload` to `answer`/`mode_change` on the wire since it produces those two itself |
31| `answer` | `{ unit_id, text }` |
32| `frame` | `{ id, t, route, kind: "gesture" \| "periodic" \| "composite", jpeg_base64, dropped?: "quota" \| "oversize" }`. `dropped` means the page discarded the bytes but kept the frame's `seq` so numbering stays contiguous (`quota`: the buffering queue evicted it; `oversize`: after a `413`); `jpeg_base64` is then empty, the endpoint keeps id and metadata, writes no image, and treats the frame as absent |
33| `mic` | `{ state: "granted" \| "denied" \| "muted" \| "unmuted" }` |
34| `mode` | `{ mode: "instant" \| "smart" \| "collect" }` (default `smart`; the switch takes effect at the next checkpoint) |
35| `stream_state` | `{ state: "streaming" \| "buffering" \| "unloading" }` (`unloading` is sent on `pagehide` with `keepalive` so an ordinary reload classifies without waiting for the page-lost grace window) |

Payload shapes

typepayload
unit{ id, statement, transcript_excerpt, anchors[], evidence { frame_ids[], annotation_ids[], transcript_span, telemetry_window?, audio_clip_id? }, status, confirmed? }; status follows initial → triaging → accepted or needs_info, then applied or blocked, and withdrawn when the riffer retracts it; confirmed { element, change } is present after the final confirmation pass
anchor (inside units and annotations){ route, selector, component?, rect, t }
annotation`{ id, kind: "stroke" \"pin", points[], bbox, anchor, text?, unit_id?, composite_frame_id? }`
transcript`{ id, role: "riffer" \"interviewer", text, t_start, t_end, final }`
unit_update{ unit_id, statement?, anchors_add?, confirmed? }; statement/anchors_add apply only while the unit is still initial and unreleased (KTD5), confirmed at any time
unit_withdraw{ unit_id, reason? }
checkpoint{ id, trigger, mode }. riffrec's CheckpointTrigger is `silence \page_change \send \answer \mode_change \final; the page sends only silence, page_change, send, final (the overlay's Done control, after the confirmation pass and before /session/end), and this endpoint answers 400 invalid_payload to answer/mode_change` on the wire since it produces those two itself
answer{ unit_id, text }
frame`{ id, t, route, kind: "gesture" \"periodic" \"composite", jpeg_base64, dropped?: "quota" \"oversize" }. dropped means the page discarded the bytes but kept the frame's seq so numbering stays contiguous (quota: the buffering queue evicted it; oversize: after a 413); jpeg_base64` is then empty, the endpoint keeps id and metadata, writes no image, and treats the frame as absent
mic`{ state: "granted" \"denied" \"muted" \"unmuted" }`
mode`{ mode: "instant" \"smart" \"collect" } (default smart`; the switch takes effect at the next checkpoint)
stream_state`{ state: "streaming" \"buffering" \"unloading" } (unloading is sent on pagehide with keepalive` so an ordinary reload classifies without waiting for the page-lost grace window)
Raw lines 20 to 36
37## Credentials
39`start` mints two tokens and writes both to `state/session.json` (mode 0600, inside a 0700 `state/`). Only the page token is printed. Every route reads its credential from `Authorization: Bearer <token>` and nothing else: no query string, no cookie, no header alias.
41| Class | Token | Extra requirement | Wrong class |
42|---|---|---|---|
43| Page routes | page token | `X-Riffrec-Session: <session_id>`; an `Origin` header, when present, must equal `--app-origin` | agent token -> 403 |
44| Agent routes | agent token | no `Origin` header at all (any `Origin` -> 403); no CORS headers are emitted | page token -> 403 |
46A missing or unknown token is 401. The endpoint binds the page token to the first `session_id` it sees; any other id is answered `409 { "active_session_id" }`. Once the session ends, the ended id receives `410 { "status": "session-ended" }`. A different id opens a fresh board on the same page token and is handled as that session's first request, or gets `409 { "error": "previous_session_draining" }` while the ended session still holds batches or its upload is in flight.
48Page routes answer `OPTIONS` with `Access-Control-Allow-Origin: <exact --app-origin>`, `Access-Control-Allow-Headers: Authorization, Content-Type, X-Riffrec-Session, X-Riffrec-OpenAI-Key`, `Access-Control-Allow-Methods: GET, POST`, `Vary: Origin`, and no credentials flag.

Credentials

start mints two tokens and writes both to state/session.json (mode 0600, inside a 0700 state/). Only the page token is printed. Every route reads its credential from Authorization: Bearer <token> and nothing else: no query string, no cookie, no header alias.

ClassTokenExtra requirementWrong class
Page routespage tokenX-Riffrec-Session: <session_id>; an Origin header, when present, must equal --app-originagent token -> 403
Agent routesagent tokenno Origin header at all (any Origin -> 403); no CORS headers are emittedpage token -> 403

A missing or unknown token is 401. The endpoint binds the page token to the first session_id it sees; any other id is answered 409 { "active_session_id" }. Once the session ends, the ended id receives 410 { "status": "session-ended" }. A different id opens a fresh board on the same page token and is handled as that session's first request, or gets 409 { "error": "previous_session_draining" } while the ended session still holds batches or its upload is in flight.

Page routes answer OPTIONS with Access-Control-Allow-Origin: <exact --app-origin>, Access-Control-Allow-Headers: Authorization, Content-Type, X-Riffrec-Session, X-Riffrec-OpenAI-Key, Access-Control-Allow-Methods: GET, POST, Vary: Origin, and no credentials flag.

Raw lines 37 to 49
50## HTTP surface

HTTP surface

Raw lines 50 to 51
52### Page routes
54| Route | Body | Response |
55|---|---|---|
56| `POST /events` | one envelope or an array of envelopes | `200 { "acked_seq" }`. Body cap 64 KB, or 2 MB for a lone `frame`; oversize is `413 { "max_bytes": 65536, "frame_max_bytes": 2097152 }` and does not count toward the page's buffering threshold. Beyond the 500 MB per-session disk cap (`CE_LIVE_DISK_CAP_BYTES`), frames that carry image bytes are refused with `507 { "reason": "disk_cap", "stream_state": "buffering", "max_bytes", "acked_seq" }`; a `dropped` frame and every other envelope still flow. Past a hard ceiling above that (`CE_LIVE_DISK_HARD_CAP_BYTES`, default the cap plus 64 MB), every envelope is refused the same way, so a session cannot grow the log without bound; the page treats the refusal as `buffering` (three failures) and replays once accepted again. |
57| `GET /stream` | none | SSE. Event names: `ack { acked_seq }`, `unit_status { unit_id, status, note?, guess? }`, `applied { checkpoint_id, unit_ids[] }`, `ask { unit_id, question }`, `session_ended { reason, session_id, log_dir }`, `agent { state, since, checkpoint_id? }` (`listening` while a `wait` is parked, `working` from the moment a batch is served until the next `wait`, `away` once no `wait` has parked for 15 s). On connect the stream replays `ack`, the current `agent` state, a `unit_status` (with `note`/`guess`) for every released or withdrawn unit, and an `ask` for every unit still in `needs_info`, so a reloaded or reconnected page reconciles its board. **The response is ended 300 ms (`CE_LIVE_STREAM_FLUSH_MS`) after any delivery other than a bare `ack`**: TLS-terminating intermediaries that hold a streaming body until it completes (cloudflared quick tunnels, which sit behind a Worker) then release it at once, and riffrec's client reopens the stream a second later and reconciles from the replay. On a direct path the same frames arrive as written. |
58| `POST /mint` | `{ "session_id" }` | `200 { "client_secret", "expires_at", "model" }`; `410` if the session ended while the upstream call was in flight; `403 { "reason": "tls_required" }` when the peer is not loopback, unless the peer is an address named with `--trust-proxy` and the request carries `X-Forwarded-Proto: https` (the header alone is never trusted); `429 { "retry_after" }` past one mint in flight or five per minute; `502 { "reason": "openai_error", "upstream_status" }` with the upstream body discarded; `503 { "reason": "no_key" \| "brief_contains_secret" }`. |
59| `POST /session/end` | the page's full-evidence archive (`application/zip` or `application/json`; may be empty) | `200 { "status": "session-ended", "log_dir", "archive_bytes" }`; `409 { "error": "session_end_in_progress" }` while another upload is in flight; `500 { "error": "archive_write_failed" }` if the disk refuses the archive or the terminal state cannot be persisted (the session stays live and the call can be retried). Stores the archive under `state/log/`, emits a `final` checkpoint only if the page never sent one and something is still held or accepted, and closes every stream with `session_ended`. The page token stays valid. |
60| `GET /session` | none | `200 { "status": "live" \| "ended", "session_id": <string \| null>, "accepts_new_session": <boolean> }`; `accepts_new_session` is true once the board has ended and nothing is held or draining. Page token only: no `X-Riffrec-Session`, binds nothing, and does not count as activity for the idle timeout. |

Page routes

RouteBodyResponse
POST /eventsone envelope or an array of envelopes200 { "acked_seq" }. Body cap 64 KB, or 2 MB for a lone frame; oversize is 413 { "max_bytes": 65536, "frame_max_bytes": 2097152 } and does not count toward the page's buffering threshold. Beyond the 500 MB per-session disk cap (CE_LIVE_DISK_CAP_BYTES), frames that carry image bytes are refused with 507 { "reason": "disk_cap", "stream_state": "buffering", "max_bytes", "acked_seq" }; a dropped frame and every other envelope still flow. Past a hard ceiling above that (CE_LIVE_DISK_HARD_CAP_BYTES, default the cap plus 64 MB), every envelope is refused the same way, so a session cannot grow the log without bound; the page treats the refusal as buffering (three failures) and replays once accepted again.
GET /streamnoneSSE. Event names: ack { acked_seq }, unit_status { unit_id, status, note?, guess? }, applied { checkpoint_id, unit_ids[] }, ask { unit_id, question }, session_ended { reason, session_id, log_dir }, agent { state, since, checkpoint_id? } (listening while a wait is parked, working from the moment a batch is served until the next wait, away once no wait has parked for 15 s). On connect the stream replays ack, the current agent state, a unit_status (with note/guess) for every released or withdrawn unit, and an ask for every unit still in needs_info, so a reloaded or reconnected page reconciles its board. **The response is ended 300 ms (CE_LIVE_STREAM_FLUSH_MS) after any delivery other than a bare ack**: TLS-terminating intermediaries that hold a streaming body until it completes (cloudflared quick tunnels, which sit behind a Worker) then release it at once, and riffrec's client reopens the stream a second later and reconciles from the replay. On a direct path the same frames arrive as written.
POST /mint{ "session_id" }200 { "client_secret", "expires_at", "model" }; 410 if the session ended while the upstream call was in flight; 403 { "reason": "tls_required" } when the peer is not loopback, unless the peer is an address named with --trust-proxy and the request carries X-Forwarded-Proto: https (the header alone is never trusted); 429 { "retry_after" } past one mint in flight or five per minute; 502 { "reason": "openai_error", "upstream_status" } with the upstream body discarded; `503 { "reason": "no_key" \"brief_contains_secret" }`.
POST /session/endthe page's full-evidence archive (application/zip or application/json; may be empty)200 { "status": "session-ended", "log_dir", "archive_bytes" }; 409 { "error": "session_end_in_progress" } while another upload is in flight; 500 { "error": "archive_write_failed" } if the disk refuses the archive or the terminal state cannot be persisted (the session stays live and the call can be retried). Stores the archive under state/log/, emits a final checkpoint only if the page never sent one and something is still held or accepted, and closes every stream with session_ended. The page token stays valid.
GET /sessionnone`200 { "status": "live" \"ended", "session_id": <string \null>, "accepts_new_session": <boolean> }; accepts_new_session is true once the board has ended and nothing is held or draining. Page token only: no X-Riffrec-Session`, binds nothing, and does not count as activity for the idle timeout.
Raw lines 52 to 61
62### Agent routes
64| Route | Body | Response |
65|---|---|---|
66| `GET /wait` | none | Long-poll. `200 <wake envelope>`; `204` after the poll window (the CLI loops); `409 { "status": "wait-taken" }` when another wait is parked; `410 { "status": "session-ended" }` when the session ended and nothing is held (the agent token itself stays valid until `stop`). |
67| `POST /checkpoints/:id/ack` | `{}` | `200 { "ok", "checkpoint_id" }`; idempotent: a repeated ack of a batch this session already dropped is `200 { "ok", "checkpoint_id", "already_acked": true }`; `404` for a checkpoint the session never served. |
68| `POST /units/:id/status` | `{ "status", "note"?, "guess"? }` with status in `triaging`, `accepted`, `needs_info`, `applied`, `blocked`, `withdrawn` | `200`; relays `unit_status` (and `applied`) on the stream. `accepted` puts the unit in the backlog below; `applied` or `blocked` takes it out. `409` once the page has withdrawn the unit: a withdrawal is terminal. |
69| `POST /units/:id/ask` | `{ "question" }` | `200`; moves the unit to `needs_info` and relays `ask` on the stream. `409` for a withdrawn unit. |
70| `GET /status` | none | The board summary, the same document the `status` CLI prints. |
72Nothing else is served: there is no file route, and every unknown path is 404.

Agent routes

RouteBodyResponse
GET /waitnoneLong-poll. 200 <wake envelope>; 204 after the poll window (the CLI loops); 409 { "status": "wait-taken" } when another wait is parked; 410 { "status": "session-ended" } when the session ended and nothing is held (the agent token itself stays valid until stop).
POST /checkpoints/:id/ack{}200 { "ok", "checkpoint_id" }; idempotent: a repeated ack of a batch this session already dropped is 200 { "ok", "checkpoint_id", "already_acked": true }; 404 for a checkpoint the session never served.
POST /units/:id/status{ "status", "note"?, "guess"? } with status in triaging, accepted, needs_info, applied, blocked, withdrawn200; relays unit_status (and applied) on the stream. accepted puts the unit in the backlog below; applied or blocked takes it out. 409 once the page has withdrawn the unit: a withdrawal is terminal.
POST /units/:id/ask{ "question" }200; moves the unit to needs_info and relays ask on the stream. 409 for a withdrawn unit.
GET /statusnoneThe board summary, the same document the status CLI prints.

Nothing else is served: there is no file route, and every unknown path is 404.

Raw lines 62 to 73
74## Checkpoints and the wake envelope
76A checkpoint releases every held unit and annotation plus any withdrawal that arrived after an earlier release. On release (checkpoint, instant unit, or mode change) the endpoint marks each unit `triaging` and broadcasts `unit_status: "triaging"` on the stream in the same request, before any agent has seen or acknowledged the batch; the page treats that event as the release marker and the board leaves "Heard". A withdrawal before release removes the unit from the batch; one after release is forwarded in the next batch with `status: "withdrawn"`.
78- Page-emitted checkpoints: `silence`, `page_change`, `send`, and `final` (the overlay's Done control), each carrying the mode at emission. A `silence`, `page_change`, or `send` checkpoint that releases nothing does not wake the agent; `final` always wakes.
79- Instant mode is continuous pickup: while `mode` is `instant`, every accepted `unit` envelope is released as it lands (together with anything else held) as its own batch, `checkpoint_id: "instant-<unit id>"`, `kind: "instant"`, and wakes the agent at once; a `mode` event switching to `instant` releases what is held as a `mode_change` batch. Page checkpoints under Instant therefore usually release nothing and do not wake. Smart and Collect keep the checkpoint semantics.
80- Endpoint-emitted checkpoints: `answer`, created whenever an `answer` event arrives (carries `answers[]` only and releases no units), and `mode_change`, created the moment a `mode` event leaves Collect (KTD12). Both always wake. riffrec exports the always-wake set as `ALWAYS_WAKE_TRIGGERS = ["answer", "mode_change", "final"]` (KTD9).
81- **Accepted backlog.** Units the endpoint released, the agent posted `accepted` for, and no `applied` or `blocked` has followed. `mode_change` carries the whole backlog in `units[]` (status `accepted`) so a Collect session's work is applied under the new mode; `final` carries the backlog too, after anything newly released. A `mode_change` or `final` envelope may therefore carry units that were already served once, or nothing at all; treat it as work to apply, not a no-op.
82- A page checkpoint id that was already used gets a `-2`, `-3`, … suffix in `checkpoint_id`, so every batch has its own file and ack route.
83- `mode_at_checkpoint` is the mode carried by the releasing checkpoint, or the mode in force for endpoint-emitted checkpoints.
85`wait` prints one envelope and exits 0:
87```json
89 "schema_version": "live/1",
90 "checkpoint_id": "ck-…",
91 "kind": "silence" | "page_change" | "send" | "instant" | "answer" | "mode_change" | "final",
92 "mode_at_checkpoint": "instant" | "smart" | "collect",
93 "session_status": "live" | "page_lost",
94 "units": [ ], "annotations": [ ], "answers": [ ]
96```
98Acknowledge with `POST /checkpoints/:id/ack` immediately after parsing. A batch served without an acknowledgment is re-served before any new batch, including after a helper restart: batches persist under `state/batches/` until acknowledged.

Checkpoints and the wake envelope

A checkpoint releases every held unit and annotation plus any withdrawal that arrived after an earlier release. On release (checkpoint, instant unit, or mode change) the endpoint marks each unit triaging and broadcasts unit_status: "triaging" on the stream in the same request, before any agent has seen or acknowledged the batch; the page treats that event as the release marker and the board leaves "Heard". A withdrawal before release removes the unit from the batch; one after release is forwarded in the next batch with status: "withdrawn".

  • Page-emitted checkpoints: silence, page_change, send, and final (the overlay's Done control), each carrying the mode at emission. A silence, page_change, or send checkpoint that releases nothing does not wake the agent; final always wakes.
  • Instant mode is continuous pickup: while mode is instant, every accepted unit envelope is released as it lands (together with anything else held) as its own batch, checkpoint_id: "instant-<unit id>", kind: "instant", and wakes the agent at once; a mode event switching to instant releases what is held as a mode_change batch. Page checkpoints under Instant therefore usually release nothing and do not wake. Smart and Collect keep the checkpoint semantics.
  • Endpoint-emitted checkpoints: answer, created whenever an answer event arrives (carries answers[] only and releases no units), and mode_change, created the moment a mode event leaves Collect (KTD12). Both always wake. riffrec exports the always-wake set as ALWAYS_WAKE_TRIGGERS = ["answer", "mode_change", "final"] (KTD9).
  • Accepted backlog. Units the endpoint released, the agent posted accepted for, and no applied or blocked has followed. mode_change carries the whole backlog in units[] (status accepted) so a Collect session's work is applied under the new mode; final carries the backlog too, after anything newly released. A mode_change or final envelope may therefore carry units that were already served once, or nothing at all; treat it as work to apply, not a no-op.
  • A page checkpoint id that was already used gets a -2, -3, … suffix in checkpoint_id, so every batch has its own file and ack route.
  • mode_at_checkpoint is the mode carried by the releasing checkpoint, or the mode in force for endpoint-emitted checkpoints.

wait prints one envelope and exits 0:

{
  "schema_version": "live/1",
  "checkpoint_id": "ck-…",
  "kind": "silence" | "page_change" | "send" | "instant" | "answer" | "mode_change" | "final",
  "mode_at_checkpoint": "instant" | "smart" | "collect",
  "session_status": "live" | "page_lost",
  "units": [ ], "annotations": [ ], "answers": [ ]
}

Acknowledge with POST /checkpoints/:id/ack immediately after parsing. A batch served without an acknowledgment is re-served before any new batch, including after a helper restart: batches persist under state/batches/ until acknowledged.

Raw lines 74 to 99
100### Page-lost
102After the endpoint relays an `applied` notice, a page stream that closes and does not reconnect within the grace window (default 15 s, `CE_LIVE_PAGE_LOST_GRACE_MS`) marks the episode lost. The next `wait` returns one envelope with `session_status: "page_lost"`, empty `units`, and `lost_after_checkpoint_id`; it is queued like any batch, persisted and re-served until acknowledged; further waits then block until the page reconnects or a new batch exists. A reconnect inside the window is a reload and nothing is reported.

Page-lost

After the endpoint relays an applied notice, a page stream that closes and does not reconnect within the grace window (default 15 s, CE_LIVE_PAGE_LOST_GRACE_MS) marks the episode lost. The next wait returns one envelope with session_status: "page_lost", empty units, and lost_after_checkpoint_id; it is queued like any batch, persisted and re-served until acknowledged; further waits then block until the page reconnects or a new batch exists. A reconnect inside the window is a reload and nothing is reported.

Raw lines 100 to 103
104## CLI (`scripts/live-endpoint.js`)
106| Command | Behavior | Exit |
107|---|---|---|
108| `start --root <dir> [--app-origin <origin>] [--host 127.0.0.1] [--port 0] [--owner-pid <pid>] [--trust-proxy <ip>[,<ip>]] [--foreground]` | Prints `{ url, port, page_token, status }` once; writes `state/session.json`. When `state/session.json` has `ended: false`, this is a resume: the same `page_token` and `agent_token` are reused, the `session_id` binding, board, acknowledged `seq`, and un-acknowledged batches are reloaded, the previous port is preferred, and only `pid`, `owner_pid`, and `url` are rewritten (`status: "resumed"`); the previous `--app-origin`, bind host, and `--trust-proxy` list are kept unless given again, so the documented recovery is a bare `start --root <dir>`. `--app-origin` is required for a fresh session. An ended session that still holds un-acknowledged batches also resumes, with its tokens, so the `final` batch survives a restart; `wait` on such a stopped root exits 2 ("not running; resume"), never 1, while batches remain. An ended and drained root is not resumed: `start` mints fresh tokens and rotates the log. Concurrent `start` calls for one root are serialized by `state/start.lock`. Fresh tokens are minted only when there is no state file, or the session ended and drained (`stop`, or `start` on such a root). | 0 |
109| `status --root <dir>` | Prints `{ status, url?, port?, session_ended, board }` from `state/` without contacting the server. | 0 |
110| `stop --root <dir>` | Stops the server, invalidates both tokens, deletes `state/batches/`, keeps `state/log/`. | 0 |
111| `wait --root <dir>` | Reads the agent token from `state/session.json`, long-polls `/wait` with it as a bearer header, prints one envelope. | 0 batch; 1 session ended with nothing held (`{ "status": "session-ended" }`); 2 error; 3 another process holds the wake (`{ "status": "wait-taken" }`, do not stop the endpoint) |
112| `replay --root <dir> --profile <name> --to <endpoint> --token <page token> [--log <log-ended-<stamp>\|dir>]` | Re-emits `state/log/` to another endpoint under an evidence profile, as a fresh session over the page routes. `--log` names an earlier session on the same root (a rotated `state/log-ended-<stamp>/` by name, or any log directory by path); without it the current `state/log/` is replayed. Batches are sized in encoded bytes against the 64 KB cap; a lone envelope is posted bare so anything the source accepted fits the target. Under any profile that keeps frames, unit `evidence.frame_ids` is pruned to the frames actually re-emitted. | 0 |
114`--trust-proxy` names the TLS-terminating proxy or tunnel addresses whose `X-Forwarded-Proto` the mint route may believe; a tunnel client on the same host connects over loopback and needs no entry.
116Owner death (`--owner-pid`) and the idle timeout (default 30 min, `CE_LIVE_IDLE_TIMEOUT_MS`) stop the process without ending the session; `state/` stays intact and `start --root` resumes. Only `/session/end`, `stop`, or a closed tab ends a session: when the bound page last reported `stream_state: unloading` (or its stream went lost) and no stream is open, a request with a different `session_id` ends that session (`{ "kind": "session_closed_by_page" }` in `agent.ndjson`) and, once it drains, opens its own. A reload keeps its `session_id` and resumes instead. `/session/end` ends the board, not the tokens: both stay valid until `stop` (or a fresh, non-resume `start` on the ended root), per I4/KTD18. While the process is up, the same link can run another session: once the ended one drains, the first page request with a new `session_id` rotates `state/log/` to `state/log-ended-<stamp>/`, clears `state/batches/`, resets the board to that id, persists `ended: false`, and logs `{ "kind": "session_opened", "session_id" }` to `agent.ndjson`. Once the `final` batch is acknowledged and nothing is held, `GET /wait` with the still-valid agent token answers `410 { "status": "session-ended" }` and the `wait` CLI exits 1; `GET /status`, `POST /checkpoints/:id/ack` (idempotent) and the `status` CLI keep working until `stop`, so an agent that crashes between the final ack and `stop` can still resume, inspect, and stop the root. After a new board opens, `wait` parks for and serves that session, and `status` reports `session_ended: false`. `stop` retires both tokens.

CLI (scripts/live-endpoint.js)

CommandBehaviorExit
start --root <dir> [--app-origin <origin>] [--host 127.0.0.1] [--port 0] [--owner-pid <pid>] [--trust-proxy <ip>[,<ip>]] [--foreground]Prints { url, port, page_token, status } once; writes state/session.json. When state/session.json has ended: false, this is a resume: the same page_token and agent_token are reused, the session_id binding, board, acknowledged seq, and un-acknowledged batches are reloaded, the previous port is preferred, and only pid, owner_pid, and url are rewritten (status: "resumed"); the previous --app-origin, bind host, and --trust-proxy list are kept unless given again, so the documented recovery is a bare start --root <dir>. --app-origin is required for a fresh session. An ended session that still holds un-acknowledged batches also resumes, with its tokens, so the final batch survives a restart; wait on such a stopped root exits 2 ("not running; resume"), never 1, while batches remain. An ended and drained root is not resumed: start mints fresh tokens and rotates the log. Concurrent start calls for one root are serialized by state/start.lock. Fresh tokens are minted only when there is no state file, or the session ended and drained (stop, or start on such a root).0
status --root <dir>Prints { status, url?, port?, session_ended, board } from state/ without contacting the server.0
stop --root <dir>Stops the server, invalidates both tokens, deletes state/batches/, keeps state/log/.0
wait --root <dir>Reads the agent token from state/session.json, long-polls /wait with it as a bearer header, prints one envelope.0 batch; 1 session ended with nothing held ({ "status": "session-ended" }); 2 error; 3 another process holds the wake ({ "status": "wait-taken" }, do not stop the endpoint)
`replay --root <dir> --profile <name> --to <endpoint> --token <page token> [--log <log-ended-<stamp>\dir>]`Re-emits state/log/ to another endpoint under an evidence profile, as a fresh session over the page routes. --log names an earlier session on the same root (a rotated state/log-ended-<stamp>/ by name, or any log directory by path); without it the current state/log/ is replayed. Batches are sized in encoded bytes against the 64 KB cap; a lone envelope is posted bare so anything the source accepted fits the target. Under any profile that keeps frames, unit evidence.frame_ids is pruned to the frames actually re-emitted.0

--trust-proxy names the TLS-terminating proxy or tunnel addresses whose X-Forwarded-Proto the mint route may believe; a tunnel client on the same host connects over loopback and needs no entry.

Owner death (--owner-pid) and the idle timeout (default 30 min, CE_LIVE_IDLE_TIMEOUT_MS) stop the process without ending the session; state/ stays intact and start --root resumes. Only /session/end, stop, or a closed tab ends a session: when the bound page last reported stream_state: unloading (or its stream went lost) and no stream is open, a request with a different session_id ends that session ({ "kind": "session_closed_by_page" } in agent.ndjson) and, once it drains, opens its own. A reload keeps its session_id and resumes instead. /session/end ends the board, not the tokens: both stay valid until stop (or a fresh, non-resume start on the ended root), per I4/KTD18. While the process is up, the same link can run another session: once the ended one drains, the first page request with a new session_id rotates state/log/ to state/log-ended-<stamp>/, clears state/batches/, resets the board to that id, persists ended: false, and logs { "kind": "session_opened", "session_id" } to agent.ndjson. Once the final batch is acknowledged and nothing is held, GET /wait with the still-valid agent token answers 410 { "status": "session-ended" } and the wait CLI exits 1; GET /status, POST /checkpoints/:id/ack (idempotent) and the status CLI keep working until stop, so an agent that crashes between the final ack and stop can still resume, inspect, and stop the root. After a new board opens, wait parks for and serves that session, and status reports session_ended: false. stop retires both tokens.

Raw lines 104 to 117
Runs code scripts/live-endpoint.js :104
118### Run directory
120```
121state/ 0700
122 session.json 0600 { page_token, agent_token, url, app_origin, host, port, pid, owner_pid, ended, root, log_dir }
123 board.json 0600 units, annotations, answers, checkpoints, acked_checkpoint_ids, acked_seq, page state
124 brief.md session brief the skill writes before start (read at mint, max 3000 chars)
125 server.pid, server.log
126 batches/<checkpoint>.json un-acknowledged wake envelopes
127 log/events.ndjson every accepted envelope, in seq order (frames reference log/frames/<seq>-<id>.jpg; a dropped frame has frame_file null)
128 log/frames/<seq>-<id>.jpg
129 log/agent.ndjson acknowledgments, statuses, asks, mints, and every refused request as
130 { kind: "rejected", method, route, status, reason, seq? } (never secrets:
131 no credential, no query string, no request header or body)
132 log/archive.{zip,json,bin} the page's archive from /session/end
133```

Run directory

state/                  0700
  session.json          0600  { page_token, agent_token, url, app_origin, host, port, pid, owner_pid, ended, root, log_dir }
  board.json            0600  units, annotations, answers, checkpoints, acked_checkpoint_ids, acked_seq, page state
  brief.md                    session brief the skill writes before start (read at mint, max 3000 chars)
  server.pid, server.log
  batches/<checkpoint>.json   un-acknowledged wake envelopes
  log/events.ndjson           every accepted envelope, in seq order (frames reference log/frames/<seq>-<id>.jpg; a dropped frame has frame_file null)
  log/frames/<seq>-<id>.jpg
  log/agent.ndjson            acknowledgments, statuses, asks, mints, and every refused request as
                              { kind: "rejected", method, route, status, reason, seq? } (never secrets:
                              no credential, no query string, no request header or body)
  log/archive.{zip,json,bin}  the page's archive from /session/end
Raw lines 118 to 134
135### Known gaps: crash windows between durable writes
137The helper persists its state as separate synchronous writes (`board.json`, `session.json`, `events.ndjson`, `batches/*.json`, `log/frames/*`). A handler that fails between two of them rolls back; a process that is killed between two of them cannot, and leaves the pair disagreeing until the next start. These windows are accepted as known gaps (decided 2026-09-19 on #1726): each is a few microseconds wide, each consequence is a duplicate or a missed announcement the agent and page already tolerate, and closing them for good means one atomically written state file with an idempotent-by-seq log, which is its own change. A review finding of this class is answered with this list, not patched.
139| Window (killed after … before …) | Consequence | Why tolerated |
140|---|---|---|
141| The event log or frame file is appended … the board records the advanced `acked_seq` | The page retries the seq; the log holds two records for it and `replay` renumbers both | Units and annotations are keyed by id, so the board is not duplicated; a repeated transcript or telemetry record, or a repeated checkpoint or answer wake, is handled as a fresh batch |
142| An `applied` notice is held for a page between stream connections … the page reconnects | The held notice is in memory only and is lost with the process | The unit's `applied` status still replays on connect; only the interviewer's spoken announcement of that moment is missed |
143| The archive is renamed into place … the terminal board and session writes | The archive counts as live-session bytes; the page's retry can be refused 413 when the two do not fit the cap | Narrow window; `stop` and a fresh start recover the root, and the riffer sees the refusal at once rather than a silently lost archive |
144| The `page_lost` batch is persisted … the board clears `watch_for_loss` | The restart re-arms the watch and may queue a second `page_lost` wake for one loss | The second wake carries no units; the agent acknowledges it and continues |

Known gaps: crash windows between durable writes

The helper persists its state as separate synchronous writes (board.json, session.json, events.ndjson, batches/*.json, log/frames/*). A handler that fails between two of them rolls back; a process that is killed between two of them cannot, and leaves the pair disagreeing until the next start. These windows are accepted as known gaps (decided 2026-09-19 on #1726): each is a few microseconds wide, each consequence is a duplicate or a missed announcement the agent and page already tolerate, and closing them for good means one atomically written state file with an idempotent-by-seq log, which is its own change. A review finding of this class is answered with this list, not patched.

Window (killed after … before …)ConsequenceWhy tolerated
The event log or frame file is appended … the board records the advanced acked_seqThe page retries the seq; the log holds two records for it and replay renumbers bothUnits and annotations are keyed by id, so the board is not duplicated; a repeated transcript or telemetry record, or a repeated checkpoint or answer wake, is handled as a fresh batch
An applied notice is held for a page between stream connections … the page reconnectsThe held notice is in memory only and is lost with the processThe unit's applied status still replays on connect; only the interviewer's spoken announcement of that moment is missed
The archive is renamed into place … the terminal board and session writesThe archive counts as live-session bytes; the page's retry can be refused 413 when the two do not fit the capNarrow window; stop and a fresh start recover the root, and the riffer sees the refusal at once rather than a silently lost archive
The page_lost batch is persisted … the board clears watch_for_lossThe restart re-arms the watch and may queue a second page_lost wake for one lossThe second wake carries no units; the agent acknowledges it and continues
Raw lines 135 to 145
146### Evidence profiles (`replay --profile`)
148| Profile | Frames | Annotations | Audio clips, telemetry |
149|---|---|---|---|
150| `anchors_transcript_only` | none | dropped | dropped |
151| `strokes_composite` | `composite` only | kept | dropped |
152| `everything` | all | kept | kept |
154Dropping telemetry also drops the `click`/`navigation`/`network_request`/`console_error` envelopes themselves; under `strokes_composite`, unit `evidence.frame_ids` is pruned to the composite frames actually re-emitted.

Evidence profiles (replay --profile)

ProfileFramesAnnotationsAudio clips, telemetry
anchors_transcript_onlynonedroppeddropped
strokes_compositecomposite onlykeptdropped
everythingallkeptkept

Dropping telemetry also drops the click/navigation/network_request/console_error envelopes themselves; under strokes_composite, unit evidence.frame_ids is pruned to the composite frames actually re-emitted.

Raw lines 146 to 155
156## Mint
158`POST /mint` is proxied through, and owned by, the endpoint. The endpoint reads `OPENAI_API_KEY` from its own environment (`OPENAI_BASE_URL` overrides the upstream, `OPENAI_REALTIME_MODEL` and `OPENAI_REALTIME_VOICE` the session). A request carrying `X-Riffrec-OpenAI-Key` still has that key win over the environment for that one mint, kept for compatibility with a page that sends it; the skill does not offer a paste path (a long-lived key persisted in the app origin's storage is readable by anything running there), the pinned riffrec build has no key box, and the endpoint never stores or logs the header. The endpoint appends `state/brief.md` to the persona after scanning it for secret shapes (known key prefixes, `KEY=`/`TOKEN=`/`SECRET=`-style assignments, URLs with credential parameters or userinfo), and calls `POST /v1/realtime/client_secrets` with:
160```json
162 "expires_after": { "anchor": "created_at", "seconds": 600 },
163 "session": {
164 "type": "realtime",
165 "model": "gpt-realtime",
166 "instructions": "<persona>\n\n[SESSION BRIEF]\n<brief>",
167 "tools": [ "<the five tools below>" ],
168 "tool_choice": "auto",
169 "audio": {
170 "input": {
171 "transcription": { "model": "gpt-4o-mini-transcribe" },
172 "turn_detection": { "type": "semantic_vad", "create_response": true, "interrupt_response": true }
173 },
174 "output": { "voice": "marin" }
175 }
176 }
178```
180The endpoint never logs request headers or mint bodies and never persists the minted `client_secret`.

Mint

POST /mint is proxied through, and owned by, the endpoint. The endpoint reads OPENAI_API_KEY from its own environment (OPENAI_BASE_URL overrides the upstream, OPENAI_REALTIME_MODEL and OPENAI_REALTIME_VOICE the session). A request carrying X-Riffrec-OpenAI-Key still has that key win over the environment for that one mint, kept for compatibility with a page that sends it; the skill does not offer a paste path (a long-lived key persisted in the app origin's storage is readable by anything running there), the pinned riffrec build has no key box, and the endpoint never stores or logs the header. The endpoint appends state/brief.md to the persona after scanning it for secret shapes (known key prefixes, KEY=/TOKEN=/SECRET=-style assignments, URLs with credential parameters or userinfo), and calls POST /v1/realtime/client_secrets with:

{
  "expires_after": { "anchor": "created_at", "seconds": 600 },
  "session": {
    "type": "realtime",
    "model": "gpt-realtime",
    "instructions": "<persona>\n\n[SESSION BRIEF]\n<brief>",
    "tools": [ "<the five tools below>" ],
    "tool_choice": "auto",
    "audio": {
      "input": {
        "transcription": { "model": "gpt-4o-mini-transcribe" },
        "turn_detection": { "type": "semantic_vad", "create_response": true, "interrupt_response": true }
      },
      "output": { "voice": "marin" }
    }
  }
}

The endpoint never logs request headers or mint bodies and never persists the minted client_secret.

Raw lines 156 to 181
182## Interviewer tools
184Five flat function tools, riffrec `LIVE_TOOLS` (`src/live/tools.ts`) copied verbatim into `INTERVIEWER_TOOLS` and pinned by `tests/fixtures/ce-polish-live/live-tools.json`. No tool emits checkpoints or reports state: the page owns all timing and tells the interviewer about page-side facts as text conversation items. No tool *parameter* carries image content: `look_at_screen` asks the page for a screenshot, and the page attaches it as an image conversation item before the tool result.
186| Tool | Parameters | Purpose |
187|---|---|---|
188| `record_unit` | `statement`, `anchors: string[]` (an anchor id from a `[PAGE]` note, "this"/"here" meaning the most recent one, or the riffer's own words for the element), `transcript_excerpt` | Record one requested change; once per change; never for questions, thinking aloud, or short utterances without a change verb. |
189| `update_unit` | `unit_id`, `statement?`, `anchors_add?: string[]` | Refine a unit the interviewer recorded; rejected once it has left `initial`, in which case the refinement is recorded as a new unit. |
190| `withdraw_unit` | `unit_id`, `reason?` | Retract a unit the riffer took back; never because the interviewer is unsure. |
191| `relay_answer` | `unit_id`, `answer_text` | Relay the riffer's answer to a question that arrived from the endpoint; not for the interviewer's own clarifying questions. |
192| `look_at_screen` | `reason?` | See the riffer's screen right now: for how something looks when the announced anchors do not settle it, for "can you see my screen?", and when asked to look. Never more than once per riffer turn, never to browse. |
194Every parameter schema carries `additionalProperties: false`.

Interviewer tools

Five flat function tools, riffrec LIVE_TOOLS (src/live/tools.ts) copied verbatim into INTERVIEWER_TOOLS and pinned by tests/fixtures/ce-polish-live/live-tools.json. No tool emits checkpoints or reports state: the page owns all timing and tells the interviewer about page-side facts as text conversation items. No tool parameter carries image content: look_at_screen asks the page for a screenshot, and the page attaches it as an image conversation item before the tool result.

ToolParametersPurpose
record_unitstatement, anchors: string[] (an anchor id from a [PAGE] note, "this"/"here" meaning the most recent one, or the riffer's own words for the element), transcript_excerptRecord one requested change; once per change; never for questions, thinking aloud, or short utterances without a change verb.
update_unitunit_id, statement?, anchors_add?: string[]Refine a unit the interviewer recorded; rejected once it has left initial, in which case the refinement is recorded as a new unit.
withdraw_unitunit_id, reason?Retract a unit the riffer took back; never because the interviewer is unsure.
relay_answerunit_id, answer_textRelay the riffer's answer to a question that arrived from the endpoint; not for the interviewer's own clarifying questions.
look_at_screenreason?See the riffer's screen right now: for how something looks when the announced anchors do not settle it, for "can you see my screen?", and when asked to look. Never more than once per riffer turn, never to browse.

Every parameter schema carries additionalProperties: false.

Raw lines 182 to 195
196### `look_at_screen`
198When the model calls it, the page grabs the current view (or takes the latest buffered frame when it is under 500 ms old), sends a `conversation.item.create` with a `user` message whose content is `[{ type: "input_image", image_url: "data:image/jpeg;base64,…" }, { type: "input_text", text: "<caption>" }]`, then the `function_call_output`, then `response.create` (deferred until the calling response's `response.done` when one is active). Grabs are downscaled to 1280 px wide. Result shapes:
200| Result | Meaning |
201|---|---|
202| `{ ok: true, frame_id, route, age_ms, fresh }` | The screenshot precedes this result in the conversation. `fresh` is false when the latest buffered frame stood in. |
203| `{ ok: false, reason: "no_frame", detail }` | The screen is not shared or capture is paused. |
204| `{ ok: false, reason: "frames_disabled", detail }` | The evidence profile is `frames: "none"`; nothing visual leaves the page. |
205| `{ ok: false, reason: "send_failed", detail }` | The image item could not be sent. |
207The frame is buffered like a gesture frame (the next unit attaches to it) and released to this endpoint as an ordinary `frame` envelope even under `frames: "one"`. Nothing on the wire changes for frames the interviewer sees. Image input needs nothing in the session body the mint sends: the Realtime API takes `input_image` content parts on any user message and its session configuration carries only `output_modalities`.

look_at_screen

When the model calls it, the page grabs the current view (or takes the latest buffered frame when it is under 500 ms old), sends a conversation.item.create with a user message whose content is [{ type: "input_image", image_url: "data:image/jpeg;base64,…" }, { type: "input_text", text: "<caption>" }], then the function_call_output, then response.create (deferred until the calling response's response.done when one is active). Grabs are downscaled to 1280 px wide. Result shapes:

ResultMeaning
{ ok: true, frame_id, route, age_ms, fresh }The screenshot precedes this result in the conversation. fresh is false when the latest buffered frame stood in.
{ ok: false, reason: "no_frame", detail }The screen is not shared or capture is paused.
{ ok: false, reason: "frames_disabled", detail }The evidence profile is frames: "none"; nothing visual leaves the page.
{ ok: false, reason: "send_failed", detail }The image item could not be sent.

The frame is buffered like a gesture frame (the next unit attaches to it) and released to this endpoint as an ordinary frame envelope even under frames: "one". Nothing on the wire changes for frames the interviewer sees. Image input needs nothing in the session body the mint sends: the Realtime API takes input_image content parts on any user message and its session configuration carries only output_modalities.

Raw lines 196 to 208
209### Page → interviewer announcements
211Page-side facts reach the interviewer as `system`-role `input_text` items, never as tool calls, and are held while a response is active. The persona keys on these shapes:
213| Shape | When |
214|---|---|
215| `[PAGE] The riffer clicked <description> (anchor id: anchor_NNNN).` | Every click on the host page. `<description>` is `<accessible name or tag>[ with text "<visible text, ≤80 chars>"][ in component <Component>] (selector <css>, route </path>)`. Repeated clicks on the same selector inside 1 s refresh the anchor but send no second note. Clicks on riffrec's own panel are not announced. |
216| `[PAGE] The riffer drew on <description> (anchor id: anchor_NNNN).` | A completed stroke. |
217| `[PAGE] The riffer pinned <description> (anchor id: anchor_NNNN).` | A pin. |
218| `[PAGE] Screenshot of the riffer's current view on </path>, <attached because they just clicked there \| attached because they just drew there \| attached because they referred to something on screen \| captured just now \| captured N s ago> (frame id: frame_NNNN). The riffrec panel docked at the top right is not part of the app.` | The caption of an image item: proactive (first three) or `look_at_screen` (last two). Proactive frames are rate-limited to one per 5 s, a `look_at_screen` counts against the same limit, and speech triggers only when the transcript contains a deictic or visual word. |
219| `[PAGE] The riffer muted their microphone; expect silence.` / `… unmuted their microphone.` | Mute toggles. |
220| `[PAGE] The page lost its connection to the coding agent and is buffering; units still land on the board.` / `… reconnected …` | Stream state changes. |
221| `[ENDPOINT QUESTION] The coding agent asks about unit <id>: "<question>" …` | An `ask` from this endpoint, voiced at the next pause. |
222| `[RECONNECT] Your connection was replaced mid-session. …` | The re-seed on a replacement connection. |
224Anchor ids are `anchor_NNNN`, minted per connection in announcement order; the most recent one is what "this", "here", and "that" resolve to when a `record_unit` reference matches nothing else within 8 s.

Page → interviewer announcements

Page-side facts reach the interviewer as system-role input_text items, never as tool calls, and are held while a response is active. The persona keys on these shapes:

ShapeWhen
[PAGE] The riffer clicked <description> (anchor id: anchor_NNNN).Every click on the host page. <description> is <accessible name or tag>[ with text "<visible text, ≤80 chars>"][ in component <Component>] (selector <css>, route </path>). Repeated clicks on the same selector inside 1 s refresh the anchor but send no second note. Clicks on riffrec's own panel are not announced.
[PAGE] The riffer drew on <description> (anchor id: anchor_NNNN).A completed stroke.
[PAGE] The riffer pinned <description> (anchor id: anchor_NNNN).A pin.
`[PAGE] Screenshot of the riffer's current view on </path>, <attached because they just clicked there \attached because they just drew there \attached because they referred to something on screen \captured just now \captured N s ago> (frame id: frame_NNNN). The riffrec panel docked at the top right is not part of the app.`The caption of an image item: proactive (first three) or look_at_screen (last two). Proactive frames are rate-limited to one per 5 s, a look_at_screen counts against the same limit, and speech triggers only when the transcript contains a deictic or visual word.
[PAGE] The riffer muted their microphone; expect silence. / … unmuted their microphone.Mute toggles.
[PAGE] The page lost its connection to the coding agent and is buffering; units still land on the board. / … reconnected …Stream state changes.
[ENDPOINT QUESTION] The coding agent asks about unit <id>: "<question>" …An ask from this endpoint, voiced at the next pause.
[RECONNECT] Your connection was replaced mid-session. …The re-seed on a replacement connection.

Anchor ids are anchor_NNNN, minted per connection in announcement order; the most recent one is what "this", "here", and "that" resolve to when a record_unit reference matches nothing else within 8 s.

Raw lines 209 to 225
226### Reconciliation after connect
228The page answers the tool calls and attaches the screenshots, so once the data channel opens it reads the session this endpoint minted (`session.created`) and sends one `session.update` only when something it must be able to answer is missing: tools in `LIVE_TOOLS` the mint did not carry are appended (tools the endpoint did define are kept verbatim, in the endpoint's order); a persona without the `[SCREEN CONTEXT]` section gets it appended; a session with no riffrec tool at all gets the default persona and all of `LIVE_TOOLS`. This helper copies both verbatim and is never patched. An endpoint persona must not tell the interviewer it cannot see the screen: the page contradicts that with notes and frames.

Reconciliation after connect

The page answers the tool calls and attaches the screenshots, so once the data channel opens it reads the session this endpoint minted (session.created) and sends one session.update only when something it must be able to answer is missing: tools in LIVE_TOOLS the mint did not carry are appended (tools the endpoint did define are kept verbatim, in the endpoint's order); a persona without the [SCREEN CONTEXT] section gets it appended; a session with no riffrec tool at all gets the default persona and all of LIVE_TOOLS. This helper copies both verbatim and is never patched. An endpoint persona must not tell the interviewer it cannot see the screen: the page contradicts that with notes and frames.

Raw lines 226 to 229
230## Default persona
232Verbatim riffrec `DEFAULT_INTERVIEWER_INSTRUCTIONS` (`src/live/realtime/persona.ts`), pinned by `tests/fixtures/ce-polish-live/interviewer-instructions.txt`; the mint appends `\n\n[SESSION BRIEF]\n<brief>` when `state/brief.md` exists. The closing `[SCREEN CONTEXT]` section is the marker the page checks for.
234> You are the riffrec interviewer: a calm, terse product partner listening to a designer or developer (the riffer) talk through changes they want while they click and draw on their own running app. The page tells you what they click, draw on, and pin, and shows you the screen when you ask for it; the last section says how.
235>
236> Your job is to turn what the riffer says into units of change on a shared board, one unit per requested change, using the record_unit tool. A sentence that asks for three things becomes three record_unit calls. Never call record_unit for questions, thinking aloud, praise, or utterances shorter than three words without a change verb.
237>
238> Ask immediately, in one short sentence, when the target element or the intended value is ambiguous: which element, which side, what color, how much. Otherwise stay quiet and let the riffer keep talking. Do not narrate, summarize, or confirm each unit aloud; the board already shows it.
239>
240> Never invent anchors. Use only the anchor ids the page announced or the element references the riffer named. When the riffer names no element and no anchor was announced, record the unit with an empty anchors list.
241>
242> When the riffer takes back a change, call withdraw_unit and acknowledge it aloud in a few words. When they refine a change already on the board, call update_unit; if it is rejected because the unit was already picked up, record the refinement as a new unit.
243>
244> When a note marked [ENDPOINT QUESTION] arrives, read the question to the riffer in your own words at the next pause and, once they answer, call relay_answer with their answer for that unit. Never answer such a question yourself.
245>
246> Keep every spoken turn under two sentences. Speak the riffer's language.
247>
248> [SCREEN CONTEXT]
249> The page keeps you informed about the screen, and this section is authoritative about it: it supersedes any earlier statement that you cannot see the page or must not claim to.
250> Every click the riffer makes arrives as a system note tagged [PAGE] that names the element (its component, visible text, selector, and route) and gives it an anchor id. Drawings and pins arrive the same way. The most recent note is what "this", "here", and "that" refer to: put its anchor id in record_unit's anchors, and never ask which element they mean when a note arrived within the last few seconds.
251> You can also see the screen. Call look_at_screen when the riffer refers to how something looks, asks whether you can see their screen, or asks you to look; the page attaches a screenshot of the current view and you may then describe or refer to what is in it. The riffrec panel docked at the top right is not part of the app. Never say you cannot see the screen: if no frame is available the tool result says so, and you ask the riffer to describe what they see instead.
253The executable copies of the tools and persona live in `scripts/live-endpoint.js`; change the script, this file, and the two fixtures together, from riffrec's source at the pinned commit.

Default persona

Verbatim riffrec DEFAULT_INTERVIEWER_INSTRUCTIONS (src/live/realtime/persona.ts), pinned by tests/fixtures/ce-polish-live/interviewer-instructions.txt; the mint appends \n\n[SESSION BRIEF]\n<brief> when state/brief.md exists. The closing [SCREEN CONTEXT] section is the marker the page checks for.

You are the riffrec interviewer: a calm, terse product partner listening to a designer or developer (the riffer) talk through changes they want while they click and draw on their own running app. The page tells you what they click, draw on, and pin, and shows you the screen when you ask for it; the last section says how. Your job is to turn what the riffer says into units of change on a shared board, one unit per requested change, using the record_unit tool. A sentence that asks for three things becomes three record_unit calls. Never call record_unit for questions, thinking aloud, praise, or utterances shorter than three words without a change verb. Ask immediately, in one short sentence, when the target element or the intended value is ambiguous: which element, which side, what color, how much. Otherwise stay quiet and let the riffer keep talking. Do not narrate, summarize, or confirm each unit aloud; the board already shows it. Never invent anchors. Use only the anchor ids the page announced or the element references the riffer named. When the riffer names no element and no anchor was announced, record the unit with an empty anchors list. When the riffer takes back a change, call withdraw_unit and acknowledge it aloud in a few words. When they refine a change already on the board, call update_unit; if it is rejected because the unit was already picked up, record the refinement as a new unit. When a note marked [ENDPOINT QUESTION] arrives, read the question to the riffer in your own words at the next pause and, once they answer, call relay_answer with their answer for that unit. Never answer such a question yourself. Keep every spoken turn under two sentences. Speak the riffer's language. [SCREEN CONTEXT] The page keeps you informed about the screen, and this section is authoritative about it: it supersedes any earlier statement that you cannot see the page or must not claim to. Every click the riffer makes arrives as a system note tagged [PAGE] that names the element (its component, visible text, selector, and route) and gives it an anchor id. Drawings and pins arrive the same way. The most recent note is what "this", "here", and "that" refer to: put its anchor id in record_unit's anchors, and never ask which element they mean when a note arrived within the last few seconds. You can also see the screen. Call look_at_screen when the riffer refers to how something looks, asks whether you can see their screen, or asks you to look; the page attaches a screenshot of the current view and you may then describe or refer to what is in it. The riffrec panel docked at the top right is not part of the app. Never say you cannot see the screen: if no frame is available the tool result says so, and you ask the riffer to describe what they see instead.

The executable copies of the tools and persona live in scripts/live-endpoint.js; change the script, this file, and the two fixtures together, from riffrec's source at the pinned commit.

Raw lines 230 to 253
Involves a person once they answer :244
Runs code scripts/live-endpoint.js :253