Docs

Reference

The core API, the attributes, the events, and the one endpoint that makes it all work. For the guided version, start with the tutorial.

Loading

The script tag

<script src="https://clayjs.com/v1/clay.js"></script>

Every clayjs URL carries a version, because a saved document hardcodes its script tag and there is no way to reach it afterwards to change it. Two forms:

URLWhat it serves
/v1/clay.jsThe newest 1.x release. Rolls forward with fixes and features, never across a major version.
/1.0.0/clay.jsThat exact release, forever. Cached for a year as immutable.
/v1/clay.standalone.jsThe whole library in one file, for a page that must load with no network. Same query params. Details on Offline.

Use /v1/ unless you have a reason not to. Both forms exist for every satellite, so /v1/clay-ui.js and /1.0.0/sap.js work the same way. Published versions are listed at versions.json. There is no unversioned URL: clayjs.com/clay.js was retired at 1.0 and returns 404.

The default build includes the save lifecycle and rich text (editable). Plugins load conditionally through the URL, in the browser only:

ParamMeaningExample
pluginsComma-separated plugins to loadclay.js?plugins=sync,undo
excludeRemove a default-on pluginclay.js?exclude=richclay

Loadable plugins: richclay (default on), indicator, sync, sortable, undo, cms, quickcrop, upload, wire, demo. The first six have a checkbox in the configurator; ask for the rest by name. The configurator composes this URL for you. Everything else is a separate library with its own script tag: see Advanced and Plugins. npm builds for bundlers live at @panphora/clayjs. For a page with no network at all, the standalone build is every module in one downloadable file.

Core API

window.clay

MemberWhat it does
clay.readyA Promise that resolves once clayjs has booted (core loaded, plugins attached). In inline scripts, await clay.ready before touching anything else. clay:ready fires on document at the same moment.
clay.save()Snapshot the page and save it. Skips when nothing changed. Returns Promise<{ok, msg, msgType, code, etag}>; check ok. msgType is success, error, skipped (nothing was sent), or unknown (the request timed out, so the write may or may not have landed). A host may answer with its own severity, such as warning, and that rides through untouched. Never rejects.
clay.save.force()Save even when nothing appears to have changed.
clay.getHTML()The exact HTML string a save would send, after all cleanup.
clay.addDocumentTransform(fn)Register a callback that receives the cloned document before serialization. The live page is never touched.
clay.onSnapshot(fn)Like addDocumentTransform, but runs for every snapshot, including live-sync broadcasts.
clay.isEditModeWhether this session may edit (see edit mode).
clay.isOwnerWhether the platform's owner cookie is set. Unlike isEditMode, the URL and global overrides don't affect it.
clay.toggleEditMode()Flip between edit and view mode (reloads the page).
clay.cacheBust(el)Re-download one resource by stamping ?v= onto its href or src. Details.
clay.MutationThe shared mutation hub. See Advanced.
clay.morph(oldEl, newEl)The DOM morphing engine: morphs oldEl in place to match newEl (sync plugin).
clay.undoThe undo singleton: clay.undo.undo() / clay.undo.redo() (undo plugin).
clay.cmsThe content panel: clay.cms.open() (cms plugin).
clay.RichClayThe rich-text editor class behind editable (richclay plugin, default on).
clay.quickcrop(file, opts)The crop modal: resolves {blob, dataURL, width, height}, or null if cancelled (quickcrop plugin). See Plugins.
clay.uploadPick a file and get it into the page (upload plugin).
clay.wireA per-file control channel to a process on your own machine: send, cancel, get, list, isBusy, on (wire plugin). See Plugins.

In view mode the edit-only members are absent: window.clay holds just ready, toggleEditMode, isEditMode, isOwner, Mutation, and region (plus morph/cms when those plugins load). Feature-detect with 'save' in clay.

Three tiers, and only the first two are a promise:

TierWhat it isPromise
clay.* from clay.jsThe everyday surface, aboveStable
clay.* from a satelliteOpt-in, one script tag each: clay-ui, clay-utils, clay-dom, clay-events, clay-options, clay-internalsStable
Anything else under src/Reachable by direct import, because src/ ships to npm and the CDNMay change in any release

The contract starts at 1.0.0: no name in the first two tiers changes without a major version. Everything in the third tier works today and is welcome to be used, but it is not a promise.

Low level

clay.internals

The pieces clayjs builds itself out of, for code that needs to sit inside the save lifecycle rather than call it. Lower level than clay.*: it assumes you know the lifecycle. One extra script tag, and clay.loaded.internals resolves when it's ready.

<script src="https://clayjs.com/v1/clay-internals.js"></script>
MemberWhat it does
captureSnapshot()The cloned document element, with form values synced, onbeforesnapshot handlers run, and no-snapshot regions plus browser-extension noise already removed. It is the shared base every lane starts from, not an untouched copy of the DOM.
captureForSave()The exact bytes a save would send, stripping and all.
addDocumentTransform(fn)Register a hook that runs on the clone during save preparation and on every change check, so keep it pure and repeatable. Same registry as clay.addDocumentTransform.
region.resolveRegionPolicy(node)The resolved region axes for a node: {watched, autosaveTriggered, dirtyTracked, undoable, persist, extension}. autosaveTriggered is whether an edit here starts a save by itself; dirtyTracked is whether it counts as unsaved work at all. They differ only inside no-trigger-autosave, which is saved work waiting for a manual save; no-dirty clears both, for content that is saved but is not work.
region.isInert(node)Whether a node is invisible to the whole mutation system (no-watch and extension noise).
region.isSnapshotRemoved(node)Whether a node is dropped from every snapshot (no-snapshot). Ancestor-aware: a child of a marked region is just as absent as the region itself.
region.addRegionToken(el, token)Add one token to an element's clay attribute, keeping the tokens already there.
region.PERSIST / region.REGION_ATTRSThe persist values (full, frozen, none) and the canonical token list.
region.selectorsstripFromSave, stripFromComparison, stripFromDirtyCheck, noTriggerAutosave, snapshotRemove, freeze: the selectors clayjs itself strips with, legacy spellings included. The same object is on clay.region, which also publishes them as STRIP_FROM_SAVE-style constants.
save.saveHtml(html, cb)Send an HTML string to the save endpoint. Resolves {ok, msg, msgType, code, etag}; never rejects.
save.replacePageWith(url, cb)Fetch another URL's HTML and save it over the current file. Same result shape.
save.isSaveInProgress()Whether a save is in flight right now.

save.saveHtml writes the bytes you hand it straight to the file: it bypasses the snapshot pipeline entirely, so nothing is stripped and no hook runs. Check save.isSaveInProgress() first, because firing into an in-flight save is how you lose an edit. For a worked example of the region helpers, see Advanced.

Attributes

The HTML surface

AttributeWhereWhat it does
editableany elementRich text editing with a floating toolbar (richclay, default on). Tokens: editable="single-line no-toolbar toolbar-on-select". Native contenteditable also works for plain text.
persistform controlsWrites the control's current value into the HTML so it survives the save. password, hidden and file inputs are always skipped, so a secret cannot be written into the file by adding one attribute.
trigger-savebuttonsClicking it calls clay.save(). Same as wiring onclick yourself. Put it on a <button type="button">: on a link the browser follows the href as well, and the navigation can outrun the save. If you want a link, give it an onclick instead: <a href="#" onclick="event.preventDefault(); clay.save()">.
autosave<html>Save automatically after edits settle. Debounced and throttled.
clay="…"any elementRegion control: no-save, no-snapshot, no-trigger-autosave, no-dirty, no-watch, no-undo, freeze. Full reference.
merge="name"JSON script tagsMulti-writer safety for JSON stored in a script tag (sync plugin). Incoming saves three-way merge the JSON per key against the last synced version instead of replacing the blob, so unsaved local keys survive. Arrays merge by identity (id-style fields; name your own with merge-key="taskId"). Bodies may use relaxed JSON: unquoted keys, single quotes, trailing commas, comments. Requires a JSON type.
editmode:contenteditableany elementEditable for the owner, inert for visitors.
editmode:onclickany elementThe onclick runs only for the owner; it's saved inert so it never runs for visitors.
The three editmode: rows and the viewmode: row are enforced by a save transform, which view mode never loads. So they hold for any file an owner has saved, which is every file in normal use, but not for freshly authored markup a visitor reaches before the first owner save. Save once after adding them.
editmode:resourcestyle, link, scriptActive for the owner, saved with an inert type so visitors never load it.
viewmode:disabled / viewmode:readonlyform controlsDisabled or read-only for visitors, live for the owner.
onbeforesave / onbeforesnapshot / onaftersaveany elementInline hooks into the save. onbeforesave and onbeforesnapshot run on the clone for every change check, not only for saves that ship, so keep them pure and repeatable: no counters, no logging, no network. Details.
savestatus<html>, set by clayjsRead-only state for your CSS: saving, saved, error, offline.
Events

Listening in

Event (on document)Fires whendetail
clay:readyclayjs finished booting (same moment clay.ready resolves){clay}
clay:save-savinga save has been in flight for 500ms (fast saves skip straight to the result){msg, timestamp}
clay:save-savedthe server confirmed the write{msg, timestamp}
clay:save-errorthe server answered with a problem{msg, timestamp}
clay:save-offlinethe browser is offline (clayjs re-saves when the connection returns){msg, timestamp}
clay:snapshot-readya snapshot has been cloned, before any stripping. Read detail.documentElement; do not change it. It is also where both change-detection baselines come from, and the change checks do not fire this event, so an edit made here is saved but never compared and the page reads dirty forever. Use clay.addDocumentTransform(fn) to change what gets saved.{documentElement}
clay:sync-applieda live-sync update landed (sync plugin){seq, source}; source is peer (another open copy) or disk (the file changed underneath you)
clay:sorteda drag-drop reorder landed (sortable plugin); fires on the container and bubbles{item, from, to, oldIndex, newIndex}
clay:view-save-attempta visitor clicked a [trigger-save] element in view mode; show your own notice
Edit mode

Who may edit

Saving requires edit mode. On the platforms, you never think about this: hyperclay.com arms it with a cookie when you're the owner, and Hyperclay Local and HTML Clay arm it for local files automatically. Resolution order:

PrioritySource
1?editmode=true / ?editmode=false in the URL
2window.clayEditMode set before clay.js loads
3A save token the host put on the root element
4The platform's owner cookie

The token sits above the cookie because it is the stronger claim: the host issued it for this document, while a cookie is ambient. A sandboxed document cannot read a cookie at all.

Serving files from your own server? Arm it yourself:

<script>window.clayEditMode = true</script>
<script src="https://clayjs.com/v1/clay.js"></script>

Edit mode is client-side behavior. Whether a save is accepted is always the server's decision.

Host it yourself

The endpoint spec

A clayjs server implements one route:

PartValue
RoutePOST /_/save
BodyThe file's full HTML, as plain text. Always: this route has exactly one body shape, on every host.
HeaderDocument-URL: the full URL of the page being saved.
Success200 with JSON { "msg": "Saved" }
FailureAny non-2xx. The status is authoritative; host-generated failures use JSON { "msg": "why" }, but clients tolerate non-JSON intermediary errors.

A complete server in a couple dozen lines of Express:

import express from "express";
import fs from "node:fs/promises";
import path from "node:path";

const root = process.cwd();
const app = express();
app.use(express.static(root));
app.use(express.text({ type: "*/*", limit: "10mb" }));

app.post("/_/save", async (req, res) => {
  try {
    const origin = req.get("Origin");
    if (origin && new URL(origin).host !== req.get("Host"))
      return res.status(403).json({ msg: "Cross-origin save refused", code: "forbidden" });
    const page = decodeURIComponent(new URL(req.get("Document-URL")).pathname);
    const target = path.join(root, path.normalize(page.endsWith("/") ? page + "index.html" : page));
    if (!target.startsWith(root + path.sep))
      return res.status(403).json({ msg: "Outside the folder", code: "forbidden" });
    if (!/^\s*<!doctype html>/i.test(req.body))
      return res.status(422).json({ msg: "Not a complete HTML document", code: "invalid-document" });
    await fs.writeFile(target, req.body);
    res.json({ msg: "Saved" });
  } catch {
    res.status(500).json({ msg: "Couldn't save" });
  }
});

app.listen(4600);

The server half is only half of it: on your own host nothing arms edit mode for you, so a page served this way is read-only until you set window.clayEditMode before the script tag. A real deployment adds authorization and atomic writes. The same contract powers hyperclay.com, Hyperclay Local, and HTML Clay. Two details you may ignore: every save also carries a Save-Trigger: user|auto header (whether a person triggered it), and HTML Clay uses a token variant, POST /_/save/{token}, read from <html savetoken>.