Collaboration

Record who changed what

Turn each collaborative edit into an attributed change record, and keep your own history from any Yjs server.

Every SuperDoc session turns each committed document change into a change record: who made it, when, which story and paragraph it touched, what text or formatting changed, and why. You read records through the Document API, from the browser or from the Node.js, Python and Rust SDKs.

SuperDoc does not store history. Your servers and storage do. This page shows how to read records, how to keep them in your own store from any Yjs server, and how to verify who a record says made the change.

What a change record holds

One record is produced for each applied Yjs transaction that changed story content, comments, tracked changes or bookmarks. Typing a word is one record. A paste is one record.

FieldMeaning
idDeterministic id. Every session that receives the same Yjs update computes the same id.
seqNumber that grows by one for each record in this session, starting at 1.
revisionThe document revision after the change, as doc.info and receipts report it.
originlocal for edits made in this session, remote for edits from other clients.
actorThe primary author: { id, name?, email?, claim? }, or null.
contributorsOther authors. Present only when more than one client wrote content in the same record.
actorTypehuman for supported SuperDoc UI edits, code for public API calls, or null.
causeuser, api, undo, redo, or null.
provenanceauthored, declared, relayed or unknown. See below.
at{ declared, observed }. declared is the writing client's clock. observed is this session's clock.
session{ id, clientId }: the SuperDoc session and its Yjs client id.
changeTagThe tag your application passed to the Document API call, when there is one.
changesWhat changed, as a list of block changes.
updateThe Yjs update for exactly this transaction, base64 encoded. Only with includeUpdate.

Each entry in changes has a kind: text, format, paragraph, split, merge, move, create, remove, table or sidecar. A text change carries the block id, the range of the inserted text, and the inserted and deleted strings. A sidecar change lists the ids of the comments, tracked changes or bookmarks it affected.

provenance says how much to trust the actor:

  • authored: this session made the edit and has a configured actor id.
  • declared: another client made the edit and its actor came from that client's declaration in the room.
  • relayed: content from more than one client arrived in one transaction, for example after a reconnect. contributors lists them.
  • unknown: no actor id is available.

A claim, if present, is copied onto the actor. SuperDoc never verifies it and never reports a verified state. See Verify identity with claims.

Normal editing stays normal editing. Suggestions stay tracked changes. A change record describes the edit that created, accepted or rejected them.

Set an identity

Records name an author only when the session knows who it is. Set an actor id:

  • Browser: user.id in the SuperDoc configuration. This field already exists and is optional.
  • SDKs: userId in the doc.open request, next to userName and userEmail. The CLI flag is --user-id.
const superdoc = new SuperDoc({
  selector: '#editor',
  user: { id: 'alex', name: 'Alex', email: '[email protected]' },
});

To attach a claim, add user.claim. It is a string, or a function that returns a string or a promise of one:

user: {
  id: 'alex',
  claim: async ({ documentId, clientId }) => fetchClaim(documentId, clientId),
}

In an SDK, pass the claim for one open request the way you pass a collaboration token. It does not travel in command-line arguments and is never saved:

SDKCall
Node.jsclient.open(params, { identityClaim })
Pythonclient.open(params, identity_claim=claim)
Rustclient.open_with_identity_claim(&params, OpenOptions::default(), IdentityClaim::new(claim)?)

For the CLI, a collaboration profile names an environment variable with claimEnv, with the same rules as tokenEnv.

SuperDoc never derives an identity from a name, an email, a cursor color or a session id.

Nothing changes for an application that sets none of these. Editing, saving and collaboration behave as before. Records from a session without an actor id carry actor: null and provenance: 'unknown', and changes.list reports identity: 'missing', so the gap is visible.

Read records in the browser

Use the Document API on the active editor:

const doc = superdoc.activeEditor.doc;

const { items, cursor, truncated, identity } = doc.changes.list({ limit: 50 });
const record = doc.changes.get({ id: items[0].id });
const history = doc.changes.resolve({ blockId: 'p-12' });
const actors = doc.changes.actors();
const snapshot = doc.changes.snapshot();
OperationReturns
changes.listRecords, oldest first, with cursor, truncated, identity and collaboration.
changes.getOne record by id, or null.
changes.resolveRecords for a block, following the paragraphs it was split from or merged with.
changes.actorsThe room's client table: Yjs client id, actor id, name, email, session id and claim.
changes.snapshotThe current revision, cursor, last change id, state vector, and optionally the full state.

All five are read operations. They do not change the document, and they work in viewing mode. changes.list accepts after, limit (1 to 1,000, default 100), story, blockId, actorId, includeUpdate and waitMs.

To react to a collaborator's edit as it arrives, use the document change listener on the UI controller. A change with cause: 'remote' carries the records behind it in records, oldest first:

const stop = superdoc.ui.document.onChange((change) => {
  for (const record of change.records ?? []) {
    console.log(
      record.actor?.id,
      record.changes.map((entry) => entry.kind),
    );
  }
});

records is present only when the engine recorded the remote transactions, and scope is then taken from the stories the records touched. For edits made in this editor, read doc.changes.list after the change.

Keep a history from any Yjs server

Change records work with any Yjs server: a y-websocket server, including Rust yrs servers such as y-sweet, Hocuspocus, or Liveblocks. You add no code to the server. A process in your backend joins the room as an observer session, reads records, and writes them to your own database.

The observer:

  1. Opens the room headless with an SDK, using a read-only token. A read-only session writes nothing to the room.
  2. Loops on changes.list({ after: cursor, waitMs: 30000, includeUpdate: true }). The call returns as soon as a record after cursor exists, or empty after 30 seconds.
  3. Appends each record to your store, with its update. A repeated read returns the same id, so it stores the same row.
  4. Saves the new cursor after the records are stored.
  5. Takes changes.snapshot({ includeState: true }) and stores the result:
    • every 250 records,
    • every 10 minutes while the room is active,
    • when the room goes idle, which is a poll that returns nothing after activity.
  6. Takes a snapshot straight away when truncated is true.

These numbers are sample defaults. SuperDoc does not schedule snapshots for you.

A snapshot holds the revision, the cursor, the last change id, the Yjs state vector and, with includeState, the full state as a base64 Yjs update. The state keeps deleted content, so a stored snapshot is enough to replay what happened after it.

truncated: true means records after your cursor are not available to this session. They were dropped before you read them, for example because the observer was slow, or your cursor came from an earlier session. Records may be missing from your list. The snapshot you take next restores a complete state, and the state vector shows where the gap was.

waitMs does not block other requests on the session, and the host keeps it below its request timeout.

Node.js

import { SuperDocClient } from '@superdoc/sdk';

const SNAPSHOT_EVERY_RECORDS = 250;
const SNAPSHOT_EVERY_MS = 10 * 60 * 1000;

export async function observeRoom(client: SuperDocClient, store: HistoryStore, signal: AbortSignal) {
  const doc = await client.open(
    {
      collaboration: {
        providerType: 'y-websocket',
        url: 'wss://collaboration.example.com',
        documentId: 'contract-123',
        roomMode: 'join',
      },
    },
    { collaborationAuth: { type: 'token', token: readOnlyToken } },
  );

  let cursor = await store.loadCursor();
  let sinceSnapshot = 0;
  let lastSnapshotAt = Date.now();

  const snapshot = async () => {
    await store.saveSnapshot(await doc.changes.snapshot({ includeState: true }));
    sinceSnapshot = 0;
    lastSnapshotAt = Date.now();
  };

  try {
    while (!signal.aborted) {
      const result = await doc.changes.list({ after: cursor ?? undefined, waitMs: 30000, includeUpdate: true });
      if (result.items.length) {
        await store.appendRecords(result.items);
        cursor = result.cursor;
        await store.saveCursor(cursor);
        sinceSnapshot += result.items.length;
      }
      const idle = result.items.length === 0 && sinceSnapshot > 0;
      const stale = sinceSnapshot > 0 && Date.now() - lastSnapshotAt >= SNAPSHOT_EVERY_MS;
      if (result.truncated || idle || stale || sinceSnapshot >= SNAPSHOT_EVERY_RECORDS) await snapshot();
    }
  } finally {
    await doc.close({ discard: true });
  }
}

HistoryStore is your own interface over your database. It needs loadCursor, saveCursor, appendRecords (ignoring ids it already has) and saveSnapshot.

Python

import time

from superdoc import SuperDocClient

SNAPSHOT_EVERY_RECORDS = 250
SNAPSHOT_EVERY_SECONDS = 10 * 60


def observe_room(client: SuperDocClient, store, should_stop):
    document = client.open(
        {
            "collaboration": {
                "providerType": "y-websocket",
                "url": "wss://collaboration.example.com",
                "documentId": "contract-123",
                "roomMode": "join",
            },
        },
        collaboration_auth={"type": "token", "token": read_only_token},
    )

    cursor = store.load_cursor()
    since_snapshot = 0
    last_snapshot_at = time.monotonic()

    def snapshot():
        nonlocal since_snapshot, last_snapshot_at
        store.save_snapshot(document.changes.snapshot({"includeState": True}))
        since_snapshot = 0
        last_snapshot_at = time.monotonic()

    try:
        while not should_stop():
            params = {"waitMs": 30000, "includeUpdate": True}
            if cursor is not None:
                params["after"] = cursor
            result = document.changes.list(params)
            if result["items"]:
                store.append_records(result["items"])
                cursor = result["cursor"]
                store.save_cursor(cursor)
                since_snapshot += len(result["items"])
            idle = not result["items"] and since_snapshot > 0
            stale = since_snapshot > 0 and time.monotonic() - last_snapshot_at >= SNAPSHOT_EVERY_SECONDS
            if result["truncated"] or idle or stale or since_snapshot >= SNAPSHOT_EVERY_RECORDS:
                snapshot()
    finally:
        document.close({"discard": True})

Rust

use std::time::{Duration, Instant};

use superdoc_sdk::{
    generated::types::{DocChangesListBoundParams, DocChangesSnapshotBoundParams, DocOpenParams},
    wire::{Nullable, Number},
    Client, CollaborationAuth, OpenOptions,
};
use tokio_util::sync::CancellationToken;

const SNAPSHOT_EVERY_RECORDS: usize = 250;
const SNAPSHOT_EVERY: Duration = Duration::from_secs(10 * 60);

pub async fn observe_room(
    client: &Client,
    store: &dyn HistoryStore,
    stop: CancellationToken,
) -> Result<(), Box<dyn std::error::Error>> {
    let params: DocOpenParams = serde_json::from_value(serde_json::json!({
        "collaboration": {
            "providerType": "y-websocket",
            "url": "wss://collaboration.example.com",
            "documentId": "contract-123",
            "roomMode": "join"
        }
    }))?;
    let options = OpenOptions {
        collaboration_auth: Some(CollaborationAuth::token(read_only_token())?),
        ..OpenOptions::default()
    };
    let document = client.open_with_options(&params, options).await?;

    let mut cursor = store.load_cursor().await?;
    let mut since_snapshot = 0;
    let mut last_snapshot_at = Instant::now();

    while !stop.is_cancelled() {
        let mut list = DocChangesListBoundParams::new()
            .with_wait_ms(Number::new(30000.0)?)
            .with_include_update(true);
        if let Some(after) = &cursor {
            list = list.with_after(after.clone());
        }
        let result = document.api().changes().list(&list).await?;

        if !result.items.is_empty() {
            store.append_records(&serde_json::to_value(&result.items)?).await?;
            cursor = match &result.cursor {
                Nullable::Value(next) => Some(next.clone()),
                Nullable::Null => cursor,
            };
            store.save_cursor(cursor.as_deref()).await?;
            since_snapshot += result.items.len();
        }
        let idle = result.items.is_empty() && since_snapshot > 0;
        let stale = since_snapshot > 0 && last_snapshot_at.elapsed() >= SNAPSHOT_EVERY;
        if result.truncated || idle || stale || since_snapshot >= SNAPSHOT_EVERY_RECORDS {
            let snapshot = document
                .api()
                .changes()
                .snapshot(&DocChangesSnapshotBoundParams::new().with_include_state(true))
                .await?;
            store.save_snapshot(&serde_json::to_value(&snapshot)?).await?;
            since_snapshot = 0;
            last_snapshot_at = Instant::now();
        }
    }
    Ok(())
}

The three loops have the same structure. HistoryStore is your own interface; in Rust it takes the records as JSON values. Run one per room. Ids match across sessions only when they receive the same updates. Yjs may merge updates, for example while a client reconnects, and a merged update forms one record with a new id that covers the same edits. Two observers can therefore store the same edit under different ids, so don't merge their histories by id alone.

Push instead of poll

The host also sends each batch of records to every subscriber as a notification. Its method is event.change and its params are { sessionId, records }. Subscribe to host notifications instead of polling. Each SDK delivers the raw notification frames { jsonrpc, method, params }. Filter on params.sessionId if the client has several documents open.

Node.js:

const notifications = client.subscribe({ capacity: 256 });

for (;;) {
  try {
    const next = await notifications.next();
    if (next.done) break;
    if (next.value.method !== 'event.change') continue;
    const { records } = next.value.params as { records: unknown[] };
    await store.appendRecords(records);
  } catch (error) {
    if (error instanceof SuperDocCliError && error.code === 'NOTIFICATIONS_LAGGED') {
      cursor = await resync(doc, store, cursor);
      continue;
    }
    throw error;
  }
}

Python:

notifications = client.subscribe(capacity=256)

while True:
    try:
        frame = notifications.recv(timeout=30)
    except SuperDocError as error:
        if error.code == "NOTIFICATIONS_LAGGED":
            cursor = resync(document, store, cursor)
            continue
        raise
    if frame is None or frame["method"] != "event.change":
        continue
    store.append_records(frame["params"]["records"])

AsyncSuperDocClient.subscribe() returns a stream with async for and await notifications.recv(timeout=None).

Rust:

let mut notifications = client.subscribe();
loop {
    match notifications.recv().await {
        Ok(frame) => {
            if frame["method"] == "event.change" {
                store.append_records(&frame["params"]["records"]).await?;
            }
        }
        Err(error) if error.code == "NOTIFICATIONS_LAGGED" => {
            cursor = resync(&document, store, cursor).await?;
        }
        Err(error) => return Err(error.into()),
    }
}

When a subscriber is slow, the SDK drops the oldest notifications and raises NOTIFICATIONS_LAGGED, never silently. Resynchronize with resync: call changes.list({ after: cursor }) until it returns no records, append them, and take a snapshot. Keep the cursor of the last record you stored. Dedupe on record id, as a record can arrive by push and by resync. event.remoteChange still exists and still reports only that something changed.

Polling with waitMs has no lag case, so it is the simpler loop to start with. Use push when you want records on arrival without a pending request.

Named versions

A named version is a pointer into your store. Keep:

{
  "name": "Sent to legal",
  "revision": "…",
  "cursor": "…",
  "snapshotRef": "snapshots/contract-123/…",
  "createdBy": "alex",
  "createdAt": "2026-11-04T10:15:00Z"
}

revision and cursor come from changes.snapshot(). snapshotRef points at the stored state. Create a version when someone saves, on a timer, or when the room goes idle. The idle snapshot is a natural boundary.

You can also export a DOCX at that point and store it next to the pointer. See Version history for DOCX versions. Restoring a version in a live room is a separate workflow that your server owns. See Restore shared documents separately.

The room contract

SuperDoc writes two keys into the room's existing meta map (the Yjs root map meta). It adds no new root type. Each client writes only its own keys. Values are plain JSON objects.

KeyWhen it is writtenValue
attribution:client:<yjsClientId>In the same transaction as the client's first editing transaction. Rewritten only if the identity or claim changes.{ v: 1, actorId, name?, email?, sessionId, declaredAt, claim? }
attribution:stamp:<yjsClientId>In the same transaction as an edit when its cause, actor type or change tag changes, at least every 10 seconds, and when the edit inserts no content of its own.{ v: 1, at, actorType, cause, changeTag?, fromClock?, toClock? }
  • actorId is a string, or null when the session has no actor id.
  • at and declaredAt are ISO 8601 UTC timestamps from the writing client's clock.
  • actorType is human or code. cause is user, api, undo, redo or maintenance.
  • sessionId is random for each SuperDoc session and stays the same for its lifetime.

Both keys are written in the same Yjs transaction as the edit they describe, so no reader sees one without the other. Read-only sessions never write them. Bootstrap, seed, replacement and repair transactions produce no records. When one of them changes content, the client writes a stamp with cause maintenance in it. fromClock and toClock give the client's struct clocks that transaction covers, from fromClock up to and including the stamp itself at toClock. Other sessions skip an update only when every struct it inserts is that client's and falls in that range. An update that also carries other content, for example a user edit merged with maintenance after a reconnect, is recorded in full. The client's next edit writes a new stamp. A server that keeps its own log can apply the same rule.

A SuperDoc session that joins an existing room removes both keys of any other client whose binding and stamp are older than 30 days. It does this once, after its first edit. Edits that reach the room after their client's keys were removed, such as edits made on a device that was offline for more than 30 days, are recorded without an actor.

The v field is the contract version. Readers should ignore keys and fields they do not know.

A server with no SuperDoc code can use these keys. Every Yjs update carries the struct ids (clientId, clock) of what it inserted. Look up attribution:client:<clientId> in the room's meta map to find the actor, and attribution:stamp:<clientId> for the declared time. In JavaScript, doc.getMap('meta'). In Rust with yrs, doc.get_or_insert_map("meta") and read the two keys. Then record "actor X changed the room at T" for each update. A server sees paragraph-level detail only through an observer session.

content.<shard>.meta.lastEditActorType is unchanged. It is still written the way Observe story edits on your server describes. Its write cadence changed: SuperDoc skips the write for some edits and follows with one trailing write, as that page describes.

Verify identity with claims

A claim is a signed statement from your application that a client is a given user. SuperDoc copies the claim into the room and onto each record from that client. SuperDoc never verifies a claim and never reports a verified state. Verification is yours, in the observer or on your server.

The recommended claim is a JWT that your application signs:

FieldValue
subThe user id. It must equal user.id.
audThe document id. A claim cannot be reused in another room.
expA short expiry, for example a few minutes.
jtiA unique id for each SuperDoc session.
cidOptional. The Yjs client id. Only the function form of user.claim can supply it, because the client id exists once the session connects.

This profile is documentation. SuperDoc does not enforce it.

A verifier checks the signature, sub against the record's actor id, aud against the document id, exp, and cid when present. It also keeps the first client id seen for each jti. A jti that appears on a second client id is a copied claim: keep the first and reject the second.

This reference verifier uses only node:crypto and HMAC-SHA256, so it needs no dependency. Use your own key management and your own algorithm in production.

import { createHmac, timingSafeEqual } from 'node:crypto';

export type VerifiedClaim = { actorId: string; jti: string };
export type ClaimResult = { ok: true; claim: VerifiedClaim } | { ok: false; reason: string };

type ClaimPayload = { sub?: unknown; aud?: unknown; exp?: unknown; jti?: unknown; cid?: unknown };

const decode = (part: string): unknown => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));

function signatureMatches(signingInput: string, signature: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(signingInput).digest();
  const actual = Buffer.from(signature, 'base64url');
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

export function createClaimVerifier(secret: string, documentId: string) {
  const clientByJti = new Map<string, number>();

  return function verify(actorId: string | null, claim: string | undefined, clientId: number): ClaimResult {
    if (!actorId || !claim) return { ok: false, reason: 'no-claim' };
    const parts = claim.split('.');
    if (parts.length !== 3) return { ok: false, reason: 'malformed' };
    const [header, body, signature] = parts;

    try {
      if ((decode(header) as { alg?: unknown }).alg !== 'HS256') return { ok: false, reason: 'algorithm' };
      if (!signatureMatches(`${header}.${body}`, signature, secret)) return { ok: false, reason: 'signature' };
      const payload = decode(body) as ClaimPayload;
      if (typeof payload.sub !== 'string' || payload.sub !== actorId) return { ok: false, reason: 'subject' };
      if (payload.aud !== documentId) return { ok: false, reason: 'audience' };
      if (typeof payload.exp !== 'number' || payload.exp * 1000 < Date.now()) return { ok: false, reason: 'expired' };
      if (typeof payload.jti !== 'string' || !payload.jti) return { ok: false, reason: 'jti' };
      if (payload.cid !== undefined && payload.cid !== clientId) return { ok: false, reason: 'client' };

      const firstClient = clientByJti.get(payload.jti);
      if (firstClient === undefined) clientByJti.set(payload.jti, clientId);
      else if (firstClient !== clientId) return { ok: false, reason: 'copied' };
      return { ok: true, claim: { actorId: payload.sub, jti: payload.jti } };
    } catch {
      return { ok: false, reason: 'malformed' };
    }
  };
}

In the observer, call it for each actor in changes.actors() and for each record's actor and session.clientId. Store the result next to the record. The record itself still says declared.

Next steps

On this page