Contents
  1. 1. The one rule
  2. 2. When to use sapjs
  3. 3. Start from the skeleton
  4. 4. Design the records
  5. 5. Build views over the records
  6. 6. Put each kind of state in its place
  7. 7. Edit one record in detail
  8. 8. Work with ClayJS
  9. 9. Three worked apps
  10. 10. Checklist and traps

Building malleable document apps with sapjs

Ask for an expense tracker in one HTML file and the usual result is an expenses array, a render() function that rebuilds the table from it, and a save step that writes the array into a <script> tag. That works until something edits the page directly. An undo, a hand edit or a live sync merge changes the table, and the array still holds the old numbers. Live sync is the ClayJS sync plugin. It merges each saved edit into the other open copies of the file: other tabs, and on Hyperclay Local and hyperclay.com, other devices.

In a sapjs app, there is no array of records that you maintain. Every change runs a pass: one run that reads the page, computes and paints. Every pass rebuilds the records from the page. Here are two pieces of the expense tracker from section 9.1, exactly as they sit in the saved file:

<tr item data-id="e-groc" state="data-category" data-category="food">
  <td><input bind="what" persist value="Groceries"></td>
  <td><input type="number" step="0.01" bind="amount" persist value="42.5"></td>
  …
</tr>
…
<output id="total" calc:total="sum(state.expenses, 'amount')" text:usd2="state.total">$54.50</output>

The <tr> is the expense. Its fields are its attributes and the bound controls inside it. The $54.50 is the total, which sapjs computed from the rows and painted into the page. Change the groceries amount to 54.5 and the output reads $66.50. Save, and the file holds value="54.5" and $66.50.

This guide shows how to build an app that way: where each piece of data goes, which sapjs feature fits each part of the app, and the traps. It is for an LLM agent or a developer building one app in one file, such as a tracker, a Kanban board, a notes list or a CRM. Read it top to bottom before writing code.

What you need:

What this guide does not cover:

The sapjs test suite mounts every worked app in section 9. It checks that each one loads with no errors, no warnings and zero writes, and it exercises the adds, edits and removals described under each app.

#1. The one rule

The saved DOM is the data. Each record is an element in the file, and its fields are attributes and text on that element. Saving the page saves the app.

Three rules follow from it, and every later section applies them:

sapjs fits this because every pass rebuilds its state from the page. Every change runs one pass:

  1. sapjs walks the app and reads every declared field, bound control and list from the live page into fresh objects.
  2. It runs the calc: expressions in dependency order.
  3. It paints the results back into the page. A paint is an attribute such as text=, show= or class: that sapjs evaluates on every pass, writing only when the value changed.
  4. It keeps those objects until the next pass replaces them.

So no copy of a saved field outlives the next pass. Transient fields are the only state that lives only in memory. They hold per-tab state (section 6).

A live sync merge, an undo, a devtools edit and a keystroke all reach the app the same way. Each one changes the page, and the next pass reads the page. Undo and redo leave one gap, in controls a person typed into, and section 8, step 2 closes it.

#2. When to use sapjs

A finished sapjs app is one <main sap> element with no render code: its records and views are declared in markup. The few commands that need JavaScript go in an inline handler or a short script.

Use sapjs attributes for:

Use plain JavaScript, in an inline handler or a short script, for:

If the app is growing a large render function, a record field or an each= view was probably missed. Sections 4 and 5 cover both.

#3. Start from the skeleton

This file loads ClayJS and sapjs, mounts the app inside <main> and prints one line in the console:

<!doctype html>
<html lang="en" autosave>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Expenses</title>
  <style>
    [item][template] { display: none }
  </style>
</head>
<body>
  <main id="app" sap>
    …
  </main>
  <script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo,indicator&exclude=richclay"></script>
  <script src="https://clayjs.com/v1/clay-router.js"></script>
  <script src="https://clayjs.com/v1/sap.js"></script>
</body>
</html>

To run sapjs without saving, replace all the script tags with this one:

<script src="https://cdn.jsdelivr.net/npm/@panphora/sapjs/dist/sap.min.js"></script>

Check that the app mounted. The console shows one line per app. For the tracker in section 9.1, it reads:

sap ✓ main[sap] · fields 5 · calcs 1 · paints 4 · actions 4 · lists 1 · rows 2 · warnings 0 · mount writes 0 · 12.7ms

The counts depend on the app, and the timing changes on every run. The app mounted when:

A failed app prints a sap ✗ block for each error instead. Each block names the code, the element and the problem, and usually the attribute and the fix. A sap ✗ summary line follows the blocks.

#4. Design the records

A finished record is one element that carries all of its own data. This card has an id, a due date, a done flag and a title:

<ol items="cards">
  <li item data-id="c-brief" state="data-due data-done:presence" data-due="2026-10-20">
    <span bind="title">Write the brief</span>
  </li>
</ol>

items="cards" on the container makes each [item] child a record in state.cards. Records must be direct children of that container. The record's fields come from state= declarations on the record element and from bind controls inside it.

The record element is a scope. A scope is an element that owns a set of fields: the sap root, an [item] record, a detail= panel, or any element with scope=. scope= marks an element as a scope that holds its own state= fields, like a record without a list. Expressions read the current record's fields as item.NAME, and root.NAME always reads the app root. Outside any record, state.NAME reads the nearest named scope, which is the app root when no scope= element sits between. Inside a record, write item.NAME and root.NAME rather than state.NAME, because state.NAME there resolves differently in a paint and in an action.

A text leaf is an element with no child elements, such as the <span bind="title"> above.

Field kind Declare it Read it as Saved as
Text in a control or a text leaf bind on an input, a textarea or a text leaf inside the record item.title the control's value or the leaf's text
A value with no visible control state="data-due" on the record item.due data-due="2026-10-20"
A number state="data-points:num" item.points data-points="3"
A yes/no flag that CSS selects on state="data-done:presence" item.done data-done present or absent
A yes/no value stored as true or false state="data-flag:bool" item.flag data-flag="true" or data-flag="false". Choose it when CSS or code outside sapjs reads the attribute as the word ([data-flag="true"]). Expressions see a boolean either way. Choose :presence when CSS or code checks whether the attribute is there.
A nested list items="checks" on a child container item.checks child [item] elements

bind reads and writes a text leaf's text, but it does not make the text editable. Typing in place needs contenteditable on the leaf. Under ClayJS, write a saved leaf as <span bind="title" editmode:contenteditable inert-contenteditable="plaintext-only">, which becomes editable in edit mode only. Inside a <template>, write editmode:contenteditable contenteditable="plaintext-only" instead, because the edit-mode transform does not reach template content (ClayJS guide 4.4).

Three terms appear in the rules below:

Rules for records:

#Nested lists and shared blueprints

A record can hold a list of records. A Kanban list holds cards, and a card holds checklist items. Each nested list needs something to clone new records from.

Instead of a template row in every copy of the list, write one shared blueprint anywhere in the app. A shared blueprint is a <template items="NAME"> element that holds one record:

<template items="checks">
  <li item data-id="" state="data-done:presence"><span bind="label">New item</span></li>
</template>

Every items="checks" list that has no template row of its own clones from this blueprint. That includes lists inside other blueprints. A list's own template row still wins.

Three forms of template appear in this guide: the template attribute on a row inside a list, which new records in that list clone from; the shared <template items="LIST"> blueprint, which every list of that name without a template row clones from; and the <template> inside an each= element, which draws each view row (section 5).

#5. Build views over the records

A finished view shows the records in a different order or subset, and leaves the records alone. This list shows the open cards, soonest first, with a checkbox that ticks the card:

<ul each="state.cards.filter(c => !c.done).sort((a, b) => a.due.localeCompare(b.due))">
  <template>
    <li><input type="checkbox" bind="done"> <span text="item.title"></span></li>
  </template>
</ul>

each= takes an expression that returns an array. sapjs clones the <template> once per array element and places the rows after it, in array order. Inside a row, item is the array element.

Filter in an each= view. Do not filter by putting show= on saved records. show= writes a hidden attribute into the record, and under ClayJS that attribute saves and syncs to every peer. Never remove a record to filter it either, because totals and counts read every record.

When the records themselves are the visible list, such as a board the user drags cards on, hide the non-matching records with a stylesheet. Paint the stylesheet into a <style> element inside the sap root, marked clay="freeze". clay="freeze" marks a ClayJS region that saves its contents as they were at load, and never saves or syncs later changes. So the hiding rule stays in this tab. This example is for a board whose cards are a top-level items="cards" list:

<main sap state="q:transient">
  <input type="search" bind="q" transient>
  <style clay="freeze" text="hideRule(state.cards, state.q)"></style>
  <ul items="cards">…</ul>
</main>
<script>
  function hideRule(cards, q) {
    const ids = cards.filter((c) => !c.title.toLowerCase().includes(q.toLowerCase())).map((c) => c.$key);
    return ids.length ? ids.map((id) => `[data-id="${CSS.escape(id)}"]`).join(",") + "{display:none}" : "";
  }
</script>

text= writes the helper's return value into the <style> element on every pass. hideRule returns one CSS rule that hides each card whose title does not contain the search text, or an empty string when every card matches. state.q is the search text, held in a transient field (section 6). The helper never touches a record. For cards nested in lists, as on the board in section 9.2, the helper receives state.lists.flatMap((l) => l.cards) instead of state.cards.

Keep the records as the main surface when the user edits structure. A sortable drag, rich text and in-place editing act on the elements they touch. View rows are never saved, so a drag inside a view moves nothing in the file. Make the records themselves the board or the table, and use views for everything secondary.

#6. Put each kind of state in its place

Every value in a finished app belongs to one of three kinds, and each kind has one home:

Kind Example Where it lives
Data a card's title, an expense amount record fields (section 4)
Per-tab the search text, the open card, the active tab transient fields, or the URL hash
Derived totals, counts, the filtered list calc:, paints and each=

#Per-tab state

Declare per-tab fields transient. A transient field drives the app and is never written into the file:

<main sap state="q:transient open:transient tab:transient=board">
  <input type="search" bind="q" transient>

The =board in tab:transient=board is the field's starting value. transient in a declaration keeps the field's attribute off the element. transient on a bound control strips its value attribute on every pass, so the search text never reaches the saved file.

A per-tab value that should survive a reload, or be shared as a link, goes in the URL hash instead. The ClayJS router keeps it there (section 7).

#Derived state

Compute derived values with calc: or an inline expression, and show them with paints (section 1):

<output calc:total="sum(state.expenses, 'amount')" text:usd2="state.total">$54.50</output>
<span text="count(item.checks, c => c.done) + '/' + item.checks.length">1/2</span>

calc:total computes a field named total. text:usd2 formats it as dollars with two decimals. In the <span>, count(item.checks, c => c.done) counts the card's ticked checklist items, so a card with one of two items ticked shows 1/2.

Paint the computed values into the file. When the saved file already shows $54.50, the first pass changes nothing, and sapjs reports mount writes 0. When the file disagrees, sapjs paints the correct value at load and logs a W30 warning. The app still works. Under ClayJS, that paint does not start an autosave and does not count as unsaved. The live page now differs from the file, and the painted value goes out with the next real save.

Where a paint goes depends on whether every tab computes the same value:

#7. Edit one record in detail

In a finished app, clicking a card opens it for editing, and typing in the editor changes the card. There are two ways to build that.

#The record is the modal

Keep the detail fields inside the record element, hidden on the card face by CSS. When the record is open, show the record element itself as an overlay. The pattern is from ClayJS guide 9.5. It needs the clay-router.js line from the skeleton in section 3.

<ol class="cards" items="cards" route="card">
  <li class="card" item data-id="c-brief">
    <h3 bind="title" onclick="clay.route.set({ card: Sap(this).$key })">Write the brief</h3>
    <div class="detail">
      <textarea bind="notes" persist></textarea>
      <button onclick="clay.route.set({ card: null })">Close</button>
    </div>
  </li>
</ol>
.card .detail { display: none }
.card[route-active] .detail { display: block }
.card[route-active] { position: fixed; inset: 2rem; z-index: 10; overflow: auto; background: white }

Nothing is copied, so there is nothing to write back. A peer's edit to the open card appears in place. Rich text toolbars work. Back closes the card, and a reload keeps it open.

#A detail= editor

detail="LIST by KEY" projects one record into a panel or a dialog. Inside the panel, item is the open record. Controls inside it are painted from the record and write through to it:

<main sap state="open:transient">
  …
  <div clay="freeze">
    <dialog detail="lists.cards by state.open">
      <input bind="title">
      <form method="dialog"><button>Close</button></form>
    </dialog>
  </div>
</main>

state.open holds the open card's $key. A set:open="item.$key" on each card sets it on click.

Use the record-as-modal shape when the detail holds rich text, needs a shareable link, or is the main editing surface. Use detail= when the fields are plain controls, or when the record must stay in its list while a panel edits it.

#8. Work with ClayJS

With ClayJS loaded, a sapjs app saves, syncs and undoes as one file. Most of that takes no code. The rest is the five steps under "What you do".

What happens by itself:

What you do:

  1. Put persist on bound <input>, <textarea> and <select> controls in records, so typed and chosen values reach the saved attributes.

  2. Re-sync controls after an undo and a redo. The browser keeps a control's typed value separately from its value attribute, or from a textarea's text. Undo and redo restore only the markup, and sapjs reads the typed value. Without a re-sync, totals and the next save keep the stale typed value. A live sync merge moves the typed values itself, except in the control the person is typing in. This script re-syncs bound inputs, textareas and selects that carry persist. Add it after the clay.js and sap.js tags. Attach the listeners inside clay.ready.then(...), because clay.undo does not exist until ClayJS has booted:

    const app = document.querySelector("main[sap]");
    function syncControls() {
      for (const el of app.querySelectorAll("input[persist], textarea[persist], select[persist]")) {
        if (el.type === "checkbox" || el.type === "radio") { el.checked = el.hasAttribute("checked"); continue; }
        if (el.tagName === "SELECT") { for (const o of el.options) o.selected = o.hasAttribute("selected"); continue; }
        const saved = el.tagName === "TEXTAREA" ? (el.getAttribute("data-value") ?? el.textContent) : (el.getAttribute("value") ?? "");
        if (el.value !== saved) el.value = saved;
      }
      Sap.refresh();
    }
    clay.ready.then(() => {
      clay.undo?.on("undo", syncControls);
      clay.undo?.on("redo", syncControls);
    });

    The undo listeners need the ClayJS undo plugin (?plugins=...,undo), which the skeleton loads.

  3. Put per-tab paints and every detail= panel in clay="freeze" (sections 5, 6 and 7).

  4. Mark controls a script adds at runtime, such as toolbars and menus, clay="editor-ui". View rows already are. ClayJS leaves editor-ui elements out of the saved file, so the script must build them again on every load, cloned from an unmarked <template>.

  5. Show controls only in edit mode with show-when:editmode="true". ClayJS sets editmode on <html> at load and removes it from every save. Nothing in the saved file hides these controls, so a viewer sees them until the scripts load. Add this rule to the stylesheet to keep them hidden before any script runs:

    html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }

    In view mode, sapjs sets hidden on these controls at load unless the clay-options satellite has already started. That write logs a W30 warning and a nonzero mount writes count for viewers. It is harmless: a viewer cannot save. In edit mode nothing is written.

#9. Three worked apps

Each app below is a complete <main sap>, written with its computed values already painted, so it mounts with zero writes. Replace the skeleton's entire <main>…</main> element with one complete app below.

#9.1 Expense tracker

A table of expenses, a form that adds them, and a total and a food subtotal.

<main id="app" sap>
  <form trigger-add="expenses">
    <input bind="what" placeholder="What" required>
    <input type="number" step="0.01" bind="amount" placeholder="0.00" required>
    <select bind="category" default="food"><option>food</option><option>travel</option><option>other</option></select>
    <button>Add</button>
  </form>
  <table>
    <tbody items="expenses">
      <tr item template data-id="" state="data-category">
        <td><input bind="what" persist></td>
        <td><input type="number" step="0.01" bind="amount" persist></td>
        <td text="item.category"></td>
        <td><button trigger-remove confirm="Delete this expense?">×</button></td>
      </tr>
      <tr item data-id="e-groc" state="data-category" data-category="food">
        <td><input bind="what" persist value="Groceries"></td>
        <td><input type="number" step="0.01" bind="amount" persist value="42.5"></td>
        <td text="item.category">food</td>
        <td><button trigger-remove confirm="Delete this expense?">×</button></td>
      </tr>
      <tr item data-id="e-train" state="data-category" data-category="travel">
        <td><input bind="what" persist value="Train"></td>
        <td><input type="number" step="0.01" bind="amount" persist value="12"></td>
        <td text="item.category">travel</td>
        <td><button trigger-remove confirm="Delete this expense?">×</button></td>
      </tr>
    </tbody>
  </table>
  <p>
    Total <output id="total" calc:total="sum(state.expenses, 'amount')" text:usd2="state.total">$54.50</output>,
    food <output id="food" text:usd2="sum(state.expenses.filter(e => e.category === 'food'), 'amount')">$42.50</output>
  </p>
</main>

#9.2 Kanban board

Two lists of cards, a checklist on each card, shared blueprints, and a card editor in a dialog.

The lists and cards inside <section items="lists"> are the records. The two <template items=...> elements after the section are the shared blueprints. The <dialog> is the editor.

<main id="board" sap state="open:transient">
  <section items="lists">
    <div class="list" item data-id="l-todo">
      <h2 bind="name">To do</h2>
      <ol class="cards" items="cards" sortable="cards">
        <li class="card" item data-id="c-brief" state="data-title" data-title="Write the brief" set:open="item.$key">
          <span text="item.title">Write the brief</span>
          <small text="count(item.checks, c => c.done) + '/' + item.checks.length">1/2</small>
          <ul class="checks" hidden items="checks">
            <li item data-id="k-outline" state="data-label data-done:presence" data-label="Outline" data-done></li>
            <li item data-id="k-draft" state="data-label data-done:presence" data-label="Draft"></li>
          </ul>
        </li>
      </ol>
      <button class="add-card" trigger-add="cards">+ Add a card</button>
    </div>
    <div class="list" item data-id="l-done">
      <h2 bind="name">Done</h2>
      <ol class="cards" items="cards" sortable="cards"></ol>
      <button class="add-card" trigger-add="cards">+ Add a card</button>
    </div>
  </section>

  <template items="cards">
    <li class="card" item data-id="" state="data-title" data-title="New card" set:open="item.$key">
      <span text="item.title">New card</span>
      <small text="count(item.checks, c => c.done) + '/' + item.checks.length">0/0</small>
      <ul class="checks" hidden items="checks"></ul>
    </li>
  </template>
  <template items="checks">
    <li item data-id="" state="data-label data-done:presence"></li>
  </template>

  <div clay="freeze">
    <dialog detail="lists.cards by state.open">
      <input class="title" bind="title">
      <ul each="item.checks">
        <template><li><input type="checkbox" bind="done"> <input class="label" bind="label"></li></template>
      </ul>
      <form trigger-add="checks"><input class="new-check" bind="label" placeholder="Add an item"></form>
      <form method="dialog"><button>Close</button></form>
    </dialog>
  </div>
</main>

#9.3 Notes

A searchable, sorted list of notes, and an editor for the selected note.

<main id="notes" sap state="q:transient open:transient">
  <input id="q" type="search" bind="q" transient placeholder="Search">
  <button id="new" onclick="const s = Sap(this); s.open = s.$add('notes').$key">New note</button>

  <section items="notes" hidden>
    <article item template data-id=""><h3 bind="title">Untitled</h3><div bind="body"></div></article>
    <article item data-id="n-ideas"><h3 bind="title">Ideas</h3><div bind="body">A board for recipes</div></article>
    <article item data-id="n-books"><h3 bind="title">Books</h3><div bind="body">Read the ClayJS guide</div></article>
  </section>

  <nav id="list" each="state.notes.filter(n => (n.title + ' ' + n.body).toLowerCase().includes(state.q.toLowerCase())).sort((a, b) => a.title.localeCompare(b.title))">
    <template><a set:open="item.$key" class:on="state.open === item.$key" text="item.title"></a></template>
  </nav>

  <div clay="freeze">
    <article id="editor" detail="notes by state.open" hidden>
      <input bind="title">
      <textarea bind="body"></textarea>
      <button trigger-remove confirm="Delete this note?">Delete</button>
    </article>
  </div>
</main>

#10. Checklist and traps

Before calling an app done, check each item. The console's "mount writes N" and Sap.status().apps[0].mountWrites are the same count.

Traps, keyed by what you see:

Symptom Cause Fix
state= has no effect, and no error appears it sits on an element that is not a scope (sap, scope=, [item], [detail]) move it to the record, or add scope=
state="due-date" halts the app with E08, or calc:due-date fails with E24 the hyphen parses as subtraction name it data-due-date in state= and read it as item.dueDate, or use one lowercase word
a flag still reads true after unticking :bool wrote "false", and CSS or code checks presence declare it :presence
a bound <span> shows the text but cannot be typed in bind does not make a text leaf editable add contenteditable, or under ClayJS editmode:contenteditable inert-contenteditable="plaintext-only" (section 4)
E43 on a bind in a view row the field belongs neither to the record nor to the scope that holds the each= element declare it on the record, or with state= on that scope
typing in a view row changes nothing in the file the row's field resolves to per-tab state declare the field on the record to save it
filtered-out records are saved and synced with hidden show= on saved records writes hidden filter in an each= view, or paint a hiding rule into a frozen <style> (section 5)
the editor dialog opens for every visitor the key is saved declare it transient
the saved file holds the open record's values a second time, in the editor's controls the detail= panel is outside clay="freeze" wrap it in clay="freeze"
the dialog is missing after a save it was inside clay="editor-ui" wrap it in clay="freeze"
W31 on a dialog the <dialog detail> has no clay="freeze" ancestor wrap it in clay="freeze"
a computed value read right after a write is stale calc: values and paints update in the next pass, which runs after your code returns call Sap.refresh() before reading
a total keeps an old number after an undo or a redo the attribute was restored, not the control's on-screen value add syncControls (section 8, step 2)
W30 and a nonzero mount writes count for viewers, in a root with show-when controls in view mode sapjs sets hidden on them, unless clay-options has already started nothing; a viewer cannot save, and in edit mode nothing is written
every expression fails under a strict CSP expressions compile with new Function allow unsafe-eval, or build the app without sapjs