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.
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:
| URL | What it serves |
|---|---|
/v1/clay.js | The newest 1.x release. Rolls forward with fixes and features, never across a major version. |
/1.0.0/clay.js | That exact release, forever. Cached for a year as immutable. |
/v1/clay.standalone.js | The 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:
| Param | Meaning | Example |
|---|---|---|
plugins | Comma-separated plugins to load | clay.js?plugins=sync,undo |
exclude | Remove a default-on plugin | clay.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.
window.clay
| Member | What it does |
|---|---|
clay.ready | A 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.isEditMode | Whether this session may edit (see edit mode). |
clay.isOwner | Whether 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.Mutation | The shared mutation hub. See Advanced. |
clay.morph(oldEl, newEl) | The DOM morphing engine: morphs oldEl in place to match newEl (sync plugin). |
clay.undo | The undo singleton: clay.undo.undo() / clay.undo.redo() (undo plugin). |
clay.cms | The content panel: clay.cms.open() (cms plugin). |
clay.RichClay | The 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.upload | Pick a file and get it into the page (upload plugin). |
clay.wire | A 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:
| Tier | What it is | Promise |
|---|---|---|
clay.* from clay.js | The everyday surface, above | Stable |
clay.* from a satellite | Opt-in, one script tag each: clay-ui, clay-utils, clay-dom, clay-events, clay-options, clay-internals | Stable |
Anything else under src/ | Reachable by direct import, because src/ ships to npm and the CDN | May 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.
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>
| Member | What 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_ATTRS | The persist values (full, frozen, none) and the canonical token list. |
region.selectors | stripFromSave, 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.
The HTML surface
| Attribute | Where | What it does |
|---|---|---|
editable | any element | Rich 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. |
persist | form controls | Writes 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-save | buttons | Clicking 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 element | Region control: no-save, no-snapshot, no-trigger-autosave, no-dirty, no-watch, no-undo, freeze. Full reference. |
merge="name" | JSON script tags | Multi-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:contenteditable | any element | Editable for the owner, inert for visitors. |
editmode:onclick | any element | The 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:resource | style, link, script | Active for the owner, saved with an inert type so visitors never load it. |
viewmode:disabled / viewmode:readonly | form controls | Disabled or read-only for visitors, live for the owner. |
onbeforesave / onbeforesnapshot / onaftersave | any element | Inline 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 clayjs | Read-only state for your CSS: saving, saved, error, offline. |
Listening in
| Event (on document) | Fires when | detail |
|---|---|---|
clay:ready | clayjs finished booting (same moment clay.ready resolves) | {clay} |
clay:save-saving | a save has been in flight for 500ms (fast saves skip straight to the result) | {msg, timestamp} |
clay:save-saved | the server confirmed the write | {msg, timestamp} |
clay:save-error | the server answered with a problem | {msg, timestamp} |
clay:save-offline | the browser is offline (clayjs re-saves when the connection returns) | {msg, timestamp} |
clay:snapshot-ready | a 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-applied | a live-sync update landed (sync plugin) | {seq, source}; source is peer (another open copy) or disk (the file changed underneath you) |
clay:sorted | a drag-drop reorder landed (sortable plugin); fires on the container and bubbles | {item, from, to, oldIndex, newIndex} |
clay:view-save-attempt | a visitor clicked a [trigger-save] element in view mode; show your own notice | — |
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:
| Priority | Source |
|---|---|
| 1 | ?editmode=true / ?editmode=false in the URL |
| 2 | window.clayEditMode set before clay.js loads |
| 3 | A save token the host put on the root element |
| 4 | The 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.
The endpoint spec
A clayjs server implements one route:
| Part | Value |
|---|---|
| Route | POST /_/save |
| Body | The file's full HTML, as plain text. Always: this route has exactly one body shape, on every host. |
| Header | Document-URL: the full URL of the page being saved. |
| Success | 200 with JSON { "msg": "Saved" } |
| Failure | Any 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>.