Agora
Engines

Yjs (Hocuspocus)

The default engine — an embedded Hocuspocus server sharing the Adonis HTTP port, y-protocols over WebSocket, shared types for text and canvases, awareness for live cursors, and snapshot-based versions.

The default. Your Adonis process embeds a Hocuspocus sync server, browsers connect a WebSocket to /collaboration on the same port your HTTP app already listens on, and they speak the binary y-protocols wire format. There is no second process, no broker, and no extra port to open in a firewall.

config/collaboration.ts
export default defineConfig({
  engine: 'yjs',
  path: '/collaboration',   // the upgrade path on the Adonis HTTP server
  debounce: 2000,           // ms of quiet before the state is flushed to storage
  storage: lucidStorage({ connection: 'primary' }),
  // ...
})

The provider attaches the upgrade handler once the HTTP server exists, and filters on path — every other upgrade request passes through untouched, so other WebSocket features in the same app keep working.

The shared types

A Yjs document is a container of named shared types, each with its own merge semantics. You rarely construct them yourself; the editor binding does it. But knowing what is in the document explains what versions capture and what getDocumentText can read:

TypeHoldsUsed by
Y.XmlFragmentrich text with marks and nestingTiptap / ProseMirror — the default fragment
Y.Mapa keyed recordExcalidraw and tldraw scenes, mirrored under one key
Y.Arrayan ordered listlists, ordered records
Y.Textplain text with formattingCodeMirror, plain editors

The document is a container: a page with a rich-text body and a canvas beside it can keep both under different keys of one document, sharing a name, a version history and a comment store.

Rich text with Tiptap

The full walkthrough lives in Tiptap collaboration; the short version:

const { doc, synced } = useCollabDoc({ docName })
const { awareness } = useAwareness({ docName, user: { userId: me.id, name: me.name } })

const editor = useEditor({
  extensions: [
    StarterKit.configure({ undoRedo: false }),         // the CRDT owns undo
    Collaboration.configure({ document: doc }),
    CollaborationCaret.configure({ provider: { awareness }, user: { name, color } }),
  ],
}, [awareness])

if (!synced) return <DocumentLoading />

Disable the editor's own history

ProseMirror's undo stack does not distinguish your operations from a collaborator's, so leaving it enabled means Ctrl+Z can undo someone else's paragraph. undoRedo: false hands undo to the CRDT's own manager, which tracks origin. The option is history on Tiptap 2 and undoRedo on Tiptap 3 — passing the old name to a Tiptap 3 StarterKit is accepted and does nothing.

Gate the first paint on `synced`, not `editable` on the connection

doc is returned before the server's state arrives, so something has to hold the first render back — that is what synced is for. editable: status === 'connected' does not do that job: 'connected' says the socket is open, not that the state was applied. It also breaks a working editor, turning it read-only on every dropped WebSocket with nothing on screen to say why. See Sessions.

Canvases

Excalidraw and tldraw both serialize to a scene object, and both are mirrored into a Y.Map key by an adapter — so the same useCollabEditor hook drives either:

const { state, update, synced, peers } = useCollabEditor({
  docName: 'whiteboards/1',
  editorId: 'excalidraw',
  adapter: createExcalidrawAdapter(),
})

Canvas mirroring is last-write-wins over the whole scene

This is the one place in the library where "the CRDT merges it" is not the whole story. The scene lives under a single key (Y.Map[editorId].value) holding the entire elements array, so every change writes the whole array. Yjs converges — but on that one value, so two people drawing at the same instant produce two whole-scene writes and the later one replaces the earlier: the other person's stroke is gone, with no conflict and no error. There is no element-level merge here.

It is usually livable — edits appear, people work in different regions, and the peer cursors keep them apart — and it is not something you can tune. If per-item concurrency actually matters, model each element as its own document key and write a CollabEditorAdapter that does it. Rich text is different: Tiptap binds to a Y.XmlFragment and does get real character-level merging.

See Editors.

Awareness

Awareness is part of the Yjs protocol, not something the library adds: each client broadcasts an ephemeral state — cursor, selection, name, colour — to the others in the room. It is what useAwareness reads, and it is per-room and per-process by nature. See Presence.

Persistence and the debounce

Every change is applied to the live in-memory document immediately and broadcast to every connected client. Writing to storage is debounced: after debounce milliseconds of quiet, the driver encodes the full state and saves it.

The window is a straight trade. Lower means less data at risk if the process dies; higher means fewer writes under sustained typing. The default of 2000 ms is a reasonable middle for prose. Two things worth knowing about the edges:

  • Sustained typing keeps resetting the timer, so a very high debounce can mean a long stretch with nothing written. Treat it as a maximum acceptable data loss dial.
  • Graceful shutdown flushes pending documents, so a normal deploy does not lose the window. A SIGKILL does.

Versions

Yjs has no built-in history, so the driver adds one: createVersion stores the full encoded state as a snapshot, and restoreVersion applies that snapshot's content back into the live document inside a transaction — so every connected client sees the restore arrive as an ordinary edit. Before it does, it writes the state it is about to replace into the history as a version labelled restored from #<seq> and attributed to restoredBy, so the restore is itself undoable. diffVersions materializes both snapshots, extracts the text and reports { added, removed } by line. See Versions.

Reading a document server-side

getDocumentText extracts plain text from the Tiptap layout — the input for search indexing, a RAG pipeline, a grammar checker, or a word count:

const text = await collaboration.current.getDocumentText({ docName })

It prefers the live document when one is open and falls back to storage otherwise, so it is correct whether or not anyone is editing. A document that is not in the Tiptap layout returns an empty string rather than throwing — check for it before feeding the result to something that expects content.

getDocumentState gives you the raw encoded bytes instead, for backup or transfer, and getVersionState({ docName, versionId }) gives you the same bytes for a point in the history — what you need to extract the text of a specific version, since listVersions only returns metadata.

Writing into a document server-side

getDocumentState hands you a copy, which is enough to export a document and useless for changing one: while you hold it, people are typing into the real one. An external change — a webhook from Drive, a nightly import — has to land in the instance the clients are connected to, or it loses their keystrokes, or they lose it.

withLiveDocument borrows that instance for the duration of a callback:

const result = await collaboration.current.withLiveDocument({
  docName,
  run: (doc) => {
    doc.transact(() => applyExternalChange(doc))
    return doc.getXmlFragment('default').toString()
  },
})

if (!result.live) {
  // Nobody has it open here. Merge into the stored state instead:
  //   'not-loaded'                 → try persistDocument
  //   'engine-has-no-live-document' → this engine syncs elsewhere; see the edge page
}

The callback receives the real Y.Doc, and everything it writes inside a transact reaches every connected client through the sync protocol, exactly like an edit typed by a person. The result is { live: true, value } or { live: false, reason } — never a thrown error and never a bare undefined, because "nobody has this document open" is an ordinary answer that your caller has to handle, and a callback may legitimately return undefined itself. hasLiveDocument({ docName }) answers the cheap half of the question on its own.

Live means live in *this* process

The document is only alive where its clients are connected, so this is a same-process API by construction — and, on more than one instance, a same-instance one. Read the section below.

Running more than one instance

The embedded server holds each document in the memory of one process. Two instances behind a load balancer will happily host two independent copies of the same document, and they will not see each other's edits.

The fix is sticky routing: every connection for a given document name must land on the same instance. Read Production before you scale out — or move the document to an edge engine, where a Durable Object is the single owner by construction.

On this page