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

# Socket.io collaboration events

> The complete client-to-server and server-to-client Socket.io event reference for ModuleX canvas collaboration: the handshake auth payload, every node, edge, workflow, lock, cursor, and presence event with payloads, acks, conflicts, and error codes, plus the live workflow:external-sync path.

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>;
};

This is the complete wire reference for the **Socket.io collaboration plane** — the bidirectional realtime channel that keeps everyone editing the same [workflow](/concepts/workflows-and-runs) canvas in sync. It documents the connection handshake, every client-to-server command you can emit, every server-to-client event you can receive, the acknowledgement and conflict model, and every error code.

This is the second of the two ModuleX realtime planes. If you are looking for run streaming (workflow, composer, or assistant output), that is the SSE plane — see [SSE run streaming](/realtime/sse-streaming). The two planes share no event names, no envelope, and no error shape; the [Realtime overview](/realtime/overview) compares them. For presence, cursor, lock, and version semantics in depth, see [Presence, locks & versioning](/realtime/presence-locks).

<MediaEmbed id="MX-MEDIA-2090" type="image" caption={"A sequence diagram of the Socket.io collaboration lifecycle for one editor."} />

## How the collaboration plane works

The collaboration plane runs on a **separate Socket.io server** from the REST API. It is bidirectional:

* The **client** emits commands — join a canvas, move a node, connect an edge, acquire a lock.
* The **server** validates each command (auth role, version, room membership), persists the change, then **acknowledges** it to the sender and **broadcasts** the result to the other editors in the same workflow room.

The transport is Socket.io over WebSocket with a polling fallback (`transports: ['websocket', 'polling']`). Every command and broadcast is a named Socket.io event; the event name is the discriminator, not a `type` field inside a JSON envelope (that is the SSE convention — do not mix them).

<Warning>
  Socket.io callback-style acks are **not used anywhere** in this plane. Every response is a **separate emitted event** — `ack`, `patch_ack`, `joined`, `conflict`, or `error`. Do not pass a callback to `socket.emit(...)` and wait on it; listen for the corresponding response event instead.
</Warning>

### Field casing on the wire

Almost every payload field is **camelCase** on the wire (`workflowId`, `nodeId`, `userId`, `sourceHandle`), even though the underlying stored columns are snake\_case. The one exception is the `WorkflowListItem` rows inside `workflows_list`, which carry snake\_case fields (`creator_id`, `live_deployment_id`, `created_at`, `updated_at`) straight from SQL. Special-case those four fields when you consume the workflow list.

## Connection handshake

You authenticate the collaboration plane in the Socket.io **handshake `auth` payload**, not with HTTP headers. This is the single most important difference from the REST and SSE planes, which use the `Authorization` and `X-Organization-ID` headers.

### The auth payload

Pass two fields in the `auth` object when you open the socket:

<ParamField path="token" type="string" required>
  A Clerk JWT (the user session token). The collaboration plane is consumed by the ModuleX web app, which authenticates with a Clerk session token rather than an `mx_live_*` API key. Missing this field rejects the handshake with a `connect_error` whose message is `Authentication required`.
</ParamField>

<ParamField path="organizationId" type="string" required>
  The organization id (a UUID) selecting the org context for this connection — the same value you would send as the `X-Organization-ID` header on a REST call. Missing this field rejects the handshake with a `connect_error` whose message is `Organization ID required`. See [Org context & X-Organization-ID](/security/org-context).
</ParamField>

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

// COLLAB_SERVER_URL and CLERK_SESSION_TOKEN stand in for your deployment's values.
// Auth goes in the handshake `auth` payload — NOT in HTTP headers.
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));
```

<Note>
  The exact production collaboration-server hostname is not standardized in these docs (the source material references hosts that conflict with the `api.modulex.dev` REST host). Use your deployment's configured collaboration-server URL — see [Open questions](#open-questions).
</Note>

### What the server does on handshake

Before the connection is accepted, the server runs an authentication middleware that, in order:

1. Verifies the Clerk token against Clerk's JWKS. An invalid token rejects with `connect_error` message `Invalid token`.
2. Confirms you are a member of the organization. A non-member rejects with `connect_error` message `Not a member of this organization`.
3. Populates your connection context: internal user id, email, display name, organization id, **organization role** (`owner` or `admin`; `member` is retired — see [Roles & permissions](/security/roles-permissions)), and a presence color assigned round-robin from an 8-color palette.
4. Joins you to two internal rooms automatically: `user:<userId>` (for targeted messages) and `org:<organizationId>` (for org-wide broadcasts).

<Warning>
  Handshake failures arrive as a Socket.io **`connect_error`** with an `Error` message — **not** as an in-band `error` event. Listen on `connect_error` for the four handshake rejection messages above. The in-band `error` event (documented under [Error events](#error-events)) only carries handler-level failures **after** a successful connect.
</Warning>

### Post-connect bootstrap (server to client)

Immediately after a successful handshake, the server **always** emits two events to you, in order:

<Steps>
  <Step title="connected">
    `ConnectedPayload`: `` `{ userId, userName, color, organizationId }` ``. Your identity and assigned presence color for this session.
  </Step>

  <Step title="workflows_list">
    `WorkflowsListPayload`: `` `{ workflows: WorkflowListItem[], total }` `` — every workflow in your organization. If the underlying query fails, the error is logged server-side and **no event is emitted** (there is no client-facing error on initial load); re-request it with `request-workflows-list`.
  </Step>
</Steps>

```json connected, then workflows_list theme={null}
{ "userId": "u_123", "userName": "Ada L.", "color": "#3B82F6", "organizationId": "a1b2c3d4-e29b-41d4-a716-446655440000" }
{ "workflows": [ { "id": "wf_123", "name": "My Flow", "status": "draft", "visibility": "private", "tags": [], "version": "1.0.0", "creator_id": "u_123", "input": {}, "live_deployment_id": null, "created_at": "2026-01-15T10:30:00.000Z", "updated_at": "2026-01-15T10:30:00.000Z" } ], "total": 1 }
```

The bootstrap puts you in the org room but **not** in any specific canvas. Joining a canvas is a separate, client-initiated `join-workflow` (below).

## Authorization and rate limits

Most write commands pass through two gates before their handler runs:

| Gate             | What it checks                                                                                                                                                                   | Failure event                                                          |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Rate limit       | A per-user message-flood limiter (a shared bucket across all gated events, keyed by your user id — **not** per-event and not per-org). The default is 100 points per 60 seconds. | `error` with code `rate_limited` (and an optional `retryAfterMs` hint) |
| Write permission | Your organization role must be `owner` or `admin`.                                                                                                                               | `error` with code `permission_denied`                                  |

The gates are checked in that order, so a flooded write returns `rate_limited` before `permission_denied`. The gating is **not uniform** across events — see the per-event tables below and these asymmetries:

* `cursor` and `cursor:move` are gated by **neither** (cursor moves are throttled server-side at 50 ms per user instead).
* `unlock` requires write permission but is **not** rate-limited, while `lock` is gated by both.
* `workflow:external-sync` has **no** gate at all — its handler is the trust boundary because it is the ingress for composer, API, and deployment changes.

<Warning>
  The `rate_limited` code and its `retryAfterMs` field are a **transport-level** flood signal. They are **not** the billing `429` — the collaboration plane does not run the credit/usage admission gate. The billing `429` is a flat `DenialEnvelope` (`{code, layer, key, current, limit, reason}`) that only appears on managed-usage REST/SSE surfaces. See [Errors & status codes](/api-reference/errors) and [Usage gating & limits](/billing/usage-gating).
</Warning>

## The write lifecycle: ack, broadcast, conflict

Every typed node, edge, and workflow-metadata write follows the same shape:

* The client emits the command with a **base `version`** (the room version your client last observed).
* The server checks the version, applies the change to the in-memory room schema, bumps the room version, and:
  * emits **`ack`** `` `{ version }` `` to the **sender only** with the new room version, and
  * **broadcasts** the typed result event (for example `node:moved`) to the other editors in the room (the sender is excluded).
* If your base version is too far behind, the server emits **`conflict`** instead of `ack` and applies nothing.

<Note>
  **Two version planes diverge by design.** An **in-memory room version** increments per accepted operation and is what `ack`, `conflict`, and broadcasts report. A separate **database `edit_version`** increments per flush (batched, roughly every two seconds) and is reported in `saved`. The `saved` version can be numerically **behind** the version your client tracks from acks — never overwrite local state from `saved`. Full detail on [Presence, locks & versioning](/realtime/presence-locks).
</Note>

The conflict gate accepts a client version within a tolerance window of the current room version (a window of 20 versions, to absorb rapid in-flight ops before their acks land). Beyond that window you get a `conflict` whose `resolution` is always `"rebase"`; refetch the room (re-`join-workflow`) and replay your edit.

```json A stale write returns conflict, not ack theme={null}
{ "yourVersion": 4, "serverVersion": 31, "resolution": "rebase" }
```

There are **two distinct ack channels**: the typed node/edge/workflow handlers emit `ack`, while the legacy `patch` path emits `patch_ack`. Both carry `` `{ version }` ``.

## Client-to-server events

The full client-to-server command set. The **gate** columns mark whether the rate-limit (RL) and write-permission (WP) gates apply before the handler. The **result** column lists the events the server emits in response.

| Command                     | Payload (camelCase)                                                                           |  RL |  WP | Result event(s)                                      | Section                                           |
| --------------------------- | --------------------------------------------------------------------------------------------- | :-: | :-: | ---------------------------------------------------- | ------------------------------------------------- |
| `join-workflow`             | `` `{ workflowId }` ``                                                                        |  ✓  |  –  | `joined` (caller)                                    | [Rooms](#connection-and-room-events)              |
| `leave-workflow`            | *(none)*                                                                                      |  ✓  |  –  | `left` (caller)                                      | [Rooms](#connection-and-room-events)              |
| `request-workflows-list`    | *(none)*                                                                                      |  –  |  –  | `workflows_list` (caller)                            | [Rooms](#connection-and-room-events)              |
| `node:add`                  | `` `{ type, version, payload: { node } }` ``                                                  |  ✓  |  ✓  | `ack` + `node:added`                                 | [Node events](#node-events)                       |
| `node:delete`               | `` `{ type, version, payload: { nodeId } }` ``                                                |  ✓  |  ✓  | `ack` + `node:deleted`                               | [Node events](#node-events)                       |
| `node:update`               | `` `{ type, version, payload: { nodeId, updates } }` ``                                       |  ✓  |  ✓  | `ack` + `node:updated`                               | [Node events](#node-events)                       |
| `node:move`                 | `` `{ type, version, payload: { nodeId, position } }` ``                                      |  ✓  |  ✓  | `ack` + `node:moved`                                 | [Node events](#node-events)                       |
| `nodes:move`                | `` `{ type, version, payload: { moves } }` ``                                                 |  ✓  |  ✓  | `ack` + `nodes:moved`                                | [Node events](#node-events)                       |
| `node:duplicate`            | `` `{ type, version, payload: { sourceNodeId, newNode } }` ``                                 |  ✓  |  ✓  | `ack` + `node:duplicated`                            | [Node events](#node-events)                       |
| `node:toggle-enabled`       | `` `{ type, version, payload: { nodeId, enabled } }` ``                                       |  ✓  |  ✓  | `ack` + `node:updated`                               | [Node events](#node-events)                       |
| `edge:connect`              | `` `{ type, version, payload: { source, target, sourceHandle?, targetHandle? } }` ``          |  ✓  |  ✓  | `ack` + `edge:connected`                             | [Edge events](#edge-events)                       |
| `edge:disconnect`           | `` `{ type, version, payload: { source, target, sourceHandle?, targetHandle? } }` ``          |  ✓  |  ✓  | `ack` + `edge:disconnected`                          | [Edge events](#edge-events)                       |
| `workflow:create`           | `` `{ type, payload: { name?, description?, tags? } }` ``                                     |  ✓  |  ✓  | `workflow:created` (caller) + `workflows_list` (org) | [Workflow lifecycle](#workflow-lifecycle-events)  |
| `workflow:delete`           | `` `{ type, payload: { workflowId } }` ``                                                     |  ✓  |  ✓  | `workflow:deleted` (room) + `workflows_list` (org)   | [Workflow lifecycle](#workflow-lifecycle-events)  |
| `workflow:metadata-update`  | `` `{ type, version, payload: { name?, description?, version?, tags? } }` ``                  |  ✓  |  ✓  | `ack` + `workflow:metadata-updated`                  | [Workflow lifecycle](#workflow-lifecycle-events)  |
| `input-params:update`       | `` `{ type, version, payload: { inputParameters } }` ``                                       |  ✓  |  ✓  | `ack` + `input-params:updated`                       | [Workflow lifecycle](#workflow-lifecycle-events)  |
| `batch`                     | `` `{ type, version, payload: { operations } }` ``                                            |  ✓  |  ✓  | one `ack` + per-op broadcasts                        | [Batch](#batch-operations)                        |
| `workflow:external-sync`    | `` `{ type, workflowId, source, editVersion, changesMade?, editedBy?, workflow?, input? }` `` |  –  |  –  | `workflow:external-updated` (room incl. sender)      | [External sync](#external-sync)                   |
| `patch` *(legacy)*          | `` `{ version, patches }` ``                                                                  |  ✓  |  ✓  | `patch_ack` (caller) + `patch` (peers)               | [Legacy patch](#legacy-patch-path)                |
| `cursor`                    | `` `{ nodeId \| null }` ``                                                                    |  –  |  –  | `presence` (room)                                    | [Cursors & presence](#cursor-and-presence-events) |
| `cursor:move`               | `` `{ x, y, nodeId? }` ``                                                                     |  –  | –\* | `cursor_moved` (peers)                               | [Cursors & presence](#cursor-and-presence-events) |
| `lock`                      | `` `{ nodeId }` ``                                                                            |  ✓  |  ✓  | `lock_ack` (caller) + `lock_acquired` (peers)        | [Locks](#lock-events)                             |
| `unlock`                    | `` `{ nodeId }` ``                                                                            |  –  |  ✓  | `unlock_ack` (caller) + `lock_released` (peers)      | [Locks](#lock-events)                             |
| `user:idle` / `user:active` | *(none)*                                                                                      |  –  |  –  | `user_idle_changed` (peers)                          | [Cursors & presence](#cursor-and-presence-events) |
| `get-status`                | *(none)*                                                                                      |  –  |  –  | `status` (caller)                                    | [Status queries](#status-query-events)            |
| `get-org-users`             | *(none)*                                                                                      |  –  |  –  | `org_users` (caller)                                 | [Status queries](#status-query-events)            |

<Info>
  \* `cursor:move` is not permission-gated; it is throttled server-side to 50 ms per user, and over-rate frames are silently dropped.
</Info>

<Warning>
  **Deleting multiple nodes over the wire.** Emit one `node:delete` per node, or wrap several `node:delete` operations in a single `batch`. See [Realtime co-editing & external sync](/workflow-builder/realtime-coediting).
</Warning>

## Server-to-client events

Every event the server can emit. Broadcast events go to the other editors in the workflow room unless noted; ack-style events go to the sender only.

| Event                       | Payload                                                                                           | Emitted when                                                                 |
| --------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `connected`                 | `` `{ userId, userName, color, organizationId }` ``                                               | On connect                                                                   |
| `workflows_list`            | `` `{ workflows, total }` ``                                                                      | On connect, on `request-workflows-list`, and org-wide after a create/delete  |
| `joined`                    | room snapshot (see [join-workflow](#connection-and-room-events))                                  | Reply to `join-workflow` (caller only)                                       |
| `left`                      | `` `{ workflowId \| null, success }` ``                                                           | Reply to `leave-workflow` (caller only)                                      |
| `ack`                       | `` `{ version }` ``                                                                               | An accepted typed write (sender only)                                        |
| `patch_ack`                 | `` `{ version }` ``                                                                               | An accepted legacy `patch` (sender only)                                     |
| `conflict`                  | `` `{ yourVersion, serverVersion, resolution }` ``                                                | Your base version is too far behind                                          |
| `saved`                     | `` `{ workflowId, version, savedAt, patchesCount }` ``                                            | After a database flush (room-wide)                                           |
| `node:added`                | `` `{ type, workflowId, version, userId, userName, payload: { node } }` ``                        | Peer added a node                                                            |
| `node:deleted`              | `` `{ type, workflowId, version, userId, userName, payload: { nodeId, deletedEdges } }` ``        | Peer deleted a node                                                          |
| `node:updated`              | `` `{ type, workflowId, version, userId, userName, payload: { nodeId, updates } }` ``             | Peer updated or toggled a node                                               |
| `node:moved`                | `` `{ type, workflowId, version, userId, payload: { nodeId, position } }` ``                      | Peer moved a node (no `userName`)                                            |
| `nodes:moved`               | `` `{ type, workflowId, version, userId, payload: { moves } }` ``                                 | Peer moved several nodes (no `userName`)                                     |
| `node:duplicated`           | `` `{ type, workflowId, version, userId, userName, payload: { sourceNodeId, newNode } }` ``       | Peer duplicated a node                                                       |
| `edge:connected`            | `` `{ type, workflowId, version, userId, userName, payload: { edge } }` ``                        | Peer connected an edge                                                       |
| `edge:disconnected`         | `` `{ type, workflowId, version, userId, payload: { source, target, sourceHandle? } }` ``         | Peer disconnected an edge (no `userName`)                                    |
| `workflow:created`          | `` `{ workflowId, workflow, version, createdBy, createdAt }` ``                                   | A workflow was created (creator only)                                        |
| `workflow:deleted`          | `` `{ workflowId, deletedBy, deletedAt }` ``                                                      | A workflow was deleted (room, incl. sender)                                  |
| `workflow:metadata-updated` | `` `{ ...payload: { name?, description?, version?, tags? } }` ``                                  | Peer updated workflow metadata                                               |
| `input-params:updated`      | `` `{ ...payload: { inputParameters } }` ``                                                       | Peer updated input parameters                                                |
| `workflow:external-updated` | `` `{ workflowId, version, source, workflow, input?, changesMade?, editedBy?, timestamp }` ``     | An external (composer / API / deployment) change synced (room, incl. sender) |
| `patch` *(legacy)*          | `` `{ workflowId, version, patches, userId, userName }` ``                                        | Peer sent a legacy patch                                                     |
| `presence`                  | `` `{ workflowId, users, nodeLocks }` ``                                                          | On join, cursor node-hover, and leave                                        |
| `cursor_moved`              | `` `{ workflowId, userId, userName, color, x, y, nodeId? }` ``                                    | Peer moved their cursor                                                      |
| `user_idle_changed`         | `` `{ userId, userName, isIdle, workflowId? }` ``                                                 | Peer toggled idle/active                                                     |
| `lock_ack`                  | `` `{ nodeId }` ``                                                                                | Your lock was accepted (sender only)                                         |
| `unlock_ack`                | `` `{ nodeId }` ``                                                                                | Your unlock was accepted (sender only)                                       |
| `lock_acquired`             | `` `{ workflowId, nodeId, userId, userName? }` ``                                                 | Peer acquired a node lock                                                    |
| `lock_released`             | `` `{ workflowId, nodeId, userId }` ``                                                            | Peer released a node lock                                                    |
| `lock_denied`               | `` `{ nodeId, lockedBy, lockedByName, error }` ``                                                 | Your lock was denied (held by someone else)                                  |
| `locks_released`            | `` `{ workflowId, nodeIds, userId }` ``                                                           | A disconnecting user released held locks                                     |
| `status`                    | `` `{ connected, organizationId, currentWorkflowId, cursorNodeId, lockedNodeId, connectedAt }` `` | Reply to `get-status` (caller only)                                          |
| `org_users`                 | `` `{ users }` ``                                                                                 | Reply to `get-org-users` (caller only)                                       |
| `error`                     | `` `{ code, message, retryAfterMs? }` ``                                                          | Any handler error                                                            |

<Note>
  The external-update event is **`workflow:external-updated`** — use that name.
</Note>

## Connection and room events

The bootstrap puts you in your org room; you enter a specific canvas with `join-workflow` and leave with `leave-workflow`.

### join-workflow

Emit `join-workflow` with the canvas you want to edit. The server loads the workflow, verifies it belongs to your organization, joins you to the `workflow:<workflowId>` room, registers your presence, and replies **`joined`** to you alone with a full room snapshot.

<ParamField path="workflowId" type="string" required>
  The id of the workflow to join.
</ParamField>

The `joined` reply (`JoinedPayload`) carries the room snapshot:

<ResponseField name="workflowId" type="string">The joined workflow id.</ResponseField>
<ResponseField name="version" type="number">The current in-memory room version. Track this as your base version for writes.</ResponseField>
<ResponseField name="workflow" type="object">The inner workflow schema: nodes, edges, state schema, and metadata.</ResponseField>
<ResponseField name="input" type="object">The input-parameters schema (from the workflow's `input` column).</ResponseField>
<ResponseField name="config" type="object">The workflow config (from the `config` column).</ResponseField>
<ResponseField name="stream" type="object">Stream settings for the workflow.</ResponseField>
<ResponseField name="liveDeploymentId" type="string | null">The current live deployment id, or `null`.</ResponseField>
<ResponseField name="users" type="UserPresence[]">Everyone currently in the room, with their cursor, lock, and idle state.</ResponseField>
<ResponseField name="nodeLocks" type="Record<string, string>">A map of `nodeId` to the `userId` holding its lock.</ResponseField>
<ResponseField name="alreadyJoined" type="boolean">Present and `true` if you were already in this room (a re-emit for the same room).</ResponseField>

```javascript Join a canvas and render the snapshot theme={null}
socket.emit('join-workflow', { workflowId: 'wf_123' });

socket.on('joined', ({ version, workflow, users, nodeLocks }) => {
  // version is your base version for subsequent writes
  renderCanvas(workflow, users, nodeLocks);
});
```

**Errors:** `workflow_not_found` (no such workflow), `access_denied` (the workflow belongs to a different organization), plus the rate-limit gate (`rate_limited`).

### leave-workflow

Emit `leave-workflow` with **no payload** to leave your current canvas. The server releases your presence and replies **`left`** to you: `` `{ workflowId, success }` `` where `workflowId` is the room you were in (or `null` if you were in none).

### request-workflows-list

Emit `request-workflows-list` with **no payload** to refresh the org workflow list. On success the server re-emits `workflows_list` to you; on failure it emits `error` with code `workflows_list_failed`.

## Node events

All node-write handlers share the [ack / broadcast / conflict lifecycle](#the-write-lifecycle-ack-broadcast-conflict). The client envelope is `` `{ type, version, payload }` ``; the broadcast envelope is `` `{ type, workflowId, version, userId, userName?, payload }` ``. Every node writes its result into the workflow's state schema under the node's id.

### node:add

Adds a node. The `payload.node` is a `NodeSchema`: `` `{ id, type, name, description, enabled, x, y, config }` ``, where `type` is one of the nine [node types](/workflow-builder/nodes/overview) (`llm`, `agent`, `tool`, `function`, `transformer`, `conditional`, `interrupt`, `guardrails`, `knowledge`). The server normalizes the node and also adds its state-schema field.

```json node:add (client to server), then ack and node:added theme={null}
{ "type": "node:add", "version": 5, "payload": { "node": { "id": "n_1", "type": "llm", "name": "Draft", "description": "", "enabled": true, "x": 100, "y": 200, "config": {} } } }

{ "version": 6 }
{ "type": "node:added", "workflowId": "wf_123", "version": 6, "userId": "u_123", "userName": "Ada L.", "payload": { "node": { "id": "n_1", "type": "llm", "name": "Draft", "description": "", "enabled": true, "x": 100, "y": 200, "config": {} } } }
```

The broadcast `node` is the **normalized** node. **Errors:** `not_in_workflow`, `room_not_found`, `conflict`, `invalid_patch` / `patch_failed`.

### node:delete

Deletes a node by id (`payload.nodeId`). The server removes the node, its incident edges, its state-schema field, and any conditional-route cleanup. The `node:deleted` broadcast carries `` `{ nodeId, deletedEdges }` ``, where each entry of `deletedEdges` is a `"<source>:<target>"` string. **Errors:** `node_not_found`, plus the shared lifecycle errors.

<Note>
  This is the node delete handler. Delete multiple nodes by emitting one `node:delete` each, or via [`batch`](#batch-operations).
</Note>

### node:update

Updates a node's fields (`payload.updates` may set `name`, `description`, `enabled`, `config`). A `name` change also rewrites the node's state-schema field description. The `node:updated` broadcast carries `` `{ nodeId, updates }` ``. An update that produces no effective change returns a bare `ack` with the current room version and no broadcast.

### node:move

Moves a single node. `payload` is `` `{ nodeId, position: { x, y } }` ``. The `node:moved` broadcast carries `` `{ nodeId, position }` `` and **omits `userName`**. Coordinates are rounded to integers; an unknown id or non-finite coordinates returns `node_not_found`.

### nodes:move

Moves several nodes at once. `payload` is `` `{ moves: [ { nodeId, position: { x, y } } ] }` ``. The `nodes:moved` broadcast carries `` `{ moves }` `` and **omits `userName`**. An empty net move returns a bare `ack`.

### node:duplicate

Duplicates a node. `payload` is `` `{ sourceNodeId, newNode: { id, name, x, y } }` ``. The server deep-clones the source, overlays the new id/name/position, normalizes it, and adds it plus its state field. The `node:duplicated` broadcast carries the **full** duplicated node: `` `{ sourceNodeId, newNode }` ``. **Errors:** `node_not_found` (missing source).

### node:toggle-enabled

Toggles a node's enabled flag. `payload` is `` `{ nodeId, enabled }` ``.

<Warning>
  There is **no** `node:toggled` event. The server reuses the **`node:updated`** broadcast with `` `payload: { nodeId, updates: { enabled } }` ``. Listen on `node:updated`, not on a hypothetical `node:toggled`.
</Warning>

## Edge events

Edge writes share the same ack / broadcast / conflict lifecycle as node writes.

### edge:connect

Connects two nodes. `payload` is `` `{ source, target, sourceHandle?, targetHandle? }` ``. Undefined handles are omitted to avoid patch errors. For conditional and loop nodes, the server also writes the corresponding routing config (conditional routes, default target, expression branches, or loop config) based on the handle. The `edge:connected` broadcast carries `` `{ edge }` ``.

```json edge:connect (client to server) theme={null}
{ "type": "edge:connect", "version": 7, "payload": { "source": "n_1", "target": "n_2", "sourceHandle": "condition_1" } }
```

### edge:disconnect

Disconnects an edge. `payload` is the same shape as `edge:connect`. The server clears any associated conditional routes or loop config symmetrically. The `edge:disconnected` broadcast carries `` `{ source, target, sourceHandle? }` `` and **omits `userName`**.

<Note>
  The `edge:disconnected` broadcast also delivers `targetHandle` on the wire when the handler has one, even though the typed payload documents only `sourceHandle`. Read `targetHandle` defensively if you depend on it.
</Note>

**Errors:** `edge_not_found` (no such edge and not an implicit loop-config edge), plus the shared lifecycle errors.

## Workflow lifecycle events

### workflow:create

Creates a workflow. `payload` is `` `{ name?, description?, tags? }` `` — there is **no `version`** because creation needs no base version. The server auto-generates a name if omitted and persists a default schema. It emits **`workflow:created`** to the **creator only** (the room is not broadcast to) with `` `{ workflowId, workflow, version, createdBy, createdAt }` ``, then broadcasts an updated `workflows_list` to the whole org. **Errors:** `create_failed`.

### workflow:delete

Deletes a workflow. `payload` is `` `{ workflowId }` `` — again **no `version`**. The server emits **`workflow:deleted`** to the room **including the sender** with `` `{ workflowId, deletedBy, deletedAt }` ``, removes the room, then broadcasts an updated `workflows_list` to the org. **Errors:** `access_denied` (different org), `workflow_not_found`, `delete_failed`.

### workflow:metadata-update

Updates workflow metadata. `payload` is `` `{ name?, description?, version?, tags? }` ``. The `workflow:metadata-updated` broadcast echoes the changed fields. An empty change returns a bare `ack`.

### input-params:update

Updates the workflow's input parameters. `payload` is `` `{ inputParameters }` ``, a map of parameter key to `` `{ value, type }` `` where `type` is one of `string`, `integer`, `number`, `boolean`, `array`, or `object`. The `input-params:updated` broadcast carries `` `{ inputParameters }` ``.

<Note>
  Input parameters are persisted to a separate database column on their own version-checked transaction, not through the workflow-schema patch path. Because of that, **`input-params:update` cannot be carried inside a `batch`** — the batch handler logs a warning and skips it. Always emit it standalone. **Errors:** `update_failed`.
</Note>

## Batch operations

Emit `batch` to apply several operations atomically in one version bump. `payload` is `` `{ operations }` ``, an array of node, edge, input, or metadata command objects (the same envelopes you would emit individually).

The server separates delete operations and applies them first in descending index order (to avoid array-index shifting), then applies the rest, all in a single patch application with **one** version bump and **one** `ack`. After success it emits **one individual broadcast per operation** — not a single `batch` broadcast. A batch of add-then-connect produces a `node:added` followed by an `edge:connected`. An empty net change returns a bare `ack`.

```json batch (client to server) theme={null}
{ "type": "batch", "version": 8, "payload": { "operations": [
  { "type": "node:add", "version": 8, "payload": { "node": { "id": "n_3", "type": "tool", "name": "Lookup", "description": "", "enabled": true, "x": 0, "y": 0, "config": {} } } },
  { "type": "edge:connect", "version": 8, "payload": { "source": "n_1", "target": "n_3" } }
] } }
```

**Errors:** `not_in_workflow`, `room_not_found`, `conflict`, `batch_failed`.

## External sync

When the [AI Composer](/concepts/ai-composer), a REST API call, or a deployment changes a workflow, that change reaches open canvases over the Socket.io **`workflow:external-sync`** command, which the server rebroadcasts as **`workflow:external-updated`** to the whole room (including the sender). This is the **live** external-sync mechanism.

<Note>
  Listen for **`workflow:external-sync`** to receive externally-originated workflow changes on an open canvas — it is the supported external-sync event. See [Realtime co-editing & external sync](/workflow-builder/realtime-coediting) and [Realtime & collaboration model](/concepts/realtime-model).
</Note>

The `workflow:external-sync` command (`WorkflowExternalSyncEvent`) carries:

<ParamField path="type" type="string" required>The event type, `"workflow:external-sync"`.</ParamField>
<ParamField path="workflowId" type="string" required>The workflow being synced.</ParamField>
<ParamField path="source" type="string" required>The origin of the change: one of `composer`, `api`, or `deployment`.</ParamField>
<ParamField path="editVersion" type="number" required>The edit version of the externally-applied change.</ParamField>
<ParamField path="changesMade" type="string[]">An optional list of change descriptions for the external edit.</ParamField>
<ParamField path="editedBy" type="string">The user id of who made the change.</ParamField>
<ParamField path="workflow" type="object">The full workflow data (`{ nodes, edges, metadata?, state_schema?, start_position? }`). When present, the server syncs directly without a database fetch; when absent, it fetches from the database.</ParamField>
<ParamField path="input" type="object">Updated input parameters, a map of key to `{ value, type }`.</ParamField>

This command is **ungated** (no rate-limit, no write-permission check) because it is the trusted ingress for the composer and the REST API. If no active room exists for the workflow, the handler returns silently. When `source` is `composer`, the server activates a short-lived **composer lock** that discards pending echo patches for up to 15 seconds, preventing the composer's own edits from being re-committed as ghost ops.

The `workflow:external-updated` broadcast carries:

<ResponseField name="workflowId" type="string">The synced workflow id.</ResponseField>
<ResponseField name="version" type="number">The room version after the sync.</ResponseField>
<ResponseField name="source" type="string">The change origin (`composer`, `api`, or `deployment`).</ResponseField>
<ResponseField name="workflow" type="object">The full updated workflow graph. Nodes are preserved in database format (intentionally not re-normalized).</ResponseField>
<ResponseField name="input" type="object">Updated input parameters, if any.</ResponseField>
<ResponseField name="changesMade" type="string[]">An optional list of change descriptions for the edit.</ResponseField>
<ResponseField name="editedBy" type="object">Who made the change (`{ userId, userName? }`).</ResponseField>
<ResponseField name="timestamp" type="string">When the sync was applied.</ResponseField>

**Errors:** wrapped failures surface as `error` with code `external_sync_failed`.

## Cursor and presence events

These power the live cursors, the "who is here" list, and idle indicators. See [Presence, locks & versioning](/realtime/presence-locks) for the full presence model.

### cursor

Emit `cursor` with `` `{ nodeId }` `` (or `` `{ nodeId: null }` ``) to indicate which node you are hovering. The server persists it and triggers a full **`presence`** broadcast. It is **not** gated and is a no-op if you are not in a workflow.

### cursor:move

Emit `cursor:move` with `` `{ x, y, nodeId? }` `` for free cursor position on the canvas. The server **throttles to 50 ms per user** and broadcasts **`cursor_moved`** `` `{ workflowId, userId, userName, color, x, y, nodeId? }` `` to peers (the sender is excluded). Cursor positions are broadcast only — not persisted.

### presence

The server emits **`presence`** `` `{ workflowId, users, nodeLocks }` `` on join, on cursor node-hover, and on leave. Each `UserPresence` entry is `` `{ userId, userName, email, color, cursor, lockedNodeId, isIdle, connectedAt, lastActivity }` ``.

### user:idle / user:active

Emit `user:idle` or `user:active` with **no payload** to flag your idle state. The server updates presence and broadcasts **`user_idle_changed`** `` `{ userId, userName, isIdle, workflowId? }` `` to peers.

## Lock events

Node locks let one editor claim a node so others see it as taken. Locks are held in a shared in-memory store with a five-minute TTL and acquire-if-absent semantics; re-locking by the same user refreshes the TTL. See [Presence, locks & versioning](/realtime/presence-locks).

### lock

Emit `lock` with `` `{ nodeId }` `` to claim a node. Gated by rate-limit and write-permission.

* **Success:** **`lock_ack`** `` `{ nodeId }` `` to you, plus **`lock_acquired`** `` `{ workflowId, nodeId, userId, userName? }` `` to peers.
* **Denied** (already held by someone else): **`lock_denied`** `` `{ nodeId, lockedBy, lockedByName, error }` `` to you, where `error` is `node_locked`.

**Errors:** `not_in_workflow`, `room_not_found`.

### unlock

Emit `unlock` with `` `{ nodeId }` `` to release a node. Gated by **write-permission only — not rate-limited**.

* **Success** (you own the lock): **`unlock_ack`** `` `{ nodeId }` `` to you, plus **`lock_released`** `` `{ workflowId, nodeId, userId }` `` to peers.
* If you are **not** the lock owner, the release is a no-op and **nothing is emitted**.

### locks\_released

When a user disconnects while holding locks, the server emits **`locks_released`** `` `{ workflowId, nodeIds, userId }` `` to the workflow room so peers can clear the freed locks.

## Status query events

### get-status

Emit `get-status` with no payload. The server replies **`status`** `` `{ connected, organizationId, currentWorkflowId, cursorNodeId, lockedNodeId, connectedAt }` `` to you.

<Note>
  `cursorNodeId` and `lockedNodeId` in the `status` reply are **hard-coded `null`** — they are not read from live state. Use the `presence` event for live cursor and lock state.
</Note>

### get-org-users

Emit `get-org-users` with no payload. The server replies **`org_users`** `` `{ users }` `` to you with everyone currently connected in your organization.

<Note>
  The `org_users` wire frame carries more fields than its typed payload declares: alongside `` `{ userId, userName, email, color, currentWorkflowId, connectedAt }` `` it also includes `cursor`, `lockedNodeId`, `isIdle`, and `lastActivity`. Treat the extra fields as available.
</Note>

## Legacy patch path

A legacy `patch` command exists for backward compatibility. Emit `patch` with `` `{ version, patches }` `` (a raw JSON-Patch array). It is version-checked and applied like a typed write, but uses its **own** ack and broadcast events:

* **`patch_ack`** `` `{ version }` `` to the sender (note: **not** `ack`).
* **`patch`** `` `{ workflowId, version, patches, userId, userName }` `` broadcast to peers.
* A stale version returns **`conflict`** (same shape as typed writes); other failures return `error` with the underlying code or `patch_failed`.

Prefer the typed node/edge/workflow events above for new clients; use the legacy `patch` path only if you already depend on raw JSON-Patch.

## Error events

Handler-level failures (after a successful connect) arrive as the in-band **`error`** event with the payload `` `{ code, message, retryAfterMs? }` ``. The `retryAfterMs` hint is present **only** on the `rate_limited` deny.

| Code                    | Meaning                                                                                         |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| `rate_limited`          | The per-user flood limit was exceeded. Carries `retryAfterMs` when the limit was genuinely hit. |
| `permission_denied`     | Your role is not `owner` or `admin` (member is read-only and retired).                          |
| `not_in_workflow`       | You must join a workflow before this command.                                                   |
| `room_not_found`        | No active room for the workflow.                                                                |
| `workflow_not_found`    | No such workflow.                                                                               |
| `access_denied`         | The workflow belongs to a different organization.                                               |
| `node_not_found`        | The target node (or duplicate source) does not exist.                                           |
| `edge_not_found`        | The target edge does not exist.                                                                 |
| `invalid_patch`         | A patch failed to apply to the schema.                                                          |
| `patch_failed`          | A node/edge write failed (generic fallback).                                                    |
| `batch_failed`          | A `batch` failed to apply.                                                                      |
| `create_failed`         | A `workflow:create` failed.                                                                     |
| `delete_failed`         | A `workflow:delete` failed.                                                                     |
| `update_failed`         | An `input-params:update` failed.                                                                |
| `external_sync_failed`  | A `workflow:external-sync` failed.                                                              |
| `workflows_list_failed` | A `request-workflows-list` failed.                                                              |
| `workflow_deleted`      | The workflow you are in was deleted (delivered to the room).                                    |

<Note>
  A version conflict is **not** an `error`. The in-memory write path surfaces it as a [`conflict`](#the-write-lifecycle-ack-broadcast-conflict) frame, and a database-layer conflict is silent (the server re-queues and retries). The collaboration `error` envelope (`{code, message}`) is also distinct from the REST/SSE `{"detail": …}` envelope and the billing `DenialEnvelope`; branch on the shape per transport. See [Errors & status codes](/api-reference/errors).
</Note>

## Disconnecting

On disconnect, the server cleans up your cursor throttle, idle tracking, and stats, releases any node locks you held (emitting `locks_released` to the room), and removes you from presence. There is no graceful "goodbye" frame to send — just close the socket.

## Open questions

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

<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 hosts that conflict with the `.dev` hosts used elsewhere. Use your deployment's configured collaboration-server URL.
</Accordion>

<Accordion title="Effective rate-limit values">
  The default flood limit is 100 points per 60 seconds in the server config, but the shipped example environment file sets 300 points per 20 seconds. The effective value depends on which environment file your deployment loads, and the canonical production value is not pinned in source. Treat the limit as deployment-configured.
</Accordion>

<Accordion title="Membership cache lag">
  Org membership and role are cached for five minutes. A role change in the backend may not be reflected in the collaboration plane's write-permission gate until that cache expires.
</Accordion>

## Next steps

<CardGroup cols={2}>
  <Card title="Presence, locks & versioning" icon="lock" href="/realtime/presence-locks">
    The presence model, lock TTLs, and the two-version-plane conflict engine in depth.
  </Card>

  <Card title="Realtime overview & event taxonomy" icon="wave-pulse" href="/realtime/overview">
    How the Socket.io plane compares to SSE run streaming, and which to use when.
  </Card>

  <Card title="Realtime co-editing & external sync" icon="users" href="/workflow-builder/realtime-coediting">
    The builder UX: how canvas edits and external (composer/REST) changes sync while you work.
  </Card>

  <Card title="Known limitations" icon="triangle-exclamation" href="/reference/known-limitations">
    The documented gaps, including the unhandled `nodes:delete` command and the dead pub/sub channel.
  </Card>
</CardGroup>
