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— thePOST /v1/sessionsresponse is the stream (one-call shape). - Or create plain (
201+ id), thenGET /v1/sessions/{id}/stream.
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:
Last-Event-ID:
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:
- Rebuild from the durable log: page
GET /v1/sessions/{id}/events?after=0(then?after=<next>whilehas_more) and replay every event through your normal handler. The paged envelope serves the same event objects flat, withseqmerged in —{"data":[{"seq":2,"phase":"search-intent",…}], "has_more":false,"next":6}; the event is not nested under adataoreventmember, andseqis the SSEid:for that event. - Attach live: connect
GET …/streamwithLast-Event-IDset to the lastseqyou replayed. The stream continues from there — same coordinate system, nothing lost, nothing duplicated.
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 aRetry-Afterheader. Poll again; a naive loop ofGET …/resultcalls is the correct implementation.
?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
Passwebhook_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.20 at terminal).
- The 200 body still says
runninguntil the drain finishes; the terminal arrives asstatus: "stopped"withstop: "stopped"onsearch-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 fromstatus.
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 SSEid:.GET /v1/sessions/{id}/rows— the complete corpus, paged. The stream’ssearch-source.rowsare bounded previews (500 rows / 1.5 MB per source, stated intruncated); this endpoint is the real population.GET /v1/sessions/{id}/result— the terminal answer, instantly (200).
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.