Skip to main content
This is the complete wire reference for the Socket.io collaboration plane — the bidirectional realtime channel that keeps everyone editing the same workflow 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. The two planes share no event names, no envelope, and no error shape; the Realtime overview compares them. For presence, cursor, lock, and version semantics in depth, see Presence, locks & versioning.

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).
Socket.io callback-style acks are not used anywhere in this plane. Every response is a separate emitted eventack, 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.

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:
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.
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.
Connect to the collaboration server
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.

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), 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).
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) only carries handler-level failures after a successful connect.

Post-connect bootstrap (server to client)

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

connected

ConnectedPayload: `{ userId, userName, color, organizationId }`. Your identity and assigned presence color for this session.
2

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.
connected, then workflows_list
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: 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.
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 and Usage gating & limits.

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.
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.
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.
A stale write returns conflict, not ack
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.
* cursor:move is not permission-gated; it is throttled server-side to 50 ms per user, and over-rate frames are silently dropped.
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.

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.
The external-update event is workflow:external-updated — use that name.

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.
string
required
The id of the workflow to join.
The joined reply (JoinedPayload) carries the room snapshot:
string
The joined workflow id.
number
The current in-memory room version. Track this as your base version for writes.
object
The inner workflow schema: nodes, edges, state schema, and metadata.
object
The input-parameters schema (from the workflow’s input column).
object
The workflow config (from the config column).
object
Stream settings for the workflow.
string | null
The current live deployment id, or null.
UserPresence[]
Everyone currently in the room, with their cursor, lock, and idle state.
Record<string, string>
A map of nodeId to the userId holding its lock.
boolean
Present and true if you were already in this room (a re-emit for the same room).
Join a canvas and render the snapshot
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 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 (llm, agent, tool, function, transformer, conditional, interrupt, guardrails, knowledge). The server normalizes the node and also adds its state-schema field.
node:add (client to server), then ack and node:added
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.
This is the node delete handler. Delete multiple nodes by emitting one node:delete each, or via batch.

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 }`.
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.

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 }`.
edge:connect (client to server)

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.
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.
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 }`.
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.

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.
batch (client to server)
Errors: not_in_workflow, room_not_found, conflict, batch_failed.

External sync

When the 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.
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 and Realtime & collaboration model.
The workflow:external-sync command (WorkflowExternalSyncEvent) carries:
string
required
The event type, "workflow:external-sync".
string
required
The workflow being synced.
string
required
The origin of the change: one of composer, api, or deployment.
number
required
The edit version of the externally-applied change.
string[]
An optional list of change descriptions for the external edit.
string
The user id of who made the change.
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.
object
Updated input parameters, a map of key to { value, type }.
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:
string
The synced workflow id.
number
The room version after the sync.
string
The change origin (composer, api, or deployment).
object
The full updated workflow graph. Nodes are preserved in database format (intentionally not re-normalized).
object
Updated input parameters, if any.
string[]
An optional list of change descriptions for the edit.
object
Who made the change ({ userId, userName? }).
string
When the sync was applied.
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 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.

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

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

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.
A version conflict is not an error. The in-memory write path surfaces it as a 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.

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

Next steps

Presence, locks & versioning

The presence model, lock TTLs, and the two-version-plane conflict engine in depth.

Realtime overview & event taxonomy

How the Socket.io plane compares to SSE run streaming, and which to use when.

Realtime co-editing & external sync

The builder UX: how canvas edits and external (composer/REST) changes sync while you work.

Known limitations

The documented gaps, including the unhandled nodes:delete command and the dead pub/sub channel.