Skip to main content
ModuleX streams the live output of a run — every node that starts, every token batch, every pause for input, and the final result — over Server-Sent Events (SSE). This page is the wire-level reference for the run-event SSE transport: the frame format, the exact event types you can receive, the heartbeat, terminal markers, and the connection lifecycle. SSE is one of two unrelated realtime transports in ModuleX. This page covers SSE only — the one-way push channel for run and agent output. For the bidirectional canvas-collaboration transport, see Socket.io collaboration events. For how the two compare, start at the realtime overview.
SSE here carries run output, server to client only. You start a run with a normal REST call (or an SDK method), then open a separate SSE connection to a listen endpoint to watch it. To cancel a run or answer a paused run, you send separate REST POST requests — the SSE channel never accepts data from the client.

What you can stream

There are three run-event SSE streams, one per run-producing surface. They share the same frame format, the same heartbeat, and most of the same event types.

Workflow runs

Stream a workflow execution: node lifecycle, retries, output, and the final result.

Composer runs

Stream the AI Composer editing a workflow graph: tool calls, applied changes, and workflow syncs.

Assistant runs

Stream the Assistant working through a task with tools — no workflow attached.
A fourth named-event SSE stream, the chat-list feed (GET /chats/stream), uses a different wire convention and is not a run stream. It is noted under The chat-list stream is different so you do not parse it with the run-stream rules.

The frame format

Every run-event stream uses data-only frames. Each event is a single data: line carrying a JSON object, followed by a blank line. There is no SSE event: line on these streams — the event type is the type key inside the JSON payload.
Run-event frame (data-only)
To dispatch on an event, parse the JSON in the data: line and switch on its type field. Do not rely on an SSE event: line for run streams — there isn’t one.
This is the single most important fact about the run-event format: the discriminator is the JSON type key, never an SSE event: line. If your client library only surfaces the SSE event: field, every run-event frame will look like the default unnamed event (message). Read data.type instead. The official SDKs already do this for you.

Transport and response headers

Run-event streams are served as a raw HTTP StreamingResponse with media_type: text/event-stream. The response sets the following headers:
string
Always text/event-stream.
string
no-cache — the stream must not be cached or buffered.
string
keep-alive — the connection stays open for the run’s duration.
string
no — disables proxy response buffering so frames are delivered as they are produced.
string
* on the workflow run stream only. The Composer and Assistant run streams do not set this header.

Flat vs wrapped payloads

Even within a single run stream, payloads are not uniformly shaped. Some event types put their fields flat at the root of the JSON alongside type; others wrap their fields under a data key. This is a property of the wire, so your client must handle both.
The wire shapes here describe what the server actually publishes. ModuleX also defines typed event models internally, and for a few events those models disagree with the wire (for example, the models name node_id/status where the wire sends node/output). Always code against the wire shapes documented below, not against any generated model. The SDKs deliberately keep the parsed payload tolerant for this reason.
user_input_request nests one level deeper than other wrapped events: the frame is { "type": "user_input_request", "data": <UserInputRequest> }, so the question payload — including its kind — lives at data.data, not data. A naive read of data.kind returns nothing. The HITL resume page documents the full question/answer contract; the Python SDK ships user_input_request_from_event() to unwrap it for you.

The listen endpoints

You open a run stream with an authenticated GET to the matching listen endpoint. All three require organization owner or admin — a plain member cannot listen (the member role is retired; see roles & permissions).
string
required
The per-execution run identifier returned when you start the run. Each Composer/Assistant resume mints a new run_id — re-listen on the new one. See workflows & runs for the distinct run-id identities.
string
required
Composer streams only. The Composer chat (thread) the run belongs to.
string
required
Assistant streams only. The Assistant chat (thread) the run belongs to.

Authentication

Every request authenticates with the standard ModuleX scheme: an Authorization: Bearer header carrying your API key, plus the X-Organization-ID header selecting the organization context. See authentication for the full scheme and the API base URL (no /v1 segment — see environments).
Browser EventSource cannot send custom request headers, so it cannot satisfy the Authorization and X-Organization-ID requirements. The official SDKs and the ModuleX app use fetch-based stream readers instead. Plan your client accordingly — raw EventSource against these endpoints will fail the auth check.

Opening a stream

The example below opens the workflow run stream. Swap the route and SDK method for Composer or Assistant. The auth and parsing are identical.
SSE streams in the SDKs are not subject to the client’s maxRetries or request timeout — those apply to ordinary requests only. A stream stays open until the run reaches a terminal state, you cancel it, or the connection drops. The heartbeat (below) keeps an idle connection warm; the SDKs do not auto-reconnect.

The event-type taxonomy

Dispatch on the JSON type. The tables below give the wire shape and key fields for each event you can receive on each stream. Field names are exactly as they appear on the wire (snake_case).

Workflow run events

These are the events emitted on GET /workflows/listen/{run_id}.
wrapped
First frame of the run. data carries run_id, thread_id, workflow_name, workflow_version, workflow_type (workflow or llm), and timestamp.
flat
A node began executing. Fields: node (the node id), name, timestamp. Knowledge nodes also include a metadata object describing the provider.
flat
A node produced output. Fields: node and output. For LLM nodes, output is streamed as a token batch — output.messages is an array of { type: "ai", content: <full response so far> }, republished on each batch interval (default 500 ms) as the response grows.
The wire fields are node and outputnot node_id, node_type, or status. Code against node/output.
flat
A node failed and is being retried. Fields: node, name, attempt, max_attempts, error_type, error_message, next_retry_in, timestamp. See error handling & retries.
flat
A node failed. Fields: node, name, error_type, error_message, reason (a stable error code, or null), attempt, max_attempts. A node_error does not end the run by itself — the run continues per its error-handling configuration and ends with done or error.
wrapped
The run paused at an interrupt node to await a resume value. data carries message, data, and optionally resume_schema and examples. Not terminal — the stream goes quiet but stays open. Resume the run with executions.resume(thread_id, run_id, resume_value) (POST /workflows/resume/{thread_id}), which reuses the same run_id. This is distinct from Composer/Assistant HITL — see HITL resume.
wrapped
Published when a paused workflow run continues. data carries run_id, thread_id, resume_value, and timestamp.
wrapped
Terminal. The run completed successfully. data carries message (for example, "Workflow completed successfully"). The full result is read from the run record, not from this frame.
wrapped
Terminal. The run was cancelled. data carries the cancel info — run_id, reason, cancelled_at — or may be null if the cancel record has expired.
flat
Terminal. The run failed, or the server hit a streaming error. Shape varies; commonly includes message and optionally error_type. If the server hits an exception mid-stream, it emits a final error frame rather than dropping the HTTP response.
flat
A keepalive. The exact frame is {"type":"heartbeat"} with no other fields. Injected on idle; see the heartbeat. Ignore it in your dispatch.

Composer run events

Emitted on GET /composer/chat/{composer_chat_id}/listen/{run_id}. Most Composer events are wrapped ({ type, data }) — the exceptions are error and heartbeat, which are flat (fields at the root next to type).
wrapped
First frame. data carries run_id, thread_id, workflow_id, workflow_type ("composer"), and timestamp.
wrapped
A streamed slice of the agent’s natural-language response. data.chunk is the text fragment.
wrapped
The agent invoked a tool. data carries tool, input, tool_call_id, and timestamp.
wrapped
A tool returned. data carries tool, tool_call_id, output, and timestamp. On failure, output is { error, success: false }.
wrapped
A Composer subagent started. data.subagent names it.
wrapped
An in-band guidance/warning message (for example, repeated failures). data carries message and consecutive_failures.
wrapped
The Composer applied an edit to the workflow graph. data carries type (for example, "applied"), tool, changes_made (array), and has_pending_changes.
wrapped
A full sync of the edited graph. data carries workflow_id, source ("composer"), edit_version, changes_made, and the complete workflow (nodes, edges, …).
wrapped (nested)
The agent paused to ask you a structured question (HITL). data is the UserInputRequest — discriminate on data.data.kind. Not terminal; the stream goes quiet. Answer with composer.resume(...), which returns a new run_id to re-listen on. Full contract: HITL resume.
wrapped
Terminal. data carries response, has_workflow_changes, tool_calls, workflow_tool, an optional workflow_changes, and usage (input_tokens, output_tokens, total_tokens, llm_calls).
wrapped
Terminal. The run was cancelled.
flat
Terminal. The run failed. Fields are at the root next to type: message, and optionally error_type. The frame is {"type":"error","message":…}, not wrapped under data.
flat
Keepalive, {"type":"heartbeat"}. Ignore it.
interrupted exists as a history-only marker (it records a past HITL pause for replay) and is never published live. Do not wait for it on a live stream — a live HITL pause arrives as user_input_request, after which the stream simply goes quiet with no terminal frame until you resume or reconnect.

Assistant run events

Emitted on GET /assistant/chat/{chat_id}/listen/{run_id}. The Assistant shares the Composer executor but has no workflow, so workflow_change and workflow_sync never fire. The declared event set is: metadata, response_chunk, tool_call, tool_result, user_input_request, run_resumed, guidance, done, error, cancelled, heartbeat. Shapes match the Composer events of the same name.
Two Assistant-specific traps:
  • The metadata frame reports workflow_type: "composer" even for Assistant runs — that field is not a reliable surface discriminator. Treat the stream as an Assistant stream because you opened the Assistant endpoint, not because of workflow_type.
  • The Assistant emits run_resumed after a resume, whereas workflow runs emit resumed. They are different literals on different streams — you cannot listen for one name across both.
Like the Composer error, the Assistant error event is flat: {"type":"error","message":…} with message (and optional error_type) at the root, not under data. The error event is flat on all three run streams — workflow, Composer, and Assistant.

The chat-list stream is different

GET /chats/stream is the only ModuleX SSE stream that uses named SSE events — real event: lines (event: connected, event: chat_list_updated) — instead of the data-only type-discriminator convention. It carries chat-list invalidation notices, not run output (in particular, it never carries response_chunk token output). Its keepalive is an SSE comment line (: keepalive) every 30 seconds, not a heartbeat data frame. Parse it with named-event rules, not the run-stream rules above.
Chat-list frame (named events — different convention)

The heartbeat

To keep an idle connection alive (for example, while an agent is “thinking” and producing no output), the server injects a heartbeat frame on the run-event streams.
flat JSON
Exactly {"type":"heartbeat"} — no data, no other fields.
fixed: 15 seconds
Emitted after 15 seconds of socket idle (HEARTBEAT_TIMEOUT_SECONDS = 15). This interval is not configurable.
Ignore heartbeats in your own dispatch logic — they carry no run information. The SDKs handle them for you:
  • Python filters heartbeat out by default; pass include_heartbeats=True to receive them.
  • JavaScript yields the { "type": "heartbeat" } data frame as a normal event, so your consumer must skip type === "heartbeat".
The : keepalive comment on /chats/stream (every 30 seconds) is a separate mechanism from the 15-second run-stream heartbeat data frame. Don’t conflate the two intervals or shapes.

Terminal markers and the run lifecycle

A run stream ends when a terminal event arrives. The terminal set is done, error, cancelled, and interrupted — though interrupted is history-only (see above), so on a live stream you will see done, error, or cancelled.
The terminal-break behavior is asymmetric across layers, and it matters for how you write your loop:
  • The workflow listen endpoint server-side breaks the stream only on done and error.
  • The Composer/Assistant listen endpoints have no server-side terminal break — they rely on the publisher closing the channel.
  • The SDKs treat the full set {done, error, cancelled, interrupted} as terminal and stop iterating on any of them.
So an SDK may stop iterating on cancelled while the underlying workflow HTTP stream is technically still open. Treat done, error, and cancelled as terminal in your own code regardless of which SDK you use.
A typical workflow run, on the wire:
Workflow run (happy path)
A Composer run that pauses for input, then resumes:
Composer run with HITL pause
After user_input_request the stream goes quiet — there is no done frame. You answer with composer.resume(...), which returns a new run_id; you then re-listen() on that new id to watch the run finish. The full pause/answer contract lives on HITL resume.

Reconnecting and missed events

On reconnect, the server replays the run’s buffered event history (held for 1 hour) in order, then tails live — so a brief disconnect does not lose events. If a replayed event is already terminal, the stream returns immediately rather than hanging on a finished run.
Replay via Last-Event-ID is an open question. ModuleX frames do not currently carry an SSE id: field, and there is no verified server code path that replays from a client-supplied Last-Event-ID header. Reconnection recovery is handled by the server-side 1-hour history replay described above, not by SSE event ids. Do not build a client that depends on Last-Event-ID-based resumption until this is confirmed — see open questions.

Errors on connect

A non-2xx status when you open the stream is raised before the first frame — the SDKs throw a typed exception synchronously as you begin iterating, never as an in-stream event. Run-producing surfaces (workflow, Composer, Assistant) are gated by the billing admission check, so starting a run can return 402/403/429 with a flat DenialEnvelope ({code, layer, key, current, limit, reason}). The listen endpoint itself authorizes and streams; the admission denial happens when you start the run, not on the listen. For the full error model and all three envelope shapes, see errors & status codes, rate limiting, and usage gating & limits. A 404 for a foreign or unknown run is returned by an ownership guard that runs before the stream subscribes — you never get a stream for a run outside your organization. See the run-id model in workflows & runs.

Errors mid-stream

A mid-stream failure does not throw. The server emits a final error frame ({"type":"error", …}) and closes the connection. Your consumer must handle the error event type as a terminal outcome, exactly as it handles done.

Cancelling a run

You cannot cancel over the SSE channel — it is one-way. Send a separate REST POST (or call the SDK cancel method), and the open stream will emit a cancelled terminal frame. In a client, you can also stop listening without cancelling the run by aborting the request. In JavaScript, pass an AbortSignal; in Python, exit the stream’s async with block. Both stop iteration and release the connection cleanly while the run keeps executing server-side.

Next steps

Streaming & HITL in the SDKs

Consume these streams and answer paused runs in JavaScript and Python.

Human-in-the-loop resume

The full user_input_request / resume contract, request and response kinds, and the new-run-id rule.

Realtime overview

How SSE run streaming and Socket.io collaboration fit together.

Errors & status codes

The three error-envelope shapes and which surface emits each.

Open questions

These items are unverified in the source and are intentionally not documented as fact:
  • Last-Event-ID replay semantics. ModuleX run-event frames do not carry an SSE id: field, and no server path replays from a Last-Event-ID header was confirmed. Reconnect recovery is via the 1-hour server-side history replay, not SSE ids. Whether Last-Event-ID-based resume is ever supported is unresolved.
  • The /workflows/{workflow_id}/changes collaboration stream. A separate workflow-definition-change SSE stream exists (events connected, workflow_updated, user_joined, user_left), but its backend wire convention and emit sites are not fully pinned. It is not a run stream and is out of scope for this page.
  • Infrastructure idle timeouts. Proxy or gateway idle timeouts (relative to the 15-second heartbeat) are deployment configuration, not part of the documented API contract.