> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.revise.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Collaboration

> Real-time multi-user editing is the SDK's native state, not an add-on — and your server is a first-class participant.

Collaboration is a core pillar of this SDK, not a mode you switch on. Every
document is a [Yjs](https://yjs.dev) CRDT from its first keystroke — the
single-user editor is just a room with one person in it. That design buys you
three things most document SDKs cannot offer together:

* **Real-time multi-user editing over a transport you own** — carets,
  presence, tracked changes, and comments are all collaborative out of the
  box, with your authentication and your infrastructure in the path, not ours.
* **Word fidelity that survives collaboration** — concurrent edits merge as
  CRDT operations, and the result still round-trips to `.docx` with native
  tracked changes and comments intact.
* **A server that participates in the same room** — `@reviseio/sdk/backend`
  runs the same converters and the same semantic tools under Node, so your
  backend can create documents before anyone opens them, edit live rooms
  headlessly, and propose tracked changes your users review in the browser.
  No other DOCX SDK offers this half at all.

The SDK never opens a socket: you own the transport, its authentication, and
its lifecycle. Hand us the provider, and the document becomes collaborative.

```tsx theme={null}
import { HocuspocusProvider } from "@hocuspocus/provider";

const provider = new HocuspocusProvider({
  url: "wss://collab.example.com",
  name: "contract-1",
});

<ReviseEditor
  currentUser={{ id: user.id, name: user.name, image: user.avatarUrl }}
  initialDocuments={[
    {
      id: "contract-1",
      docx: file,
      collaboration: { provider, synced },
    },
  ]}
/>;
```

That is the whole integration. The provider's document is edited in place, its
awareness carries carets and presence, and the provider object itself
identifies which edits came from other people.

## One copy of Yjs

Yjs identifies its own types with `instanceof`, so your application and the
SDK must resolve to **the same** Yjs module. Install it yourself — it is a
peer dependency, not something we bundle:

```bash theme={null}
npm install yjs y-protocols
```

If a bundler hands the two sides different copies, seeding a shared document
fails with `Unexpected content type in insert operation`. In Vite, keep the
SDK and Yjs on the same side of the dependency optimiser:

```ts vite.config.ts theme={null}
export default defineConfig({
  resolve: { dedupe: ["yjs", "y-protocols"] },
  optimizeDeps: { exclude: ["@reviseio/sdk", "yjs", "y-protocols"] },
});
```

Webpack and Next.js resolve a single copy on their own provided there is only
one `yjs` in your lockfile; `npm ls yjs` is the check.

## Let the editor build the document

The editor reserves eight top-level keys in the Yjs document — `content`,
`chrome`, `notes`, `comments`, `annotations`, `metadata`, `sdt-wrappers`,
`paragraphs` — and relies on specific document settings. So the recommended
form hands you a document and an awareness instance and asks you only to
connect them:

```ts theme={null}
collaboration: {
  connect: (doc, awareness) =>
    new HocuspocusProvider({ url, name: "contract-1", document: doc, awareness }),
  synced,
}
```

Return the provider: that is what lets the editor recognise a peer's edit.
Your application still owns the connection, its authentication, and its
lifetime; the editor only builds the document it has to be able to read.

## Bringing your own document

Some transports create the document themselves, so both of these still work:

```ts theme={null}
collaboration: { provider }            // reads document/doc/getYDoc() + awareness
collaboration: { ydoc, awareness, remoteOrigin }
```

A document you supply is **validated** before the editor writes to it. An
empty one is seeded; one that already holds a Revise document is joined;
one whose reserved keys hold something else is refused outright:

```
Revise collaboration: this Y.Doc already uses "metadata", which the editor
reserves for its own document.
```

That last case is the one to watch if you keep a single Yjs document per room
for your whole application — `comments` and `metadata` are names you may well
have used first. Give the editor its own document, or keep your data in a
separate one.

The explicit path is otherwise the escape hatch — for a transport we do not
recognise, a custom sync layer, or a setup where the document and the
connection are separate objects:

```ts theme={null}
collaboration: {
  ydoc,
  awareness,                 // omit or pass null for no presence
  remoteOrigin: myTransport, // see below
  synced: isSynced,
  seed: "if-empty",
}
```

<Warning>
  With the explicit path you **must** pass `remoteOrigin`: the value your
  transport gives `Y.applyUpdate` as its transaction origin. Without it a
  peer's edit is indistinguishable from a local one, which breaks cursor
  restoration and lets Ctrl+Z undo someone else's typing. A transport that
  applies updates with a `null` origin cannot be used directly — wrap it so it
  tags its own transactions. Passing `provider` handles this for you, because
  virtually every provider passes itself.
</Warning>

## Who seeds the document

The first participant establishes the content; everyone else joins it. That is
what `seed` controls:

* `"if-empty"` (default) — if the shared document has no content, the `docx`
  source is written into it. If it already has content, the source is ignored
  and you join what is there.
* `"never"` — always join. `docx` becomes unnecessary, and a document input
  with no source is valid:

```ts theme={null}
{ id: "contract-1", collaboration: { provider, synced, seed: "never" } }
```

<Note>
  Seeding happens after the first sync, never before. A participant who seeded
  a room they had not yet received the state of would duplicate the entire
  document.
</Note>

## Telling us when you are synced

`synced` is how you say the provider has received the room's initial state.
Until it turns true the editor holds the document closed rather than showing an
empty page someone can type into:

```tsx theme={null}
const [synced, setSynced] = useState(false);

useEffect(() => {
  provider.on("synced", () => setSynced(true));
}, [provider]);
```

Later disconnections do **not** close the document. Yjs merges offline edits
when the transport returns, so a dropped connection is a presence problem, not
an editing one.

If your transport gives you no sync signal, omit `synced` and the editor opens
immediately — accepting the duplicate-seed risk on a brand-new room.

## Who is in the room

`collaboration.getState()` normalises awareness into a roster, using the same
colours the carets are drawn in:

```ts theme={null}
const { enabled, synced, peers } = editor.collaboration.getState();
// peers: [{ clientId, isLocal, id, name, email, image, color }]

useEffect(() => editor.collaboration.subscribe(setPresence), [editor]);
```

It fires on join, leave, caret movement, and sync-state changes — enough to
render a facepile that stays honest. A participant with no `currentUser` does
not appear, because there is nothing to show.

## Presence and cursors

Remote carets render automatically when awareness is available, labelled and
coloured per participant. Everything a peer sees comes from `currentUser`:

```tsx theme={null}
<ReviseEditor
  currentUser={{
    id: user.id,
    name: user.name,
    email: user.email,
    image: user.avatarUrl, // drawn on the caret flag
    color: user.brandColor, // caret, flag, and roster colour
  }}
/>
```

`name` labels the caret and `image` is drawn on its flag — any URL the browser
can load, including a `data:` URI, so an app with no avatar CDN can still give
each participant a face.

`color` is optional. Without it, a stable colour is derived from the user `id`,
so the same person is the same colour for everyone with no coordination. Set it
when the colours are yours to choose — brand palettes, or matching an avatar
you already show elsewhere.

A participant with no `currentUser` has no caret drawn for others: there is
nothing to label it with. Pass one for anybody who should be visible.

<Note>
  `currentUser` is also what stamps authorship on tracked changes. Without it,
  suggestions export to Word as reviewer "Anonymous". Set it once and both
  problems go away.
</Note>

## Your server is a participant

Everything above treats the server as plumbing: it moves updates between
browsers. `@reviseio/sdk/backend` makes it a peer. Because the room is a
Yjs document and the backend entry runs the same converters and mutation
engine as the editor, anything your server does to the `Y.Doc` reaches every
open browser as an ordinary update — and everything a browser does reaches
your server the same way. The full surface is in the
[backend reference](/api/backend); this section is the workflows.

### Seeding from your own server

The browser is not the only place that can create a document. Your server can
turn a file your systems already hold into the room itself:

```ts theme={null}
import { seedYDocFromFile, encodeYDoc, ydocToDocx } from "@reviseio/sdk/backend";

// When a room is first opened, before any client finishes syncing:
await seedYDocFromFile(ydoc, docxBytes, "agreement.docx");

// Persist it however you like — the bytes are opaque:
await store.put(roomId, encodeYDoc(ydoc));

// And export the live room with no browser involved:
const bytes = await ydocToDocx(ydoc);
```

Clients then pass `seed: "never"` and no source file: everyone joins a
document that already exists. This is the difference between a product where
the first browser to open a document imports it — a race when two people click
at once — and one where documents exist before anyone opens them.

`jsdom` is a peer dependency of that entry point; the converters parse XML and
HTML with DOM APIs. A full worked example, with a syncing and persisting
server, is in `examples/collaborative-docx`.

### Editing the live room headlessly

To mutate a room on the server, bind a document session to it. The session
exposes the same semantic tools the browser does — read, search, measure,
mutate, comment — transacting through the same Yjs-backed mutation engine, so
peers receive ordinary updates and remote carets never miss a beat:

```ts theme={null}
import { createServerDocumentSession } from "@reviseio/sdk/backend";

const session = await createServerDocumentSession(ydoc, {
  documentId: "agreement-1",
});
try {
  // Suggesting by default: this lands as a tracked change in every open tab.
  const edit = await session.tools.call("replace", {
    search_result_id: searchResultId,
    replacements: [{ find: "Delaware", replace: "New York" }],
  });
  await notifyReviewers(edit.suggestionIds ?? []);
} finally {
  session.dispose(); // your Y.Doc stays alive
}
```

Sessions default to **suggesting** mode: a service proposes tracked changes
unless it explicitly opts into direct edits (`mode: "editing"`, or a per-call
`directMode`). That default is what makes an unattended backend workflow safe
to point at a shared document — nothing is applied until a person accepts it.

### One review loop, both surfaces

Server and browser speak the same tool contract — `tools.execute()` returns
the same `ReviseToolResult` envelope, mutations report the same
`suggestionIds`, and accept/reject returns the same `ReviseSuggestionDecision`
on either side. So a review pipeline composes across the stack: the server
proposes, the person in the browser decides.

```ts theme={null}
// Server: propose, and hand the IDs to your workflow.
const edit = await session.tools.call("replace", { ... });
await queueForReview(edit.suggestionIds ?? []);

// Browser: settle exactly that batch, later, from your review UI.
const decision = editor.review.acceptSuggestions(await reviewBatch());
// { resolved, missing, unresolved } — stale IDs land in `missing`.
```

The reverse composes too: a human suggests in the browser, and a nightly job
inspects `session.listSuggestions()` — every pending record carries
authorship metadata — and settles its own agent's proposals while leaving
human ones alone. See [tracked changes](/guides/tracked-changes) for the
review UI half.

### Working without a live transport

A server does not need a socket to participate. For request/response
integrations — an API endpoint, a queue worker — the client posts its
document as one encoded update, the server works on a decoded copy, and answers with only the delta —
which merges into the live document like any peer's edit, even if the user
kept typing during the round-trip:

```ts theme={null}
// Client
const response = await api.review({ update: Y.encodeStateAsUpdate(ydoc) });
Y.applyUpdate(ydoc, response.delta, "my-backend");

// Server
import { decodeYDoc } from "@reviseio/sdk/backend";

const ydoc = decodeYDoc(update);
const baseline = Y.encodeStateVector(ydoc);
// ...run a session, propose suggestions...
const delta = Y.encodeStateAsUpdate(ydoc, baseline);
```

Stateless on the server, no transport dependency, and the same review loop as
above — the response carries the created `suggestionIds`.

## What the SDK does not do

* **It does not connect.** No socket, no retries, no auth, no reconnection
  backoff. Your provider owns all of it.
* **It does not persist.** Where the document lives between sessions is your
  server's business.
* **It does not destroy your `Y.Doc`.** A document you passed in outlives the
  component; unmounting only clears this client's caret.
* **It does not arbitrate who may edit.** Per-participant
  [roles](/guides/roles) constrain what each embed can do, but they are set by
  each client, and there is no document locking. If a role must be
  authoritative, your server has to enforce it on the transport — which the
  [backend entry](/api/backend) makes practical, since the enforcing process
  can read and edit the document it is guarding.
