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.
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 singledata: 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)
data: line and switch on its type field. Do not rely on an SSE event: line for run streams — there isn’t one.
Transport and response headers
Run-event streams are served as a raw HTTPStreamingResponse 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 alongsidetype; 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.The listen endpoints
You open a run stream with an authenticatedGET 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: anAuthorization: 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 JSONtype. 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 onGET /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.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 onGET /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 onGET /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.
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.- Python filters
heartbeatout by default; passinclude_heartbeats=Trueto receive them. - JavaScript yields the
{ "type": "heartbeat" }data frame as a normal event, so your consumer must skiptype === "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 isdone, error, cancelled, and interrupted — though interrupted is history-only (see above), so on a live stream you will see done, error, or cancelled.
A typical workflow run, on the wire:
Workflow run (happy path)
Composer run with HITL pause
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.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 finalerror 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 RESTPOST (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-IDreplay semantics. ModuleX run-event frames do not carry an SSEid:field, and no server path replays from aLast-Event-IDheader was confirmed. Reconnect recovery is via the 1-hour server-side history replay, not SSE ids. WhetherLast-Event-ID-based resume is ever supported is unresolved.- The
/workflows/{workflow_id}/changescollaboration stream. A separate workflow-definition-change SSE stream exists (eventsconnected,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.