Agora
Engines

Automerge

A JSON-like CRDT document with a native change graph — the wire protocol, the dedicated useAutomergeDoc hook, why it cannot back a Y.Doc, and the single-instance constraint.

Automerge models a document as JSON you mutate inside a callback. Every mutation produces a change with a hash, and the accumulated changes are the history — no snapshot mechanism bolted on top. That native graph is the reason to choose it.

config/collaboration.ts
documents: {
  'forms/:id': {
    engine: 'automerge',
    async authorize(ctx, { params }) { /* ... */ },
  },
}

The wire protocol

Deliberately simple, and worth knowing because it explains the trade-offs:

  1. the client opens ws://…?doc=<name>&token=<token>;
  2. the server sends the whole saved document as a binary on connect;
  3. after a local change the client sends its whole saved document back, debounced;
  4. the server merges it into the room's document and re-broadcasts to the other peers.

Full-document exchange rather than incremental deltas. Automerge compresses well, so this is fine for the structured documents it is meant for and unsuited to a 500-page manuscript.

The handshake refuses with a close code rather than an HTTP status, and the codes are meant to be read:

CodeMeaning
4000no doc in the query string
4001no token, or one that fails verification — bad signature, expired, or issued for another document
4003authorize said canRead: falsefinal, do not retry
4503authorize itself threw, or the server has no signing key configured — retry with backoff
4500the document could not be loaded from storage

4003 and 4503 are deliberately different. "You may not" is an answer a client should respect; "we could not tell" is a transient condition, and reporting the second as the first is how a database blip on your side turns into a reconnect storm. useAutomergeDoc honours the distinction: it retries a 4503 and stops dead on a 4003. Both are reported server-side through the error stream as well.

The client hook

Automerge documents get their own hook, not useCollabDoc:

import { useAutomergeDoc } from '@adonis-agora/collaboration-client'

interface Ficha extends Record<string, unknown> {
  content: string
  tags: string[]
}

function FichaEditor({ id }: { id: string }) {
  const { doc, status, pendingChanges, error, change } = useAutomergeDoc<Ficha>({
    docName: `fichas/${id}`,
  })

  if (error) return <p role="alert">{error.message}</p>
  if (!doc) return <p>Loading…</p>

  return (
    <>
      <textarea
        value={doc.content}
        onChange={(event) => change((draft) => { draft.content = event.target.value })}
      />
      {status !== 'connected' && (
        <p role="status">Reconnecting — {pendingChanges} change(s) not sent yet.</p>
      )}
    </>
  )
}

change(fn) mutates a draft, applies it locally so the UI updates immediately, and schedules the debounced send. doc is null until the first server payload lands — render a loading state rather than reaching into it.

synced is the Automerge counterpart of the Yjs synced flag: status === 'connected' only means the socket is open, while synced means the server's state has actually been merged on the current connection. It goes back to false on a drop.

Reconnection

The hook rebuilds its socket on a drop, with a fresh token and the same budget as the Yjs DocSession: exponential backoff from 1s, capped at 15s, five attempts, reset by a connection that opens. Run out of attempts and error reads reconnect: exceeded 5 attempts (last failure: …) and carries the real failure as its cause — the exhausted message names what kept failing rather than replacing it.

A permanent failure is not retried at all, because retrying it cannot succeed and the retry would end by burying the message that explained it. Those are the close codes the handshake uses for an answer — 4000, 4001, 4003 — and a document the server routes to another engine (useAutomergeDoc requires engine=automerge). 4503 and 4500 are outages, so they are retried.

Changes made while disconnected are not dropped

Do not disable the editor on a disconnect. A change applied while the socket is down stays in the local document and is counted by pendingChanges; the whole saved document is written the moment a socket is open again, and the count returns to zero. That works precisely because this protocol exchanges whole documents — the local doc is its own outbox.

What you owe the user is the fact, not a locked input: show pendingChanges (and status) so nobody types into a document they believe is syncing when it is not.

useCollabDoc fails on an Automerge document

useCollabDoc returns a Y.Doc, and an Automerge document is a fundamentally different structure — there is no honest way to hand one back as the other. Pointing useCollabDoc at a document the server resolves to automerge puts a clear message on the hook's error and leaves status at 'error', instead of silently returning an empty document. It does not throw out of the render, so read error rather than wrapping the hook. Two hooks, chosen by engine — this is the one seam the library deliberately does not unify.

Both hooks live under the same CollaborationProvider, so a page with a Yjs body and an Automerge sidebar is fine — each hook opens the document it names.

What you give up

  • No rich-text editor. Tiptap, ProseMirror and CodeMirror all bind to Yjs types. Automerge has no equivalent binding here.
  • No awareness. There is no cursor channel in this protocol, so useAwareness has nothing to read for an Automerge document. Server-side presence still works, since it is fed by the connection lifecycle rather than the protocol.
  • No multi-instance broadcast. Rooms live in one process's memory and fan out to the sockets that process holds. Two Node instances mean two divergent copies. Automerge documents need a single instance, or sticky routing that guarantees one — see Production.

Versions

createVersion records the document's heads — the hashes identifying its exact state — as the version id, and stores the saved document alongside so a restore is one load rather than a replay. restoreVersion applies the old content as a compensating change: non-destructive, and visible to every connected client immediately. See Versions.

Because the id is the heads, a version id is meaningful outside the library: it identifies a state Automerge itself can reason about.

Reading server-side

getDocumentText returns the document's content field as a string — the convention this driver assumes for a text-bearing Automerge document. Anything else in the document is yours to read through the raw state:

const bytes = await collaboration.current.getDocumentState({ docName })

When to use it

Reach for Automerge when the document is structured data several clients mutate — a shared form, a configuration object, a records board — and the native change graph is worth more than the editor bindings. For anything a person types prose into or points at with a cursor, Yjs is the better answer.

On this page