> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Realtime overview & event taxonomy

> ModuleX has two independent realtime planes: SSE run streaming served by the REST backend, and Socket.io canvas collaboration served by a separate server. Compare transports, auth, and event taxonomies, and learn when to use which.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

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:

<CardGroup cols={2}>
  <Card title="SSE run streaming" icon="wave-pulse" href="/realtime/sse-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.
  </Card>

  <Card title="Socket.io collaboration" icon="users" href="/realtime/socket-events">
    Bidirectional **Socket.io** from a separate realtime server, syncing the **workflow canvas** across everyone editing it — node moves, locks, cursors, and presence.
  </Card>
</CardGroup>

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.

<MediaEmbed id="MX-MEDIA-2060" type="image" caption={"A two-pane architecture diagram of the ModuleX realtime planes."} />

## The two planes at a glance

|               | SSE run streaming                                                            | Socket.io collaboration                                                                                |
| ------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Purpose       | Stream a single run's events to one client                                   | Sync the workflow canvas across multiple editors                                                       |
| Server        | The ModuleX REST backend                                                     | A separate Socket.io collaboration server                                                              |
| Protocol      | HTTP `GET`/`POST`, `Content-Type: text/event-stream`                         | Socket.io over WebSocket (with polling fallback)                                                       |
| Direction     | Server to client only (one-way push)                                         | Bidirectional (client to server commands, server to client broadcasts)                                 |
| Discriminator | The `type` field inside each JSON frame                                      | The Socket.io event name                                                                               |
| Auth carrier  | `Authorization` and `X-Organization-ID` **headers**                          | The same two values in the Socket.io **handshake `auth` payload**                                      |
| Consumers     | JavaScript SDK, Python SDK, the web app                                      | The web app                                                                                            |
| Resume/cancel | Separate REST `POST` calls                                                   | Inline Socket.io events                                                                                |
| Reference     | [SSE run streaming](/realtime/sse-streaming) · [HITL resume](/realtime/hitl) | [Socket.io events](/realtime/socket-events) · [Presence, locks & versioning](/realtime/presence-locks) |

<Warning>
  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](#external-sync-is-not-a-background-channel).
</Warning>

## 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:

* [Canvas collaboration](/platform/collaboration/canvas) and [Chat collaboration](/platform/collaboration/chat) describe collaboration as a **product feature** — what multi-user editing looks like in the app.
* [Realtime co-editing & external sync](/workflow-builder/realtime-coediting) describes the **builder UX** — how edits sync while you work on the canvas.
* [Realtime & collaboration model](/concepts/realtime-model) is the **conceptual** overview for a non-technical reader.

This page and its siblings under [Realtime](/realtime/overview) 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.

```text Run-event frame (data-only) theme={null}
data: {"type":"metadata","data":{"run_id":"r1","thread_id":"c1","workflow_type":"composer","timestamp":"2026-06-21T10:00:00Z"}}

```

The response carries `Cache-Control: no-cache`, `Connection: keep-alive`, and `X-Accel-Buffering: no` so intermediaries do not buffer the stream.

<Note>
  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](/realtime/sse-streaming); the run streams below are the ones you stream a run's progress from.
</Note>

### 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](/security/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](/api-reference/authentication) for the full header reference.

```bash Open a workflow-run stream theme={null}
curl -N https://api.modulex.dev/workflows/listen/9d1f0c2e-4b7a-4c1e-8f3a-2b6d5e0a1c34 \
  -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
  -H "X-Organization-ID: a1b2c3d4-e29b-41d4-a716-446655440000" \
  -H "Accept: text/event-stream"
```

<Warning>
  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](#open-questions). If you are building a browser client, use a `fetch`-based reader as the SDKs do.
</Warning>

### 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.

| Run kind      | Backend route                                           | JavaScript SDK                           | Python SDK                                 |
| ------------- | ------------------------------------------------------- | ---------------------------------------- | ------------------------------------------ |
| Workflow run  | `GET /workflows/listen/{run_id}`                        | `client.executions.listen(runId)`        | `client.executions.listen(run_id)`         |
| Composer run  | `GET /composer/chat/{composer_chat_id}/listen/{run_id}` | `client.composer.listen(chatId, runId)`  | `client.composer.listen(chat_id, run_id)`  |
| Assistant run | `GET /assistant/chat/{chat_id}/listen/{run_id}`         | `client.assistant.listen(chatId, runId)` | `client.assistant.listen(chat_id, run_id)` |

Consume a workflow run the same operation three ways:

<CodeGroup>
  ```bash cURL theme={null}
  curl -N https://api.modulex.dev/workflows/listen/9d1f0c2e-4b7a-4c1e-8f3a-2b6d5e0a1c34 \
    -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
    -H "X-Organization-ID: a1b2c3d4-e29b-41d4-a716-446655440000" \
    -H "Accept: text/event-stream"
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex

  async def main():
      async with Modulex(
          api_key="mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u",
          organization_id="a1b2c3d4-e29b-41d4-a716-446655440000",
      ) as client:
          async with client.executions.listen("9d1f0c2e-4b7a-4c1e-8f3a-2b6d5e0a1c34") as stream:
              async for event in stream:
                  print(event.type, event.data)
                  if event.is_terminal:
                      break

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from 'modulex-js';

  const client = new Modulex({
    apiKey: 'mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u',
    organizationId: 'a1b2c3d4-e29b-41d4-a716-446655440000',
  });

  for await (const event of client.executions.listen('9d1f0c2e-4b7a-4c1e-8f3a-2b6d5e0a1c34')) {
    console.log(event.type, event.data);
  }
  ```
</CodeGroup>

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](/sdks/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](/realtime/sse-streaming), and the human-in-the-loop request/response contract lives on [Human-in-the-loop (HITL) resume](/realtime/hitl).

<Note>
  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](/realtime/sse-streaming), not against generated model types.
</Note>

<Tabs>
  <Tab title="Workflow run">
    Emitted by `GET /workflows/listen/{run_id}`:

    | `type`         | Meaning                                                      |
    | -------------- | ------------------------------------------------------------ |
    | `metadata`     | Run identifiers and workflow info, sent first                |
    | `node_started` | A node began executing                                       |
    | `node_update`  | A node produced output (LLM token batches arrive here too)   |
    | `node_retry`   | A node failed and is being retried                           |
    | `node_error`   | A node errored                                               |
    | `interrupt`    | The run paused for a human (workflow HITL); **not terminal** |
    | `resumed`      | A paused run resumed under the same run id                   |
    | `done`         | The run completed (terminal)                                 |
    | `cancelled`    | The run was cancelled (terminal)                             |
    | `error`        | The run errored (terminal)                                   |
    | `heartbeat`    | Keepalive during idle gaps                                   |

    A workflow `interrupt` is resumed with `POST /workflows/resume/{thread_id}` and **reuses the same run id**. See the [Interrupt node (HITL)](/workflow-builder/nodes/interrupt) and [HITL resume](/realtime/hitl).
  </Tab>

  <Tab title="Composer run">
    Emitted by `GET /composer/chat/{composer_chat_id}/listen/{run_id}`:

    | `type`               | Meaning                                                        |
    | -------------------- | -------------------------------------------------------------- |
    | `metadata`           | Run identifiers, sent first                                    |
    | `response_chunk`     | A chunk of the agent's text response                           |
    | `tool_call`          | The agent invoked a tool                                       |
    | `tool_result`        | A tool returned (an error variant carries `success:false`)     |
    | `guidance`           | A guidance/warning message (for example, repeated failures)    |
    | `subagent_start`     | A composer subagent started                                    |
    | `workflow_change`    | A workflow edit was applied                                    |
    | `workflow_sync`      | The full updated workflow graph, after an edit                 |
    | `user_input_request` | The agent paused to ask you something (HITL); **not terminal** |
    | `done`               | The run completed (terminal)                                   |
    | `error`              | The run errored (terminal)                                     |
    | `heartbeat`          | Keepalive during idle gaps                                     |

    The composer is the [AI Composer](/concepts/ai-composer) editing a [workflow graph](/concepts/workflow-engine).
  </Tab>

  <Tab title="Assistant run">
    Emitted by `GET /assistant/chat/{chat_id}/listen/{run_id}`. The [Assistant](/concepts/assistant) shares the composer's executor but has **no** workflow, so `workflow_change` and `workflow_sync` never fire:

    | `type`               | Meaning                                                        |
    | -------------------- | -------------------------------------------------------------- |
    | `metadata`           | Run identifiers, sent first                                    |
    | `response_chunk`     | A chunk of the agent's text response                           |
    | `tool_call`          | The agent invoked a tool                                       |
    | `tool_result`        | A tool returned                                                |
    | `guidance`           | A guidance/warning message                                     |
    | `user_input_request` | The agent paused to ask you something (HITL); **not terminal** |
    | `run_resumed`        | A paused run resumed under a **new** run id                    |
    | `done`               | The run completed (terminal)                                   |
    | `error`              | The run errored (terminal)                                     |
    | `cancelled`          | The run was cancelled (terminal)                               |
    | `heartbeat`          | Keepalive during idle gaps                                     |

    See [Streaming responses](/assistant/streaming) for the assistant view of this stream.
  </Tab>
</Tabs>

<Accordion title="Terminal events, heartbeats, and the pause traps">
  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](/realtime/hitl).
  * **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`.
</Accordion>

### 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](/realtime/sse-streaming) for the replay window and [System status & health](/reference/status) 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](/api-reference/errors); for the gate itself see [Usage gating & limits](/billing/usage-gating).

## 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](/platform/collaboration/canvas) and the builder's [Realtime co-editing](/workflow-builder/realtime-coediting).

### 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.

```javascript Connect to the collaboration server theme={null}
import { io } from 'socket.io-client';

// Use your deployment's collaboration-server URL. The web app authenticates
// the handshake with a Clerk session token, not an mx_live_ API key.
const socket = io(COLLAB_SERVER_URL, {
  transports: ['websocket', 'polling'],
  auth: {
    token: CLERK_SESSION_TOKEN,
    organizationId: 'a1b2c3d4-e29b-41d4-a716-446655440000',
  },
});

socket.on('connected', (payload) => console.log('connected as', payload.userName));
socket.on('connect_error', (err) => console.error('handshake failed:', err.message));
```

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](/security/authentication) and [Org context & X-Organization-ID](/security/org-context) for the underlying auth model.

<Note>
  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](#open-questions).
</Note>

### 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](/security/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](/realtime/socket-events), and presence/lock/version semantics on [Presence, locks & versioning](/realtime/presence-locks). The overview groups events by purpose:

| Group                         | Client to server (examples)                                                                                  | Server to client (examples)                                                                          |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| Connection & rooms            | `join-workflow`, `leave-workflow`, `request-workflows-list`                                                  | `connected`, `workflows_list`, `joined`, `left`                                                      |
| Node edits                    | `node:add`, `node:update`, `node:move`, `nodes:move`, `node:duplicate`, `node:delete`, `node:toggle-enabled` | `ack`, `node:added`, `node:updated`, `node:moved`, `nodes:moved`, `node:duplicated`, `node:deleted`  |
| Edge edits                    | `edge:connect`, `edge:disconnect`                                                                            | `edge:connected`, `edge:disconnected`                                                                |
| Workflow lifecycle & metadata | `workflow:create`, `workflow:delete`, `workflow:metadata-update`, `input-params:update`, `batch`             | `workflow:created`, `workflow:deleted`, `workflow:metadata-updated`, `input-params:updated`, `saved` |
| External sync                 | `workflow:external-sync`                                                                                     | `workflow:external-updated`                                                                          |
| Presence & cursors            | `cursor`, `cursor:move`, `user:idle`, `user:active`                                                          | `presence`, `cursor_moved`, `user_idle_changed`                                                      |
| Locks                         | `lock`, `unlock`                                                                                             | `lock_ack`, `unlock_ack`, `lock_acquired`, `lock_released`, `lock_denied`, `locks_released`          |
| Status                        | `get-status`, `get-org-users`                                                                                | `status`, `org_users`                                                                                |
| Errors & conflicts            | —                                                                                                            | `error`, `conflict`                                                                                  |

<Accordion title="Acks, conflicts, and version planes">
  * **No ack callbacks.** Socket.io's callback-style acks are not used anywhere. Every response is a **separate emitted event** — `ack` (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.
</Accordion>

<Warning>
  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](/realtime/socket-events) and [Known limitations](/reference/known-limitations).
</Warning>

### 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](/api-reference/errors).

### External sync is not a background channel

When the [AI Composer](/concepts/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](/workflow-builder/realtime-coediting), [Socket.io collaboration events](/realtime/socket-events), and [Realtime & collaboration model](/concepts/realtime-model).

## Which plane do you need?

<CardGroup cols={2}>
  <Card title="Use SSE run streaming when…" icon="wave-pulse" href="/realtime/sse-streaming">
    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.
  </Card>

  <Card title="Use Socket.io collaboration when…" icon="users" href="/realtime/socket-events">
    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.
  </Card>
</CardGroup>

A quick decision guide:

| If you want to…                                       | Use                     | Reference                                                                   |
| ----------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------- |
| Stream a workflow run's progress                      | SSE                     | [SSE run streaming](/realtime/sse-streaming)                                |
| Stream composer or assistant output                   | SSE                     | [Streaming & HITL](/sdks/streaming-hitl)                                    |
| Pause and resume a run for a human decision           | SSE + REST resume       | [Human-in-the-loop (HITL) resume](/realtime/hitl)                           |
| Co-edit a workflow canvas with teammates              | Socket.io               | [Socket.io collaboration events](/realtime/socket-events)                   |
| Show who is online, their cursors, and node locks     | Socket.io               | [Presence, locks & versioning](/realtime/presence-locks)                    |
| Reflect a composer or REST change onto an open canvas | Socket.io external sync | [Realtime co-editing & external sync](/workflow-builder/realtime-coediting) |

## Open questions

A couple of realtime behaviors are not pinned in source and are tracked here so you do not build on an assumption:

<Accordion title="Browser EventSource authentication for SSE">
  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.
</Accordion>

<Accordion title="Production collaboration-server hostname">
  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.
</Accordion>

## Next steps

<CardGroup cols={2}>
  <Card title="SSE run streaming" icon="wave-pulse" href="/realtime/sse-streaming">
    Frame format, the full event taxonomy, and the run lifecycle.
  </Card>

  <Card title="Human-in-the-loop (HITL) resume" icon="hand" href="/realtime/hitl">
    Pause and resume semantics: request kinds, response kinds, and the new-run-id contract.
  </Card>

  <Card title="Socket.io collaboration events" icon="users" href="/realtime/socket-events">
    The complete client-to-server and server-to-client event reference.
  </Card>

  <Card title="Presence, locks & versioning" icon="lock" href="/realtime/presence-locks">
    Presence, cursors, node locks, and the patch/version/conflict model.
  </Card>
</CardGroup>
