---
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 PostgreSQL/PGlite 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.
- **PostgreSQL-backed** — Shared collaboration state uses PostgreSQL, including
  local PGlite development.
  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-data#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 uses PostgreSQL. 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}

Apps default to the poll/SSE transport described above. Any app can opt in to
the hosted Realtime Sync Gateway instead, which tails the app's `sync_events`
table centrally and fans changes out to every connected client — so change
delivery no longer depends on each serverless instance holding its own
long-lived connections.

Setup depends on who deploys the app.

### Builder-hosted apps: nothing to do {#hosted-gateway-builder}

An app deployed by Builder's hosting pipeline is provisioned automatically. The
deploy injects the transport, the gateway URL, the channel id and the signing
secret (`AGENT_NATIVE_REALTIME_HMAC_SECRET`) as reserved environment variables,
because the pipeline already provisioned the database the gateway reads.

To opt a project OUT, add a `realtime` block to its `builder.config.json`:

```json
{
  "realtime": { "transport": "local" }
}
```

### Deployed anywhere else: self-registration {#hosted-gateway-self}

An app on its own Netlify, Vercel, container or VM has no such provisioning, so
it introduces itself to the gateway instead. Set one environment variable on
the deployment:

```bash
AGENT_NATIVE_REALTIME_TRANSPORT=hosted
```

Setting it is the opt-in, and it is the only variable this feature adds.
Nothing here happens on a deployment that has not set it.

The deployment must also already satisfy four things, all of which a normal
production app does:

| Requirement                                                    | Why                                                                                                                                                                             | How to check                                                                                                                                                                                                            |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Postgres on a publicly reachable host**                      | The gateway dials the database from its own network. PGlite, `localhost`, `*.internal` and bare IP addresses are refused before the connection string ever leaves your machine. | `DATABASE_URL` points at a managed Postgres such as Neon                                                                                                                                                                |
| **`BUILDER_PRIVATE_KEY` set as a DEPLOY environment variable** | This authenticates the registration and decides which Builder organization owns the channel.                                                                                    | See the note below — this is the step people miss                                                                                                                                                                       |
| **A production deploy**                                        | Deploy previews keep local sync, so a throwaway branch credential never leaves the machine and a busy repo does not mint one channel per pull request.                          | `CONTEXT=production` / `BRANCH=production`, or `VERCEL_ENV=production`                                                                                                                                                  |
| **The platform identifies the deployment**                     | Registration is keyed on the app's public origin, so a process that is not actually the deployment must not claim it.                                                           | Netlify, Vercel, AWS Lambda, Cloud Run, and Render mark themselves. Cloudflare Workers do not, so set `AGENT_NATIVE_REALTIME_APP_URL` to the deployment's own origin. On a bare container or VM, set that same variable |

<Callout tone="warning">

**Using Builder.io from inside the app is not enough.** The in-app Builder
connection stores its credential per user or per organization in the app's
secret vault. Self-registration deliberately ignores that credential — a
single user's connection must not enroll the whole deployment's database under
whichever organization happened to make the first request — and reads only a
deployment-level `BUILDER_PRIVATE_KEY` environment variable. An app that shows
"Builder connected" and has no such variable will stay on local sync.

</Callout>

Registration then happens on demand, on the first request that needs a channel.
The app posts its PostgreSQL connection string and public origin, gets back a channel id
and signing secret, and mints subscribe tokens exactly as a Builder-hosted app
does. It re-runs by itself whenever the database URL, the app origin or the
Builder credential changes, so a credential rotation carries over without a
redeploy of anything else.

Workspace deployments (several apps served from one origin) do not
self-register: siblings share an origin, and registration is keyed on it, so
they would collide on one channel. They log a line saying so and stay on local
sync.

### Verify it worked {#hosted-gateway-verify}

`GET /_agent-native/health` reports the outcome, and resolving it performs the
registration, so one curl both enables and confirms:

```bash
curl -s https://your-app.example.com/_agent-native/health | jq .realtime
```

```json
{
  "transport": "hosted",
  "registered": true,
  "channelHash": "3f9a2c17"
}
```

| Field         | Meaning                                                                                                                                                                                          |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `transport`   | `"hosted"` once the environment variable is set, `"local"` otherwise. If this says `local`, the variable did not reach the running deployment.                                                   |
| `registered`  | A channel resolved. `false` means the app is still on its own `/_agent-native/poll`.                                                                                                             |
| `channelHash` | First 8 characters of a SHA-256 of the channel id — enough to tell two deploys apart or confirm a rotation, useless for connecting. The endpoint is public, so the id itself is never published. |
| `unavailable` | Present only when resolution FAILED, as opposed to resolving to "no channel". An unreachable gateway and an app that is not registered are different problems.                                   |

When `registered` is `false`, the deployment's logs carry a `[realtime]` line
naming the reason — a refused credential, an unparseable database URL, a
process that does not look like the deployment. Walk the requirements table
above in order.

### What Builder stores, and the trust boundary {#hosted-gateway-trust}

**Self-registration means sending Builder your PostgreSQL connection string.**
Builder stores it and connects to your database to tail `sync_events`. The
connection is not read-only: version allocation and out-of-band change
detection both write. If you do not want that, leave the transport variable
unset. That is the whole decision, and there is no other path that shares the
credential.

**The organization is the trust boundary.** Any credential that can register
can also re-register an existing origin, which returns that channel's signing
secret and repoints its database. Holding that secret allows minting a
subscribe token for any user in the app, which exposes their _change stream_ —
which records changed, and when — but not the records themselves, since
clients re-read content from the app with their own session. Treat a
registration credential as organization-wide.

### How it works {#hosted-gateway-mechanics}

- `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.
- `AGENT_NATIVE_REALTIME_GATEWAY_URL` overrides the gateway endpoint for
  staging. Unset, it derives from `BUILDER_GATEWAY_BASE_URL`.

Every failure here is soft by design: no transport variable, no deployment-level
Builder key, an unavailable PostgreSQL connection, or an unreachable gateway all leave the
app on local polling rather than erroring. `GET /_agent-native/realtime-token`
returning 404 means no channel resolved.

## 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}

- [Sync Internals](/docs/client-sync-internals) — 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.
