Collaboration
Live canvas sessions, presence, CRDT sync, and checkpointed versions.
Several people can edit the same workflow canvas at once. Collaboration is a session on that workflow: presence (cursors, selection), a Yjs CRDT of nodes and edges, and optional draft saves that do not each create a numbered version until someone checkpoints.
There is no /api/v1 collaboration surface. The editor uses authenticated tRPC workflows.* procedures and a server event stream (the same session cookie and MFA requirements as the app). Callers without workflow access get the usual tRPC NOT_FOUND / FORBIDDEN errors.
Start and stop
| Procedure | Role | Effect |
|---|---|---|
workflows.collaborationState | member | Active session state, participants, and high-water marks; includes the graph only when the CRDT revision changes |
workflows.startCollaboration | member | Creates/joins an active session; returns { session, sync, workflow } |
workflows.stopCollaboration | member | Ends the session. Uncheckpointed draft nodes/edges are discarded |
session includes id, status (active while open), baseVersion, createdById, timestamps, and participants[]. Stopping without a checkpoint restores the graph to the last checkpointed (or pre-session) version and re-syncs triggers.
When another editor stops the session, your canvas reloads that saved graph before returning to ordinary editing. If the refresh fails, the current canvas remains visible until synchronization succeeds.
Production, named previews, and the base editor use the same synchronization and presence behavior. Each destination has its own session and draft. Changing destinations cannot apply a delayed response from the previous destination. Wait for pending edits before leaving the editor.
workflows.collaborationStatus and workflows.collaborationSync remain available for older clients. workflows.collaborationState accepts targetId for a selected destination.
Presence
workflows.updateCollaborationPresence body: { sessionId, presence? }.
| Field | Meaning |
|---|---|
cursor.x / cursor.y | Rounded to integers |
selectedNodeId | Trimmed node id |
editingPath | Trimmed inspector path (for example node:agent) |
connectionId | Browser connection UUID; separate tabs have separate presence |
connectionSequence | Increasing per-connection counter; late requests cannot restore older presence |
drag | Temporary { nodeId, x, y } position in canvas coordinates; omit to retain this connection's gesture, or send null when finished |
Participants with lastSeenAt older than 45 seconds are idle and hidden from the visible list. Color is a stable hash of userId from a fixed palette. The editor renews presence while the canvas is open. With a live connection, other editors see nodes move during a drag. Temporary movement is sent at most ten times per second; releasing the node saves its final position. Presence is temporary and does not create a version or a stored graph event. Reduced motion disables the remote position transition.
Live presence supports up to 8 connections per user and 100 per session. Additional tabs can still edit and receive saved changes, but their cursors and temporary drags stay hidden until an existing connection expires. New connections never replace an active tab's presence.
CRDT sync
The graph is a Yjs document (nodes and edges maps plus order). The server stores a binary snapshot (base64 in sync.snapshot) plus revision and sequence.
For a named preview, collaboration also synchronizes the workflow name and description. Saving and checkpointing preserve these fields in that preview's version; other previews and Production keep their own content. The preview's display name remains a separate setting.
| Procedure | Input | Notes |
|---|---|---|
workflows.collaborationState | { targetId?, sessionId?, revision?, sequence? } | Returns changed with sync + workflow for a new or stale cursor, unchanged without graph payload when the cursor is current, or inactive |
workflows.collaborationSync | { sessionId } | Latest snapshot + workflow graph decoded from CRDT |
workflows.applyCollaborationUpdate | { sessionId, updateId, update } | update is base64 Yjs update, max 3_000_000 chars. updateId must match ^[a-zA-Z0-9:_-]{8,160}$ |
The sessionId, revision, and sequence fields are high-water marks from the last complete response. Omit them on the first request to receive a complete changed payload. Reuse all three on later requests; an unchanged response still refreshes participants but deliberately omits sync and workflow. Never interpret those missing fields as an empty graph.
An update is acknowledged only after its event and graph are committed together. In an active session, repeating the same updateId with the same decoded update bytes is idempotent. Reusing that ID with different bytes returns HTTP 409 with code workflow_collaboration_update_conflict and leaves the saved graph unchanged. After the session closes, updates and retries return NOT_FOUND (HTTP 404); they do not restore its discarded draft. Checkpoints and edits allocate their event sequence under the same workflow lock.
The editor retains one local document and queues edits in order. An earlier acknowledgement does not replace a later pending edit or restore metadata from before a checkpoint already received by the editor. On a temporary failure it retries the same update bytes and identifier. If retries fail, the pending count remains visible with Retry synchronization. Synced means the local queue is empty; Live or Polling describes the connection. Keep the tab open while edits are pending: the queue does not survive a reload.
The authenticated stream at /api/workflows/{id}/collaboration/stream subscribes before loading a snapshot, then delivers graph updates and presence. Reconnection or a missing revision causes a full-state reconciliation. A periodic reconciliation repairs missed notifications. If streaming is unavailable, the editor falls back to polling; the pending-edit and checkpoint guarantees still apply, but movement may arrive less smoothly.
After an interrupted stream, the editor closes that connection and retries after 5 seconds, doubling the wait up to 60 seconds while polling continues. A healthy stream heartbeat resets the wait. A terminal refusal, including streaming being disabled, stays on polling until the editor opens a new connection after a reload or session change.
Versions
workflows.update with versionMode: "collaboration-draft" requires an active session id. Invalid or stopped sessions fail the save.
workflows.checkpointCollaboration { sessionId } saves the current draft as one grouped version labeled with the checkpointing user and clears the draft overlay. It does not publish that version or replace the destination's active triggers. The editor waits for its own queued edits before requesting a checkpoint. While either checkpoint or close is in progress, both actions stay disabled until pending edits finish synchronizing and the request completes. Other editors must synchronize their pending edits too if those changes should be included.
GET /api/v1/workflows/{id}/versions lists those versions like any other save. Restore (POST .../versions/{n}/restore) is the same API as non-collaborative history.
Persistence
Sessions, graph snapshots, and committed CRDT events use Prisma when DATABASE_URL is set. Live connection presence uses Redis with a 45-second lease. Legacy readers include those leases. Without Redis, the editor uses slower persisted presence heartbeats, without storing a graph event per cursor movement. Otherwise they live in process memory and vanish on restart — the same limitation as other in-memory platform data. Hosted preview/production expects Prisma.
If live presence becomes unavailable, the next successful state refresh removes obsolete connection cursors and temporary drag positions. Saved node positions remain available through polling.
Limitations
- No REST v1 for start/stop/CRDT. Automate review/import instead.
- Presence is not a lock. Two people can edit the same node; CRDT last-merge wins per field.
- 45s idle timeout is not configurable in product settings.
- Checkpoint is explicit. Closing the tab does not checkpoint.
- Git review hashes the checkpointed graph, not an unsaved CRDT buffer.
