GET /api/public/v1/runs/{runId}/stream is the primary live-transport for a turn’s output. It requires the runs:read scope — see Authentication for how scopes and bearer keys work. This page assumes you already have a runId from sending a message.
Minimal client example
Manual testing with curl
--http1.1 is recommended for manual testing: the 15-second heartbeat (below) exists specifically because idle HTTP/1.1 connections get dropped by intermediaries, and forcing HTTP/1.1 with curl reproduces the exact framing behavior the heartbeat is protecting against.
Event types
Every data event’s SSE frame has the shape:id: field carries the event’s stream index and becomes the Last-Event-ID a reconnecting client sends back — see Resume semantics.
run.status
message.delta
tool.status
Ordering key is Group and order your UI by
(turnIndex, callIndex) — never array position or arrival order. Parallel tool calls in the same turn are always separate tool.status events keyed by toolInvocationId, never collapsed into one field. For example, two tools dispatched in parallel in the same turn produce two independent events:(turnIndex, callIndex), and key rows by toolInvocationId — never assume delivery order reflects call order.waitpoint.created / waitpoint.resolved
run.completed / run.failed / run.cancelled
Any of these three events is terminal: the server closes the connection immediately after sending it.
stream.reset
See Long-turn reconnect below — this is not an error condition.
Resume semantics
A reconnecting client should send the last event id it received as aLast-Event-ID request header. The server resumes from parsedLastEventId + 1.
A fresh subscriber (no Last-Event-ID header) resumes from the ?fromIndex= query parameter, defaulting to 0 if omitted. This is explicitly not lastStreamIndex + 1 from a run.status snapshot — lastStreamIndex is a reconciliation cursor only, never a default resume point. fromIndex in the query string only matters for a client resuming a session it already has a cursor for (e.g. after receiving stream.reset’s nextFromIndex); it carries no credential weight, since the Authorization header is the only accepted credential location — ?access_token= is never accepted.
Reconnect protocol
Long-turn reconnect is expected protocol
The route runs withmaxDuration=300 (the Vercel Fluid-compute ceiling for this runtime). The server proactively closes the connection at 285 seconds — well before Vercel would cut it mid-frame — by sending a stream.reset event ({"reason": "duration_limit", "nextFromIndex": ...}), then an SSE retry: 1000 directive, then closing.
If your turn involves a long-running tool call (e.g. a media-generation tool), crossing this 285-second boundary is expected, not a bug. The correct client behavior is to reconnect using Last-Event-ID (or ?fromIndex=<nextFromIndex>) and keep consuming — the run keeps executing server-side throughout; nothing is lost or restarted.
Heartbeat
The server sends a: ping SSE comment every 15 seconds to keep intermediary connections (proxies, load balancers) from treating the connection as idle and dropping it. This matters most over HTTP/1.1, where idle connections are more aggressively reaped by intermediaries than over HTTP/2 — see the curl -N --http1.1 note above for reproducing this locally.
Terminal-before-connect
If the run is already in a terminal status (completed, failed, or cancelled) at the moment the stream connection opens, the server sends the run.status snapshot, then immediately the corresponding terminal event, and closes. It does not attempt to subscribe to a finished run’s live output — a finished run has nothing left to emit, and subscribing anyway would hang against the underlying realtime provider’s own read timeout instead of returning promptly.
Live transport vs. REST recovery
SSE is the only live transport for a run — this endpoint re-emits events via Trigger.dev Realtime. Polling is never the primary mechanism for consuming a run’s progress.GET /api/public/v1/runs/{runId} (REST) exists as a recovery/reconciliation fallback: use it after a stream disconnect you don’t want to reconnect from, or to confirm a run’s final status and totalCreditsUsed after the fact. Do not poll it in a loop during normal operation — reconnect the SSE stream instead.
CORS
This route is public CORS (Access-Control-Allow-Origin: *), since bearer auth carries no ambient credential for an arbitrary origin to ride on. See Authentication’s security model for the full reasoning.
Related
- Authentication — scopes, key lifecycle, 401 vs. 403
- Quickstart — end-to-end flow from creating a chat through streaming a response
- MCP — connecting an MCP client directly instead of consuming SSE yourself
- Webhooks — an alternative to polling/streaming if you want push notifications on run completion