Skip to main content
ModuleX ships two completely separate realtime systems, and the first thing to know is that they are unrelated. They run on different servers, use different protocols, authenticate differently, and share no event names and no message envelope. “Realtime” in ModuleX always means one of these two planes:

SSE run streaming

One-way Server-Sent Events from the REST backend, pushing the live events of a workflow, composer, or assistant run to a single client. This is how you watch a run unfold and consume agent output token by token.

Socket.io collaboration

Bidirectional Socket.io from a separate realtime server, syncing the workflow canvas across everyone editing it — node moves, locks, cursors, and presence.
The two planes meet a reader in different places, so this page is the map. It compares the transports, their authentication, and their event taxonomies side by side, and gives you a decision guide for which one a given task needs. Deep per-event references live on the linked pages.

The two planes at a glance

Do not conflate the two planes. They share no event names, no message envelope, and no error shape. The only intended bridge between them — an internal pub/sub channel that would forward REST workflow changes into the collaboration server — is dead today and must not be relied on. Live external sync flows over the Socket.io workflow:external-sync event instead. See External sync is not a background channel.

How this differs from the product collaboration pages

This is the developer-facing realtime reference. If you are looking for the product experience rather than the wire protocol, you want a different page: This page and its siblings under Realtime are the protocol-level truth: frames, event names, and auth.

Plane 1 — SSE run streaming

Server-Sent Events stream the events of one run to one client over a long-lived HTTP response. The backend uses a raw streaming response with Content-Type: text/event-stream; it does not use any higher-level SSE framework. You start a run with a normal REST call, then open a listen stream for that run’s id.

SSE transport and frame format

Run-event streams use a data-only frame convention: each event is a single data: line carrying JSON, terminated by a blank line, with no SSE event: line. The discriminator is the type field inside the JSON, not an SSE event name.
Run-event frame (data-only)
The response carries Cache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no so intermediaries do not buffer the stream.
One stream is the exception to the data-only rule. The chat-list feed at GET /chats/stream uses named SSE events (a real event: line such as event: chat_list_updated) and carries chat-list invalidation — not run output or token chunks. It is documented for completeness on SSE run streaming; the run streams below are the ones you stream a run’s progress from.

SSE authentication

SSE streams are REST endpoints, so they authenticate exactly like the rest of the API: the Authorization: Bearer mx_live_… header plus the X-Organization-ID header. The run-listen endpoints require an owner or admin role (member is retired — see Roles & permissions), and each runs an ownership check before subscribing, so a run that is not in your organization returns 404, never 403. See Authentication for the full header reference.
Open a workflow-run stream
A raw browser EventSource cannot set Authorization or X-Organization-ID headers, yet these endpoints require them. The SDKs and the web app consume the stream with a fetch-based reader (not EventSource), which can. How a bare-browser EventSource would authenticate the stream is not pinned in source — see Open questions. If you are building a browser client, use a fetch-based reader as the SDKs do.

The SSE listen surfaces

There are three run-listen surfaces — one per run kind — plus the chat-list feed and a credentials helper. Each listen surface has an SDK method that wraps the raw stream into an async iterator. Consume a workflow run the same operation three ways:
For the consumer-facing details — the AbortSignal contract, how a connect-time non-2xx becomes a typed exception before the first frame, and how each SDK handles heartbeats — see Streaming & HITL.

SSE event taxonomy overview

Each run kind emits its own set of event type values. The tables below are the overview; the per-field schema for every event lives on SSE run streaming, and the human-in-the-loop request/response contract lives on Human-in-the-loop (HITL) resume.
The published wire shapes differ from the backend’s typed event models in some cases (for example, a node_update frame carries node and output on the wire, not node_id/node_type/status). Always build against the wire shapes documented on SSE run streaming, not against generated model types.
Emitted by GET /workflows/listen/{run_id}:A workflow interrupt is resumed with POST /workflows/resume/{thread_id} and reuses the same run id. See the Interrupt node (HITL) and HITL resume.
A few behaviors trip up consumers and are worth knowing before you read the per-event reference.
  • Terminal set. The client-side terminal events are done, error, cancelled, and interrupted. Iteration stops on any of them. interrupt and user_input_request are not terminal — they pause the run but keep the stream open.
  • interrupted is history-only. A live composer or assistant interrupt does not publish an interrupted frame and does not close the stream — the stream stays open with no terminal frame until a reconnect replays history. The live pause you observe is the user_input_request frame.
  • user_input_request nests its payload. Its real question payload sits one extra level deep (under data.data), unlike every other event. The Python SDK ships user_input_request_from_event(event.data) to unwrap it; a naive read of event.data["kind"] returns nothing. See Human-in-the-loop (HITL) resume.
  • Two resume contracts. A workflow interrupt resumes with the same run id via POST /workflows/resume/{thread_id}. A composer/assistant user_input_request resumes via POST /composer/chat/{id}/resume or POST /assistant/chat/{id}/resume, which returns a new run id and a new stream url — you must re-listen on the new id. The resume body’s llm field is optional in the schema but required at runtime (the endpoint returns 400 without it).
  • Heartbeats. A {"type":"heartbeat"} frame is injected on each idle gap (a short interval, on the order of seconds) on the run streams. The Python SDK filters these out unless you pass include_heartbeats=True; in JavaScript you receive them and should ignore any frame whose type is heartbeat.

Reconnects and delivery

Run events are fanned out through a shared server-side pub/sub layer, so any backend replica can serve a stream for any run, and each run’s recent history is buffered (with a short time-to-live) for replay. On (re)connect the listener replays the buffered history in order and then tails live — so a reconnect is replay-safe and a just-missed event is not lost. The SDKs do not auto-reconnect; if your connection drops, open the stream again and the history replay covers the gap. See SSE run streaming for the replay window and System status & health for liveness.

SSE error envelopes

A non-2xx on connect surfaces as a typed SDK exception before the first frame (404 → not found, 401 → authentication, 403 → permission, 429 → rate limit). The error body for these REST failures is the standard FastAPI shape, {"detail": "<message>"}. Once the stream is open, a mid-stream failure arrives as a final data: frame whose type is error rather than as an HTTP error. Because workflow runs, composer, and assistant are managed-usage surfaces, they also pass through the billing admission gate, which can deny a run before the stream opens with a 402, 403, or 429 flat DenialEnvelope ({code, layer, key, current, limit, reason}). That is a different shape from {"detail": …}. For all three error-envelope shapes and which surface emits each, see Errors & status codes; for the gate itself see Usage gating & limits.

Plane 2 — Socket.io canvas collaboration

The collaboration plane runs on a separate Socket.io server and keeps everyone editing the same workflow canvas in sync. It is bidirectional: clients emit commands (move a node, acquire a lock), the server validates and broadcasts the result to the other editors in the room. This is the plane behind Canvas collaboration and the builder’s Realtime co-editing.

Socket.io transport and connection lifecycle

Clients connect over Socket.io (WebSocket, with a polling fallback). On a successful handshake the server joins you to a per-user room and a per-organization room, then emits, in order, a connected event ({userId, userName, color, organizationId}) and a workflows_list event for your organization. You enter a specific canvas by emitting join-workflow, which replies with a room snapshot.

Socket.io authentication

Socket.io does not use HTTP headers for auth. The same two values you would send as REST headers go into the Socket.io handshake auth payload instead: a token and an organization id.
Connect to the collaboration server
The server verifies the token, confirms you are a member of the organization, and assigns you a presence color. Handshake failures arrive as a Socket.io connect_error (with a message such as Authentication required, Organization ID required, Invalid token, or Not a member of this organization) — not as an in-band error event. See Auth model: JWT vs API key and Org context & X-Organization-ID for the underlying auth model.
The collaboration plane is consumed by the ModuleX web app, which authenticates the handshake with a Clerk session token rather than an mx_live_ API key. The neutral placeholders above (COLLAB_SERVER_URL, CLERK_SESSION_TOKEN) stand in for your deployment’s values. The exact production collaboration-server hostname is not standardized in these docs — see Open questions.

Socket.io authorization and rate limits

Most write commands pass two gates: a per-user rate limit and a write-permission check that requires an owner or admin role (member is read-only and retired as a first-class role — see Roles & permissions). The gates are not uniform: cursor events are ungated, unlock requires write permission but is not rate-limited, and workflow:external-sync has no gate at all because its handler is the trust boundary. A rate-limited command returns an error with code rate_limited; a permission failure returns error with code permission_denied.

Socket.io event taxonomy overview

The collaboration plane has a large, structured event set. The full client-to-server and server-to-client reference — every payload, ack, conflict, and error code — lives on Socket.io collaboration events, and presence/lock/version semantics on Presence, locks & versioning. The overview groups events by purpose:
  • No ack callbacks. Socket.io’s callback-style acks are not used anywhere. Every response is a separate emitted eventack (for typed writes), patch_ack (for the legacy patch path), joined, conflict, or error — not a callback reply.
  • Two version planes. An in-memory room version increments per accepted operation and is reported in ack/conflict/broadcasts. A separate database edit_version increments per flush (batched, every couple of seconds) and is reported in saved. The saved version can be numerically behind the version your client tracks from acks — do not overwrite local state from saved.
  • Conflicts. A conflict frame (the only user-visible conflict signal) fires when your version is too far behind the room version; its payload always carries resolution: "rebase". Database-layer conflicts are silent — they re-queue and retry server-side.
Two known UI-to-server mismatches are documented as known limitations, not as working flows — do not build on them:
  • The web app emits nodes:delete (plural) for multi-delete, but the server registers only node:delete (singular). The plural emit has no server handler.
  • The gold docs and a stale UI constant name the external-update event workflow_updated_externally, but the server emits workflow:external-updated. Trust the code name.
See Socket.io collaboration events and Known limitations.

Socket.io error envelope

Collaboration errors use a {code, message, retryAfterMs?} shape — a different envelope from the REST/SSE {"detail": …}. The retryAfterMs hint appears only on the rate_limited deny and is a transport hint, not the billing 429 envelope. There is no unified error schema across the two planes; branch on the shape per transport. See Errors & status codes.

External sync is not a background channel

When the AI Composer or a REST call changes a workflow, those changes reach the canvas over the Socket.io workflow:external-sync event (which the server rebroadcasts as workflow:external-updated) — not over a background pub/sub channel. Live external sync runs over the Socket.io path. See Realtime co-editing & external sync, Socket.io collaboration events, and Realtime & collaboration model.

Which plane do you need?

Use SSE run streaming when…

You want to watch a run — a workflow execution, a composer edit session, or an assistant conversation — and consume its events or token output as it happens, from a server, a script, or an SDK.

Use Socket.io collaboration when…

You are building canvas editing — moving nodes, connecting edges, acquiring locks, or showing presence and cursors — and need changes to propagate to other editors of the same workflow.
A quick decision guide:

Open questions

A couple of realtime behaviors are not pinned in source and are tracked here so you do not build on an assumption:
A raw browser EventSource cannot send Authorization / X-Organization-ID headers, yet the run-listen endpoints require them. The SDKs and the web app use fetch-based readers instead, which can set headers. How a bare-browser EventSource is expected to authenticate (cookie, query parameter, or proxy) is not confirmed in source. Use a fetch-based reader for browser clients.
The collaboration server is a separate service from the REST API at https://api.modulex.dev. The exact production hostname for the Socket.io endpoint is not standardized in these docs; the source material references .io-suffixed hosts that conflict with the .dev hosts used elsewhere here. Use your deployment’s configured collaboration-server URL.

Next steps

SSE run streaming

Frame format, the full event taxonomy, and the run lifecycle.

Human-in-the-loop (HITL) resume

Pause and resume semantics: request kinds, response kinds, and the new-run-id contract.

Socket.io collaboration events

The complete client-to-server and server-to-client event reference.

Presence, locks & versioning

Presence, cursors, node locks, and the patch/version/conflict model.