> ## 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 co-editing & external sync

> How two or more people edit the same ModuleX workflow canvas at once — the Socket.io edit events, presence and node locks, the live workflow:external-sync path for Composer/API/deployment changes, the two-plane version model, and conflict handling.

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 page explains how the [Workflow Builder](/workflow-builder/overview) canvas stays in sync when more than one person edits it, and how changes that originate **outside** the canvas — from the [AI Composer](/workflow-builder/composer), the [REST API](/api-reference/overview), or a [deployment](/workflow-builder/execution/deploy) — are pushed back into every open editor in realtime.

It is the engineering companion to two other pages, and it is important to know which one you want:

<CardGroup cols={3}>
  <Card title="Canvas collaboration" icon="users" href="/platform/collaboration/canvas">
    The product feature: what co-editing looks like and how to use it. Start here if you are a user, not an integrator.
  </Card>

  <Card title="Socket.io collaboration events" icon="plug" href="/realtime/socket-events">
    The complete wire reference: every client-to-server and server-to-client event, with exact payloads, acks, and error codes.
  </Card>

  <Card title="Presence, locks & versioning" icon="lock" href="/realtime/presence-locks">
    The low-level presence, cursor, lock, and version/conflict model, with in-memory store key schemas and timings.
  </Card>
</CardGroup>

This page sits between them: it describes how co-editing actually works end to end — the edit flow, presence, locks, external sync, and conflict handling — and links to the wire reference for exact field-by-field payloads rather than repeating them. The terms used here match the [glossary](/reference/glossary).

<MediaEmbed id="MX-MEDIA-3210" type="app_video" caption={"Two people co-editing the same workflow canvas, with live cursors, presence avatars, and a node lock."} />

## The collaboration plane in one paragraph

Live canvas editing runs over a dedicated **Socket.io collaboration server** — a separate realtime plane from the [SSE run-streaming plane](/realtime/sse-streaming) that carries workflow, Composer, and Assistant output. The two planes share no event names, no envelope, and no error shape; the [Realtime overview](/realtime/overview) compares them. On the collaboration plane, every open editor for a given workflow is a member of a server-side **room**. When you make an edit, your client emits a typed command with the room **version** it last observed; the server validates it (role, version, room membership), applies it to the room's in-memory schema, **acknowledges** it to you, and **broadcasts** the result to the other editors. Roughly every two seconds the server saves accumulated edits in a batch and emits a `saved` event. Presence, cursors, and node locks ride the same socket.

<Warning>
  **External sync flows over Socket.io `workflow:external-sync`.** Changes made from the Composer, the API, or a deployment reach open editors via the `workflow:external-sync` → `workflow:external-updated` Socket.io path described in [External sync](#external-sync-composer-api-and-deployment-changes) below.
</Warning>

## Who can co-edit, and how it authenticates

The collaboration plane is part of the **app session**, not the programmatic API surface. There is no public REST or SDK operation for canvas co-editing — it is driven by the ModuleX web app over the socket.

* **Authentication is the handshake `auth` payload**, not HTTP headers. The client sends `` `{ token, organizationId }` `` in the Socket.io handshake, where `token` is your Clerk session token and `organizationId` is the organization id (a UUID). This is different from the [REST/SDK auth model](/api-reference/authentication), which uses `Authorization: Bearer mx_live_…` + `X-Organization-ID` headers. API keys (`` `<mx_live_*>` ``) are **not** accepted on the collaboration handshake.
* **Editing requires `owner` or `admin`.** The collaboration server gates every write command (node, edge, workflow, lock) on your organization role. The retired `member` role is read-only and cannot edit — see [Roles & permissions](/security/roles-permissions) and [Organizations, roles & membership](/concepts/organizations-roles). Read-only viewers can still receive presence and watch edits stream in; they just cannot emit writes.

<ParamField path="token" type="string" required>
  Your Clerk session token. The server verifies it against Clerk's JWKS during the handshake. An invalid token rejects the connection with a `connect_error` whose message is `Invalid token`.
</ParamField>

<ParamField path="organizationId" type="string" required>
  The organization id (UUID) whose workflows you want to co-edit. A missing value rejects with `Organization ID required`; a value you are not a member of rejects with `Not a member of this organization`.
</ParamField>

<Note>
  Handshake rejections arrive as a Socket.io **`connect_error`** carrying an `Error` message — not as an in-band `error` event. The in-band `error` event (see [Edit errors](#edit-errors)) only carries handler-level failures **after** a successful connect. The exact production collaboration-server hostname is not standardized in these docs — use your deployment's configured collaboration-server URL. See [Open questions](#open-questions).
</Note>

## Joining a workflow room

After connecting, the server immediately emits `connected` (your identity and assigned presence color) followed by `workflows_list` (the org's workflows for the sidebar). Joining a **specific** workflow to co-edit it is a separate, client-initiated step:

1. Your client emits `join-workflow` with `` `{ workflowId }` ``.
2. The server loads the workflow, verifies it belongs to your organization, creates or reuses the room, registers your presence in the shared in-memory store, and replies with **`joined`** — a full snapshot of the room.

The `joined` snapshot is what your canvas renders from. It contains the current room `version`, the workflow schema (nodes, edges, state schema, metadata), the input-parameters and config blocks, the live deployment id, the list of users currently present, and the current node locks. The exact field set is in the [Socket.io events reference](/realtime/socket-events#joining-a-workflow).

<Note>
  Joining a different workflow first leaves the previous room. You are in at most one workflow room at a time per socket. Leaving (or disconnecting) releases any node locks you held — see [Presence & node locks](#presence-and-node-locks).
</Note>

## Edit events: the write lifecycle

Every canvas edit is a typed **client-to-server command** that produces a server **acknowledgement** to you and a **broadcast** to the other editors in the room. Sender exclusion is reliable — you do not receive a broadcast of your own edit; you get the `ack` instead.

### The common envelope

Most edit commands carry the same outer envelope:

```json Client-to-server edit command theme={null}
{ "type": "node:move", "version": 5, "payload": { "nodeId": "n_abc", "position": { "x": 120, "y": 40 } } }
```

<ParamField path="type" type="string" required>
  The event name (for example `node:add`, `edge:connect`, `node:move`). This is also the Socket.io event you emit on.
</ParamField>

<ParamField path="version" type="integer" required>
  The **base room version** your client last observed — the value from the most recent `joined`, `ack`, or broadcast. The server version-gates the command against this. Omit-or-stale handling is covered under [Conflict handling](#conflict-handling). Creation and deletion of whole workflows (`workflow:create` / `workflow:delete`) carry **no** `version` because they have no base to compare against.
</ParamField>

<ParamField path="payload" type="object" required>
  The per-event body. The shape depends on `type`; see the per-event payloads in the [Socket.io events reference](/realtime/socket-events).
</ParamField>

On success the server replies to **you** with an `ack`:

```json Server acknowledgement (sender only) theme={null}
{ "version": 6 }
```

and broadcasts the typed result to **everyone else** in the room with an outer shape of `` `{ type, workflowId, version, userId, userName?, payload }` ``:

```json Server broadcast (peers only) theme={null}
{ "type": "node:moved", "workflowId": "wf_123", "version": 6, "userId": "u_123", "userName": "Ada L.", "payload": { "nodeId": "n_abc", "position": { "x": 120, "y": 40 } } }
```

<Note>
  Move broadcasts (`node:moved`, `nodes:moved`) and the `edge:disconnected` broadcast carry **no** `userName`. Plan your UI presence labels accordingly. The legacy `patch` path acks with `patch_ack` rather than `ack` — both carry `` `{ version }` ``. Socket.io callback-style acks are **not** used anywhere; every response is a separate emitted event, so do not pass a callback to `socket.emit(...)` and await it.
</Note>

### The edit commands and their broadcasts

These are the writes you can make to a canvas. Each requires `owner`/`admin` and is rate-limited (the per-user flood limiter; see [Rate limits & errors](#rate-limits-and-errors)) unless noted. The exact per-event payloads, including handle mapping for conditional and loop edges, are in the [events reference](/realtime/socket-events).

| Command (you emit)         | Broadcast (peers receive)       | What it does                                                                                                                                                                                                                                                  |
| -------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `node:add`                 | `node:added`                    | Adds a node; also creates its state-schema field (every node writes its result to run state under its own id — see [Variables & references](/workflow-builder/variables-and-references)).                                                                     |
| `node:delete`              | `node:deleted`                  | Removes a node, its incident edges, its state-schema field, and any conditional-route cleanup. The broadcast lists the removed edges.                                                                                                                         |
| `node:update`              | `node:updated`                  | Updates a node's `name`, `description`, `enabled`, or `config`. A `name` change also rewrites the node's state-schema field description.                                                                                                                      |
| `node:move`                | `node:moved`                    | Moves one node. The virtual `__start__` node is moved via `start_position`, not the nodes array.                                                                                                                                                              |
| `nodes:move`               | `nodes:moved`                   | Moves several nodes in one command.                                                                                                                                                                                                                           |
| `node:duplicate`           | `node:duplicated`               | Deep-clones a source node with a new id/name/position.                                                                                                                                                                                                        |
| `node:toggle-enabled`      | `node:updated`                  | Enables or disables a node. **Reuses the `node:updated` broadcast** — there is no `node:toggled` event; listen on `node:updated`.                                                                                                                             |
| `edge:connect`             | `edge:connected`                | Adds an edge. For [conditional](/workflow-builder/nodes/conditional) and loop sources/targets it also writes the route/branch/loop config keyed by the handle.                                                                                                |
| `edge:disconnect`          | `edge:disconnected`             | Removes an edge and clears its conditional/loop config symmetrically.                                                                                                                                                                                         |
| `workflow:metadata-update` | `workflow:metadata-updated`     | Updates workflow `name`, `description`, `tags`.                                                                                                                                                                                                               |
| `input-params:update`      | `input-params:updated`          | Updates the workflow's [input parameters](/workflow-builder/variables-and-references). Saved separately with its own version-conflict handling — it **cannot** be carried inside a `batch`; emit it standalone.                                               |
| `batch`                    | one broadcast **per operation** | Applies several node/edge/metadata/input ops as one versioned write. Deletes are applied first in descending index order to avoid array-index shifting. A batch of add + connect produces `node:added` then `edge:connected`, not a single `batch` broadcast. |
| `patch` (legacy)           | `patch`                         | Backwards-compatible raw RFC-6902 patch path. Acks with `patch_ack`. Prefer the typed commands above.                                                                                                                                                         |

<Warning>
  **Deleting multiple nodes.** Delete nodes one at a time with `node:delete`, or include several `node:delete` ops in a single `batch`.
</Warning>

### Worked example: add a node, wire it, and watch it land for a peer

Two editors, **A** and **B**, are both joined to workflow `wf_123` at room version `5`. A adds a [tool node](/workflow-builder/nodes/tool) and connects it. The example uses the same handshake auth payload shown above.

<CodeGroup>
  ```javascript A emits (socket.io-client) 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, NOT an mx_live_ key.
  const socket = io(COLLAB_SERVER_URL, {
    transports: ['websocket', 'polling'],
    auth: {
      token: CLERK_SESSION_TOKEN,
      organizationId: 'a1b2c3d4-e29b-41d4-a716-446655440000',
    },
  });

  socket.on('connected', () => {
    socket.emit('join-workflow', { workflowId: 'wf_123' });
  });

  socket.on('joined', ({ version, workflow, users, nodeLocks }) => {
    // version === 5; render the canvas, presence, and lock badges.

    // Add a tool node as one batched write: add the node, then connect it.
    socket.emit('batch', {
      type: 'batch',
      version,
      payload: {
        operations: [
          {
            type: 'node:add',
            version,
            payload: {
              node: {
                id: 'n_lookup',
                type: 'tool',
                name: 'Lookup',
                description: '',
                enabled: true,
                x: 320,
                y: 160,
                config: {},
              },
            },
          },
          {
            type: 'edge:connect',
            version,
            payload: { source: 'n_draft', target: 'n_lookup' },
          },
        ],
      },
    });
  });

  // A receives a single ack for the whole batch.
  socket.on('ack', ({ version }) => console.log('applied at room version', version)); // 6

  // Roughly every 2s after edits, the whole room (incl. A) gets a saved event.
  socket.on('saved', ({ version, patchesCount }) =>
    console.log('saved at edit_version', version, 'patches', patchesCount));

  // A version that is too far behind comes back as conflict, not ack.
  socket.on('conflict', ({ yourVersion, serverVersion }) => {
    console.warn('stale; rebase from', yourVersion, 'to', serverVersion);
    socket.emit('leave-workflow');
    socket.emit('join-workflow', { workflowId: 'wf_123' });
  });

  socket.on('error', ({ code, message }) => console.error(code, message));
  ```

  ```javascript B receives (the other editor) theme={null}
  // B is already joined to wf_123 and tracking room version 5.
  // The server broadcasts one event per batch operation, in order.

  socket.on('node:added', ({ version, userId, userName, payload }) => {
    // version === 6; payload.node is the normalized node A added.
    addNodeToCanvas(payload.node);
    setRoomVersion(version);
  });

  socket.on('edge:connected', ({ version, payload }) => {
    // payload.edge is the new edge { source, target, sourceHandle?, targetHandle? }.
    addEdgeToCanvas(payload.edge);
    setRoomVersion(version);
  });

  // B sees the same saved event A sees once the batch is saved.
  socket.on('saved', ({ version }) => markSaved(version));
  ```
</CodeGroup>

<Note>
  B does **not** receive a `batch` broadcast — it receives `node:added` then `edge:connected`, the individual broadcasts the batch expands into. A receives a single `ack` for the batch and the room-wide `saved` after the next save.
</Note>

## Presence and node locks

Presence and locking ride the same socket and let editors see each other and avoid clobbering the same node.

### Presence and cursors

* **Presence** (`presence`) carries the room's users and the current node locks. It is rebroadcast when someone joins, hovers a node, or leaves. Each user entry includes id, name, email, presence color, the node their cursor is on, the node they hold a lock on, idle state, and timestamps.
* **Node-hover cursor** (`cursor`) is persisted and triggers a full `presence` broadcast.
* **Canvas-position cursor** (`cursor:move` → `cursor_moved`) is high-frequency, **server-throttled to 50 ms per user**, and not persisted — over-rate frames are dropped.
* **Idle state** (`user:idle` / `user:active` → `user_idle_changed`) toggles a user's idle flag for presence display.

Neither cursor event is rate-limited or role-gated, so read-only viewers still appear as presence and move a live cursor. Exact payloads and timings are in [Presence, locks & versioning](/realtime/presence-locks).

### Node locks

A **node lock** reserves one node for one editor so two people do not edit the same node's config at once. Locks are advisory at the UI level and enforced server-side for ownership.

| Command / event                | Direction           | Effect                                                                                              |
| ------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------- |
| `lock`                         | you emit            | Request a lock on `` `{ nodeId }` ``. Requires `owner`/`admin`; rate-limited.                       |
| `lock_ack`                     | you receive         | Your lock succeeded.                                                                                |
| `lock_acquired`                | peers receive       | Someone locked a node: `` `{ workflowId, nodeId, userId, userName? }` ``.                           |
| `lock_denied`                  | you receive         | The node is already held: `` `{ nodeId, lockedBy, lockedByName, error: "node_locked" }` ``.         |
| `unlock`                       | you emit            | Release your lock. Requires `owner`/`admin`; **not** rate-limited.                                  |
| `unlock_ack` / `lock_released` | you / peers receive | The lock was released.                                                                              |
| `locks_released`               | room receives       | A disconnecting user held locks; all are released at once: `` `{ workflowId, nodeIds, userId }` ``. |

Key behaviors to design around:

* **Locks auto-expire after 5 minutes.** A lock is a key in the shared in-memory store with a 300-second TTL; re-locking the same node by the same user refreshes the TTL. A user who walks away does not block a node forever.
* **Disconnect releases all your locks** and emits `locks_released` to the room.
* **`unlock` is only honored for the owner.** If you are not the lock holder, the release is a silent no-op (no event is emitted).
* **`lock` is rate-limited but `unlock` is not** — an asymmetry to account for if you script bulk lock/unlock.

<Note>
  There are two presence/lock state layers — a shared in-memory store layer (authoritative across server instances, read on `join-workflow`) and a per-instance in-memory layer (drives the high-frequency `presence` fan-out). They can briefly diverge; treat the `joined`/`presence` lock map as advisory and rely on `lock_ack` / `lock_denied` for the authoritative outcome of a lock attempt. Full detail in [Presence, locks & versioning](/realtime/presence-locks).
</Note>

## The two-plane version model

Understanding versions is the single most important thing for building anything against this plane, because there are **two** version counters and they diverge by design.

<Steps>
  <Step title="Plane A — in-memory room version">
    The collaboration server holds an authoritative room version that increments by **one per accepted edit operation**. This is the value reported in `ack`, in `conflict`, and in every broadcast. Your client should track Plane A from these events and send it as the base `version` on your next edit.
  </Step>

  <Step title="Plane B — saved edit_version">
    Separately, the saved `edit_version` increments by **one per save**, not per operation. One save (roughly every two seconds) can carry many operations. This is the value reported in the `saved` event.
  </Step>
</Steps>

The consequence: after several edits but only one save, Plane A has advanced by several while Plane B has advanced by one. **The `saved` version (Plane B) can be numerically behind the version your client tracks from acks (Plane A).**

<Warning>
  **Never overwrite your local room version from the `saved` event.** `saved.version` is the saved `edit_version` (Plane B), which lags the `ack`/broadcast version (Plane A) you must keep sending as your edit base. Treat `saved` as a "changes are persisted" confirmation for your save indicator only — keep tracking version from `ack` and broadcasts.
</Warning>

### Persistence and the `saved` event

Accepted edits are queued in the room and saved in a batch on a \~2-second timer. A successful save emits **`saved`** to the whole room (including the sender):

```json saved (server, room-wide) theme={null}
{ "workflowId": "wf_123", "version": 41, "savedAt": "2026-06-21T10:30:00.000Z", "patchesCount": 7 }
```

<ResponseField name="workflowId" type="string">
  The workflow that was saved.
</ResponseField>

<ResponseField name="version" type="integer">
  The new saved `edit_version` (Plane B) after the save. May be behind the room version you track from acks.
</ResponseField>

<ResponseField name="savedAt" type="string">
  ISO-8601 timestamp of the save.
</ResponseField>

<ResponseField name="patchesCount" type="integer">
  How many patch operations were saved in this batch.
</ResponseField>

Each save also records an edit-history entry, so a workflow's edit history is the record of these saves — see [Versioning & history](/workflow-builder/versioning-history). If a save fails (for example a version conflict caused by an external writer), the patches are silently re-queued and retried on the next tick — there is **no** client-facing `error` for a failed save; only an in-memory `conflict` (next section) is ever surfaced to you.

## Conflict handling

A single, user-visible conflict signal exists: the **`conflict`** event. It is emitted **instead of** `ack` when the base `version` you sent is too far behind the room.

```json conflict (server, sender only) theme={null}
{ "yourVersion": 4, "serverVersion": 31, "resolution": "rebase" }
```

<ResponseField name="yourVersion" type="integer">
  The base version your command carried.
</ResponseField>

<ResponseField name="serverVersion" type="integer">
  The current room version.
</ResponseField>

<ResponseField name="resolution" type="string">
  Always the literal string `rebase`. No other value exists. It is advisory — the server does **not** rebase for you and applies **nothing** from the rejected command.
</ResponseField>

How conflicts actually trigger:

* The server accepts a base `version` that is within a tolerance window of **20** versions behind the current room version. This absorbs the normal case where several of your in-flight edits have not yet been acked. Only a base more than 20 versions behind triggers `conflict`.
* On `conflict`, the recommended recovery is to **re-sync the room**: emit `leave-workflow` then `join-workflow` again to pull a fresh `joined` snapshot, then replay any local intent against the new version. Do not retry the same command with the old version.
* The save-time version conflict (caused by an **external** writer pushing `edit_version` past the room's last save) is handled internally by re-queue-and-retry and is **never** surfaced as a `conflict` or `error` to clients. The only client-visible conflict is the in-memory one above.

<Note>
  Because the room version (Plane A) advances faster than the saved version (Plane B), the save-time conflict branch effectively fires only when an external writer — the Composer, the API, or a deployment — has advanced `edit_version` between saves. That case is reconciled for you via the [external-sync path](#external-sync-composer-api-and-deployment-changes), which pushes the authoritative state into your editor.
</Note>

## External sync: Composer, API, and deployment changes

Not every change to a workflow comes from a person editing the canvas. The [AI Composer](/workflow-builder/composer) rewrites the graph from a prompt, the [REST API](/api-reference/overview) can patch a workflow, and a [deployment](/workflow-builder/execution/deploy) can change the live version. **External sync** is how those changes reach editors who already have the canvas open — and it runs over Socket.io, not a background pub/sub channel.

### The live path

```mermaid theme={null}
sequenceDiagram
  participant Src as Composer / API / deployment
  participant App as App client (singleton socket)
  participant WS as Collaboration server
  participant Peers as Other open editors
  Src->>App: change is saved (new edit_version)
  App->>WS: emit workflow:external-sync { workflowId, source, editVersion, workflow, input? }
  WS->>Peers: broadcast workflow:external-updated (whole room, incl. sender)
  WS->>WS: if source == composer, hold a composer lock (~15s) to drop echo patches
```

1. After an external change is saved, the app emits **`workflow:external-sync`** on the singleton socket.
2. The collaboration server re-broadcasts it to the **whole room, including the sender**, as **`workflow:external-updated`**.
3. Each open editor replaces its in-memory schema with the synced workflow, sets its room version to the synced version, merges any input parameters, and re-renders.

<Note>
  The live server-to-client event is **`workflow:external-updated`**.
</Note>

### `workflow:external-sync` (client-to-server)

```json workflow:external-sync theme={null}
{
  "type": "workflow:external-sync",
  "workflowId": "wf_123",
  "source": "composer",
  "editVersion": 42,
  "changesMade": [],
  "workflow": { "nodes": [], "edges": [], "metadata": {}, "state_schema": {} },
  "input": { "topic": { "value": "launch", "type": "string" } }
}
```

<ParamField path="type" type="string" required>
  Always `workflow:external-sync`.
</ParamField>

<ParamField path="workflowId" type="string" required>
  The workflow being synced.
</ParamField>

<ParamField path="source" type="string" required>
  Where the change originated. The server accepts `composer`, `api`, or `deployment`. The web app currently sends only `composer`.
</ParamField>

<ParamField path="editVersion" type="integer" required>
  The authoritative `edit_version` after the external change.
</ParamField>

<ParamField path="changesMade" type="array">
  A summary of what changed. The Composer currently sends an empty array; treat this as informational only and re-render from `workflow`.
</ParamField>

<ParamField path="workflow" type="object">
  The full synced schema (`nodes`, `edges`, `metadata`, `state_schema`). When present, the server broadcasts this directly without loading stored state; the nodes are passed through unchanged (not re-normalized) to preserve the stored format. When absent, the server loads the stored workflow.
</ParamField>

<ParamField path="input" type="object">
  Optional input-parameters bag, same shape as `input-params:update` (a map of `` `{ value, type }` `` entries).
</ParamField>

<Warning>
  **`workflow:external-sync` has no rate-limit or role gate.** Unlike the per-node edit commands, the external-sync ingress is ungated at the socket layer — its handler is the trust boundary. Do not treat it as an authorization point. If the room has no active editors, the server returns silently (there is nothing to broadcast to).
</Warning>

### `workflow:external-updated` (server-to-client)

```json workflow:external-updated (room-wide, incl. sender) theme={null}
{
  "workflowId": "wf_123",
  "version": 42,
  "source": "composer",
  "workflow": { "nodes": [], "edges": [], "metadata": {}, "state_schema": {} },
  "input": { "topic": { "value": "launch", "type": "string" } },
  "changesMade": [],
  "editedBy": { "userId": "u_123", "userName": "Ada L." },
  "timestamp": "2026-06-21T10:30:00.000Z"
}
```

<ResponseField name="version" type="integer">
  The synced room version. Set your tracked room version to this and re-render from `workflow`.
</ResponseField>

<ResponseField name="source" type="string">
  `composer`, `api`, or `deployment`.
</ResponseField>

<ResponseField name="workflow" type="object">
  The authoritative synced schema to render.
</ResponseField>

<ResponseField name="input" type="object">
  Synced input parameters, when present.
</ResponseField>

<ResponseField name="editedBy" type="object">
  Who triggered the sync: `` `{ userId, userName? }` ``. (Some clients read this under `updatedBy` — match your client to `editedBy`.)
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO-8601 time of the sync.
</ResponseField>

### The composer lock (avoiding ghost edits)

When `source` is `composer`, the server activates a **composer lock** on the room for about 15 seconds. While the lock is active, the room **discards** its pending local echo patches instead of saving them. This prevents a race where the canvas re-emits the just-synced state as fresh edits and "ghost-commits" a stale version over the Composer's authoritative write. In practice: right after a Composer run, your local in-flight patches are dropped in favor of the synced state — which is correct, because the Composer already wrote the authoritative graph.

## Rate limits and errors

### The collaboration rate limiter

Write commands are subject to a per-user flood limiter on the collaboration plane (a fixed window keyed by user id). When you exceed it, the server emits an `error` with code `rate_limited` and an optional `retryAfterMs` hint and **skips** the handler.

<Warning>
  The `rate_limited` code on this plane is a **transport-level flood limiter** — it is **not** the billing rate-limit. It does **not** carry the billing [`DenialEnvelope`](/api-reference/errors) (`` `{ code, layer, key, current, limit, reason }` ``) and is unrelated to plan limits. Live canvas co-editing does not pass through the billing usage gate at all — see [Credit impact](#credit-impact). For the billing 402/403/429 envelope, see [Usage gating & limits](/billing/usage-gating).
</Warning>

### Edit errors

Handler-level failures arrive as an in-band `error` event with `` `{ code, message, retryAfterMs? }` ``:

```json error (server) theme={null}
{ "code": "permission_denied", "message": "Admin or owner role required for this operation" }
```

<Expandable title="Collaboration error codes">
  | `code`                                              | When it happens                                                                                         |
  | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
  | `rate_limited`                                      | You exceeded the per-user flood limiter. Carries optional `retryAfterMs`. Transport-level, not billing. |
  | `permission_denied`                                 | You attempted a write without `owner`/`admin`. The retired `member` role is read-only.                  |
  | `not_in_workflow`                                   | You emitted an edit before joining a workflow room.                                                     |
  | `room_not_found`                                    | The room for your current workflow does not exist (no active room).                                     |
  | `workflow_not_found`                                | The workflow id does not exist (on `join-workflow` or `workflow:delete`).                               |
  | `access_denied`                                     | The workflow belongs to a different organization.                                                       |
  | `node_not_found`                                    | The target node (or duplicate source node) does not exist.                                              |
  | `edge_not_found`                                    | The target edge does not exist on `edge:disconnect`.                                                    |
  | `invalid_patch`                                     | The resulting patch could not be applied to the schema.                                                 |
  | `patch_failed`                                      | Generic fallback when an edit could not be applied.                                                     |
  | `batch_failed`                                      | A `batch` could not be applied.                                                                         |
  | `create_failed` / `delete_failed` / `update_failed` | The corresponding workflow/input operation failed at the database.                                      |
  | `external_sync_failed`                              | A `workflow:external-sync` could not be processed.                                                      |
  | `workflows_list_failed`                             | The org workflow list could not be fetched.                                                             |
  | `workflow_deleted`                                  | The workflow you are in was deleted out from under the room.                                            |
</Expandable>

<Note>
  `version_conflict` is **not** an `error` code on the wire. An in-memory version conflict surfaces as the `conflict` event ([Conflict handling](#conflict-handling)); a save-time version conflict is handled silently by re-queue-and-retry and never reaches you.
</Note>

## Credit impact

Realtime canvas co-editing — joining a room, edit events, presence, cursors, locks, and external sync — **does not consume [credits](/billing/credits)** and does **not** pass through the [billing usage gate](/billing/usage-gating). It is metered only by the transport-level rate limiter above. Credits are charged for **managed run/turn usage**: [running a workflow](/workflow-builder/execution/running), an [AI Composer](/concepts/ai-composer) turn, or an [Assistant](/assistant/overview) turn (one run credit per turn). So a Composer edit that arrives over the external-sync path costs the Composer turn that produced it, not the sync itself.

## Related pages

<CardGroup cols={2}>
  <Card title="Socket.io collaboration events" icon="plug" href="/realtime/socket-events">
    The exhaustive wire reference: every event, payload, ack, and error code.
  </Card>

  <Card title="Presence, locks & versioning" icon="lock" href="/realtime/presence-locks">
    Presence, cursors, node locks, in-memory store key schemas, and the version/conflict model in depth.
  </Card>

  <Card title="Canvas collaboration" icon="users" href="/platform/collaboration/canvas">
    The product feature: co-editing from the user's point of view.
  </Card>

  <Card title="Realtime collaboration walkthrough" icon="play" href="/guides/realtime-collaboration">
    Invite a teammate and co-edit a workflow live, step by step.
  </Card>

  <Card title="Versioning & history" icon="clock-rotate-left" href="/workflow-builder/versioning-history">
    How saves become edit-history entries, and how versions and deployments relate.
  </Card>

  <Card title="Realtime & collaboration model" icon="diagram-project" href="/concepts/realtime-model">
    The conceptual model of the two realtime planes.
  </Card>
</CardGroup>

## Open questions

* **Production collaboration-server hostname.** The source material references conflicting hosts for the collaboration server (an `.io` CORS origin versus a `.dev` domain used elsewhere) and a documented `wss://ws.modulex.*` endpoint that is not confirmed against the live REST host `api.modulex.dev`. The canonical public hostname is not pinned here — use your deployment's configured collaboration-server URL until it is confirmed.
* **`nodes:delete` routing.** To delete multiple nodes over the wire, emit one `node:delete` per node, or wrap several `node:delete` operations in a single `batch`.
* **`editedBy` vs `updatedBy` on `workflow:external-updated`.** The server emits `editedBy`; some client code reads `updatedBy`. Match your client to the server field `editedBy`.
