Skip to main content
Every long-running ModuleX operation — a workflow run, an AI Composer edit session, or an Assistant turn — pushes its progress to you over Server-Sent Events (SSE). The JavaScript SDK and the Python SDK wrap that transport in listen() methods that return async generators, so you iterate frames with for await (JS) or async for (Python). This page covers consuming those streams, cancelling them, and the human-in-the-loop (HITL) contract: how a run pauses to ask you a structured question and how you answer it. For the raw wire format and the per-event payload taxonomy, see SSE run streaming. For the request/response kinds and resume semantics at the API level, see Human-in-the-loop (HITL) resume. This page is the SDK view of both.
SSE and Socket.io are two unrelated transports. The listen() generators on this page are SSE (run and agent output, served by the ModuleX API). Multi-user canvas collaboration runs over Socket.io and is covered in Socket.io collaboration events — the two share no event names and no envelope.

The streaming model

Each surface follows the same shape: a call mints a run_id, and you open a stream for that run_id. The stream is one-way (server to client); you resume or cancel through separate calls.
1

Start the operation

Call executions.run, composer.chat, or assistant.chat. The response carries a run_id (and a stream_url).
2

Open the stream

Pass the run_id to listen(). You get back an async generator that yields parsed frames as they arrive.
3

Iterate frames

Loop over the generator. Switch on the frame’s type discriminator. Output frames such as response_chunk arrive incrementally; the stream ends on a terminal frame.
4

Pause for HITL (optional)

If a frame of type user_input_request arrives, the run is paused and waiting for you. Stop reading and answer it.
5

Resume or cancel

Answer a HITL question with a resume call (which mints a new run_id you re-listen() on), or stop the run with a cancel call.

Authentication

Every streaming call authenticates the same way as the rest of the API: an Authorization: Bearer header with your mx_live_* key, plus an X-Organization-ID header naming the organization the run belongs to. The SDKs add both headers for you from the client config; with cURL you set them yourself. SSE GET requests also send Accept: text/event-stream and Cache-Control: no-cache. See Authentication and Org context & X-Organization-ID. All three run-listen endpoints require the owner or admin organization role (organization_admin_required); the member role is retired. See Roles & permissions.

Opening a stream with listen()

listen() returns an async generator. The first network call happens when you begin iterating, not when you call the method. In JavaScript you consume it with for await…of; in Python listen() returns an EventSourceStream that is both an async iterator and an async context manager — prefer async with so the connection always closes. Workflow runs use executions.listen, which maps to GET /workflows/listen/{run_id}.

The five listen surfaces

Every SDK stream is built on the same SSE primitive. The methods differ only in route and payload union. There is also one POST-based SSE stream — credentials.bulkModulexKeys() (JS) / credentials.bulk_modulex_keys_stream() (Python), mapping to POST /credentials/bulk-modulex-keys/stream — used for bulk managed-key provisioning.
string
required
The per-execution run identifier returned by executions.run, composer.chat, assistant.chat, or a resume call. This is not the run record’s id (returned by list/get) and not the chat id. See the three run-id identities in Workflows & runs.
string
required
For composer.listen and assistant.listen only: the chat the run belongs to. The chat id is stable across a conversation; the run_id changes per turn and per resume.
AbortSignal | string
JavaScript accepts a RequestOptions object whose signal is an AbortSignal for cancellation (see Cancelling a stream). Both SDKs accept a per-call organizationId / organization_id that overrides the client default for that stream.

What a frame looks like

Run, Composer, and Assistant streams are data-only: the server emits data: {json}\n\n with no SSE event: line, and the real discriminator is the type field inside the JSON. (GET /chats/stream is the one exception — it uses named event: lines.) The SDKs normalize this for you so you always switch on a single field.
Each listen() yields the payload object (frame.data), cast to a typed union, so you discriminate on evt.type:
Response payloads stay snake_case — only request bodies are converted to snake_case on the wire, never responses. Read evt.run_id, not evt.runId.
Payload shape is flat for most events but wrapped for some, and run-event fields are not guaranteed. Within one run stream, metadata, interrupt, resumed, done, cancelled, and user_input_request are wrapped ({ type, data: {...} }), while node_started, node_update, node_error, and error are flat ({ type, ...fields }). The Python WorkflowRunEvent model is intentionally tolerant (all fields optional, extra allowed) — consume event.data defensively and do not assume a field is present. The published wire shape, not the typed models, is authoritative; see SSE run streaming.

Terminal and heartbeat frames

The stream ends on a terminal frame. The exact set differs by layer, which matters when you write a break condition:
  • A workflow run stream (GET /workflows/listen/{run_id}) is closed server-side only on done or error.
  • The Python SDK’s SSEEvent.is_terminal is broader — True for done, error, cancelled, and interrupted — so it may stop iterating on cancelled even where the backend would keep the socket open.
  • The JavaScript SDK does not expose an is_terminal helper; break on done / error yourself (and on cancelled if you cancelled the run).
heartbeat frames ({"type":"heartbeat"}) are injected about every 15 seconds of idle to keep the connection warm. The Python SDK filters them out unless you construct the stream with include_heartbeats=True. In JavaScript a heartbeat data frame is yielded as a normal event — ignore it in your switch. /chats/stream instead emits an SSE comment keepalive every 30 seconds, which both SDKs skip.
interrupt (a workflow pause) and user_input_request (a Composer/Assistant HITL pause) are not terminal. After either, the stream goes quiet but is not closed and no done arrives — the run is paused waiting for you. Do not treat silence as completion; handle the pause frame explicitly (see Human-in-the-loop).

Cancelling a stream

You can stop reading at any time, and you can also cancel the underlying connection. The mechanism differs by SDK. In JavaScript, pass an AbortSignal in the request options. Aborting it stops the fetch, breaks the parser’s read loop, and releases the reader cleanly. Note that SSE streams bypass the client’s timeout and maxRetries — only your signal cancels them. In Python, use async with (the context manager closes the connection on exit) or call close() to break the loop. The SDK disables the read timeout on streams (so a long, idle HITL pause is not killed by a read timeout); the connect/write timeout still applies. Stopping the stream does not stop the run. To actually terminate a run server-side, call the cancel method, which posts to the cancel endpoint and emits a cancelled frame.
Cancel methods exist for each surface: executions.cancel(runId) (POST /workflows/cancel/{run_id}), composer.cancel(composerChatId) (POST /composer/chat/{composer_chat_id}/cancel), and assistant.cancel(chatId) (POST /assistant/chat/{chat_id}/cancel). Assistant cancel also clears any pending HITL question. Each raises a not-found error if the run or chat does not exist or is not owned by your organization.

Human-in-the-loop

A Composer or Assistant run can pause to ask you a structured question — pick an option, confirm a yes/no, supply free text, or connect a credential. The pause arrives as a user_input_request frame; you answer with a resume call carrying a UserInputResponse. This is the SDK view; the wire contract is in Human-in-the-loop (HITL) resume.

The user_input_request frame

This frame is doubly wrapped: the SSE frame’s data is { "type": "user_input_request", "data": <UserInputRequest> }, so the actual question lives one level deeper at data.data. A naive read of event.data["kind"] returns nothing — the kind is at event.data["data"]["kind"].
  • In JavaScript, the yielded user_input_request event is typed { type: "user_input_request"; data: UserInputRequest }, so the question is evt.data.
  • In Python, use the helper user_input_request_from_event(event.data), which extracts and parses the nested question into the right subtype (and returns None if the frame is not a HITL question).
object
The HITL question, discriminated on kind. Base fields (all kinds): request_id (string), message (markdown), required (boolean, default true), allow_free_text (boolean, default false), context (object, optional), timeout_hint_seconds (integer, optional).ChoiceOption is { value, label, description?, icon?, badge? }.

Answering with a UserInputResponse

You answer with a resume call. The response is also discriminated on kind — there are seven response kinds for the five request kinds, because credential_request is answered by either credential_added or credential_failed, and skipped can answer any non-required question.
object
The answer, discriminated on kind.

Resume mints a new run_id

The most important HITL fact: a resume call returns a NEW run_id and a fresh stream_url. The original stream does not carry the resumed events — you must call listen() again on the new run_id. The chat id stays the same; only the run changes.
string
required
Path argument — the chat whose paused question you are answering.
string
required
The request_id from the user_input_request you are answering. Resuming with a stale or already-answered request_id returns 410 Gone.
UserInputResponse
required
The answer object, discriminated on kind (above). In Python you may pass a typed model (e.g. YesNoResponse(answer=True)) or a plain dict.
ComposerLLMConfig
required
The model config to continue with: { integration_name, provider_id, model_id, temperature?, credential_id? }. Required in practice — the endpoint returns 400 if it is missing, because the executor rebuilds the chat model on resume. The two SDKs reflect this differently: assistant.resume makes llm a required argument with no default, while composer.resume types it as optional but the server still rejects a missing value. Always send it.
Two distinct “resume” flows exist — do not conflate them. HITL chat resume (composer.resume / assistant.resume) answers a user_input_request and mints a new run_id. Workflow thread resume (executions.resume, POST /workflows/resume/{thread_id}) resumes a workflow interrupt node with a resume_value and reuses the same run_id. The first is for chat agents; the second is for the interrupt node in a workflow.

Error handling

A non-2xx response on connect throws synchronously before the first frame — it never arrives as a yielded event. The SDKs map status codes to typed exceptions. Discriminate by instanceof (JS) / isinstance (Python), not by class-name string — for example 403 maps to PermissionError, not “ForbiddenError”. A backend failure mid-stream does not throw. It arrives as a normal yielded frame of type === "error" (JS) / event == "error" (Python) — your loop must handle it.
Composer, Assistant, and workflow runs sit behind the live billing admission gate. A chat(), run(), or resume() that exceeds your plan can return a flat DenialEnvelope{code, layer, key, current, limit, reason} — as 402, 403, or 429 (this is distinct from the {detail} shape returned by plain CRUD routes). Surface and branch on these in your error handler. See Usage gating & limits and Errors & status codes.
For the full SDK error tree, retry policy, and idempotency behavior, see Errors & retries.

Reconnecting

Neither SDK auto-reconnects a dropped stream. On the server side, a run’s recent events are replayed from a one-hour history buffer when you reconnect by calling listen() again with the same run_id, so a brief disconnect during an active run does not lose frames. The Python EventSourceStream records last_event_id, but the SDKs do not replay it via a Last-Event-ID header — reconnection relies on the server-side history replay, not on SSE id:. See SSE run streaming for the replay window.

SSE run streaming

The raw frame format, the full event taxonomy, and the heartbeat and replay model.

Human-in-the-loop (HITL) resume

Request and response kinds, the resume endpoints, and the new-run_id contract.

JavaScript SDK

Install and configure modulex-js, including headers and config.

Python SDK

Install and configure the modulex-python async client.