clay.js
  1. ClayJS
  2. How ClayJS works
  3. Live sync and collaboration

Live sync and collaboration

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

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 anotherboth kept
A and B edit different words of one paragraphboth kept
A and B change the same wordB wins; A’s version goes to the conflict ledger
Both add class tokens (done and urgent)both kept
Both set one attribute (data-status) differentlyB wins; ledger record
Both move different items in one listboth moves kept
Both move the same itemB’s order; ledger record
Both change an attribute declared with merge-tokens or merge-maxmerged (token set union, or the larger number), no conflict
A edits a card, B reorders the listB’s order with A’s edit, no conflict
A edits a card B deletedthe card survives with A’s edit
A deleted a card B editedthe card comes back with B’s edit; ledger record
Both append an item with its own data-idboth land
Both append an item without idsdistinct text: both land; similar text: A’s item dropped, ledger record; identical or blank: one dropped, no record
A is typing in a focused inputits 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).
  • 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).

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").
  • 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:

JavaScript
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") 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.

JavaScript
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) 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).

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:

JavaScript
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:

JavaScript
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 }:

JavaScript
{
  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). 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").

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). 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>
JavaScript
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>
JavaScript
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).

Part of the ClayJS complete guideDownload the whole guide as Markdown