novo agents

Streaming

Every run streams back as Server-Sent Events. This page is the wire-protocol reference: framing, chunk order, reconnect semantics, and the public data-novo-* taxonomy.

If you just want to consume the stream, use the SDK: for await (const event of stream) and discriminate on event.type. This page is for protocol implementors and for understanding what every chunk means.

Framing

GET /v1/runs/:id/stream
Authorization: Bearer …
Accept: text/event-stream

HTTP/1.1 200 OK
Content-Type: text/event-stream
Novo-Run-Id: run_…
Novo-Thread-Id: thread_…
Novo-Agent-Id: agent_…
Novo-Stream-Schema: 2
X-Request-Id: req_…
Cache-Control: no-cache, no-transform

id: 0
data: {"type":"data-novo-run-meta","data":{…}}

id: 1
data: {"type":"text-start","id":"…"}



data: [DONE]

Every payload frame has an id: cursor followed by one JSON chunk on a single data: line. Keepalive comments and [DONE] are not cursor frames: they carry no resumable id:. data: [DONE]\n\n ends the current HTTP response segment. Only data-novo-terminal means the run itself is complete; if a segment ends before terminal, reconnect with the last SSE event id or use streamToCompletion().

While a run is executing but producing no chunks, for example during a single multi-minute tool call, the server interleaves SSE keepalive comment frames (: keepalive\n\n) about every 15 seconds. Comment frames are part of the SSE grammar (any line that starts with :): the SDK decoder and browser EventSource skip them, they carry no id: so they never move your reconnect cursor, and they never surface as events. They exist only to keep the response body from going idle long enough to trip an HTTP body-inactivity timeout; you can ignore them.

The keepalive comment keeps the connection alive, but it is discarded by decoders and fires from the reading server whether or not the run is healthy, so it cannot tell a working run apart from a stuck one. When the orchestrator is blocked collecting delegated subagents, it additionally emits a real data-novo-heartbeat event about every 15 seconds. Unlike the comment frame, a heartbeat is produced by the run itself, so its arrival is positive evidence the orchestrator is still executing while its children work.

The Novo-Run-Id, Novo-Thread-Id, and Novo-Agent-Id headers fire before the first body byte. In the SDK they show up synchronously on stream.runId, stream.threadId, stream.agentId; you do not need to wait for the first chunk to know which run you're attached to.

Novo-Stream-Schema is a monotonic integer identifying the public chunk schema. It is diagnostic and cache-key material for protocol implementations, not a negotiation header: clients should continue to discriminate by event.type and ignore fields they do not use.

What you'll see, in order

The stream is composed of a fixed sequence of chunk families. Per assistant step you'll see text and tool chunks interleaved; between steps and at the boundaries you'll see Novo's own data-novo-* chunks.

  1. data-novo-run-meta: first data chunk. Carries runId, threadId, agentId, progressEnabled, the persisted status, terminalReason, and run timestamps known when the stream was opened.
  2. data-novo-workspace: present before the first assistant step when a managed workspace is created or resumed. Carries workspace status and preview URLs.
  3. Per step, bracketed by start-stepfinish-step, interleaved:
    • data-novo-model-step-restart appears before successor content when a durable model producer is reexecuted. Remove only the text/reasoning blocks, tool calls, and sources named by its superseded*Ids fields; prior successful calls and unrelated data parts remain authoritative. Model chunks without a stable target id (including files and errors) are held server-side until the model step commits. The Novo browser reducer applies the targeted removal automatically. A stock AI SDK reducer does not interpret this Novo extension and therefore is not sufficient by itself for authoritative reexecution recovery; handle the marker directly or use the Novo browser reducer.
    • data-novo-step-restart can appear after model frames when the durable completion step executes with attempt >= 2. It is reexecution evidence, not evidence of why Workflow scheduled that attempt: preserve preceding content and use attempt / supersededAttempt for observability only.
    • start-step opens each assistant step (it adds a step-start part in a UIMessage reducer); finish-step closes it (no visible part; it resets the reducer's active text/reasoning part map). Use these to scope content ids (text-start/reasoning-start ids like txt-0 are reused across steps).
    • text-starttext-delta* → text-end for visible model text
    • tool-input-starttool-input-available for each public tool call the model decides on
    • tool-output-available (or tool-output-error, tool-output-denied) when the tool returns, keyed by toolCallId; join to the preceding tool-input-* chunk with the same toolCallId for the tool name
    • reasoning-startreasoning-end brackets when the model thinks aloud (deltas are stripped; you get the boundaries only)
    • data-novo-progress replaces the current progress line when streamProgress: true; it never contains raw reasoning
    • source-url, source-document, file when the model produces citation or file outputs
    • data-novo-tool-result-overflow interleaved when a tool result spills past the budget
    • data-novo-browser-session interleaved when the managed browser opens, changes page, or closes, always before the turn's data-novo-step-terminal
    • data-novo-task interleaved when an orchestrator starts or finishes a delegated subagent task
    • data-novo-heartbeat interleaved about every 15 seconds while the orchestrator is blocked waiting on delegated subagents, so a long collect is not indistinguishable from a hung run
  4. data-novo-step-terminal: closes an assistant turn, a maximal run of model steps that isn't interrupted by a pause. The model takes as many start-stepfinish-step model steps as the task needs inside one turn, and a single data-novo-step-terminal closes the turn once the model stops or the run pauses. A run that never pauses is one turn, so you get exactly one data-novo-step-terminal, just before the run's data-novo-usage/data-novo-terminal; a run that pauses (approval, question, worker) and resumes emits one per resumed segment. It carries the turn's messages, the turn's cumulative usage delta (summed across the turn's model steps), a modelFinishReason, a generic provider-neutral modelLabel, the run's chargeId (the join key to the usage.events debit row), and optional perf timing.
  5. Pause chunks (between steps, when applicable):
    • data-novo-input-requesteddata-novo-input-resolved around any pause. The request.kind ('approval_request' or 'question') tells the two apart; an approval is just an input request whose options are [approve, deny].
  6. data-novo-usage: fires once before terminal. Carries the run's terminal UsageTotal receipt and the terminal-reason classification. For a delegating run this receipt is tree-inclusive, summing the orchestrator's own inference and every delegated subagent worker, so stream.usage / finalResult.usage reconcile to the billed Run resource. It equals novo.runs.get(runId).usage for the terminal run.
  7. data-novo-terminal: the run is done. Carries the final status and terminalReason, plus an error object ({ type, code, message }) when status is failed (a model error, or a policy termination that also carries a terminalReason).
  8. data: [DONE]: stream end-of-frame.

The per-step start-step / finish-step boundaries are the only AI SDK lifecycle frames on the wire; the message-level start / finish are not emitted (Novo brackets the message with data-novo-run-meta / data-novo-terminal). Reasoning emits start/end boundaries only; reasoning-delta is stripped.

Thinking and activity progress

Set streamProgress: true on runs.start or threads.stream to receive one small, replace-in-place view across thinking, public tool work, and waiting. When safe readable reasoning is available, Novo may provide a short reasoning-derived summary; this surface never contains raw reasoning:

if (event.type === 'data-novo-progress') {
  renderProgress(event.id, event.data.text);
  // event.data.kind: 'summary' | 'activity'
  // event.data.summaryAvailable: boolean
}

Every event uses the stable id novo-progress:<run-or-worker> and carries the run identity, optional workerRef, phase: 'thinking' | 'tool' | 'waiting', text, kind, and summaryAvailable. A summary is a short activity label derived from readable reasoning evidence, not the reasoning itself and not a claim that the answer is correct or complete. Activity lines come from deterministic runtime state such as a public tool title or delegated wait.

Some model steps expose safe readable reasoning and some do not. When they do not, summaryAvailable remains false; consumers do not need provider-specific branches. Enabling progress never turns reasoning on or changes its effort or budget. It is independent from the explicit streamReasoning debug surface.

There is no progress-clear chunk. First answer text, a model-step restart, a pause, and terminal chunks are authoritative clear boundaries. The Novo browser reducer implements these rules and exposes the current snapshot as state.progress (null when inactive), initialized to Thinking… for an active progress-enabled run.

Tool names in public stream chunks are Novo canonical names, not provider-native names. Build UI and policy branches on the public toolName value; provider-specific tool identifiers are not part of the stream contract. Novo canonicalizes every public JSON field named toolName, including nested tool-result and step-terminal payloads.

Terminal vocabulary

Four closed unions cover run and step boundaries:

  • RunStatus: the run's lifecycle, queued | preparing | running | waiting_for_approval | waiting_for_input | completed | failed | cancelled.
  • StepStatus: the assistant turn lifecycle in data-novo-step-terminal, continue | paused | completed | failed | cancelled. paused is the single status for approval, user-input, and worker pauses (the turn ended at a pause; the follow-up data-novo-*-requested chunks identify the details, and a later segment resumes it); continue marks a turn that ended so the run could keep going; the rest are run-final.
  • ModelFinishReason: why the model stopped on a step, stop | tool-calls | length | content-filter | error | other. Exposed on data-novo-step-terminal.data.modelFinishReason.
  • NovoTerminalReason: why Novo terminated the run by policy or fault, budget_exceeded | insufficient_quota | per_run_cap_exceeded | pricing_unavailable | workspace_unavailable | data_handling_unavailable | max_steps_exceeded | max_duration_exceeded | step_duration_exceeded | context_overflow | interaction_timeout | stream_replay_unavailable | engine_fault | snapshot_unavailable | run_recovery_reaped. null on normal completion. step_duration_exceeded means a single agent step ran past its execution budget, distinct from max_duration_exceeded, the whole-run maxDurationMs cap. context_overflow means the conversation grew past the model's context window before the run could finish (shorten the task or split it across runs); it is a capacity outcome, not a fault, so no error.reference is minted. interaction_timeout means a human-in-the-loop pause went unresolved past the platform max-park window and the run was cancelled. stream_replay_unavailable means a durable stream replay could not safely reconstruct already-emitted bytes. engine_fault is an internal engine failure (a structured error.reference accompanies it), snapshot_unavailable means durable runtime state could not be read or written, and run_recovery_reaped means an out-of-band recovery pass finalized a run that wedged without writing its own terminal. Exposed on data-novo-terminal.data.terminalReason and data-novo-usage.data.terminalReason.

A status: 'failed' with terminalReason: null means the model errored. A status: 'failed' with a terminal reason means Novo cut the run short for policy or finalized a platform fault/recovery state. When the model or platform errored, data-novo-terminal.data.error carries { type, code, message } and may include reference; the SDK surfaces the same object on the stream's finalResult and streamToCompletion() result as error.

The data-novo-* taxonomy

| Chunk | When | |---|---| | data-novo-run-meta | Once, first. Carries run identity, progressEnabled, and persisted status at stream open. | | data-novo-progress | Latest replace-in-place progress snapshot for a progress-enabled run or worker. Carries { runId, rootRunId, threadId, agentId, workerRef?, phase, text, kind, summaryAvailable }; its stable chunk id is the replacement key. | | data-novo-model-step-restart | A durable model producer was reexecuted (attempt >= 2). It arrives before successor content and lists the exact abandoned text/reasoning, tool-call, and source ids to remove; preserve every unlisted part. Chunks without a stable target id are held until commit instead of being retracted. | | data-novo-step-restart | The durable completion step executed with attempt >= 2. This is reexecution evidence, not a causal retry classification. It arrives after model frames and is diagnostic; never erase preceding content because of this marker. | | data-novo-step-terminal | Closes an assistant turn (a maximal run of model steps between pauses). One per turn: exactly one for a run that never pauses, one per resumed segment otherwise. Carries the turn's messages, cumulative usage delta, generic modelLabel, run chargeId, and optional perf stage timing. | | data-novo-usage | Once, just before terminal. Carries the run's terminal UsageTotal receipt, tree-inclusive (orchestrator + every delegated subagent) for a delegating run, so it reconciles to the billed Run resource. | | data-novo-terminal | Once, last. Carries final status + terminalReason, plus an error { type, code, message } when status is failed. | | data-novo-workspace | Once before the first assistant step when a managed workspace is created or resumed. Carries workspace status and preview URLs. | | data-novo-browser-session | The managed browser opened, changed page, or closed. Carries { runId, threadId, agentId, sessionId, status: 'started' \| 'updated' \| 'closed' }. Arrives within a step, before that step's data-novo-step-terminal. | | data-novo-input-requested | A pause opened: an approval or a question. Carries { id, runId, rootRunId, workerRef?, threadId, agentId, stepNumber, toolName, toolCallId, request }. request carries { requestId, kind: 'approval_request' \| 'question', display: 'confirmation' \| 'select' \| 'text', options?, allowFreeform, ... }. An approval has display: 'confirmation', options: [approve, deny], allowFreeform: false, plus the tool input and a rendered preview; a question's rich ask_question payload rides request.details. Pair with data-novo-input-resolved. | | data-novo-input-resolved | A pause was resolved or cancelled, from your interactions.resolve or from a synchronous hook/policy block. Carries { id, runId, rootRunId, workerRef?, threadId, agentId, kind, toolName, toolCallId, status: 'resolved' \| 'cancelled', resolvedAt }, plus decision + reason for an approval or resolution for a question. A denial carries a verbatim reason only; there is no source label. Every denial is also followed by a terminal tool-output-denied for the underlying toolCallId. | | data-novo-followup-replayed | A message held while the run was active, sent mid-run (steering) or during a pause, was delivered into the conversation as the next user turn. Carries { id, runId, rootRunId, threadId, agentId, text, queuedAt }; id matches the followup_id from the original 409 thread_busy details. One per held message, oldest-first, at the delivering turn boundary (after the gate's data-novo-input-resolved when resuming a pause). Delivered user turns never appear as assistant output; render this chunk as the user message it carries. | | data-novo-tool-result-overflow | A tool returned too much data; payload was clipped, with recovery set to workspace, remote-handler, or none. | | data-novo-compaction | The engine compacted the conversation to stay within context. Carries phase and mode. | | data-novo-tool-server-unavailable | An MCP tool server failed health check. | | data-novo-http-connection-unavailable | An HTTP connection is unavailable to the run: disabled by policy (errorCode: 'policy_denied'), or its OpenAPI spec failed to load or parse (errorCode: 'spec_unreachable' \| 'spec_invalid' \| 'base_url_missing'). Operation-level non-2xx responses are not this chunk; they return to the model as tool output. | | data-novo-task | A delegated subagent task lifecycle on the orchestrator stream. One id-reconciled card per task carrying { rootRunId, workerRef, role: 'worker' \| 'advisor', status: 'started' \| 'completed' \| 'failed' \| 'cancelled', label?, summary? }. Render label as the UI title; open the sub-stream with runs.workers.stream(rootRunId, workerRef). | | data-novo-heartbeat | A liveness frame emitted from the run itself while it is blocked and would otherwise be silent, today only while the orchestrator collects delegated subagents. Carries { runId, rootRunId, threadId, agentId, phase: 'awaiting_subagents', beat, elapsedMs, activeTaskCount, workerRefs }. beat is a 1-based counter and elapsedMs its scheduled elapsed (a lower bound), so successive heartbeats prove the run is still executing; workerRefs are the outstanding tasks, matching their data-novo-task cards. Purely informational and safe to ignore. | | data-novo-worker-meta | First frame of a worker sub-stream. Carries root run identity, workerRef, progressEnabled, status, role, label, and the full dispatch prompt. | | data-novo-worker-terminal | Last frame of a worker sub-stream. Carries workerRef, terminal status/reason, and an optional structured error. |

Measuring time to first response

Use the stepNumber: 0 terminal to decompose the first response without adding a parallel event type:

  • accepted → workflow executing: data-novo-run-meta.data.createdAtstartedAt (when present)
  • run bootstrap → first turn: data-novo-run-meta.data.startedAtperf.turnStartedAt
  • turn preparation: perf.turnStartedAtcontextReadyAt
  • prepared context → model request: perf.contextReadyAtmodelRequestAt
  • model request → first response content: perf.modelRequestAtfirstTokenAt

The opening run metadata on a fresh stream can describe a queued run before startedAt is known. To measure accepted → workflow executing in that case, reconnect after execution starts or read the refreshed run with runs.get. The perf instants are captured inside journaled durable steps, so replay does not recompute them. They may originate on different workers; clamp a negative derived duration to 0 rather than treating clock skew as negative latency. realStreamMs sums the first-content → last-content spans across all model calls in the turn, and decodeTps is the turn's cumulative usage.outputTokens divided by that span.

Prompt-cache attribution needs no extra enum: usage.cacheReadTokens > 0 means the turn read cached input, while 0 means it did not. Step-terminal usage is cumulative across the turn's model calls, so treat that as a turn-level signal rather than a per-call cache verdict. All perf fields are optional for compatibility with runs journaled before they shipped.

If Cloud recovers a durable model-step producer after it already emitted a partial text or reasoning block, the terminal's optional perf.streamIntegrity is {status: "degraded", repairKinds: [...]}. The projection keeps the stream structurally valid and separates the abandoned prefix from the successor block, but the turn includes infrastructure recovery time. Exclude it from model-quality, latency, and throughput comparisons; retain its usage and cost when measuring actual spend.

When an agent has the subagents capability, the orchestrator stream carries a data-novo-task card per delegated task. Use event.data.label as the task title in your UI. To follow one task's full transcript, open its sub-stream with runs.workers.stream(rootRunId, workerRef); it yields the worker's own chunks and emits data-novo-worker-meta first and data-novo-worker-terminal last. The opening data-novo-worker-meta carries the task's label, role, and the full dispatch prompt (the instructions the subagent ran on); how the task is routed to a model is internal and never appears on the stream. See Subagents.

Every chunk has a type field; narrow on it.

Ordering and de-duplication use one cursor: the SSE frame id. Every delivered frame carries an id: (the value the SDK exposes as stream.lastEventId and sends back as Last-Event-Id on reconnect). It is a strict, per-run monotonic delivery cursor assigned after Novo projects and repairs the public AI SDK UI-message stream. Order by it and drop any frame whose id you have already processed. Do not depend on ids being consecutive: terminal fallback frames reserve high deterministic ids, and protocol repair/control frames may change the exact sequence. (SDK consumers that iterate decoded events read stream.lastEventId immediately after each yielded event to get that frame's id.)

Do not confuse the SSE frame id with a chunk's content id, e.g. the id on text-start / text-delta / reasoning-start (often txt-0, reasoning-0). Those are content-correlation ids scoped to a span and are reused across spans within a turn; the start-step / finish-step boundary chunks tell you where one step's spans end and the next begins, so scope content ids per step (reset them on finish-step). Only the SSE frame id is a run-wide ordering cursor.

For attribution, every data-novo-* chunk's data payload carries the stepNumber it belongs to and a per-step frameInStep ordinal. These identify which assistant step emitted a frame (useful for grouping in a UI). They are not an ordering key; order by the SSE frame id. Standard AI-SDK content chunks (text-*, tool-*, reasoning-*, source-*, file) are name-and-shape-strict and do not carry these fields.

If you drain a parent run and its subagent/worker sub-streams (runs.workers.stream), never merge their frames into one ordered sequence: each run is a separate stream with its own independent id space. Order within each stream by that stream's own id.

Browser lifecycle chunks are status-only. Fetch live-view bearer URLs just-in-time from your server with novo.browserSessions.liveView(sessionId); do not log or persist those URLs.

For exact cost reconciliation across inference, browser, and workspace debits, page through novo.usage.events.list(). The terminal data-novo-usage receipt is the run's whole-tree inference total (orchestrator + subagents) and equals the billed Run resource; each data-novo-step-terminal usage is that turn's cumulative delta on the orchestrator run only (summing the turns reconstructs the orchestrator's own inference, not the subagent tree). Thread-scoped runtime charges (browser, workspace) can happen while no run stream is active and are not in the run receipt. To break the tree total into rows, join on chargeId rather than summing costs: delegated subagent inference is reported as its own subagent_run event under the root run's runId, so usage.events.list({ runId }) returns the orchestrator row plus one subagent_run row per worker.

Type guards

The SDK exports guards for the stream chunks most applications branch on, plus every data-novo-* chunk family:

import {
  isNovoTextDelta,
  isNovoToolOutput,
  isNovoBrowserSession,
  isNovoCompaction,
  isNovoRunMeta,
  isNovoProgress,
  isNovoUsage,
  isNovoStepTerminal,
  isNovoTerminal,
  isNovoTask,
  isNovoHeartbeat,
  isNovoInputRequested,
  isNovoInputResolved,
  isNovoToolResultOverflow,
  isNovoToolServerUnavailable,
  isNovoHttpConnectionUnavailable,
  isNovoWorkspace,
  readNovoToolError,
} from 'novoagents';

for await (const event of stream) {
  const toolError = readNovoToolError(event);
  if (toolError) console.log('tool error', toolError.code, toolError.action ?? toolError.message);
  else if (isNovoTextDelta(event)) process.stdout.write(event.delta);
  else if (isNovoToolOutput(event)) console.log('tool', event.output);
  else if (isNovoWorkspace(event)) console.log('workspace', event.data.status);
  else if (isNovoTask(event)) console.log('subagent', event.data.label ?? 'Subagent', event.data.status);
  else if (isNovoUsage(event)) console.log('cost', event.data.costCents);
}

AI SDK compatibility

The standard chunks (text-*, tool-input-*, tool-output-*, reasoning-*, source-*, file, error, abort) are AI SDK UIMessageChunks. The Novo wire is a superset of the AI SDK uiMessageChunkSchema: every data-novo-* family rides the schema's generic data-${string} branch ({ type, id?, data, transient? }). If you validate chunks with a strict per-chunk schema, branch on data-novo-* first; don't assume vanilla parity, or strict validation will reject Novo's extension chunks. In particular, tool-output-available / tool-output-error / tool-output-denied are name-free by contract (correlate by toolCallId); Novo never stamps a toolName onto them.

Expected tool failures can be represented either as a standard tool-output-error chunk or as a successful tool-output-available chunk whose output is { type: 'tool-output-error', code, message?, retryable?, action? }. Novo preserves that model-facing output. Non-zero terminal command exit codes remain ordinary tool-output-available results and are not reclassified as tool errors. Retryable precondition failures use the same tool-output surface; join to the preceding tool-input-* chunk by toolCallId when you need the public tool name.

Reconnect

A long quiet run no longer drops on its own. The server's keepalive comment frames (see Framing) keep the response body active through multi-minute idle tool calls, so the default for await (const event of stream) survives them: the idle-body timeouts in undici (Node's fetch, ~5 min), browsers, and proxies never fire mid-run. You do not need the reconnect path just to outlast a slow tool call.

What still bounds a single connection is its lifetime: the server's SSE cap of up to 30 minutes per connection, plus any shorter function timeout your client or proxy imposes. A run that outlives the connection has it closed while the run keeps executing, and the close looks like a normal end-of-stream, so a single for await that stops on EOF will silently miss the result of a run longer than the connection lifetime (data-novo-usage / data-novo-terminal never arrive; the run reads status: "running" client-side even though it completes and bills server-side).

Decide completion on the terminal chunk, not on the stream ending. stream.reachedTerminal is true only once a data-novo-terminal chunk has been observed; reconnect with the last event ID whenever the stream ends without it:

if (!stream.reachedTerminal) {
  const fresh = await stream.reconnect({ lastEventId: stream.lastEventId });
}

Or let the SDK drain to terminal for you: await stream.streamToCompletion() reconnects as many times as the run needs and returns the final text, status, and usage.

The SDK sends the cursor as the standard Last-Event-Id SSE header. If you omit the option, stream.reconnect() uses stream.lastEventId automatically. The cursor is the SSE frame id, not any JSON payload id; server-side chunks have deterministic IDs, so reconnect resumes after your checkpoint in order. See the reconnect guide for the full pattern.

Closing the stream

You can drop a connection at any time without affecting the run. The run keeps executing on Novo's side until it terminates. Reattach with runs.stream(runId) whenever you want. To stop a run, call runs.cancel(runId).