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 arun_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: anAuthorization: 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 emitsdata: {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.
- JavaScript — yielded payload
- Python — SSEEvent
Each Response payloads stay snake_case — only request bodies are converted to snake_case on the wire, never responses. Read
listen() yields the payload object (frame.data), cast to a typed union, so you discriminate on evt.type:evt.run_id, not evt.runId.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 ondoneorerror. - The Python SDK’s
SSEEvent.is_terminalis broader —Truefordone,error,cancelled, andinterrupted— so it may stop iterating oncancelledeven where the backend would keep the socket open. - The JavaScript SDK does not expose an
is_terminalhelper; break ondone/erroryourself (and oncancelledif 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.
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 anAbortSignal 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.
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 auser_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_requestevent is typed{ type: "user_input_request"; data: UserInputRequest }, so the question isevt.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 returnsNoneif 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.
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 callinglisten() 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.
Related
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.