---
url: https://spellingcreator.org/docs/developers/web-app/live-collaboration.md
---

# Live collaboration

For how to use this, see [Live collaboration](../../guide/live-collaboration.md).

Each participant opens **a single WebSocket** to a server-side **room**, a
[Cloudflare Durable Object](https://developers.cloudflare.com/durable-objects/)
(`CollabRoom`) that is the authority and relay for the session. The Worker
verifies your **Supabase sign-in** before the connection reaches the room, so
**only signed-in users can host or join**, and your identity is established
server-side (a client can't spoof it).

## How a session works

**Host vs. guest.** Whoever opens the session is the *host*; everyone else is a
*guest*. The room caches the current document so it can hand the latest copy to a
guest the moment they're added.

1. The host clicks **Start a collaboration session** and gets a short **session
   code** plus a one-click **invite link** (`/?join=<code>`, which deep-links a
   recipient straight to the join screen with the code filled in).

2. A guest pastes the code (or opens the invite link) and connects. Connecting
   does **not** yet make them a collaborator.

3. The guest appears in the host's **Waiting to join** list (with an **AI**
   badge when the request comes from an assistant). The host presses the add
   button (tooltip **Add to lesson**); this is the gate the feature is built around: only after a
   guest is *added* does the room send them the lesson and start syncing edits.
   The host can decline a request or remove a collaborator at any time. **Trusted
   collaborators** (an email list saved on the lesson as
   `doc.trustedCollaborators`) skip the waiting room and are admitted
   automatically. When a host session goes live, `CollaborateDialog` also sends
   each trusted collaborator the invite link as a notification (`sendLink` in
   `@spelling-creator/core/notifications`), once per session code and address.

   That trusted list carries further privileges outside the live session: a
   trusted collaborator may **save the lesson** (the only non-author who can)
   and may **merge a pull request** into it, deciding alongside the author which
   proposed changes land. See [Version history](../version-history.md) for what
   that does and does not let them do, and [Pull requests](./pull-requests.md)
   for the review flow.

4. Once added, edits sync **both ways**: the room merges each change into the
   session's document, re-broadcasts it to the other admitted collaborators, and a
   presence roster shows everyone in the lesson.

**Where a guest's copy lives.** A guest joins from their own editor, which has
one of their own lessons open, and that lesson's library entry and version
history are tied to whatever the editor holds. So when the host adds them, the
editor first saves and checkpoints the lesson they had open, then moves them
into a new lesson in [their library](./local-lessons.md) for the session (or
reuses the open one if it is untouched), and only then shows the host's
document. Edits that arrive during the move wait in the session's Yjs document,
and the guest sends nothing until the move is done, so their own lesson is
never overwritten and never merged into the host's. The session's copy stays in
their library after the session ends. It isn't attached to the host's hub
lesson: publishing it makes a new lesson rather than updating the host's. If
the move fails, the guest leaves the session with an error instead. That
includes the guest opening or starting another lesson while the move is still
saving: their choice wins, and since the session then has nowhere safe to go,
they leave it rather than have it land in the lesson they just opened. A
session lesson already created by then is deleted again rather than left in the
library unopened.

`useCollaboration` takes this as `onAdmitted`, which `EditorPage` answers with
`openSessionLesson`.

**Conflict handling (CRDT).** Edits are merged with a **CRDT** ([Yjs](https://yjs.dev)),
not applied last-write-wins. Two people working on **different blocks, sections or
fields** both keep their work; previously the document was synced whole, so
whoever typed last silently overwrote the other. Every participant keeps a Yjs
document mirroring the lesson, the room holds the authoritative copy, and only the
**changes** travel over the wire rather than the whole lesson on every keystroke.

The one deliberate limit: text is merged **per field**, not per character. If two
people type into the **same** field at the same time, one of them still wins (both
sides agree on which). Editing different blocks, the normal case, always merges.
A formatted text block is one field too: its whole tiptap document is a single
value, so the same rule applies to it (see
[Formatting, footnotes & sources](./formatting-and-footnotes.md)).
The lesson's sources are a list keyed by id, so two people editing different
sources both keep their work.

**What the room never carries.** The trusted list itself is stripped out of the
document (`stripLocalFields`) before it is reconciled into the Y.Doc, and so
never reaches the room or anyone in it. Those are email addresses, and the host
admits people who aren't on the list; there is no reason for a guest to receive
everyone else's address to edit a lesson. Nobody in the session needs it: only
the host reads it, to auto-admit trusted guests, from their own copy. The host
puts it back on each document they adopt from the room; a guest doesn't, because
their local copy is a lesson of their own made for the session, not the host's
lesson. See [Version history](../version-history.md).

**Binary wire protocol.** Messages are sent as **binary WebSocket frames** for
speed: a one-byte type tag followed by the payload. Cursor and chat payloads are
UTF-8 JSON; document payloads are opaque Yjs update bytes, which the room relays
without parsing. A participant is identified by a server-assigned numeric **slot**
rather than by name in every packet; the client maps slot to identity from the
presence roster to label cursors and chat. The frame shapes are defined once, in
`@spelling-creator/core/collabFrames`.

**Who gets credit in version history.** Everyone's editor keeps committing the
lesson as usual while a session runs, so the commit that picks up a guest's
edits is taken in the host's editor and signed by the host. To make sure the
guest isn't left out, the room sends an `EDITED` frame naming the sender's slot
just ahead of every update it relays (stamped by the room, like a cursor's slot,
so nobody can claim someone else's edit). The client looks that slot up in the
roster, which carries each participant's account id, and keeps a list of who
has edited since the last commit. The next commit credits each of them with a
standard git `Co-authored-by:` trailer, and the history then reads "Alex, with
Sam and Priya".

Only people whose edits actually arrived are credited. Someone who only watched
isn't, and the room sends `EDITED` only for an update that changed its
document, so re-sending an update the room already holds earns nobody credit.
Neither is the author themselves, which also covers an assistant connected on
the author's own account. The trailer holds the account id rather than an email
address, because a published lesson's history is public; see
[Version history](../version-history.md).

`EDITED` is a frame of its own rather than a slot added to `UPDATE`, so an
editor or MCP server built before it existed simply skips it and goes on
working; its commits just don't credit anyone.

**Live cursors.** Each collaborator's text selection is relayed to the others, so
you can see where everyone is working. `useSelectionBroadcast`
(`apps/web/src/lib/useSelectionBroadcast.js`) reports the local selection, the
hook exposes everyone else's via `collab.selections`, and `CollabCursors.jsx`
renders the floating colored carets and avatars over the editor.

A selection travels as `{ field, start, end }` character offsets whatever kind
of field it is in. Inputs and textareas report `selectionStart`/`selectionEnd`,
and their caret is placed by mirroring the field off screen. A text block's body
is a tiptap editor (a contenteditable) instead, so its offsets are counted over
the block's plain text: each paragraph's words, one character for each break
between paragraphs, and nothing for a footnote marker. Every collaborator holds
the same plain text, so an offset taken on one screen lands on the same
character on another, and the caret is placed with a DOM `Range` at that
character (`contentEditableSelection` and `contentEditableCaretRect` in
`@spelling-creator/core/browser/presence`). Since text blocks commit 200ms
after a pause (`COMMIT_DELAY` in `LessonTextInput.jsx`), a caret can sit a few
characters off for that moment while a collaborator is mid-word; an offset past
the end of the block clamps to it.

A caret is drawn only for a field that's actually on screen. Sections you have
[collapsed](./navigating-large-lessons.md) are hidden with
`content-visibility`, whose descendants still measure as full-size, so
`CollabCursors` tests `Element.checkVisibility()` rather than geometry;
otherwise a collaborator editing inside a folded section would have their avatar
pinned over the collapsed card. Their edits still arrive as normal; only the
marker is suppressed. Collapsed state is per-person and never leaves the
browser, so nobody else's view is affected by what you fold away.

**Live chat.** Once you're collaborating, a floating chat panel (`CollabChat.jsx`,
pinned to the bottom-left) lets everyone in the session talk. It appears for the
host as soon as a session is live and for a guest once the host has added them.
The transcript is **ephemeral**: it lives only in memory for the duration of the
session and is not saved anywhere; a launcher badge shows the unread count
(capped at "99+") while the panel is collapsed. The input takes up to 2,000
characters, and the room refuses a chat payload over 8 KB.

## Rate limits

Because the relay is server-side, it is rate-limited to keep it cheap and
abuse-resistant:

| Limit                               | Value                                                               | Where                                                                                         |
| ----------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Joins (socket or agent), per user   | 5 per minute (token bucket)                                         | `takeJoinToken` in `routes/collab.js`                                                         |
| Concurrently hosted rooms, per user | 6                                                                   | `handleCollab` in `routes/collab.js` (a KV counter with a 2 hour TTL so a leaked count heals) |
| Participants per room               | 10, host included                                                   | `MAX_PARTICIPANTS` in `collab-room.js`                                                        |
| Messages per connection             | 30 document updates, 15 cursor moves and 2 chat messages per second | `RATE` in `collab-room.js`                                                                    |
| One document update                 | 512 KB                                                              | `MAX_UPDATE_BYTES`                                                                            |
| One cursor / chat payload           | 1 KB / 8 KB                                                         | `MAX_CURSOR_BYTES` / `MAX_CHAT_BYTES`                                                         |

The 512 KB ceiling is one only the host's opening copy of the lesson ever
approaches, since ordinary edits are a few bytes. Over-budget traffic is
dropped, and a connection that exceeds its budget 100 times
(`MAX_VIOLATIONS`) is closed as abusive.

## Endpoints and configuration

| Path                                                          | What it is                                                                                                                                                                                                                                                                                                                    |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /collab/:code?token=<jwt>[&create=1][&assistant=<name>]` | The WebSocket upgrade. The token rides in the query because a browser can't set an `Authorization` header on a WebSocket. `create=1` marks the host opening a room (counted against the hosted-room cap). `assistant` is a self-declared label that shows the participant with an **AI** badge; it is not a security control. |
| `/collab/:code/agent` and `/collab/:code/agent/*`             | The same room over plain HTTP, for an assistant with no socket (`handleCollabAgent`). `Authorization: Bearer <jwt>` as elsewhere. See [MCP live sessions](../mcp-server/live-sessions.md).                                                                                                                                    |

The Worker needs the `COLLAB_ROOM` Durable Object binding (class `CollabRoom`,
in `apps/api/wrangler.jsonc`) and a rate-limit store (`RATE_LIMIT_KV` on
Cloudflare; see the [platform seam](../platform-seam.md)). Without the binding
every request answers "Collaboration is not configured."

## Implementation

`@spelling-creator/core/ydoc` owns the CRDT: it maps the editor's plain
lesson document (`{ title, sections: [...] }`) onto a Yjs document and back. The
editor itself is untouched by any of this: it keeps working on plain objects, and
`ydoc` keeps a Yjs document in step underneath, matching sections, blocks,
spelling words and answers by the stable `id` they already carry. Its `reconcile`
is **idempotent**, which is what stops a received edit from bouncing straight back
to the sender.

`apps/web/src/lib/collab.js` is a `useCollaboration` hook that owns the
WebSocket, the Yjs document, the slot-to-identity roster, the admission state and
the chat transcript. `apps/web/src/components/CollaborateDialog.jsx` is the
control panel (host/join landing, invite sharing, the waiting-to-join admission
list, the trusted list, and the roster). It is addressed by URL rather than by
component state: `/editor/collaborate` opens it and leaving the panel closes it,
so the back button works and a host can send someone a link to it. Navigating in
or out preserves the query string: an invite arrives as `?join=<code>`, and
dropping it on the way into the panel would break the very flow that opened it.
`EditorPage` wires the hook's `onRemoteDoc` to its `setDoc`, passes the access
token, and watches `doc` so local edits broadcast automatically.

The server side lives in `apps/api/src/collab-room.js` (the `CollabRoom` Durable
Object, which holds the session's authoritative Yjs document, persists it to
SQLite so it survives hibernation, and relays updates) and `handleCollab` in
`apps/api/src/routes/collab.js` (the JWT gate, connection rate limits, and
forwarding to the room).

Not every participant has a socket. An AI assistant joining through the
[MCP server](../mcp-server/live-sessions.md) can't hold a connection between tool
calls, so the room keeps it as a record instead: it joins, is admitted and
removed through the same `ADMIT` and `REMOVE` frames, and appears in the roster
like anyone else (badged **AI**), but it reads the document and its chat by
asking over HTTP (`handleCollabAgent`, same file) rather than being pushed
frames. The room keeps a bounded chat inbox (200 messages) and the latest cursor
positions for it between asks, and a once-a-minute sweep lets go of one that
stops asking, since it has no socket to close: a request nobody answers is
withdrawn after five minutes, and an admitted assistant that stops asking is let
go after half an hour. Its edits arrive as ordinary Yjs updates, so `EDITED`
credits them to its slot and version history treats them like anyone's.

`collab.coAuthors` is how the session hands its list of editors to version
history. Every path in `useLessonGit` that commits the live document (the
regular checkpoint, switching or starting a variation, and first publishing)
reads it (`peek`) and then drops the people it checked (`clear`), whether or
not there turned out to be anything to commit: finding nothing means their
edits are already in a commit or were undone. Someone who edits again while
that commit is being written stays on the list for the next one. Moving to
another lesson empties the list (`discard`), and the editor takes a checkpoint
the moment a session ends, so a session's edits are never credited on work done
after it.

Yjs is used **only for the live session**. Lessons are still stored as plain JSON,
so nothing about saving, exporting or forking changes, and version history
changes only in who a commit credits.

## Where it lives

| File                                            | What it does                                                                   |
| ----------------------------------------------- | ------------------------------------------------------------------------------ |
| `apps/api/src/collab-room.js`                   | The `CollabRoom` Durable Object: admission, relay, persistence, agents, limits |
| `apps/api/src/routes/collab.js`                 | `handleCollab` and `handleCollabAgent`: sign-in gate, join limits, forwarding  |
| `packages/core/src/collabFrames.js`             | The binary frame shapes, shared by the room and the browser                    |
| `packages/core/src/ydoc.js`                     | Plain lesson document to Yjs and back (`reconcile`, `docFromY`)                |
| `packages/core/src/browser/presence.js`         | Caret offsets and positions inside a text block                                |
| `apps/web/src/lib/collab.js`                    | `useCollaboration`: socket, Yjs document, roster, admission, chat, `coAuthors` |
| `apps/web/src/lib/useSelectionBroadcast.js`     | Reports the local selection                                                    |
| `apps/web/src/components/CollaborateDialog.jsx` | The control panel                                                              |
| `apps/web/src/components/CollabCursors.jsx`     | Other people's carets                                                          |
| `apps/web/src/components/CollabChat.jsx`        | The chat panel                                                                 |
