NorthlightSDK
Guides

Stream a run

Forward a run's events to a browser, follow the reply to one message, and resume after a reload.

Forward a run to a browser

Your server keeps the runtime token and connection. The browser connects only to your server. Call forwardRunEvents(runtime, runId, {after: cursorFromRequest(request), signal: request.signal}) and return the Fetch Response. After a host cuts the browser connection, EventSource sends its last SSE id as Last-Event-ID; cursorFromRequest prefers it over the URL's after query parameter.

Every forwarded run event is an unnamed SSE record with its sequence_number as id and the complete event object as data. One handler receives all of them:

const source = new EventSource(`/api/runs/${runId}/events?after=${cursor}`);
source.onmessage = ({data}) => {
  const event = JSON.parse(data);
  if (event.type === 'stream.refused') { showRefusal(event.error); source.close(); return; }
  if (event.type === 'stream.reconnecting') { showConnectionFailure(event.error); return; }
  if (event.type === 'stream.reconnected') { clearConnectionFailure(); return; }
  if (event.type === 'stream.idle') { source.close(); markIdle(event.cursor); return; } // reopen on your own trigger
  showRunEvent(event);
};

The four stream.* records are notices from the forwarder, not run events. They have no sequence_number or SSE id.

  • stream.idle ({type: 'stream.idle', cursor}) means the runtime closed the stream after 60 s with no running turn and no new event. The helper writes it and ends the response. Close the EventSource on it and reopen with after=cursor on your own trigger: the person sends a message, or your server handles a run notice (Background runs). Without that, the browser would reconnect at once and keep a hosted runtime awake.
  • stream.refused carries the full error envelope, including stream_gap's details.latest_sequence and details.earliest_sequence. The helper ends the response after a refusal. The SDK does not recover a gap by skipping events.

Resume after a reload or a gap

When the page reloads mid-reply, or you get stream_gap, read runs.trace(runId) through your server and show each node's in_progress_message ({thinking, text} or null) as that node's open message. Then resume runs.events(runId, {after: trace.latest_sequence}), or forward with that cursor. Do not use a cursor from a separate runs.get.

Keep appending new deltas to the open message, but replace its thinking or text with the complete content repeated by node.thinking.done or node.output_text.done. That shows the message from its beginning without duplicating content. runs.get and every runs.list entry carry latest_sequence and last_activity_at; a cursor equal to latest_sequence is not a gap even when older events have expired.

Follow the reply to one message

A chat app sends a message and shows the answer to it. runs.reply(runId, sent, {signal?}) takes what runs.message returned and follows only the turn that delivers that message:

const sent = await runtime.runs.message(runId, 'What did the report say?');
for await (const event of runtime.runs.reply(runId, sent)) {
  if (event.type === 'text.delta') append(event.delta);
  if (event.type === 'turn.completed' && event.error) showError(event.error); // {code: 'content_refused' | 'model_error', message}
}
// or collect it: text as it streams, then the whole text and how the turn ended
const {text, ended, error, outcome} = await runtime.runs.reply(runId, sent).collect({onDelta: (delta) => append(delta)});
// ended: 'completed' | 'error' (error set) | 'waiting_on_user' | 'settled' (outcome set)
EventFieldsFrom
text.deltadeltanode.output_text.delta
message.donetext, stop_reasonnode.output_text.done, once per assistant message
turn.completedrun_status, stop_reason, error?, message_idsnode.turn.completed; always last
run.settledoutcomenode.settled, when the run settles before the turn completes; always last

Every event also carries its sequence_number. The reply ends after turn.completed or run.settled and closes its stream. .text() resolves to the text of the message.done events joined with a blank line. A reply can be consumed once.

To get a reply to the run's first message, pass it as runs.start({prompt, message}) and follow runs.reply(run.run_id, run.message!). A message stored with the run always joins the first turn.

A refused or failed model call ends the turn with an empty message.done (stop_reason: 'error') and turn.completed.error; the run stays usable. An interrupt, runs.cancel or a closed node ends the turn with stop_reason: 'aborted' and no error. An expired cursor throws APIError with code stream_gap; aborting signal rejects with APIUserAbortError.

A quiet run

A runtime that sleeps when nothing holds a connection to it ends a run's stream after 60 s with no running turn and no new event. RunStream reports that and does not reconnect:

const stream = runtime.runs.events(runId, {after: cursor});
for await (const event of stream) handle(event);
const end = await stream.whenEnded(); // rejects on a transport failure or abort()
if (end.reason === 'idle') {
  // the run is quiet and nothing was missed; reopen later with runtime.runs.events(runId, {after: end.cursor})
}

A settled run ends with reason: 'settled'. runs.reply reopens through an idle close on its own.

Typed events

RunEvent is the union of every run event, discriminated on type. RunEventOf<'node.turn.completed'> picks one, and RunStream.on(type, listener) passes the listener that event. An event type a newer runtime adds and your SDK version does not declare is still delivered as-is.

On this page