Skip to main content
A run legitimately takes tens of minutes (measured 11–68 minutes), and a dropped connection never stops it: the run continues server-side and every event lands in a durable, replayable log. So the three ways to watch differ only in ergonomics: Use them together freely — e.g. create with a webhook_url and stream the same run.

SSE streaming

Two ways to get the stream:
  • Create with Accept: text/event-stream — the POST /v1/sessions response is the stream (one-call shape).
  • Or create plain (201 + id), then GET /v1/sessions/{id}/stream.
Each SSE message carries id: (the event’s sequence number) and data: (one JSON event from the event vocabulary). The first message of a live run is the meta envelope:
Resume is built in. Sequence numbers are global per session and monotonic across turns. If your connection drops, reconnect with Last-Event-ID:
You get every event after 41 — replayed instantly from the log if the run has moved on, live from the wire if it hasn’t. A dropped stream is an inconvenience, never data loss. Reload recovery: rebuild, then attach. Last-Event-ID alone assumes your in-memory state survived the drop. After a full page reload (or process restart) it didn’t — resuming the stream from your stored sequence number gives you the tail with none of the state that came before it. The recipe every client needs:
  1. Rebuild from the durable log: page GET /v1/sessions/{id}/events?after=0 (then ?after=<next> while has_more) and replay every event through your normal handler. The paged envelope serves the same event objects flat, with seq merged in{"data":[{"seq":2,"phase":"search-intent",…}], "has_more":false,"next":6}; the event is not nested under a data or event member, and seq is the SSE id: for that event.
  2. Attach live: connect GET …/stream with Last-Event-ID set to the last seq you replayed. The stream continues from there — same coordinate system, nothing lost, nothing duplicated.
Persist the last seen seq (and the session id) somewhere that survives reload; that pair is the whole resume state. Reads are free and unthrottled, so rebuilding from 0 on every reload is a correct, cheap default. Stream lifetime: the stream stays open only while a run is in flight; it closes after the turn’s terminal envelope (session-turn). Connecting to a session with no run in flight replays the log from your Last-Event-ID (or 0) and closes — it does not hang open awaiting future turns. Each spending turn gets its own stream connection. Expect quiet stretches. Between the fan-out plan and the first harvested rows the agent is driving real browsers; the only traffic may be orchestrate decision events and heartbeat pulses. Any event — including phases you don’t recognize — means the run is alive. Render nothing for unknown phases; never error on them. Streaming from a browser? EventSource cannot connect (Bearer-only auth), CORS is open, and shipping a live key to a page is the one thing you must not do — the whole story, including the recommended patterns, is in Streaming in the browser.

Long-poll: one blocking call

GET /v1/sessions/{id}/result?timeout=240 blocks server-side until the run reaches a terminal or the timeout lapses:
  • 200 — always and only a terminal. The body is the result: status, qualified, rows, unchecked, spend_usd, receipt ids.
  • 202 — the timeout lapsed first. The body is the session object (so you can show progress), with a Retry-After header. Poll again; a naive loop of GET …/result calls is the correct implementation.
Use timeout ≤ 240 and loop on the 202. The server accepts up to timeout=600, but stock Node fetch (undici) times out response headers at 300 seconds by default — a single timeout=600 call dies at exactly 5 minutes with an opaque TypeError: fetch failed while the run continues server-side (nothing is lost; the record is durable — but your harvest path just broke). ≤ 240 leaves headroom for every mainstream HTTP client and proxy; the 202 + Retry-After loop is the designed pattern, not a workaround. If you must hold longer in Node, configure an undici Agent with headersTimeout: 0.
?format=ndjson streams the rows one per line for bulk ingestion. Read GETs are not rate-limited — polling is never the thing we throttle.

Webhooks: no connection at all

Pass webhook_url on create (or register endpoints via /v1/webhooks) and the terminal comes to you: session.answered, session.abstained, session.error, session.stopped — plus session.proposal when a follow-up turn needs confirmation. Payloads are summaries with ids; fetch rows via the API. Deliveries are signed per the Standard Webhooks spec and retried for 24 hours. Full catalog, signing, and verification: Webhooks.

Stopping a run — in-flight passes drain

POST /v1/sessions/{id}/stop is honored immediately in one specific sense: nothing new is dispatched after the 200. But it is not an abort — set the right expectation at the moment you call it:
  • In-flight passes drain to completion. A source pass that is already spending finishes (each up to its own pass budget, ~10 minutes), and several can be in flight at once. In a measured run, a stop posted mid-harvest took ~20 minutes to reach the terminal, and spend continued for the draining work (0.06atthestopcall,0.06 at the stop call, 0.20 at terminal).
  • The 200 body still says running until the drain finishes; the terminal arrives as status: "stopped" with stop: "stopped" on search-done. Watch it like any other terminal (stream, long-poll, webhook).
  • Losing the stop-vs-terminal race is harmless: a stop posted after the run ended returns 409 session_terminal — treat it as success and read the terminal from status.
If your UI has a stop button, say “finishing in-flight work — this can take minutes” the moment it’s pressed, not when the terminal lands.

Reading a finished run (free, forever within retention)

The record is durable and free to re-read for at least 90 days:
  • GET /v1/sessions/{id} — status, spend, turn count.
  • GET /v1/sessions/{id}/events?after=n — the full typed event log, paged on the same sequence numbers as the SSE id:.
  • GET /v1/sessions/{id}/rows — the complete corpus, paged. The stream’s search-source.rows are bounded previews (500 rows / 1.5 MB per source, stated in truncated); this endpoint is the real population.
  • GET /v1/sessions/{id}/result — the terminal answer, instantly (200).
Re-reading never re-runs and never re-bills. If you want fresh data for the same question, that’s refresh — a separate, explicit, priced action.

Statuses, mapped honestly

running → judging → answered | abstained | error | stopped (open enum — tolerate unknown values). One subtlety worth stating twice: a stream that has shown search-done is a run still judging, not a lost answer — the adjudicated search-answer and the terminal envelope follow. Wait for the terminal; GET …/result does this correctly for you. A run that starts and ends badly — abstained, error, stopped — is a status, not an HTTP error. The ledger settles only for signed receipts and the rest of the reservation is released. HTTP errors are reserved for “this request did not start a run” — see Errors.