# 6. Live sync and collaboration

> How every open copy of a file converges: the merge, its rules, presence, and the collaboration plugins.

Source: https://clayjs.com/docs/guide/live-sync/

## What sync is

`?plugins=sync` keeps every open copy of the file converging through a **save-gated, whole-document, three-way DOM merge**, not keystroke streaming.

- An edit-mode tab sends nothing until the host accepts its save. It then relays that saved version as a frame to the other editors on the live lane. With `autosave`, this happens about 1.5 s after typing pauses, at most about 10 s.
- Each receiving tab merges the frame three ways: the last version both agreed on (base), its own current page including unsaved edits (local), and the frame (remote).
- View-mode tabs use the saved lane: they receive every accepted save and update in place.
- Outside edits to the file on disk (an agent, a text editor, or a version restore on desktop hosts) arrive as `disk` frames and merge the same way.

Collaborators see each other's work a few seconds after each pause. Without `autosave`, they see nothing until someone saves, and overlapping work only meets at save time. **Every collaborative file uses `autosave`.**

### Sync in the view-mode reader

A Document in view mode loads its reader (`page-view`), not the editor. The reader has no write half and sees saved files only:

- **Saved, not live.** It uses its own receive-only transport (`src/sync/saved-stream.js`): a startup GET once the stream opens, then frames from the saved lane, and a refetch and repair after a reconnect or gap. It never POSTs, never sends a live relay, and needs no write credentials.
- **One receipt.** The reader holds the bytes on screen, their ETag, and the sequence they cover as one `{ bytes, etag, seq }` receipt. Duplicate or older frames are dropped without advancing it, a stamp alone never repairs missing bytes, and a refused or failed read keeps the existing article and receipt.
- **Keyed replacement.** An accepted file replaces only the blocks whose saved form changed (`src/page/reader-patch.js`). Unchanged blocks keep their nodes, so open toggles, mounted tools, and find-in-page survive.
- **Explicit `sync` keeps one owner.** On `plugins=page,sync` in view mode, the `sync` plugin already owns the saved lane. The reader opens no stream of its own and takes the files that lane applied (`clay:sync-applied` with `source: "disk"`). Neither case adopts a peer's unsaved preview as a saved file.

### A frame from another template version

When an incoming frame has a different template version than the open page, ClayJS refuses to merge it and stops saving until you reload.

A document copied from a starter template carries `<meta name="hyper-version" content="1.1.0">` and `hyper-source` beside it so an upgrade can find it. An upgrade replaces layout, code, and data together. If a saved file names a **different** version, it is not a change to this template: the frame is refused whole, never merged, and never applied, because morphing it in would interleave two templates.

Once a frame is refused, this tab's DOM holds a version the host no longer stores. To prevent the next save from overwriting the upgrade, the tab holds every save from then on and drops every later frame. The notice says **This document was upgraded.** with one action, **Reload**, and `clay:document-upgraded` fires on `document` with `{ message }`.

Only a page that states its own version can make this check, and only a frame that states one can disagree, so a document without the meta tag behaves as usual. Holding saves belongs to edit mode, so a reader with nothing to write downloads none of it for the hold.

## What a merge does

When an incoming frame (B) meets unsaved edits in this tab (A), ClayJS merges the DOM and records any replaced local work in the conflict ledger:

| Situation (this tab A unsaved, incoming B) | Result |
|---|---|
| A edits one paragraph, B another | both kept |
| A and B edit different words of one paragraph | both kept |
| A and B change the same word | B wins; A's version goes to the conflict ledger |
| Both add class tokens (`done` and `urgent`) | both kept |
| Both set one attribute (`data-status`) differently | B wins; ledger record |
| Both move different items in one list | both moves kept |
| Both move the same item | B's order; ledger record |
| Both change an attribute declared with `merge-tokens` or `merge-max` | merged (token set union, or the larger number), no conflict |
| A edits a card, B reorders the list | B's order with A's edit, no conflict |
| A edits a card B deleted | the card survives with A's edit |
| A deleted a card B edited | the card comes back with B's edit; ledger record |
| Both append an item with its own `data-id` | both land |
| Both append an item without ids | distinct text: both land; similar text: A's item dropped, ledger record; identical or blank: one dropped, **no** record |
| A is typing in a focused input | its value and focus kept |

More engine rules:

- Text merges word by word inside a block, and formatting merges per character. Inline `<style>` and textarea text merge word by word. Scripts and comments merge as whole values.
- Matched live nodes are moved, not recreated, so listeners, focus, caret, and iframes survive. An element that matches nothing (re-keyed, or changed beyond recognition among similar siblings) is removed and inserted fresh, and listeners attached directly to it are gone. **Use delegated listeners** on `document` or a stable root.
- A new or changed inline `<script>` in the frame runs once after the merge. Guard one-time setup with `clay.start` ([Starting the app once](https://clayjs.com/docs/guide/architecture/#starting-the-app-once)).
- Window scroll is restored. Inner scroll containers are not, except the focused element's own.
- The merge runs with the mutation hub, the dirty gate, and undo paused: incoming changes never autosave and never enter undo history. `clay.effect` re-runs after it through `clay:sync-applied`, which is how the examples re-render ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)).

### Declared attribute merges

A `data-*` attribute that both sides change is normally a conflict: B wins and A's value goes to the ledger. A container can declare two merge rules for attributes inside it instead:

- `merge-tokens="data-labels"`: the attribute is a space-separated set. Additions and removals from both sides apply.
- `merge-max="data-next-card"`: the attribute is a number that only grows, such as a counter for the next card number. The larger value wins, with no ledger record.

```html
<main merge-tokens="data-labels" merge-max="data-next-card" data-next-card="15">
  <article data-id="c1" data-labels="bug urgent">…</article>
</main>
```

Separate multiple attribute names with spaces. `data-id` is never merged this way, and undeclared attributes still conflict as before. The Kanban starter template uses both: labels as tokens, the card counter as a maximum.

## Regions and JSON under sync

`clay` region attributes and `<script type="application/json">` tags control how parts of the page merge:

- `editor-ui`, `no-save`, `no-snapshot`: **ignored on every side.** Your copy is never touched, and the incoming copy is never read.
- `freeze`: **the element merges, its contents never do.** A frozen element pairs, moves, is inserted and removed with its record, and merges its attributes. Your copy's contents are never touched, the incoming contents are never read, and a frozen element new to this tab arrives empty.
- `no-dirty`, `no-watch`: **remote wins.** The incoming copy replaces yours with no merge. Use this for derived values that every tab recomputes.
- `no-trigger-autosave`: merged like ordinary content.
- `live`: **ignored by the merge on every side.** The `live` plugin takes the value itself ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).
- Root attributes the library owns (`editmode`, `savestatus`, tokens) are never written by a frame.

**Known issue (ClayJS 1.8.2):** If a tab holds an unsaved edit outside `no-dirty` and also changed a `no-dirty` region, a frame that replaces that `no-dirty` region logs a loss in `clay.conflicts` and raises the conflict notice, even though `no-dirty` replacements should happen without warning. Because two tabs editing data will each recompute totals differently, this fires on concurrent edits whenever computed totals live in `no-dirty`. Until it is fixed, acknowledge those records yourself, as the finance tracker does:

```js
document.addEventListener("clay:sync-applied", () => {
  const derived = clay.conflicts.list().filter((c) =>
    c.detail === "remote-wins" && [c.el, c.local].some((n) => n?.closest?.('[clay~="no-dirty"]')));
  if (derived.length) clay.conflicts.acknowledge(derived.map((c) => c.id), { reason: "reconciled" });
});
```

**JSON.** Without `merge`, a JSON script is one value: if two writers change it, one whole blob goes to the ledger. With `merge="name"`, it merges by key: objects per key, arrays of objects by an `id` field, arrays of primitives by value, and lists without an id (grids, boards, repeated values) position by position when both sides kept the same length. `merge-key="taskId"` names another id field for every list, and `merge-key="players:uid, items:sku"` names one per list path (no spaces inside an entry).

**A key both sides changed takes the incoming value.** The lost value goes to the ledger like any other replaced edit, and the notice shows a row such as `game → players → alice → x` with yours and now. Revert cannot put a JSON value back; Download keeps your copy. Use per-entry keys (`notes: { "n-1": ..., "n-2": ... }`) so two people adding entries never touch the same key. For data several people change at once, use a live store ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) instead. When a merge mixes both sides, the text is rewritten in the incoming text's style (one line stays one line, indented stays indented) with `<` written as `\u003c`.

## Focus, typing and long-running editors

Merges protect active typing in the current tab:

- The focused `<input>` or `<textarea>` keeps its value through a merge. Checkbox, radio, and select state are not protected.
- A caret in `contenteditable` text follows its characters through the merge.
- A dirty tab that has no merge base (because the record was rewritten at boot) **holds** incoming frames and fires `clay:sync-held` until it saves. Avoid touching the record at boot so merges can run immediately.

## Reconciling in app code

Draw everything derived from the record inside `clay.effect(fn)`. It runs `fn` on the next microtask, then again after every change the mutation hub observes (local edits, drags, undo, and redo) and after every merge, batched to one run per microtask. Changes inside `editor-ui` or `no-watch` regions, ephemeral attributes, and live properties with no attribute change do not re-run it. `fn` runs with the hub paused, so its own writes never re-trigger it, start an autosave, or enter undo history. It returns a function that stops the effect.

```js
clay.effect(() => {
  const rows = items.querySelectorAll(":scope > li");
  const done = items.querySelectorAll("input[checked]").length;
  setText(summary, `${done} of ${rows.length} read`);
});
```

Rules for `fn`:

- **Keep it synchronous.** Writes after an `await` land outside the pause and would re-trigger the effect forever. An effect whose `fn` returns a promise is stopped with a console error.
- **Do not call `clay.undo.commit` inside it.** A commit drains the hub's records, so the effect's writes reach autosave and the effect again.
- **One effect does not see another's writes**, because each runs paused. Draw values that depend on each other in one effect.
- **A derived value saved in the record** (a count painted in markup, [Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) is written with the hub paused, so a repaint after a merge leaves the page dirty with no save scheduled. Put saved derived values in `clay="no-dirty"`, in chrome, or in sapjs paints, which ClayJS leaves out of every comparison ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)).

In edit mode, each effect warns once in the console when it breaks one of these rules: writing to saved content outside `no-dirty` and `editor-ui`, calling `clay.undo.commit`, creating an effect inside `fn`, or running more than 50 times in a second (usually a timer or async callback it started that keeps changing the document). The effect keeps running; the warning names the element or command.

Work that belongs to a merge alone goes in a `clay:sync-applied` listener:

```js
document.addEventListener("clay:sync-applied", (event) => {
  if (openId && !cardById(openId)) closeCard();
});
```

- Close or repoint any view whose record a peer deleted. The kanban closes the open card when its id is gone: `if (openId && !cardById(openId)) return writeHash({ card: "" }, { replace: true });`
- Re-parse any JSON you parsed earlier.
- Re-attach any per-element setup that a recreated element lost (the writer re-injects block gutters on every render).

`detail` on `clay:sync-applied` carries `{ seq, source: "peer" | "disk", by, report, conflictIds, unresolved }`, plus `etag` and `html` for disk frames. `by` is `{ id, name }` on hyperclay.com peer frames and `null` elsewhere. `report.applied`, `report.conflicts`, and `report.localDiverged` describe the merge.

## The conflict ledger and notice

Losses (where a merge replaced this tab's work) enter `clay.conflicts`:

- `list()`, `get(id)`, `size`, and `recoveryOf(id)` (a clone of the page as it was just before the frame).
- `acknowledge(ids, { reason })` with `reason` of `accepted`, `reverted`, or `reconciled`. It is the only way a record leaves. Nothing clears records automatically, and the close warning stays up while any is open.
- `claim(ids, { owner })` hides records from the built-in notice so your app can resolve them itself, then `acknowledge` them.

The built-in notice reads "Another edit replaced N changes" and offers **Revert to mine**, **Accept theirs**, **Download my copy**, and a list of the edits. Revert puts the selected losses back as an ordinary local edit, then saves.

## Presence

`?plugins=sync` loads presence. `clay.presence` is the facade (`clayjs/src/sync/presence-store.js`), installed on `window.clay.presence` when the store loads. It has three page-facing methods:

```js
const snapshot = clay.presence.getSnapshot();  // the same object until something changes
const stop = clay.presence.subscribe(render);  // the current snapshot at once, then every change
clay.presence.setTarget({ kind: "record", id: "card-1", field: "title" });
```

`subscribe(listener)` returns an unsubscribe function. It emits the current snapshot at once, even before the host answers, so a page never waits for a first frame. `setTarget(null)` clears this tab's target and publishes nobody anywhere; it does not fall back to the generic editing marker.

**The snapshot.** `{ supported, recordAwareness, pageAwareness, people, anonymous }`. Each person is `{ id, name, you, canEdit, editing, sessions }`, and each session is `{ id, you, n, editing }`:

```js
{
  supported: true, recordAwareness: true, pageAwareness: true,
  people: [{
    id: "p_ada", name: "Ada Lovelace", you: false, canEdit: true,
    editing: { kind: "record", id: "card-1", field: "title" },
    sessions: [{ id: "s1", you: false, n: 12, editing: { kind: "page", v: 1, epoch, generation, selection } }],
  }],
  anonymous: 0,
}
```

Before discovery answers, the snapshot is unsupported with no people. The host decides who appears: each recipient gets a roster filtered by its own access, and nothing in the page can widen it (`src/sync/live-sync.js`, the `presence` event).

**Targets.** A target is `null`, a legacy dotted path string, a record target `{ kind: "record", id, field }` with `field` optional, or a page target `{ kind: "page", v: 1, epoch, generation, selection }`. A page selection is `text` (`anchor` and `head`, each `{ block, rel }`), `blocks` (`{ ids }`), or `cell` (`{ block, row, col }`).

The bounds come from `src/sync/presence-target.js`:

- A record id of 1 to 128 printable ASCII characters.
- A field of `title`, `description`, `labels`, `members`, `dates`, `checklist`, `comment`, or `attachments`.
- 8 KiB per target.
- 256 bytes per page id.
- 1 KiB per decoded relative position.
- At most 128 block ids.

An invalid target is refused, never repaired. Updates are coalesced to one every 250 ms, a stable target renews every 5 seconds, and it expires 15 seconds after the host receives it.

**Hosts.** The client picks exactly one transport from `/_/meta` (`connect` in `presence-store.js`):

- hyperclay.com relays presence through the host. The target goes to `POST /_/presence`, and the roster comes back as a named `presence` event on the document's stream, with one person per account and one session per tab.
- HTML Clay announces `awareness: { transport: "broadcast", scope }`. Its presence travels between tabs of the same browser on a channel named for the document scope (`src/sync/local-presence.js`). Each tab is a fresh random session, not an account, shown under the label "This computer". Two documents that share a file name never meet.
- Hyperclay Local likewise shows tabs on this computer, not authenticated people.

In view mode the reader draws the roster too. It takes the roster from the stream it already follows, as a named `presence` event, so it opens no second connection. The roster UI is a first-use download that comes with the first presence frame, and a reader that receives no frame downloads none of it. A reader publishes no target and joins no writable room, because its lease has no write half: a target a page sets in view mode sends nothing. Its roster goes away when its stream closes.

Each part needs its own extension: `presence` for the roster, `presence-editing` for the generic editing marker, `record-awareness-v1` for record targets, and `page-awareness-v1` for remote carets.

**Runtime only.** The roster UI is marked `clay="no-save no-watch no-snapshot"` (`RUNTIME_ONLY` in `src/ui/bevel-controls.js`, applied in `src/sync/presence.js`). It never reaches a save, a snapshot, or the page's own change detection, and a roster change is not an edit. Remote carets are editor decorations, not document content ([Remote carets](https://clayjs.com/docs/plugins/page/#remote-carets)). All three tokens are needed: `no-save` alone only keeps a name out of the file, and the snapshot clone that peers receive is emitted before the save-only regions are stripped, so `no-snapshot` is what keeps a name out of that clone, while `no-watch` is what keeps a roster change from counting as an edit (`src/sync/presence.js:15-28`).

Presence is not for app data. For state that must be shared and is not presence, use a live store ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).

## Agents

An agent edits a malleable file the way a person in another tab does:

- **Editing the file on disk** (HTML Clay, Hyperclay Local): the host notices the change and pushes a `disk` frame. The open tab merges it three ways, so the user's unsaved typing in other paragraphs survives, and an overlap goes to the ledger. Tell agents to address elements by `data-id`, never by position, and to leave chrome alone (it is not in the file anyway).
- **`clay.wire`** (`?plugins=wire`, HTML Clay and Hyperclay Local): `clay.wire.send(payload)` sends a request to a local process started with `htmlclay wire serve file.htmlclay -- my-agent`. The page saves first so the agent reads what the user sees, and autosave pauses until the agent's edit lands as a disk frame. `run.done` resolves then.
- **`ai-edit`** (`?plugins=ai-edit`): select text, press Cmd+J, and describe the change. The rewrite is previewed in place; Keep saves it, Revert discards it. Available where the host offers the `ai-edit` helper: HTML Clay, Hyperclay Local, and hyperclay.com (for the owner and team editors on a paid plan, billed to the document's AI credits).
- **The data API**: a host that announces `data-read`/`data-write` serves `/_/api/<file>`, a JSON read and write interface over the file defined by a rules tag ([Import and export](https://clayjs.com/docs/guide/architecture/#import-and-export)). `clay.readData()` and `clay.writeData()` call it from the page; `writeData` is for agents and other processes, and refuses while the page has unsaved edits.

## Live data: `clay="live"`

Everything above moves at save speed: a peer sees your change after your next save. A live store is for state that changes faster than that, such as a game board, a score, a cursor, or a timer. It is one JSON script that ClayJS holds as an object in memory. Page code changes it through ops, every tab of the document sees each change at once, and ClayJS writes the value back into the file at checkpoints.

```html
<script type="application/json" merge="game" clay="live">
{"turn": "x", "cells": {"0": "", "1": "", "2": ""}, "wins": {"x": 0, "o": 0}}
</script>
<script src="https://clayjs.com/v1/clay.js?plugins=sync,live"></script>
```

```js
await clay.ready;
const game = clay.live("game");

game.on("change", render);            // { source: "local" | "remote" | "state", ops }
render();

function play(cell) {
  game.update((d) => {                // one batch, one frame, rolled back if it throws
    if (d.cells[cell]) return;
    d.cells[cell] = d.turn;
    d.turn = d.turn === "x" ? "o" : "x";
  });
}

function render() {
  const d = game.data;                 // read it like a plain object
  // ...paint the board from d.cells
}
```

**The tag.** Declare a `<script type="application/json">` with `merge="name"` and `clay="live"`, holding a JSON object or list. The name is 1 to 64 letters, digits, `_`, `.`, `:`, or `-`, and two live tags with the same name are both ignored. Write the tag into the file before people share it: a tag added later is not in the rooms (in-memory sessions that relay ops between tabs) of tabs that loaded before.

Checkpoints add a `live-at` attribute to the tag with the room position the value was saved at and a hash of the text. A save made while the tab is out of the room also adds `live-by` (that tab and the last update it sent), so the room does not apply those updates a second time. The room trusts `live-by` only while the value still matches the hash in `live-at`, and never on a restored version. Leave both in place when you or an agent edit the tag's JSON by hand: the room then merges your edit with the play since that checkpoint. Without them, the edit is merged over what the room last saved. A save hook (`onbeforesave`) may reformat a live tag's JSON, but must not change its value. A tag is written in the layout it was loaded with: one line, or indented when the authored JSON had line breaks. `live-format="lines"` writes each entry of each top-level object on its own line instead, so version history shows only the entries that changed, which suits a large store keyed by id (the page editor's blocks).

**The API.** `clay.live(name)` returns the same handle every time:

- `game.data` is the value, read like a plain object. Writes to it become ops: `game.data.players.alice = { x: 0 }`, `delete game.data.players.bob`.
- `game.set(path, value)`, `game.delete(path)`, and `game.add(path, n = 1)`. A path is a dotted string (`"players.alice.x"`) or an array (`["players", "alice", "x"]`).
- `game.update(fn)` runs `fn(data)` synchronously and sends what it changed as one batch. If `fn` throws, every change it made is undone.
- `game.on("change", fn)` and `game.off("change", fn)`. `source` is `local` (this tab), `remote` (another tab), or `state` (the value was replaced from the room or a save).
- `game.toJSON()` is a copy, `game.ready` a promise of the handle, and `game.status.mode` is `fast` or `file` (below).

Two rules for the proxy:

- **Counters use `add`.** `game.data.score += 1` reads the number and writes the sum, so two tabs adding at once both write the same sum and one point is lost. `game.add("score", 1)` sends the increment, and both count.
- **Lists are written whole.** `push`, `splice`, and `sort` replace the entire list, so two tabs pushing at once keep one push. An index assignment (`grid[2][3] = "x"`) changes one cell. Keep collections as objects keyed by id (`players: { "p-7f3a": {...} }`).

**What gets saved, and when.** A live change never marks the page unsaved, never starts autosave, and never raises the close warning. A checkpoint writes the current value into the file 8 seconds after the first change, and again when the tab is hidden or closed. Up to 8 seconds of play can be lost. The close save uses `keepalive`, which browsers only honor for bodies under about 60 KB, so a large page may lose its last few seconds. Every checkpoint is a save, with a version in the host's history. A game page needs no `autosave` attribute for its live data.

**Never-saved stores.** `clay="live ephemeral"` shares the value like any store but never writes it into the file: the tag keeps the text you authored. Use it for cursors, lobbies, and presence. Anyone who can open the file can read the authored text, so keep secrets out of it.

**How it travels.** Which lane a store uses depends on the host:

- **hyperclay.com** (the `live-data` capability): every change goes to the document's room on the server, which numbers it and passes it to every editor within a network round trip. Tabs that join late or miss a change fetch the room's whole state. Only one tab, the room's writer, saves checkpoints. Viewers do not get the fast lane; they see the game at each checkpoint through ordinary sync. When an outside save changes a live tag (the code editor, a version restore, a device sync, or an agent), the room takes the file's value and replays its own changes since the version the file came from on top, so the edit lands and no move is lost or counted twice. An edit of a version older than the room's history is overwritten by the room's next checkpoint instead. The history covers 256 KB of changes per store, and the writer saves early when half of it is used, so this requires restoring an old version or a writer that cannot save.
- **Tabs of one browser on HTML Clay or Hyperclay Local**: the tabs share the store at the same speed through the browser itself. One tab runs the room and saves checkpoints. When it closes, another tab takes over with every move the room accepted. Use this to playtest a game in several tabs.
- **Everywhere else** (another device on a local host, or a browser without Web Locks): the store moves at save speed. Each incoming save replaces the value, and this tab's own unsaved changes are replayed on top. A change counts as saved only when this tab's own save holding it is accepted, so an `add` counts once.

`game.status.mode` is `fast` on the first two lanes and `file` on the third. In view mode, a store reads normally, but writes stay in the tab and are never sent or saved.

**The page editor's own store.** A Document's blocks are a live store named `page`, and its merge state is not in the file. Saved files and downloads never carry it. On hyperclay.com and Hyperclay Local, the host keeps the merge state beside the file and hands it back to an editor only while it matches the article's `data-rev`. HTML Clay keeps none on disk: open tabs share it in memory, and a fresh open rebuilds it from the article. A page changed outside the editor (by the code editor, an agent, a version restore, another device, or a text editor) merges into open page tabs the way a peer's edit does: each block merges three ways against the article the change started from, so edits on both sides survive. An edit beats a delete. Where both sides changed the same words, the outside change wins and the tab offers a download of what it had. Only a change with no article this host can trace it back to shows a reload notice and stops the tab from saving over the new article.

**When a room is lost.** A room lives in memory: a server restart or two minutes with nobody connected ends it. The next room starts from the last checkpoint, so up to 8 seconds of play can be lost. A change that the old room may already have taken is not resent to the new one, even when its acknowledgment was lost: if the old room's last checkpoint lands afterward, it brings that change back, and resending it would count it twice. A change that never left the tab is sent as usual. Closing the tab that runs a local room is not a loss: the next tab takes over the room with every move.

**Load the plugin wherever a live tag exists.** A page without `?plugins=live` still leaves live tags out of the merge, so an incoming save does not update them and the next save writes the old text back.

**Limits on hyperclay.com.** A store holds at most 4 MB of JSON, and a document holds at most 6 MB across at most 16 stores, giving a Document roughly 900 KB of text that keeps compacting. A frame carries at most 128 ops and 64 KB. One value larger than 64 KB is sent in its own request of up to 5 MB, so a single change can write a value up to the store's 4 MB. The page editor splits its changes into 12 KB pieces, so frame size does not limit a Document's size. One person sends at most 25 frames a second per document (bursts to 40), and ClayJS paces itself to stay under that. A change the room refuses is undone in the tab with a warning. When the server holds as much live data as it can, a document without a room (or a change that would grow one) moves at save speed and tries the room again a minute later.

**A two-player example.** The board above plus a never-saved lobby for pointers:

```html
<script type="application/json" merge="lobby" clay="live ephemeral">{"pointers": {}}</script>
```

```js
const lobby = clay.live("lobby");
const me = crypto.randomUUID?.() ?? String(Math.random());
document.addEventListener("pointermove", (e) => {
  lobby.set(["pointers", me], { x: e.clientX, y: e.clientY });
});
lobby.on("change", drawPointers);
```

Wins are counters, so they use `add`: `game.add(["wins", winner], 1)`.
