{"protocol":"clank-doc/1","frameworkVersion":"0.13.0","slug":"collaboration","title":"Realtime collaboration","description":"Clank includes dependency free primitives for live presence, cursors, selections, typing state, and ephemeral signals between people authorized to the same application room. The transport uses ordinary authenticated Fetch writes plus an SSE","group":{"id":"framework","title":"Framework"},"url":"https://docs.clank.run/docs/collaboration","source":"docs/collaboration.md","headings":["Add an authenticated room","Connect the browser","Protocol and lifecycle","Limits and privacy"],"tableOfContents":[{"id":"add-an-authenticated-room","title":"Add an authenticated room","level":2},{"id":"connect-the-browser","title":"Connect the browser","level":2},{"id":"protocol-and-lifecycle","title":"Protocol and lifecycle","level":2},{"id":"limits-and-privacy","title":"Limits and privacy","level":2}],"markdown":"# Realtime collaboration\n\nClank includes dependency-free primitives for live presence, cursors, selections, typing state,\nand ephemeral signals between people authorized to the same application room. The transport uses\nordinary authenticated Fetch writes plus an SSE stream, so it works through the same Node adapter,\ncookies, origin policy, and rolling HTTP infrastructure as the rest of an app.\n\nCollaboration is deliberately separate from persistent live queries:\n\n- backend mutations and SQLite live queries are the durable source of truth;\n- collaboration presence is short-lived, replaceable UI context;\n- signals announce an event but do not become an event log; and\n- MCP tools continue to operate on documented backend actions, not another person's cursor.\n\n## Add an authenticated room\n\nUse the auth adapter to reuse Clank's current session and CSRF boundary. The one application rule\nyou must provide is room authorization:\n\n```ts\nimport { createAuthCollaborationHub } from \"@clank.run/framework/collaboration\";\n\nconst collaboration = createAuthCollaborationHub(auth, {\n  async authorizeRoom(session, { room, operation }) {\n    // Recheck membership on every connect, stream, update, signal, and heartbeat.\n    return await canAccessDocument(session.requireUser().id, room);\n  },\n  displayName: (user) => user.profile.name ?? \"Collaborator\"\n});\n\nasync function handle(request: Request) {\n  const url = new URL(request.url);\n  if (url.pathname === \"/__clank/collaboration\") {\n    return collaboration.handle(request);\n  }\n  return application.handle(request);\n}\n```\n\n`authorizeRoom` is not optional in applications with object- or workspace-level access. A valid\nsession proves who someone is; it does not prove they may observe a particular document.\n`createAuthCollaborationHub` resolves the current session on every request and delegates writes to\nthe auth runtime's ordinary `verifyCsrf()` check.\n\nFor another identity system, use `createCollaborationHub({ authorize, verifyCsrf })`. Both\ncallbacks are mandatory. The public principal contains a private stable `id` and a room-visible\n`name`; peers receive only a random connection-scoped participant ID.\n\n## Connect the browser\n\n```ts\nimport { createCollaborationClient } from \"@clank.run/framework/collaboration\";\n\nconst room = createCollaborationClient({\n  room: documentId,\n  csrfToken: () => boot.auth.csrfToken,\n  initialPresence: {\n    route: location.pathname,\n    cursor: { x: 0, y: 0 },\n    selection: null\n  }\n});\n\nawait room.connect();\n\ncanvas.addEventListener(\"pointermove\", (event) => {\n  void room.update({\n    route: location.pathname,\n    cursor: { x: event.clientX, y: event.clientY },\n    selection: selectedId.value\n  });\n});\n\neffect(() => {\n  renderPresence(room.participants.value);\n});\n```\n\nThe reactive surface is:\n\n| Value | Meaning |\n| --- | --- |\n| `state` | `idle`, `connecting`, `connected`, `reconnecting`, `closed`, or `error`. |\n| `participants` | Immutable current room participants and their complete presence objects. |\n| `lastEvent` | Latest snapshot, join, presence, leave, or signal event. |\n| `error` | Latest transport error, without changing the participant contract. |\n\n`update()` replaces this connection's entire presence object. Replacement avoids stale cursor or\ntyping keys surviving after a UI mode changes. Keep a local presence signal when updates are\nassembled by multiple components.\n\nUse a signal for transient notification:\n\n```ts\nawait room.signal(\"comment.created\", { commentId });\n```\n\nSignals are delivered to currently connected room participants, including the sender, and are\nnever retained. Persist the comment through a typed backend mutation first; use the signal only to\nguide immediate UI attention.\n\n## Protocol and lifecycle\n\nThe endpoint defaults to `/__clank/collaboration`:\n\n1. a CSRF-protected `connect` write creates a random connection and returns a complete snapshot;\n2. the client opens an authenticated SSE stream for that exact room and connection;\n3. presence writes replace bounded participant data;\n4. signals broadcast bounded ephemeral payloads;\n5. heartbeats keep the connection lease current; and\n6. abort, explicit disconnect, or idle expiry publishes a leave event.\n\nEvery event uses `clank-collaboration/1` and a monotonically increasing in-room revision. A stream\nalways begins with a full snapshot, so reconnect does not require replaying an unbounded event\nhistory. The browser client reconnects with bounded exponential backoff after proxy restarts or\nrolling deployment transitions.\n\n## Limits and privacy\n\nDefaults are conservative and configurable:\n\n| Limit | Default |\n| --- | ---: |\n| Active rooms | 1,000 |\n| Participants per room | 50 |\n| Connections per authenticated principal | 8 |\n| Presence JSON | 4 KiB |\n| Signal JSON | 8 KiB |\n| Presence/signals per connection | 240/minute |\n| Idle lease | 45 seconds |\n\nData is JSON-only, five levels deep, with bounded keys, arrays, strings, bodies, and SSE events.\nOrigins are same-origin, cross-site Fetch Metadata is rejected, responses are `no-store`, mutation\nrates are bounded, and stream access rechecks both the principal and room.\n\nPresence is visible to every authorized participant in the room. Do not put passwords, tokens,\nprivate drafts, email addresses, or hidden database fields in it. Connection IDs are opaque routing\nhandles, not bearer credentials; the server still re-authenticates their owner on every request.\n\nThe built-in hub is intentionally process-memory state. It survives ordinary connection churn but\nnot a process restart, which is correct for ephemeral presence. The client reconnects and sends a\nnew snapshot. A deployment with multiple simultaneous app processes must provide sticky routing to\none hub or a reviewed shared collaboration adapter; SQLite live data remains the durable shared\nstate either way.\n\nCall `hub.diagnostics()` for aggregate room, participant, and stream counts. It returns no room\nnames, participant identities, presence, connection IDs, or signal payloads. Call `hub.close()`\nduring graceful application shutdown.\n"}