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

# Presence, locks & versioning

> The Socket.io presence, cursor, node-lock, and JSON-Patch version/conflict model behind ModuleX canvas collaboration: every event, payload, TTL, error, and edge case, plus what happens to your locks on disconnect.

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 is the wire-level reference for three parts of the [Socket.io](/realtime/socket-events) canvas-collaboration plane: **presence** (who is in a workflow, their cursor, idle state, and assigned color), **node locks** (claiming a single node so others see it as held), and the **JSON-Patch version model** (how concurrent edits are applied, batched to the database, and reconciled when versions diverge). It is the protocol detail behind the product experience on [Canvas collaboration](/platform/collaboration/canvas) and the builder UX on [Realtime co-editing & external sync](/workflow-builder/realtime-coediting).

Everything here lives on the **collaboration plane only**. Presence, cursors, locks, and patches are emitted and consumed over Socket.io; there is **no REST endpoint and no JavaScript or Python SDK method** for any of them (see [No REST or SDK surface](#no-rest-or-sdk-surface)). For the other realtime plane — one-way run streaming over SSE — see [Realtime overview](/realtime/overview). For the complete event catalog, including node and edge edits not repeated here, see [Socket.io collaboration events](/realtime/socket-events).

<MediaEmbed id="MX-MEDIA-2100" type="image" caption={"A layered diagram of the collaboration state model for one workflow."} />

## Prerequisites

You should already have a Socket.io connection to the collaboration server and have joined a workflow room. Both are covered on [Socket.io collaboration events](/realtime/socket-events); the essentials:

* **Connect** with a token and an organization id in the Socket.io handshake `auth` payload (not HTTP headers). The collaboration plane is consumed by the ModuleX web app, which authenticates with a Clerk session token rather than an `mx_live_` API key. See [Auth model: JWT vs API key](/security/authentication).
* **Join** a workflow by emitting `join-workflow` with `{ workflowId }`. The server replies with a `joined` snapshot that already contains the current `users` and `nodeLocks` — your initial presence and lock state come from that snapshot, not from a separate query.

Throughout this page, **C→S** marks an event your client emits to the server and **S→C** marks an event the server emits to your client. Field names are **camelCase on the wire**.

## Presence and cursors

Presence answers "who else is editing this workflow, where is their cursor, and are they idle?" Each member is assigned a **color** at handshake time (round-robin from a fixed palette) so the UI can tint their cursor and node highlights consistently.

### Where your presence state comes from

There are two reads of presence, and they can briefly differ:

* The `joined` snapshot and any `presence` broadcast carry the **member list and lock map** for the room. This is the canonical "who is here" view your client renders.
* A separate presence record in a shared in-memory store tracks each user's cursor node, idle flag, and heartbeat across server instances. It is what survives a single-instance restart and what seeds the `joined` snapshot.

<Note>
  The live `presence` broadcast is built from the server's in-memory room state, while the `joined` snapshot's `users`/`nodeLocks` are read from the shared presence store. The two are kept in step in normal operation but are **not a single source of truth** and can momentarily diverge (for example, right after a server instance restarts). Treat the most recent `presence` broadcast as the authoritative live view and re-`join-workflow` to resync if you suspect drift.
</Note>

### `presence` (S→C) — the full room snapshot

The server emits `presence` to every other member of the room (the sender is excluded) whenever someone joins, leaves, or moves their **node-hover cursor**. It is the one event that carries the complete member list and lock map together.

<ResponseField name="workflowId" type="string">
  The workflow this presence snapshot is for.
</ResponseField>

<ResponseField name="users" type="UserPresence[]">
  Every member currently in the room (see the `UserPresence` shape below).
</ResponseField>

<ResponseField name="nodeLocks" type="object">
  A map of `nodeId → userId` for every currently locked node, for example `{ "node_abc": "u1" }`. An empty object means no node is locked.
</ResponseField>

<Expandable title="UserPresence object">
  <ResponseField name="userId" type="string">
    The member's internal ModuleX user id.
  </ResponseField>

  <ResponseField name="userName" type="string">
    The member's display name.
  </ResponseField>

  <ResponseField name="email" type="string">
    The member's email.
  </ResponseField>

  <ResponseField name="color" type="string">
    The hex color assigned to this member for the session, for example `#7C3AED`. Used to tint their cursor and locked-node highlight.
  </ResponseField>

  <ResponseField name="cursor" type="object | null">
    The member's node-hover cursor as `{ nodeId }`, or `null` when they are not hovering a node. This is the persisted node-hover cursor (see [`cursor`](#cursor-c-s-node-hover-persisted) below), not the high-frequency canvas pointer.
  </ResponseField>

  <ResponseField name="lockedNodeId" type="string | null">
    The node this member currently holds a lock on, or `null`.
  </ResponseField>

  <ResponseField name="isIdle" type="boolean">
    Whether the member is currently flagged idle.
  </ResponseField>

  <ResponseField name="connectedAt" type="string">
    ISO-8601 timestamp of when the member connected.
  </ResponseField>

  <ResponseField name="lastActivity" type="string">
    ISO-8601 timestamp of the member's last recorded activity.
  </ResponseField>
</Expandable>

```json presence (S→C) theme={null}
{
  "workflowId": "wf_123",
  "users": [
    {
      "userId": "u1",
      "userName": "Ada L.",
      "email": "ada@example.com",
      "color": "#7C3AED",
      "cursor": { "nodeId": "node_abc" },
      "lockedNodeId": "node_abc",
      "isIdle": false,
      "connectedAt": "2026-06-21T10:00:00.000Z",
      "lastActivity": "2026-06-21T10:01:30.000Z"
    }
  ],
  "nodeLocks": { "node_abc": "u1" }
}
```

### Two kinds of cursor

ModuleX tracks two **separate** cursor concepts. Do not conflate them — they use different events, different persistence, and different fan-out.

|                   | Node-hover cursor                   | Canvas pointer                                                                                |
| ----------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- |
| Client emits      | `cursor` (C→S)                      | `cursor:move` (C→S)                                                                           |
| Server emits      | `presence` (S→C, full snapshot)     | `cursor_moved` (S→C)                                                                          |
| What it means     | "I am hovering node X"              | "My pointer is at canvas coordinates (x, y)"                                                  |
| Persisted         | Yes — stored in the presence record | No — broadcast only, never stored                                                             |
| Frequency control | None                                | Server-side throttle, at most one per \~50 ms per user; over-rate frames are dropped silently |
| Gate              | None                                | None (the throttle is the only limiter)                                                       |

#### `cursor` (C→S, node-hover, persisted)

Tell the room which node you are hovering, or pass `null` to clear it. The server records the node in your presence and re-broadcasts a full `presence` snapshot to the other members. It is a no-op if you have not joined a workflow.

<ParamField path="nodeId" type="string | null" required>
  The node you are hovering, or `null` to clear your node-hover cursor.
</ParamField>

```javascript cursor (C→S) theme={null}
socket.emit('cursor', { nodeId: 'node_abc' });
// Server records cursorNodeId and broadcasts `presence` to the other members.
```

#### `cursor:move` (C→S) → `cursor_moved` (S→C)

Send your live pointer position. This is the high-frequency event behind a moving cursor on the canvas. It is **throttled server-side to about one frame every 50 ms per user**; frames sent faster are dropped without an error. The position is never persisted — there is no ack to you, only a broadcast to the other members.

<ParamField path="x" type="number" required>
  Canvas x coordinate of the pointer.
</ParamField>

<ParamField path="y" type="number" required>
  Canvas y coordinate of the pointer.
</ParamField>

<ParamField path="nodeId" type="string | null">
  The node under the pointer, if any.
</ParamField>

The resulting `cursor_moved` broadcast carries the mover's identity and color so you can render their cursor without a presence lookup.

<ResponseField name="workflowId" type="string">The workflow.</ResponseField>
<ResponseField name="userId" type="string">The mover's user id.</ResponseField>
<ResponseField name="userName" type="string">The mover's display name.</ResponseField>
<ResponseField name="color" type="string">The mover's assigned color.</ResponseField>
<ResponseField name="x" type="number">Canvas x coordinate.</ResponseField>
<ResponseField name="y" type="number">Canvas y coordinate.</ResponseField>
<ResponseField name="nodeId" type="string | null">The node under the pointer, if any.</ResponseField>

```json cursor_moved (S→C) theme={null}
{
  "workflowId": "wf_123",
  "userId": "u1",
  "userName": "Ada L.",
  "color": "#7C3AED",
  "x": 412,
  "y": 88,
  "nodeId": "node_abc"
}
```

### Idle and active

A member is shown as idle either because their client told the server so, or because the server's fallback timer marked them idle after a period of inactivity.

* **`user:idle` (C→S)** and **`user:active` (C→S)** take **no payload**. They flip your idle flag in the presence record and broadcast `user_idle_changed` to the other members.
* The server also runs a **fallback timer**: a member with no recorded activity for **5 minutes** is flagged idle automatically. Activity is recorded on connect and on `user:active`.

<Warning>
  The server's fallback timer only ever marks a member **idle**, never back to active — only an explicit `user:active` event clears the idle flag. Separately, the fallback timer does **not** observe edit, lock, or cursor traffic, so a member who is actively editing but whose client never emits `user:active` can still be flagged idle after 5 minutes. If you build a client, emit `user:active` on real interaction, not only on focus.
</Warning>

The `user_idle_changed` broadcast:

<ResponseField name="userId" type="string">The member whose idle state changed.</ResponseField>
<ResponseField name="userName" type="string">Their display name.</ResponseField>
<ResponseField name="isIdle" type="boolean">The new idle state.</ResponseField>
<ResponseField name="workflowId" type="string">The workflow, if the change is room-scoped.</ResponseField>

```json user_idle_changed (S→C) theme={null}
{ "userId": "u1", "userName": "Ada L.", "isIdle": true, "workflowId": "wf_123" }
```

### Heartbeats and stale presence

Each connection refreshes a heartbeat key in the in-memory presence store (a **30-second** time-to-live, refreshed every 10 seconds) so a hard-killed client expires from the heartbeat record on its own. Other presence records — the org and room member sets and the per-user presence hash — are cleaned up on a normal disconnect. A hard instance kill can leave those entries stale until the next normal cleanup, because there is no active reaper that sweeps them; only the heartbeat key self-expires. This is invisible in normal operation but is the reason a re-`join-workflow` is the reliable way to resync a suspected-stale member list.

## Node locks

A node lock is a soft, advisory claim on a **single node** so collaborators see it as "being edited by someone." Locks are **not** required to edit, and they do **not** block writes at the patch layer — they exist so the UI can show a held node and discourage simultaneous edits. Locks are held in a shared in-memory store (so they are shared across server instances), are owned by exactly one user at a time, and **auto-expire after 5 minutes**.

### `lock` (C→S) → `lock_ack` / `lock_acquired` / `lock_denied`

Request a lock on a node. This event is gated by **both** a per-user rate limit and a **write-permission** check (owner or admin role; `member` is retired — see [Roles & permissions](/security/roles-permissions)). You must already be in the workflow room.

<ParamField path="nodeId" type="string" required>
  The node you want to lock.
</ParamField>

On success the server replies to **you** with `lock_ack` and broadcasts `lock_acquired` to the **other** members. On denial — the node is already held by someone else — it replies to **you only** with `lock_denied`.

<Tabs>
  <Tab title="lock_ack (S→C, to caller)">
    Confirms your lock succeeded.

    <ResponseField name="nodeId" type="string">The node you now hold.</ResponseField>

    ```json theme={null}
    { "nodeId": "node_abc" }
    ```
  </Tab>

  <Tab title="lock_acquired (S→C, to others)">
    Tells the other members that a node is now held.

    <ResponseField name="workflowId" type="string">The workflow.</ResponseField>
    <ResponseField name="nodeId" type="string">The locked node.</ResponseField>
    <ResponseField name="userId" type="string">The lock holder.</ResponseField>
    <ResponseField name="userName" type="string">The lock holder's display name.</ResponseField>

    ```json theme={null}
    { "workflowId": "wf_123", "nodeId": "node_abc", "userId": "u1", "userName": "Ada L." }
    ```
  </Tab>

  <Tab title="lock_denied (S→C, to caller)">
    Tells you the node is already locked by someone else.

    <ResponseField name="nodeId" type="string">The node you tried to lock.</ResponseField>
    <ResponseField name="lockedBy" type="string">The user id of the current holder.</ResponseField>
    <ResponseField name="lockedByName" type="string">The current holder's display name.</ResponseField>
    <ResponseField name="error" type="string">Always the literal `node_locked`.</ResponseField>

    ```json theme={null}
    { "nodeId": "node_abc", "lockedBy": "u2", "lockedByName": "Lin", "error": "node_locked" }
    ```
  </Tab>
</Tabs>

```javascript Acquire a lock (C→S) theme={null}
socket.emit('lock', { nodeId: 'node_abc' });

socket.on('lock_ack',     ({ nodeId }) => markHeldByMe(nodeId));
socket.on('lock_denied',  ({ nodeId, lockedByName }) => showHeldBy(nodeId, lockedByName));
socket.on('lock_acquired',({ nodeId, userName }) => showHeldBy(nodeId, userName));
```

<Note>
  Re-locking a node **you already hold** succeeds and **refreshes** the 5-minute expiry rather than failing — so a client can keep a lock alive by re-emitting `lock` while editing. A lock held by **another** user always returns `lock_denied`.
</Note>

### `unlock` (C→S) → `unlock_ack` / `lock_released`

Release a lock you hold. This event requires **write permission** but is **not rate-limited** (an intentional asymmetry with `lock`). On release the server replies to **you** with `unlock_ack` and broadcasts `lock_released` to the **other** members.

<ParamField path="nodeId" type="string" required>
  The node to unlock.
</ParamField>

<Tabs>
  <Tab title="unlock_ack (S→C, to caller)">
    <ResponseField name="nodeId" type="string">The node you released.</ResponseField>

    ```json theme={null}
    { "nodeId": "node_abc" }
    ```
  </Tab>

  <Tab title="lock_released (S→C, to others)">
    <ResponseField name="workflowId" type="string">The workflow.</ResponseField>
    <ResponseField name="nodeId" type="string">The released node.</ResponseField>
    <ResponseField name="userId" type="string">The user who released it.</ResponseField>

    ```json theme={null}
    { "workflowId": "wf_123", "nodeId": "node_abc", "userId": "u1" }
    ```
  </Tab>
</Tabs>

<Warning>
  `unlock` is **silent in two failure cases**, by design:

  * If you are not in a workflow or no room exists, the server **returns with no event** — no ack and no error.
  * If you try to unlock a node you do **not** own, the release is refused and **nothing is emitted at all** — no `unlock_ack`, no `lock_released`, no `error`.

  Do not wait on an ack to confirm an unlock of a node you may not own. If you need certainty that your own lock was released, key off `unlock_ack` only after confirming you held the lock.
</Warning>

### Lock lifetime, expiry, and edge cases

<AccordionGroup>
  <Accordion title="5-minute auto-expiry">
    A node lock is stored with a **300-second (5-minute) time-to-live**. If the holder neither releases it (`unlock`) nor refreshes it (re-emitting `lock`), it expires automatically and the node becomes lockable again. A long edit session should re-emit `lock` periodically to keep the claim alive.
  </Accordion>

  <Accordion title="Locks do not block edits">
    A lock is advisory. The version/patch engine described [below](#the-json-patch-version-model) does not consult locks — a node-edit patch from a non-holder is still applied. Locks are a UI affordance to coordinate humans, not a server-enforced mutex on writes.
  </Accordion>

  <Accordion title="One lock per node, one node per holder in presence">
    A node maps to at most one holder. A member's `lockedNodeId` in presence reflects the single node they are shown as holding. The lock map in a `presence`/`joined` payload (`nodeLocks`) is the room-wide `nodeId → userId` view.
  </Accordion>

  <Accordion title="Lock state can briefly lag presence">
    Because the live `presence` broadcast is built from in-memory room state while the `joined` snapshot's `nodeLocks` is read from the shared in-memory store, the lock map you see immediately after joining and the lock map in a subsequent live `presence` can differ for a moment during instance churn. The shared lock keys are the cross-instance source of truth; the in-memory map drives the high-frequency broadcast. Re-join to resync if needed.
  </Accordion>
</AccordionGroup>

## What happens on disconnect

When a member disconnects — closing the tab, losing the network, or leaving the workflow — the server **releases every lock that member held** and tells the room in one broadcast.

### `locks_released` (S→C)

Emitted to the workflow room when a disconnecting (or leaving) member held one or more locks. Unlike `lock_released`, which carries a single `nodeId`, this is the **bulk** release for everything the departing member held.

<ResponseField name="workflowId" type="string">The workflow.</ResponseField>
<ResponseField name="nodeIds" type="string[]">Every node the departing member held, now free.</ResponseField>
<ResponseField name="userId" type="string">The departing member.</ResponseField>

```json locks_released (S→C) theme={null}
{ "workflowId": "wf_123", "nodeIds": ["node_abc", "node_def"], "userId": "u1" }
```

On a clean **leave** (`leave-workflow`), the departing member also gets a `left` acknowledgement; the other members get the `locks_released` bulk release and an updated `presence`. On a hard **disconnect**, presence records are cleaned up and the bulk `locks_released` is emitted from the disconnect handler.

<Note>
  A disconnect can drive the lock-release path twice — once from the in-memory room cleanup and once from the shared-store disconnect handler — so your client may observe more than one `locks_released` for the same member. Make your handler **idempotent**: releasing a node that is already free should be a no-op. The disconnect of the **last** member in a room also tears the room down after a final save of any pending patches (see [the version model](#the-json-patch-version-model)).
</Note>

## The JSON-Patch version model

Canvas edits are not saved one at a time. The live canvas state is held in memory in a **room** that owns the live schema and a version counter. A client edit (move a node, connect an edge, rename, and so on) is sent with the client's current **version**; the server version-gates it, applies the resulting [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902) JSON-Patch operations to the in-memory schema, bumps the room version, acknowledges the sender, broadcasts the change to peers, and **queues** the patch. A timer saves the queue in a batch every **2 seconds**. Each save records one entry in the workflow's [edit history](/workflow-builder/versioning-history).

The full set of edit events (`node:add`, `node:update`, `node:move`, `nodes:move`, `node:duplicate`, `node:delete`, `node:toggle-enabled`, `edge:connect`, `edge:disconnect`, `workflow:metadata-update`, `input-params:update`, `batch`, and the legacy `patch`) is documented on [Socket.io collaboration events](/realtime/socket-events). This section covers the **version, conflict, and persistence model** common to all of them.

<MediaEmbed id="MX-MEDIA-2101" type="image" caption={"A sequence diagram of one edit from client to database."} />

### The two version planes

This is the single most important thing to understand about the version model, and the most common source of client bugs: **there are two version counters, and they advance at different rates.**

|                                        | Room version                               | Saved `edit_version`                                 |
| -------------------------------------- | ------------------------------------------ | ---------------------------------------------------- |
| Lives in                               | The in-memory workflow room                | The saved workflow record                            |
| Advances by                            | **+1 per accepted operation**              | **+1 per save** (one save can carry many operations) |
| Reported to clients in                 | `ack`, `conflict`, and all edit broadcasts | the `saved` event only                               |
| What the version gate compares against | This one                                   | —                                                    |

Because the room version increments per operation while `edit_version` increments per save, **the room version is normally ahead of the saved `edit_version`**. After a save that batched N operations, the room is roughly N ahead.

<Warning>
  The `version` in a `saved` event is the **saved `edit_version`**, which can be numerically **behind** the version your client has been tracking from `ack`/broadcast frames (the room version). **Do not overwrite your local version from `saved`** — doing so makes your client regress and can trigger spurious conflicts on your next edit. Track the version from `ack`/`conflict`/broadcasts; treat `saved` as a "your changes are persisted up to this version" signal, not as the counter to mirror.
</Warning>

### The version gate and conflicts

When you emit an edit, you include your current room `version`. The room accepts it if you are not too far behind:

> **The edit is accepted when** `clientVersion >= roomVersion - 20`.

That **20-version tolerance window** absorbs rapid in-flight edits whose acks have not yet landed at your client. If your version is more than 20 behind the room, the edit is rejected **before** it is applied and the server replies with a `conflict`.

#### `conflict` (S→C)

<ResponseField name="yourVersion" type="number">
  The version your client sent.
</ResponseField>

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

<ResponseField name="resolution" type="string">
  Always the literal `rebase`. No other value exists. It is **advisory** — the server does not perform any rebase for you.
</ResponseField>

```json conflict (S→C) theme={null}
{ "yourVersion": 4, "serverVersion": 31, "resolution": "rebase" }
```

The documented recovery on a `conflict` is to **re-sync**: leave and re-join the workflow (`leave-workflow` then `join-workflow`) to fetch a fresh `joined` snapshot, then replay your unsent intent against the new version. The `resolution: "rebase"` string is a hint about what your client should do, not an action the server takes.

<Warning>
  A `conflict` is the **only** user-visible conflict signal, and it comes **only** from the in-memory version gate. A separate save-time conflict can occur during the background save when an external writer (the [AI Composer](/concepts/ai-composer), a REST call, or a deployment) has advanced `edit_version` past the room's last saved base. That save-time conflict is **silent to the client** — the server re-queues the affected patches and retries on the next 2-second tick; it never emits a `conflict` event for it. So a missing `conflict` does not guarantee your patch persisted on the first try.
</Warning>

### Persistence and the `saved` event

The save runs every 2 seconds. When the queue is non-empty, the server snapshots and clears it, flattens the queued operations into one patch list, and saves them in a single step that:

1. Serializes concurrent saves of the same workflow, so they apply one at a time.
2. Runs the **version check**: if the expected base version is strictly behind the saved `edit_version`, it fails with a `version_conflict` (the silent, re-queued case above).
3. **Sanitizes** the patches: it drops `add`/`replace`/`test` operations whose value is `undefined`, deep-clones object values to strip embedded `undefined`, and filters out `remove` operations whose target path does not exist.
4. **Applies** the patches with strict validation. A single invalid operation (for example, a `replace` on a path that does not exist) throws and **fails the whole batch** with `invalid_patch`; the batch is then re-queued for retry.
5. Saves the new schema and bumps `edit_version` by one, along with `last_edited_by` and `last_edited_at`.
6. Records an edit-history entry (`workflow_id`, `edit_version`, `user_id`, `patches`, `created_at`); the entry is idempotent, so a retry of the same `edit_version` is not duplicated.

On success the server emits `saved` to the whole room.

<ResponseField name="workflowId" type="string">The workflow.</ResponseField>
<ResponseField name="version" type="number">The new saved `edit_version` (Plane B). See the warning above about not mirroring this.</ResponseField>
<ResponseField name="savedAt" type="string">ISO-8601 timestamp of the save.</ResponseField>
<ResponseField name="patchesCount" type="number">The number of queued patch entries saved (not the raw operation count).</ResponseField>

```json saved (S→C) theme={null}
{ "workflowId": "wf_123", "version": 9, "savedAt": "2026-06-21T10:00:02.000Z", "patchesCount": 3 }
```

<Note>
  The in-memory applier the room uses for the live broadcast is **more permissive** than the strict applier used when saving: the in-memory applier auto-creates missing intermediate objects, skips out-of-bounds array indices, and skips `undefined` values, whereas the save-time applier validates strictly and fails the whole batch on a bad operation. This is why an edit can be accepted and broadcast live yet still be reconciled (re-queued and retried) when it saves. The divergence is by design and self-corrects on a subsequent save.
</Note>

### Composer edits and saving

While the [AI Composer](/concepts/ai-composer) is rewriting a workflow, the canvas still emits **echo patches** that mirror what the composer already saved. Saving those would double-write ("ghost commits"). To prevent that, an external composer sync engages a **composer lock** on the room: while it is active (auto-clearing after 15 seconds), the next save **discards** any pending echo patches instead of saving them. Composer changes reach your open canvas over the `workflow:external-sync` event, which the server rebroadcasts as `workflow:external-updated` — **not** over a background pub/sub bridge. See [Realtime co-editing & external sync](/workflow-builder/realtime-coediting) and [Socket.io collaboration events](/realtime/socket-events).

### Edit history

Every successful save records one edit-history entry, with each `edit_version` unique per workflow, storing the original client patch paths. This is the data behind workflow [versioning and history](/workflow-builder/versioning-history). Note that one history entry corresponds to one save (Plane B), not to one edit operation (Plane A) — an entry's `patches` array can contain many operations batched in that 2-second window.

## Errors

Collaboration errors arrive as an `error` event with a `{ code, message, retryAfterMs? }` shape — a **different** envelope from the REST/SSE `{"detail": …}` and from the billing `DenialEnvelope`. See [Errors & status codes](/api-reference/errors) for all three error shapes across the platform. The `retryAfterMs` field appears **only** on the `rate_limited` deny and is a transport hint, **not** the billing `429` envelope.

Conflicts are **not** delivered as `error` — they arrive as the dedicated `conflict` event described above.

| `code`              | When it fires on this surface                                                                                                             |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `rate_limited`      | You exceeded the per-user Socket.io rate limit (applies to `lock`; not to `unlock` or cursor events). Carries an optional `retryAfterMs`. |
| `permission_denied` | You lack write permission for `lock`/`unlock` — an owner or admin role is required (`member` is retired).                                 |
| `not_in_workflow`   | You emitted `lock` before joining a workflow room. (`unlock` returns silently in this case instead.)                                      |
| `room_not_found`    | The room for your current workflow no longer exists when you emitted `lock`. (`unlock` returns silently instead.)                         |
| `invalid_patch`     | An edit's patch operations were invalid (surfaced for edit events, not for lock/cursor).                                                  |

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

<Note>
  The gates are intentionally **asymmetric**. `lock` is both rate-limited and write-gated, while `unlock` is write-gated but **not** rate-limited. Cursor and idle events are **ungated** (the cursor throttle is the only limiter on `cursor:move`). Do not assume symmetric throttling across these events.
</Note>

## No REST or SDK surface

Presence, cursors, locks, and the patch/version model are exposed **only** over Socket.io. There is no `GET`/`POST` REST endpoint for them, and the [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python) do **not** wrap them — the SDKs cover the REST API (and the SSE run streams), not the collaboration plane, which today is consumed by the ModuleX web app. For the parity picture across the whole platform, see the [SDK ⇄ API parity matrix](/sdks/parity).

Because there is no REST surface, the canonical examples on this page are `socket.io-client` frames rather than the cURL / Python / JavaScript SDK trio used on REST-backed pages. To work with presence and locks programmatically, connect a Socket.io client and emit the events documented here.

```javascript Full presence + lock client (socket.io-client) theme={null}
import { io } from 'socket.io-client';

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

socket.on('connect_error', (err) => console.error('handshake failed:', err.message));

// 1. Join a workflow; the snapshot carries initial presence + locks.
socket.emit('join-workflow', { workflowId: 'wf_123' });
socket.on('joined', ({ users, nodeLocks }) => renderRoom(users, nodeLocks));

// 2. Presence + cursors.
socket.on('presence', ({ users, nodeLocks }) => renderRoom(users, nodeLocks));
socket.on('cursor_moved', (p) => renderCursor(p));
socket.on('user_idle_changed', ({ userId, isIdle }) => setIdle(userId, isIdle));
socket.emit('cursor', { nodeId: 'node_abc' });            // node-hover (persisted)
socket.emit('cursor:move', { x: 412, y: 88 });            // pointer (throttled)

// 3. Locks.
socket.emit('lock', { nodeId: 'node_abc' });
socket.on('lock_ack', ({ nodeId }) => markHeldByMe(nodeId));
socket.on('lock_acquired', ({ nodeId, userName }) => showHeldBy(nodeId, userName));
socket.on('lock_denied', ({ nodeId, lockedByName }) => showHeldBy(nodeId, lockedByName));
socket.on('lock_released', ({ nodeId }) => markFree(nodeId));
socket.on('locks_released', ({ nodeIds }) => nodeIds.forEach(markFree)); // idempotent
socket.emit('unlock', { nodeId: 'node_abc' });

// 4. Versioning: track from ack/conflict/broadcasts, NOT from `saved`.
socket.on('conflict', ({ serverVersion }) => resyncFrom(serverVersion));
socket.on('saved', ({ version }) => markPersisted(version)); // do not mirror this version

// 5. Errors.
socket.on('error', ({ code, message }) => handleError(code, message));
```

## Open questions

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

<AccordionGroup>
  <Accordion title="Production collaboration-server hostname">
    The collaboration server is a separate service from the REST API at `https://api.modulex.dev`. Its production hostname is not standardized in these docs — the source material references both `.io` and `.dev` hosts, and the value is environment-configured in the web app. Use your deployment's configured collaboration-server URL in place of `COLLAB_SERVER_URL`.
  </Accordion>

  <Accordion title="Stale presence after a hard instance kill">
    Heartbeat keys self-expire after 30 seconds, but the org/room member sets and per-user presence hashes have no active reaper and are cleaned up only on a normal disconnect. After a hard instance kill, a stale member can linger in the room list until the next normal cleanup. Re-`join-workflow` to fetch a fresh snapshot if you suspect a stale member.
  </Accordion>

  <Accordion title="Lock map vs lock TTL reconciliation">
    A node lock's underlying key expires after 5 minutes, but the room-wide lock map entry is removed on explicit release or disconnect rather than swept against the expired key. A lock that expires by TTL without a release can therefore linger in the lock map until the next release or disconnect. Treat the lock map as advisory and confirm a lock with a fresh `lock` (which refreshes the TTL) when it matters.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Socket.io collaboration events" icon="users" href="/realtime/socket-events">
    The complete client-to-server and server-to-client event reference, including every node and edge edit.
  </Card>

  <Card title="Realtime co-editing & external sync" icon="arrows-rotate" href="/workflow-builder/realtime-coediting">
    How canvas edits sync between collaborators and from external (composer and REST) changes.
  </Card>

  <Card title="Canvas collaboration" icon="object-group" href="/platform/collaboration/canvas">
    The product view of multi-user canvas editing.
  </Card>

  <Card title="Versioning & history" icon="clock-rotate-left" href="/workflow-builder/versioning-history">
    Workflow versions, deployments, and the canvas edit history written by each save.
  </Card>

  <Card title="Realtime overview" icon="wave-pulse" href="/realtime/overview">
    The two realtime planes — SSE run streaming and Socket.io collaboration — side by side.
  </Card>

  <Card title="Errors & status codes" icon="triangle-exclamation" href="/api-reference/errors">
    The three error-envelope shapes across the platform and which surface emits each.
  </Card>
</CardGroup>
