Northlight
SDK

Streaming

Follow assistant text, tool activity, reports, and outcomes as an agent runs.

Watch a run as it works: assistant text as it is produced, tool calls as they start and finish, reports as they are pushed, and the settled outcome.

Streaming a run of an app (forwarding it to a browser, following the reply to one message, event: idle, typed run events) is on the SDK docs site: Stream a run.

The SDK surface

const stream = client.nodes.stream({ prompt: 'Fix the failing test', cwd }, { headers: { 'x-trace': 'run-9' } });

stream.on('node.output_text.delta', (event) => process.stdout.write(event.delta));

for await (const event of stream) {
  // the same events, typed by `type`
}

const outcome = await stream.finalOutcome(); // resolves on node.settled
stream.abort(); // ends the HTTP response; the node keeps running
MemberBehaviour
client.nodes.stream(params, options?)Creates the node, then opens its event stream. Returns synchronously; stream.node is a Promise<NodeDetailDTO> for the created node. The request options apply to creation and to opening the stream, except timeout, because a stream has no wall-clock timeout.
client.nodes.events(id, options?)Validates and retrieves an existing node, then streams it. options is NodeEventsOptions: after plus request headers, signal, and maxRetries; it deliberately has no timeout.
.on(type, listener) / .off(type, listener)Typed event emitter; each returns the NodeStream.
[Symbol.asyncIterator]()Yields the same NodeEventDTO union.
.finalOutcome()Returns Promise<NodeOutcomeDTO>, resolving on node.settled and rejecting on a terminal error event or a connection failure.
.abort()Aborts the underlying fetch. An in-flight iterator and finalOutcome() reject with APIUserAbortError. Passing your own signal in the options aborts the stream the same way; NodeStream exposes no signal property.

The method is called events, not subscribe: a subscription is already a push-delivery edge between two nodes on the canvas, and one word must not mean two things.

A stream is an observer, never a lifecycle hold. Disconnecting drops your subscriber and leaves the node running. Opening a stream never revives a dormant node — call client.nodes.revive(id) first if that is what you want.

Activity helper

followActivity(stream, { describe? }) turns streamed tool-call events into immutable ActivityStep[] snapshots for a plain activity feed. describeToolDefault(tool, summary) names the common tools; pass describe to use labels for your application or return null to hide a tool call. Tool summaries describe the argument shape, never argument values or raw arguments.

import { followActivity } from '@north-light/sdk';

for await (const steps of followActivity(stream)) {
  render(steps);
}

A step starts as running; a node.tool_call.completed event changes it to done when status is ok or failed when status is error.

Events

Every event except error carries node_id and sequence_number in addition to the fields listed. error carries only its error object.

Eventdata
node.output_text.delta{ node_id, sequence_number, delta }
node.output_text.done{ node_id, sequence_number, text }
node.tool_call.started{ node_id, sequence_number, tool_call_id, tool, summary }
node.tool_call.completed{ node_id, sequence_number, tool_call_id, tool, status: 'ok' | 'error', summary }
node.turn.started{ node_id, sequence_number }
node.turn.completed{ node_id, sequence_number }
node.report.pushed{ node_id, sequence_number, report }
node.status.changed{ node_id, sequence_number, status }, where status can also be stream-only 'dormant'
node.settled{ node_id, sequence_number, outcome } — terminal; the daemon ends the response after it
error{ error: { code: 'stream_gap' | 'stream_dropped' | 'stream_error', message, details?: { earliest_sequence?: number } } } — stream_gap is recoverable; the other codes terminate the response

summary on a tool-call event states whether arguments are null, an array and its item count, an object and its field count, or a primitive type. It never includes argument values or tool output.

Deliberately not carried: thinking deltas, the model's tool-call construction deltas, raw tool output, and the system prompt. Those belong to the owner's viewer, not to an application's run stream.

The route

GET /v1/nodes/{id}/events        Accept: text/event-stream
    ?after=<sequence_number>     resume from a cursor (optional)

Server-sent events: one record per event as event: <type>, data: <JSON>, blank line, plus : keepalive comment lines every 15 s. Authentication is the daemon's existing rule — filesystem permission on the unix socket, bearer token over TCP.

What you get depending on the node's state

Node state when you callWhat the stream does
Already settledWrites retained events when available, ending in node.settled; otherwise writes node.settled with the outcome and ends. Nothing is revived.
RunningStreams live, seeded as described below.
Dormant and not settledWrites node.status.changed { status: 'dormant' } and holds the response open with keepalives. It starts streaming if and when the daemon brings a broker up for that node.

A node can settle between your create and your events call, so both branches are ordinary. Either way the terminal event is node.settled, read from the same outcome row nodes.outcome reads — a client that joins after settlement and a client that was live at settlement see the same outcome.

Sequence, resume, and failure

sequence_number is monotonic per node while that node's in-memory stream hub exists. The daemon keeps one in-memory ring per streamed node holding the last 512 events or 256 KiB, whichever binds first.

SituationBehaviour
You join a run already in progressFirst replays already-pushed reports oldest first, then seeds from the broker's snapshot: one node.output_text.done per completed assistant message, then one node.output_text.delta carrying the accumulated partial, then live. No content is lost — only delta granularity.
A second subscriber joinsSeeded from the ring, so both subscribers see the same sequence numbers.
after=N within the retained rangeReplays events after N.
after=N outside the retained range, including a future cursorAn error event with code stream_gap carries the earliest available sequence, then the stream continues from that point. It never silently skips.
One event exceeds the 256 KiB replay budgetLive subscribers receive it, but the daemon clears the resume ring. A cursor before that event gets stream_gap; a cursor on it resumes at the next retained event.
Your HTTP reader is too slowThe daemon ends the response with an error event whose code is stream_dropped. One slow reader cannot stall the engine.
The node's broker is replaced or its observer socket closesThe daemon reconnects and emits node.status.changed. Sequence continues while the stream hub remains in memory.
The daemon restartsThe ring is gone. A resuming client gets stream_gap with earliest_sequence: 1. There is no durable event log.
You disconnectYour subscriber is dropped. The node keeps running.

Resuming

import { APIError } from '@north-light/sdk';

let cursor: number | undefined;

for (;;) {
  const stream = client.nodes.events(id, { after: cursor });
  try {
    for await (const event of stream) {
      if (event.type === 'error') {
        if (event.error.code === 'stream_gap') {
          console.warn('missed events before', event.error.details?.earliest_sequence);
          continue; // the stream continues from the floor
        }
        throw new APIError(0, event.error.code, event.error.message, event.error.details);
      }
      cursor = event.sequence_number;
      handle(event);
    }
    return; // ended on node.settled
  } catch (error) {
    if (error instanceof APIError && error.code === 'stream_dropped') continue;
    throw error;
  }
}

stream_gap is recoverable and the stream continues after it. stream_dropped is terminal for that HTTP response — reconnect with the cursor you last saw.

Identifier validation

nodes.events(id, options?) validates id before it requests the node or opens SSE. Because it returns a NodeStream synchronously, an invalid node identifier rejects stream.node, stream.finalOutcome(), and an iteration with TypeError; no request is sent. See Errors.

Event shapes depend on the pinned pi version

The delta and tool-call shapes come from the installed @earendil-works/pi-agent-core and pi-ai packages, not from crouter. The daemon's translator is the one place that depends on them. A pi version bump must be checked against it.

On this page