---
title: "Real-Time Collaboration"
description: "Multi-user collaborative editing where the AI agent is a first-class peer: CRDT merging, live presence, SSE fast-path, and granular server-side merge — on any SQL database and any host."
---

# Real-Time Collaboration

<Callout tone="info">

**Developer page.** This page is for developers wiring collaborative editing and presence. For the end-user experience of co-editing with the agent, see [Using Your Agent](/docs/using-your-agent).

</Callout>

Imagine opening a document and seeing a peer's cursor scroll to a paragraph,
then the text rewrite itself — surgically, without losing your place. That
peer might be a teammate. It might be the agent. From the framework's
perspective they are identical: both produce Yjs operations that merge
conflict-free into the shared document. This is the keystone of the
agent-native collaboration model.

## Vision {#vision}

Editing alongside the agent feels like working in a collaborative editor with
a coworker who is both instant and tireless. This is built on three
battle-tested technologies: **Yjs** (CRDT for conflict-free merging),
**TipTap** (rich text editor), and **polling-based sync** (works in all
deployment environments including serverless and edge).

- **CRDT merging** — Concurrent edits from humans and agents merge without
  conflicts. You type in one paragraph; the agent rewrites another; both
  land cleanly.
- **Presence** — A `PresenceBar` shows who is in the document right now,
  including an agent presence indicator when the agent is actively editing.
- **The agent as a peer editor** — Agent edits flow through the same Yjs
  infrastructure as human edits. They appear live, without disrupting cursor
  positions, selections, or the undo stack.
- **Works everywhere** — Any SQL database Drizzle supports (SQLite, Postgres).
  Any hosting target Nitro supports, including serverless and edge.

<Callout tone="info">

**Just need the UI to refresh, not character-level co-editing?** Use [`useDbSync`](/docs/client#usedbsync) instead — it's for reacting to SQL writes from the agent or other users. This page covers real-time character-level co-editing of a single rich-text document (shared cursors, conflict-free merging). Both ride the same `/_agent-native/poll` channel.

</Callout>

## Architecture {#architecture}

The collaboration system has five interlocking layers.

<Diagram id="doc-block-878u5w" title="Five interlocking layers" summary={"From the in-memory CRDT down to the transport that carries updates between peers — each layer has one job."}>

```html
<div class="diagram-stack">
  <div class="diagram-card layer">
    <span class="diagram-pill accent">1 &middot; Yjs Y.Doc</span
    ><small class="diagram-muted"
      >CRDT &mdash; conflict-free merge, no coordinator</small
    >
  </div>
  <div class="diagram-card layer">
    <span class="diagram-pill">2 &middot; SQL canonical content</span
    ><small class="diagram-muted"
      >_collab_docs &mdash; durable source of truth, versioned</small
    >
  </div>
  <div class="diagram-card layer">
    <span class="diagram-pill">3 &middot; updatedAt-gated reconcile</span
    ><small class="diagram-muted">agent edits propagate via the SQL bump</small>
  </div>
  <div class="diagram-card layer">
    <span class="diagram-pill">4 &middot; Lead-client election</span
    ><small class="diagram-muted">exactly one tab applies the snapshot</small>
  </div>
  <div class="diagram-card layer">
    <span class="diagram-pill ok">5 &middot; SSE fast-path + polling</span
    ><small class="diagram-muted"
      >~tens of ms, degrades to 2s poll anywhere</small
    >
  </div>
</div>
```

```css
.diagram-stack {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.diagram-stack .layer {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px 14px;
}
```

</Diagram>

### 1. Yjs Y.Doc (CRDT layer)

Each collaborative document is a `Y.Doc` containing shared types — usually a
`Y.XmlFragment` for rich text (the ProseMirror node tree that TipTap reads) or
`Y.Map` / `Y.Array` for structured JSON data. Yjs merges concurrent updates
with no central coordinator; any two clients that exchange their state reach
the same result regardless of order.

### 2. SQL canonical content (durable source of truth)

Yjs state is persisted in a `_collab_docs` table as base64-encoded binary.
The table is framework-managed and provider-agnostic (SQLite and Postgres use
identical schemas). Each row carries an optimistic-concurrency version column
to prevent concurrent write races. Tombstone compaction runs opportunistically
when the stored blob exceeds 4× the freshly encoded state — no background job
required.

### 3. `updatedAt`-gated reconcile (agent-edit propagation)

Agent actions do not push into Yjs in-process. Instead, the action edits the
canonical SQL content column and bumps `updatedAt`. The change-sync system
detects the bump, the open editor refetches the record, and the lead client
applies the new content into the shared Y.Doc via `setContent`. An `updatedAt`
gate ensures only genuinely newer content is adopted — lagging poll responses
cannot revert the edit.

### 4. Lead-client election (deduplication)

When multiple tabs are open, exactly one applies an authoritative SQL snapshot
into the shared Y.Doc. The lead is the tab with the lowest Yjs `clientID`
among currently visible peers. The agent's awareness entry uses
`AGENT_CLIENT_ID` (max int) so it can never be the lead. A client editing
alone is always the lead. The election is deterministic with no coordination
round-trip (`isReconcileLeadClient` from `@agent-native/core/client`).

### 5. SSE fast-path + polling fallback (transport)

Collab update events travel via two paths:

- **SSE fast-path** — The client subscribes to `/_agent-native/poll-events`
  (the same `EventSource` used by `useDbSync`). Collab update events arrive
  push-style, and the poll loop relaxes to a slow cadence while SSE is healthy.
- **Polling fallback** — `/_agent-native/poll?since=N` is polled on a fixed
  interval when SSE is unavailable. This makes collaboration work on any
  deployment target — including serverless functions where persistent
  connections are impossible and different invocations can handle different
  requests.

Local Yjs updates are debounced and coalesced with `Y.mergeUpdates` before
being sent to the server, reducing keystroke-level network traffic, and the
batch is flushed immediately on `visibilitychange` or `pagehide`. See
[Transport and performance](#transport) for the exact intervals, state-vector
refetch cadence, and backoff values.

<Diagram id="doc-block-1h873p8" title="Two edit paths, one merge" summary={"Human keystrokes flow Y.Doc → server → SSE. Agent edits go through SQL: the action bumps updatedAt, the lead client reconciles, then the change re-enters Yjs."}>

```html
<div class="diagram-collab">
  <div class="lane">
    <span class="diagram-pill">Human edit</span>
    <div class="diagram-node">
      Y.Doc update<br /><small class="diagram-muted">debounce ~80ms</small>
    </div>
    <span class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</span>
    <div class="diagram-box" data-rough>
      POST /update<br /><small class="diagram-muted">apply + persist</small>
    </div>
    <span class="diagram-arrow diagram-accent" aria-hidden="true">&rarr;</span>
    <div class="diagram-box diagram-accent">
      SSE push<br /><small class="diagram-muted">to all peers</small>
    </div>
  </div>
  <div class="lane">
    <span class="diagram-pill warn">Agent edit</span>
    <div class="diagram-node">
      Action writes SQL<br /><small class="diagram-muted"
        >bumps updatedAt</small
      >
    </div>
    <span class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</span>
    <div class="diagram-box" data-rough>
      Lead client<br /><small class="diagram-muted"
        >setContent into Y.Doc</small
      >
    </div>
    <span class="diagram-arrow diagram-accent" aria-hidden="true">&rarr;</span>
    <div class="diagram-box diagram-accent">
      POST /update<br /><small class="diagram-muted"
        >re-enters Yjs &middot; SSE push</small
      >
    </div>
  </div>
</div>
```

```css
.diagram-collab {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.diagram-collab .lane {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.diagram-collab .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
```

</Diagram>

## Quickstart {#quickstart}

### 1. Install packages

```bash
pnpm add @tiptap/extension-collaboration @tiptap/extension-collaboration-caret @tiptap/y-tiptap @tiptap/core
```

### 2. Add Vite optimizeDeps

Prevents Vite from re-bundling TipTap in incompatible ways during dev:

```ts
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { agentNative } from "@agent-native/core/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [reactRouter(), agentNative()],
  optimizeDeps: {
    include: [
      "yjs",
      "y-protocols/awareness",
      "@tiptap/core",
      "@tiptap/extension-collaboration",
      "@tiptap/extension-collaboration-caret",
      "@tiptap/y-tiptap",
    ],
  },
});
```

### 3. Add the collab server plugin

Choose the access mode that matches the data model. Owned or shareable records
use `mode: "resource"` with the type registered via
`registerShareableResource`. Records deliberately shared with every
authenticated user on the deployment use explicit `mode: "all-authenticated"`.
Never invent a resource type merely to silence a warning.

```ts
// server/plugins/collab.ts
import { createCollabPlugin } from "@agent-native/core/server";

export default createCollabPlugin({
  table: "documents",
  contentColumn: "content",
  idColumn: "id",
  access: { mode: "resource", resourceType: "document" },
});
```

Omitting `access` is a deprecated legacy behavior. It behaves like
`all-authenticated`, logs a warning, and is flagged by Doctor.

### 4. Use the client hook

```ts
import {
  generateTabId,
} from "@agent-native/core/client/agent-chat"import {
  useCollaborativeDoc,
} from "@agent-native/core/client/collab"
import { emailToColor, emailToName } from "@agent-native/toolkit/collab-ui";

const TAB_ID = generateTabId(); // or Math.random().toString(36)

const { ydoc, awareness, isLoading, activeUsers, agentActive, agentPresent } =
  useCollaborativeDoc({
    docId: documentId,
    requestSource: TAB_ID,
    user: {
      name: emailToName(session.email),
      email: session.email,
      color: emailToColor(session.email),
    },
  });
```

### 5. Add TipTap extensions

```ts
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCaret from "@tiptap/extension-collaboration-caret";

const editor = useEditor({
  extensions: [
    StarterKit.configure({ history: false }), // Yjs owns undo
    Collaboration.configure({ document: ydoc }),
    CollaborationCaret.configure({
      provider: { awareness },
      user: { name, color },
    }),
  ],
  // Do NOT pass content here — Yjs owns the content
});
```

### 6. Seed on first load (if content exists)

The Collaboration extension does not auto-seed from a `content` prop. If the
Y.Doc is empty and the document has existing content, seed it:

```ts
useEffect(() => {
  if (!ydoc || !editor || !isLoaded) return;
  const fragment = ydoc.getXmlFragment("default");
  if (fragment.length === 0 && initialContent) {
    editor.commands.setContent(initialContent);
  }
}, [ydoc, editor, isLoaded]);
```

User identity is derived from the session email. The framework provides `emailToColor()` and `emailToName()` helpers to generate consistent cursor colors and display names from email addresses.

## Comments {#comments}

Templates can add a comments system with threaded discussions on documents. The content template's comments system includes a full implementation with:

- `document_comments` SQL table (threads, replies, resolved status)
- Comment create, list, update, and delete all run through the `add-comment` / `list-comments` / `update-comment` / `delete-comment` actions. Comment anchors are resolved entirely in the browser by fuzzy-matching the stored quote text (plus prefix/suffix context) against the live editor document — no server round-trip.
- Comments sidebar with threaded view and reply UI
- Resolve/unresolve threads
- **Send to AI** button — sends the comment thread context to the agent chat via `sendToAgentChat()`
- Agent actions: `list-comments`, `add-comment`
- Notion comment sync: `sync-notion-comments` action for bidirectional pull/push

All collab routes are auto-mounted under `/_agent-native/collab/` — see the [route table](#routes) for the full list.

## Agent edit action {#edit-document}

The content template's `edit-document` action is the primary way agents make changes to documents in collaborative mode:

```bash
# Single edit
pnpm action edit-document --id doc123 --find "old text" --replace "new text"

# Batch edits
pnpm action edit-document --id doc123 --edits '[{"find":"old","replace":"new"}]'

# Delete text
pnpm action edit-document --id doc123 --find "delete me" --replace ""
```

## Presence Kit {#presence-kit}

The presence kit provides Liveblocks/collaboration-grade live-cursor and selection primitives on top of the existing awareness layer. Finer-grained presence — such as which users are viewing a given slide in a deck — layers on top of the same awareness state using the same hooks below.

Import client-side presence and editor UI from the focused browser subpath:

```ts
import {
  useCollaborativeDoc,
  usePresence,
} from "@agent-native/core/client/collab";
import {
  PresenceBar,
  LiveCursorOverlay,
  RemoteSelectionRings,
} from "@agent-native/toolkit/collab-ui";
```

Server-side agent presence helpers stay in the lower-level collab package:

```ts
import {
  agentEnterDocument,
  agentLeaveDocument,
  agentUpdateSelection,
} from "@agent-native/core/collab";
```

### Public API {#presence-public-api}

| API                                                 | Purpose                                                                                                                                                |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `useCollaborativeDoc(options)`                      | Creates the stable `Y.Doc` and awareness instance, handles state-vector sync, SSE fast-path, polling fallback, active users, and agent presence flags. |
| `usePresence(awareness, localClientId)`             | Derives remote participants and publishes arbitrary local awareness fields such as cursor, selection, viewport, or tool mode.                          |
| `<PresenceBar>`                                     | Renders active collaborators plus the AI agent, with optional avatar-click follow mode wiring.                                                         |
| `<LiveCursorOverlay>`                               | Renders remote cursor labels over a positioned container from normalized 0-1 coordinates.                                                              |
| `<RemoteSelectionRings>`                            | Renders colored rings and labels around selected DOM elements resolved by your app.                                                                    |
| `useFollowUser(options)`                            | Invokes a callback when the followed participant publishes viewport changes.                                                                           |
| `toNormalized()` / `fromNormalized()`               | Convert pointer coordinates to/from normalized container coordinates.                                                                                  |
| `dedupeCollabUsersByEmail()`                        | Build custom avatar stacks without one user showing once per open tab.                                                                                 |
| `useCollaborativeMap()` / `useCollaborativeArray()` | Client hooks for Y.Map/Y.Array structured collaboration. Treat as lower-level until a template proves the exact product pattern.                       |

`UseCollaborativeDocOptions`:

| Option                | Description                                                         |
| --------------------- | ------------------------------------------------------------------- |
| `docId`               | Document id, or `null` to disable the hook.                         |
| `pollInterval`        | Poll interval when SSE is unavailable. Default: `2000`.             |
| `pollIntervalWithSse` | Slow poll interval while SSE is healthy. Default: `12000`.          |
| `pauseWhenHidden`     | Pause remote update/presence polling while hidden. Default: `true`. |
| `baseUrl`             | Collab endpoint prefix. Default: `/_agent-native/collab`.           |
| `requestSource`       | Stable tab/source id used to ignore self-originated refresh noise.  |
| `user`                | `{ name, email, color }` shown in cursor and presence UI.           |

`UseCollaborativeDocResult`:

| Field          | Description                                                          |
| -------------- | -------------------------------------------------------------------- |
| `ydoc`         | Stable `Y.Doc` for the current `docId`.                              |
| `awareness`    | Yjs Awareness instance used by cursors, selections, and follow mode. |
| `isLoading`    | Initial server state is still loading.                               |
| `isSynced`     | The hook has caught up to server state.                              |
| `activeUsers`  | Human collaborators from awareness.                                  |
| `agentActive`  | The agent is actively editing right now.                             |
| `agentPresent` | The agent has an awareness entry for this document.                  |

### Fast awareness {#fast-awareness}

Awareness state changes propagate at ~150ms instead of waiting for the full poll cycle:

- **Client → server**: any call to `setPresence()` or `awareness.setLocalStateField()` triggers a throttled POST to `/_agent-native/collab/:docId/awareness` within 150ms, coalescing rapid changes into one request.
- **Server → clients**: the `postAwareness` handler emits an `AWARENESS_CHANGE_EVENT` after storing. The `/_agent-native/poll-events` SSE stream forwards these events push-style to connected peers. Polling-only deployments continue to work — cursors degrade to poll cadence without errors.

### `usePresence(awareness, localClientId)` {#use-presence}

Returns a reactive list of remote participants and a setter for the local presence payload:

```ts
import { usePresence } from "@agent-native/core/client/collab";
const { others, setPresence } = usePresence(awareness, ydoc?.clientID);

// Publish cursor position (normalized 0–1)
setPresence({ cursor: { x: 0.4, y: 0.7 }, selection: "#hero" });

// others: OtherPresence[]
// {
//   clientId: number
//   user: { name, email, color }
//   presence: { cursor?, selection?, viewport?, ... }
//   isAgent: boolean   ← true for AGENT_CLIENT_ID
// }
```

The agent (AGENT_CLIENT_ID) appears as a first-class participant with `isAgent: true`. When `agentUpdateSelection()` is called server-side, its selection metadata flows through `usePresence` like any other participant.

### `LiveCursorOverlay` {#live-cursor-overlay}

Renders remote cursors as absolutely-positioned labels over a container element:

```tsx
import { LiveCursorOverlay } from "@agent-native/toolkit/collab-ui";

// cursor positions stored as { x, y } normalized 0–1 under presence.cursor
<div ref={containerRef} style={{ position: "relative" }}>
  {content}
  <LiveCursorOverlay
    others={others} // from usePresence
    containerRef={containerRef}
    cursorKey="cursor" // key in presence payload (default: "cursor")
  />
</div>;
```

The agent's cursor renders with a distinct style so it's clear at a glance which cursor is the agent's. Cursors fade out after 10s of inactivity with smooth CSS transitions at 120ms.

### `RemoteSelectionRings` {#remote-selection-rings}

Renders colored outline rings + name tags over remotely-selected elements:

```tsx
import { RemoteSelectionRings } from "@agent-native/toolkit/collab-ui";

<div ref={containerRef} style={{ position: "relative" }}>
  {content}
  <RemoteSelectionRings
    others={others}
    selectionKey="selection" // key in presence payload (default: "selection")
    resolveRect={(descriptor) =>
      document.querySelector(descriptor)?.getBoundingClientRect() ?? null
    }
    containerRef={containerRef}
  />
</div>;
```

### `useFollowUser` {#follow-user}

Invoke a callback whenever the followed participant's viewport changes:

```ts
import { useFollowUser } from "@agent-native/core/client/collab";
const { isFollowing, stopFollowing } = useFollowUser({
  others,
  followingId, // null to stop following
  viewportKey: "viewport",
  onViewport: (vp) => {
    if (vp.fileId) setActiveFileId(vp.fileId);
    if (vp.zoom) setZoom(vp.zoom);
  },
});
```

Participants publish their viewport with `setPresence({ viewport: { fileId, zoom } })`.

### `PresenceBar` follow-mode props {#presence-bar-follow}

The `PresenceBar` component accepts optional follow-mode props:

```tsx
<PresenceBar
  activeUsers={activeUsers}
  agentActive={agentActive}
  onAvatarClick={(user) => {
    // user is null for the agent avatar
    const email = user?.email ?? "agent@system";
    setFollowing((prev) => (prev === email ? null : email));
  }}
  followingEmail={followingEmail} // highlighted avatar + "Following X" chip
/>
```

### Normalized coordinate helpers {#norm-coords}

```ts
import { toNormalized, fromNormalized } from "@agent-native/core/client/collab";
// In a pointer event handler:
const norm = toNormalized(
  e.clientX,
  e.clientY,
  container.getBoundingClientRect(),
);
setPresence({ cursor: norm });

// In a cursor renderer:
const px = fromNormalized(norm, container.getBoundingClientRect());
```

### Agent cursor plumbing {#agent-cursor}

Server-side actions call `agentUpdateSelection()` to publish where the agent is working. The design template's `edit-design` and `generate-design` actions call this automatically. Other templates can do the same:

```ts
import {
  agentEnterDocument,
  agentLeaveDocument,
  agentUpdateSelection,
} from "@agent-native/core/collab";

agentEnterDocument(docId);
agentUpdateSelection(docId, {
  selection: "#target-element",
  editingFile: "index.html",
});
try {
  // ... perform edits ...
} finally {
  agentLeaveDocument(docId);
}
```

The selection metadata flows through `usePresence` on connected clients as `other.presence.selection`.

## Route table {#routes}

All routes are auto-mounted under `/_agent-native/collab/` by the collab
plugin:

| Route                         | Purpose                                                     |
| ----------------------------- | ----------------------------------------------------------- |
| `GET /:docId/state`           | Full Y.Doc state (base64). Accepts `?stateVector=` for diff |
| `POST /:docId/update`         | Apply client Yjs update (base64). Max 2 MB by default       |
| `POST /:docId/text`           | Apply full text replacement (diff-based)                    |
| `POST /:docId/search-replace` | Surgical find/replace in Y.XmlFragment                      |
| `POST /:docId/json`           | Apply full JSON diff to Y.Map/Y.Array                       |
| `GET /:docId/json`            | Read current JSON state                                     |
| `POST /:docId/patch`          | Apply surgical JSON patch ops (upsert/remove/reorder)       |
| `POST /:docId/awareness`      | Sync cursor/presence state                                  |
| `GET /:docId/users`           | List active users on a document                             |

## Transport and performance {#transport}

| Property                     | Value                                                      |
| ---------------------------- | ---------------------------------------------------------- |
| Update debounce              | ~80 ms (coalesces rapid keystrokes via `Y.mergeUpdates`)   |
| Poll interval (no SSE)       | 2 s (configurable via `pollInterval`)                      |
| Poll interval (SSE healthy)  | ~12 s (configurable via `pollIntervalWithSse`)             |
| State-vector fetch frequency | On reconnect, ring-buffer gap, or every 15th poll cycle    |
| Backoff on error             | Exponential with jitter, cap ~15 s                         |
| Max payload (writes)         | 2 MB default, configurable via `maxPayloadBytes`           |
| Compaction threshold         | Stored blob > 4× fresh encoding triggers tombstone compact |
| Per-write DB reads           | 1 (CAS version read inside `persistMergedState` only)      |

## Hosted Realtime Sync Gateway {#hosted-gateway}

Self-hosted apps always use the poll/SSE transport described above — nothing
in this section changes for them. Builder-hosted apps can additionally be
provisioned onto a hosted Realtime Sync Gateway that fans sync events out
across multiple serverless instances instead of each instance holding its own
long-lived connections.

- `AGENT_NATIVE_REALTIME_TRANSPORT=hosted` plus `AGENT_NATIVE_REALTIME_GATEWAY_URL`
  switch the client onto the gateway transport. Both fail closed — if either
  is unset, the client silently stays on the local poll/SSE path. The signing
  secret (`AGENT_NATIVE_REALTIME_HMAC_SECRET`) is a reserved env var injected
  by Builder's provisioning path, not something you set by hand.
- `GET /_agent-native/realtime-token` mints a short-lived (10 minute),
  session-scoped subscribe token the browser uses to open the gateway
  connection. It's same-origin only and 401s without an active session.
- `GET /_agent-native/can-see` is the gateway's per-event visibility check: it
  verifies a signed gateway access token, then re-runs the app's own
  `resolveAccess` so the gateway can never forward an event to a sharee who
  has since lost access. Unknown resource types or lookup failures fail closed
  (`{ allowed: false }`).
- On Postgres, hosted apps allocate sync versions from the database
  (`dbAssignedVersions`) instead of an in-process counter, because gateway
  instances and the app's own serverless instances share one database and
  must agree on write ordering.

This is an internal hosted-infrastructure detail — most apps never set these
env vars directly. If you're debugging why hosted realtime isn't active for a
Builder-hosted app, confirm both the transport env var and the gateway URL are
set; either being absent falls back to local polling rather than erroring.

## Security {#security}

### Choose the access model explicitly

For owned or shareable resources, use the resource type registered through
`registerShareableResource`:

```ts
createCollabPlugin({
  access: { mode: "resource", resourceType: "document" },
});
```

For a resource intentionally shared deployment-wide, say so explicitly:

```ts
createCollabPlugin({
  access: { mode: "all-authenticated" },
});
```

Do not invent a `resourceType` to make a warning disappear: resource mode is
valid only when the corresponding ownership and sharing model exists. Omitting
`access` is supported for legacy compatibility, behaves like
`all-authenticated`, logs a warning, and is flagged by Doctor.

Non-owner sharees with explicit access use state-vector catch-up through the
poll path (safe, higher latency).

### Access checks

All collab routes require authentication. In resource mode, reads require at
least viewer access and writes require editor access, using the
same `resolveAccess` / `assertAccess` helpers as the sharing system. Reads and
awareness routes return 404 on access failure, to avoid leaking document
existence. Writes require editor access and return 403 (via `assertAccess`)
when the caller has some access to the document but not editor access.

### Payload limits

Write routes (`update`, `text`, `json`, `patch`, `search-replace`) reject
payloads exceeding the configured limit with HTTP 413. The default is 2 MB.
Override per-plugin:

```ts
createCollabPlugin({
  access: { mode: "resource", resourceType: "document" },
  maxPayloadBytes: 512 * 1024, // 512 KB
});
```

### Awareness scoping

Awareness routes (`POST /awareness`, `GET /users`) are gated by the same
access check as reads — a user who lacks viewer access cannot learn who else
is editing a document.

## Patterns {#patterns}

### Granular server-side merge for structured data

For structured documents (slide decks, form builders, design files) the Yjs
body collab model can conflict when two agents or users rewrite the same
top-level record simultaneously. The safer pattern is **granular server-side
merge**: define an action that accepts a set of targeted operations and
applies them atomically, so concurrent edits to different items both survive.

**Slides (`patch-deck`)** — Instead of replacing the entire deck JSON on every
change, the action accepts per-slide operations:

```ts
// Conceptual patch-deck action shape
type PatchDeckOp =
  | { type: "patch"; slideId: string; fields: Partial<SlideFields> }
  | { type: "add"; position: number; slide: SlideData }
  | { type: "delete"; slideId: string }
  | { type: "reorder"; slideId: string; newIndex: number };
```

Two users editing different slides both succeed; there is no LWW clobber at
the deck level.

**Forms (`patch-form-fields`)** — Field-level merge with upsert/remove/reorder
ops so concurrent edits to different form fields both survive.

Use this pattern when:

- The document is structured (items inside a container).
- Concurrent edits target different items.
- Body collab (Yjs `Y.XmlFragment`) is overkill or inapplicable.

Use body collab (Y.XmlFragment + TipTap) when:

- The document is free-form rich text where any region can be edited.
- Cursor-level CRDT merge matters.

### Collaborative undo scoping (Y.UndoManager)

The Design template uses `Y.UndoManager` to scope undo/redo to the local
user's own edits. Remote peer edits and agent edits are never undone by a
user's Cmd+Z.

```ts
import * as Y from "yjs";

const LOCAL_EDIT_ORIGIN = "local";

const undoManager = new Y.UndoManager(ydoc.getText("content"), {
  trackedOrigins: new Set([LOCAL_EDIT_ORIGIN]),
  captureTimeout: 800, // coalesce rapid slider drags into one undo step
});

// Wrap local edits with the tracked origin
ydoc.transact(() => {
  // apply local style change
}, LOCAL_EDIT_ORIGIN);

// Undo/redo — only reverses LOCAL_EDIT_ORIGIN transactions
undoManager.undo(); // Cmd+Z
undoManager.redo(); // Shift+Cmd+Z
```

Key properties:

- `trackedOrigins` must be a `Set`. Only transactions with a matching origin
  are captured in the undo stack.
- Remote updates (origin `"remote"`) and agent updates (origin `"agent"`) are
  never captured.
- Recreate and dispose the manager when the active document changes; stale
  managers hold references that can grow unboundedly.

## Known limitations {#limitations}

<Callout id="doc-block-45stvr" tone="risk">

**Same-region simultaneous rewrite is last-write-wins.** If the agent rewrites a passage while a human has unsaved edits in the _exact same region_, the lead-client snapshot can clobber the in-flight human edit. Edits in different regions always merge cleanly via the CRDT. For structured documents, use granular server-side merge to sidestep this entirely.

</Callout>

- **In-process write locks on serverless** — The `_writeLocks` map is
  process-local. Concurrent requests landing on different serverless
  invocations serialize at the SQL CAS layer (optimistic concurrency) rather
  than the in-memory lock. This is safe but means high-throughput scenarios on
  serverless may see more CAS retries.
- **Awareness is per-process** — The awareness in-memory store is
  process-local. Serverless / multi-process deployments see partial awareness
  state per invocation. Clients still receive full awareness snapshots on each
  poll cycle, so presence indicators update within one poll interval.

## What's next {#whats-next}

- [Real-Time Sync](/docs/client#usedbsync) — the `useDbSync` + `useChangeVersion`
  system that delivers the `updatedAt` bump driving editor reconciliation.
- [Security](/docs/security) — `registerShareableResource`, `resolveAccess`,
  and `assertAccess` for resource-scoped collaboration.
- [Sharing](/docs/sharing) — how documents are shared and how access is granted.
- [Template: Content](/docs/template-content) — reference implementation of
  collaborative rich-text editing.
- [Template: Slides](/docs/template-slides) — granular `patch-deck` action for
  structured concurrent editing.
- [Template: Forms](/docs/template-forms) — field-level `patch-form-fields`
  server-side merge.
- [Template: Design](/docs/template-design) — `Y.UndoManager` undo/redo scoped
  to local user edits.
- [Toolkit piece: Collaboration Kit](/docs/toolkit-collaboration) — the presence,
  live-cursor, and undo components built on this transport.
