# htmlclay > HTML Clay is a Go desktop app that turns a self-saving HTML file into a native OS document. > Double-click a `.htmlclay` file and it opens in your normal browser, served by a loopback > HTTP server that writes every save back into that same file on disk. No account, no cloud, > no build step, and the file is still ordinary HTML. It also gives a page a way to hand work > to a program running in your own terminal: the program edits the file, and the edit reaches > the open page as an ordinary file change. That channel is called the wire, and an AI agent > is its intended handler. This file is the complete reference for an agent working with HTML Clay. The human-readable version is the site at https://htmlclay.com (https://htmlclay.com/get-started, https://htmlclay.com/features, https://htmlclay.com/agent-editing). The source is at https://github.com/panphora/htmlclay, and the two in-repo documents worth reading in full are `README.md` and `docs/wire.md`. The security model, including the risks it accepts on purpose, is `SECURITY.md`. HTML Clay is licensed under the First Million Stays Yours License: free to use for anyone, a 3% fee only on the excess when products built on it bring you more than US $1,000,000 in a calendar year, and each released version becomes plain MIT 18 months after it shipped. Under the line there is nothing to pay, report, or register. Details are in the License section at the end. Three names get confused, so fix them once and never rotate them: - **malleable HTML file** is the format. Its real extension is `.html`, its specification is at https://malleablehtmlfile.com, and several programs can host one. - **HTML Clay** is this app, one host of that format. - **`.htmlclay`** is an operating system convention that tells the OS to open the file with HTML Clay, the way `.psd` names Photoshop rather than naming the image format. The rename goes both ways and loses nothing: ```text notes.html -> notes.htmlclay the OS now opens it with HTML Clay notes.htmlclay -> notes.html it opens unchanged in any browser ``` Renaming is only an association. It does not invent an editor: a file with no save-aware code in it, normally ClayJS, gains nothing from the extension. And a `.htmlclay` file renamed back to `.html` keeps its content and its JavaScript, but no ordinary browser can write later changes back to disk unless some malleable HTML file host is serving it. ## If you are the agent on the other end of the wire The shortest correct summary of your job, expanded in the wire section below. 1. You are started by `htmlclay wire serve -- `, once per request. 2. Read the request envelope as JSON on stdin. What the person asked for is inside `payload`, and by convention `payload.said` is the newest message. `text` is the same thing as a plain line if you would rather not parse. 3. Edit the file at `$HTMLCLAY_WIRE_FILE` in place, with whatever tools you already have. Do not write HTML back through the wire; there is no such path. The file is the state. 4. Print a line on stdout every few seconds while you work. Each line becomes a status the page shows, and each line rearms a 120 second silence timeout. A silent process is killed at two minutes. 5. To say something back in words, print one line that is a JSON object with `redpen` first: `{"redpen":1,"reply":"..."}`. Print it before you exit, because frames for a finished request are dropped. 6. Exit 0 for success, anything else for failure. The page sees the edit arrive on its own, through live sync. 7. Edit inside elements that have a stable `id` or `data-id`. A change inside an anonymous element may not appear in the open page until a reload. 8. Never author an element carrying `clay="no-save"` into the file. Those are runtime-only, and the first save deletes an authored one permanently. ## Install The current published release is 1.7.0. The authoritative answer for what is current is the in-app update feed, which is a plain JSON file: ```bash curl https://download.htmlclay.com/htmlclay-release-info.json # {"latest":"1.7.0","url":"https://htmlclay.com/#downloads","date":"...","files":[...]} ``` Downloads, all from https://htmlclay.com/#downloads: | Platform | File | |---|---| | macOS, universal, signed and notarized | https://download.htmlclay.com/HTMLClay-1.7.0-universal.dmg | | Windows x64, signed | https://download.htmlclay.com/htmlclay-windows-amd64.zip | | Windows ARM64, signed | https://download.htmlclay.com/htmlclay-windows-arm64.zip | | Linux x86_64 | https://download.htmlclay.com/htmlclay-linux-amd64.tar.gz | | Linux ARM64 | https://download.htmlclay.com/htmlclay-linux-arm64.tar.gz | | Checksums | https://download.htmlclay.com/SHA256SUMS | Only the macOS dmg carries the version in its filename. The other four names are stable across releases. Verify a download against `SHA256SUMS`: `shasum -a 256 ` on macOS, `sha256sum ` on Linux, `certutil -hashfile SHA256` on Windows. ### macOS Open the disk image, drag HTML Clay into Applications, open it once. The app lives in the menu bar and has no main window. The app bundle is what registers `.htmlclay` as a document type. **`htmlclay` is not on PATH on macOS.** The binary lives inside the app bundle, so the wire CLI needs an alias: ```bash alias htmlclay="/Applications/HTMLClay.app/Contents/MacOS/htmlclay" ``` Put that line in `~/.zshrc` to keep it across terminal sessions. ### Windows Unzip either archive somewhere permanent and run `htmlclay.exe` once. It writes the `.htmlclay` association under `HKCU\Software\Classes`, so the normal path needs no administrator prompt, then stays in the system tray. It also adds HTML Clay to the Open With list for `.html` and `.htm` without taking those extensions away from the browser. Every launch refreshes the registration, so after moving the executable, run it once from its new location. `register.bat` in the archive is a fallback for machines whose policy refuses the app's normal per-user registration. To remove the associations and the Start on Login entry without touching documents, settings, or backups: ```powershell htmlclay.exe --unregister ``` ### Linux Unpack the tarball and run the installer from that folder: ```bash bash install.sh ``` The default installs the binary into `~/.local/bin` and writes a freedesktop MIME entry, a desktop entry and icons under `~/.local/share`. It never asks for sudo. `bash uninstall.sh` reverses it. Two overrides exist: `bash install.sh --system` installs into `/usr/local/bin` and does use sudo, and `BIN_DIR=/some/path bash install.sh` picks any other directory. Permission dialogs need `zenity`, falling back to `kdialog`. With neither installed every prompt fails closed, so a file outside an already trusted folder is refused. Folders trusted from the tray still work. ### From Go source Go 1.26 or later: ```bash go install github.com/panphora/htmlclay/cmd/htmlclay@latest ``` That gives the tray app, the local server, and the `htmlclay wire` CLI. On Windows it also gives double-click association, because the binary registers that itself. On macOS and Linux the association lives outside the binary (in the `.app` bundle's `Info.plist` and in the freedesktop MIME database), so use a packaged build if you want double-click to work. From a checkout, the Makefile targets are `make build`, `make test`, `make dist-macos`, `make dist-linux`, `make dist-windows`, `make clean`. ## The binary's own flags The executable takes exactly two flags plus one subcommand. Both `-name` and `--name` spellings work. ``` htmlclay [--no-tray] [--unregister] [file] htmlclay wire ... ``` - `--no-tray` runs without the system tray and shuts down on SIGINT or SIGTERM. - `--unregister` removes the file associations and the Start on Login entry, then exits. It runs before the single-instance lock, so it works whether or not the app is running. It does not touch `config.json`, the trusted-folder list, or saved versions. - A trailing path argument opens that file. Only the first is used. If another instance already holds the lock, the path is forwarded to it and this process exits 0. ## A minimal `.htmlclay` file The recommended way to make a file malleable is ClayJS, a separate MIT-0 library loaded from a CDN. HTML Clay never injects a script into your page; the page brings its own. ```html My list

My list

Make my first file

This part is for notes. Click anywhere and type.

``` Save it as plain text with a `.htmlclay` extension, put it anywhere inside your home folder, and double-click it. It opens at an address beginning `http://127.0.0.1:`. You can also open it from a terminal with `htmlclay my-list.htmlclay`. The three attributes that did the work: | Attribute | Where | Effect | |---|---|---| | `autosave` | the `` element | saves after edits settle | | `editable` | heading, paragraph, span, div | click into it and edit its contents as rich text | | `persist` | checkbox, text field, textarea, radio, select | writes the control's value into the file | Command+S on macOS, Control+S elsewhere, saves at once. There is no save button. The plugin list on the loader URL is `?plugins=` comma separated. Loadable plugins: `richclay` (on by default), `indicator`, `sync`, `sortable`, `undo`, `cms`, `quickcrop`, `upload`, `wire`, `demo`. Remove a default-on plugin with `?exclude=`. The complete ClayJS reference is at https://clayjs.com/llms.txt. ClayJS is client-side, so a file that loads it from `https://clayjs.com` needs network access for that one script. Download ClayJS beside your file and point the script tag at it to work entirely offline. HTML Clay itself never uploads the document. A file needs no library at all. The save endpoint is a plain POST, and this is the whole of it: ```html ``` ## How the bridge works ``` User double-clicks a .htmlclay file -> OS launches HTML Clay (registered handler for .htmlclay) -> App picks the file's origin: the trusted folder containing it, else its own folder -> App binds that origin's remembered port on 127.0.0.1 and mints a session token -> App opens the system default browser at http://127.0.0.1:/ -> Server injects savetoken into the tag on the way out -> Page edits, saves: POST /_/save/{token} with the whole document as the body -> Server writes to a temp file, fsyncs, renames it over the target ``` Facts that matter when you are reasoning about this: - The server binds `127.0.0.1` only, never `0.0.0.0`, and validates the `Host` header. - One origin per project tree. A trusted folder gets its own port; each folder a loose file was opened from gets its own port. Two projects are therefore two browser origins and cannot read each other. - **The port is never fixed.** `52341` throughout this file is illustrative. The port is remembered per anchor folder in `config.json` and bound again at every startup, before any file is opened, so a bookmarked address still answers after a restart. A remembered port that is taken at startup is given up and the new one recorded. - An address HTML Clay remembers but is not currently serving answers with a fixed recovery page that holds no permissions and never touches the disk. Three host additions ride on a document navigation, and none of them is a script: - **`savetoken`** on the root `` element, the per-file save capability. It is 32 cryptographically random bytes as unpadded URL-safe base64, lives for the lifetime of the process, and is redacted from the log. It is injected only when `Sec-Fetch-Dest` is `document` or absent, so a background `fetch()` of a sibling file gets the bytes it is allowed to read and no token. - **`documentid`** on `.htmlclay` documents, a UUID that is the durable identity behind version history. It reaches disk on the page's first save, and it is what makes history follow a renamed or moved file. Both names are recent. Before the save protocol settled on `savetoken` and `documentid`, HTML Clay called them `htmlclaytoken` and `htmlclayid`, and it still handles both, permanently. The token is injected under **both** names carrying the same value, and stripped under both on save. A document reads this attribute by name in its own inline script and no update can reach that script, so serving one name only would silently stop every older document from saving. One value under two names is safe where two credentials would not be: a reader takes the first name it recognises and gets the same token. The id is injected under the current name only. Nothing frozen reads it — it is this host's version-history key, and the clients that touch it know both spellings — so serving both would put two ids in every saved file for no reader. A file already carrying `htmlclayid` keeps its history, and serving re-stamps it as `documentid`, so it migrates on its next save. Write new pages against `savetoken` and `documentid`. - **`isAdminOfCurrentResource=true`**, a host-only cookie that tells ClayJS edit mode is available. The response also carries `Cache-Control: no-store`, so a 304 after a restart cannot hand back a dead token. The save token and the read-only banner are stripped from the body on every save, so neither reaches disk. **Opening a file changes nothing.** The token, the cookie and the initial identity exist only in the served response; a file you open and never save stays byte for byte unchanged. **HTML Clay always opens the system default browser** (`open` on macOS, `xdg-open` on Linux, `cmd /c start` on Windows), in an ordinary tab. App Mode, an older chromeless window backed by a private Chromium profile, has been removed. A launch that finds the old `chrome-profile` directory under its config dir deletes it and posts a notification saying so. Do not write instructions that assume a chromeless app window, and note there is no flag or setting to bring it back. ## Server endpoints Content is served at the top level; actions live under the `/_/` marker, matching the Hyperclay platform convention. | Method | Route | Purpose | |---|---|---| | `GET` | `/{path}` | serve a file, with `savetoken` injected on document navigations | | `GET` | `/_/read/{token}` | raw file contents | | `POST` | `/_/save/{token}` | write the document back to disk, atomically | | `POST` | `/_/upload/{token}` | store a file beside the document | | `GET` | `/_/meta` | what this host supports, no token needed | | `GET` | `/_/meta/{token}` | the same, plus metadata for one file | | `GET` | `/_/versions/{token}` | list saved versions | | `GET` | `/_/version/{token}/{name}` | read one version | | `POST` | `/_/restore/{token}/{name}` | restore one version | | `GET` | `/_/sync` | live-sync SSE stream (also `GET /_/live-sync/stream`) | | `POST` | `/_/sync` | live-sync snapshot or document relay (also `POST /_/live-sync/save`) | | `POST` | `/_/open-request` | ask to widen reads to a named folder | | `POST` | `/_/workspace-request/{token}` | ask to trust the requesting file's own folder | | `GET` | `/_/api/{path}` | read the document as JSON, using rules the file publishes | | `GET` | `/{path}?data={...}` | read the document as JSON, using rules you supply | | `GET` | `/_/wire/subscribe` | wire SSE stream | | `POST` | `/_/wire/send` | send one wire frame | Every mutating route except the wire is wrapped in a same-origin guard: it requires `Sec-Fetch-Site: same-origin`, and if `Origin` is present it must equal `http://`. The wire is deliberately outside that guard, because it admits local processes, which attest neither header. Two global gates sit in front of everything. The `Host` header must be exactly `127.0.0.1:` or `localhost:`, so both spellings work from `curl`. Any request with `Sec-Fetch-Site: cross-site` is refused. Both failures are `403 Forbidden` in plain text. A read refused for being outside the page's permitted folder answers `403` with `Content-Type: application/problem+json`, the header `X-HTMLClay-Error: read-access-required`, and this body, which names no path and does not depend on whether the file exists: ```json {"type":"about:blank","title":"Read access required","status":403, "detail":"This page requested a file outside its permitted folder; access was not granted."} ``` ### `POST /_/save/{token}` The body is the whole document as text. That is the one body shape this route has. - A JSON `Content-Type` is refused with `415` and `{"ok":false,"msg":"/_/save takes the document as text, not JSON.","msgType":"error","code":"unsupported-type"}`. A JSON body is never guessed at. - An empty body is `400 {"ok":false,"error":"empty body"}`. - A body with no `` tag is `400 {"ok":false,"error":"body is not an HTML document"}`. - Over 50 MB is `413 {"ok":false,"error":"body too large (max 50MB)"}`. - An invalid or expired token is `401 {"ok":false,"error":"invalid token"}`. - Success is `{"ok":true,"msg":"Saved","msgType":"success","etag":"…"}`. - When the file changed on disk since this server last wrote it, the save still succeeds and the body is `{"ok":true,"msg":" had been changed outside this tab. Your version was saved; the previous one is in Backups.","msgType":"warning","etag":"…"}`. **Conditional saves.** Send `If-Match` carrying the stamp you last saw and the save is refused rather than allowed to overwrite a newer copy: ```json {"ok":false,"code":"conflict","msgType":"error","etag":"…", "msg":" changed since you last loaded it. Your version was not saved.", "changedBy":"another-tab"} ``` with status `412`, and nothing written: not the file, not a version, not the file's identity. The `etag` in the refusal is the current one, so a client can reconcile without a second request. `changedBy` names what moved the document, and appears only when this host actually knows. It says `another-tab` when the bytes on disk are ones this process wrote itself, which on a desktop host means you, in another tab or on another device. A change made by anything else — an editor, a script, an agent, a sync client — is indistinguishable from here, so the field is **omitted** rather than guessed at. A save with no `If-Match` is the plain last-write-wins save and behaves exactly as it always has, so nothing already saved is affected by this capability existing. The stamp is `sha256` of the bytes **stored**, hex, first 16 characters, and it is the same computation hyperclay uses, so a document that moves between the two hosts keeps a stamp both recognise. Stored, not sent: a save arrives carrying a save token that is stripped before the write, so the stamp describes the file rather than the request. `If-Match: *` matches any document that has bytes; a field naming no stamp this host issued is refused rather than ignored. ### `GET /_/meta/{token}` ```json { "spec": 1, "extensions": ["conditional", "sync", "upload"], "path": "notes.htmlclay", "absolutePath": "/Users/you/Desktop/notes.htmlclay", "name": "notes.htmlclay", "size": 3122, "lastModified": "2026-08-27T18:04:11Z", "documentid": "…", "htmlclayid": "…", "document": { "etag": "…", "upload": { "allowed": true, "maxBytes": 26214400 } } } ``` `document` is the per-file half of the answer, and where a client seeds the stamp it later sends as `If-Match`. It is the only part of a discovery answer a host ever withholds. Its `upload` block is what makes the announced `upload` capability usable: a client that finds no block does not upload and does not probe the route to find out, so the cap is reported rather than discovered by a refusal. `htmlclayid` is the pre-spec spelling of `documentid` and carries the same value. It is permanent. Every earlier HTML Clay answered here with that name, and this route exists for a document that has no other way to learn about itself, so its own inline script is the reader that would have hardcoded it. `path` is always forward-slashed, so a client can build a URL from it. `absolutePath` keeps the platform's own separators, because it names a file on disk rather than a route. ### `GET /_/meta` ```json { "spec": 1, "extensions": ["conditional", "sync", "upload"] } ``` The same answer without the per-file half, and it takes no token. `spec` and `extensions` describe the host, so they are identical for every file it serves and nothing in them belongs to any one person; the protocol forbids gating that behind a credential, because the caller is a page a person just loaded and this is the only place a client may learn what a host supports. Ask here when the page carries no save token, which on this host means any file served read-only. A client that gets no answer treats the host as save-only, which is a correct thing to be, so discovery failing never costs a save. ### `GET /_/versions/{token}` and restore ```json {"ok": true, "name": "notes.htmlclay", "versions": [{"name": "2026-08-27-14-03-52-431-0400.html", "time": "…", "seq": 0, "size": 3122}]} ``` Newest first. `GET /_/version/{token}/{name}` returns that version's raw bytes as `text/html; charset=utf-8`, and `{name}` must be exactly one generated filename or the answer is `400 invalid version name`. `POST /_/restore/{token}/{name}` answers `{"ok":true,"msg":"Restored ","msgType":"success"}`. Before an old version replaces the current file, HTML Clay backs up what is there now; if that copy cannot be made, the restore is refused with `500 current file cannot be read, so no safety backup is possible`. A version over 50 MB is `413`, and one that is not a complete HTML document is `400`. ### `POST /_/upload/{token}` `multipart/form-data` with exactly one part named `file`. ```json {"ok": true, "msg": "Uploaded", "msgType": "success", "uploads": [{"name": "photo-a3f19c.jpg", "url": "assets-notes/photo-a3f19c.jpg", "bytes": 84210}]} ``` The file lands in a folder beside the document named `assets-`, so `notes.htmlclay` uploads into `assets-notes/`. The stored name is the submitted stem, a dash, the first 6 hex characters of the content's SHA-256, and the extension. That tail lengthens only on a real collision between different content, so identical bytes reuse one file and different bytes never overwrite each other. The returned `url` is relative and percent-encoded per segment. Limit 25 MB (`413`, `code: "too-large"`). Active content is refused with `415` and `code: "unsupported-type"`: `.html`, `.htm`, `.xhtml`, `.htmlclay`, `.js`, `.mjs`, `.cjs`, `.xml`, `.xht`, `.xsl`, `.xslt`. SVG is accepted and served inert. ## The wire The wire is a per-file control channel between an open page and one program you started yourself. The page sends a small request, the program edits the `.htmlclay` file, and the edit reaches the page the same way any other file change does. **No HTML travels on the wire in either direction.** The file is the only thing the two sides share. This is the part of HTML Clay written for agents. An agent reading this file is the intended handler. Requirements: HTML Clay 1.6.0 or later with the file already open in a browser during the current app run, and ClayJS 0.6.0 or later with the `wire` plugin. Load `sync` alongside it, because without live sync the page never sees the change the program made and a finished request reports done several seconds late rather than the moment the text appears. ```html ``` ### Five minutes Open the file in a browser first. Then attach a program to it: ```bash htmlclay wire serve ~/notes/page.htmlclay -- ./my-agent.sh ``` The CLI answers with one line and waits: ``` [wire] attached as handler to http://127.0.0.1:52341 (/Users/you/notes/page.htmlclay) ``` `my-agent.sh` runs once per request. The request envelope arrives on stdin and the file's path in the environment: ```bash #!/bin/bash echo "working on it" # becomes a status line the page can show node rewrite.js "$HTMLCLAY_WIRE_FILE" # edit the file however you like ``` From the page: ```js const request = clay.wire.send( { instruction: "make the intro shorter", target: "#intro" }, { text: "make the intro shorter" } ); clay.wire.on(state => console.log(state.state, state.text)); const outcome = await request.done; // { state: "done" | "error" | "cancelled", ... } ``` The payload is yours. HTML Clay passes it through untouched and never looks inside it. ### The CLI ``` htmlclay wire serve -- [args...] run for every request htmlclay wire listen [--handler] print frames as JSON lines htmlclay wire send --type ... send one frame, payload on stdin htmlclay wire where print the origin serving ``` Flags may sit on either side of the file argument. Exactly one file operand is required. | Subcommand | Flags | |---|---| | `serve` | `--port ` | | `listen` | `--handler`, `--port ` | | `send` | `--type `, `--id `, `--text `, `--port ` | | `where` | `--port ` | `serve` requires a literal `--` with at least one argument after it; without one it prints usage and exits 1. It always takes the handler role, so it has no `--handler` flag. `--port ` names the origin directly. Use it to reach a file whose folder HTML Clay does not remember a port for, such as a file loose in your home directory: read the port off the page's own address bar. Otherwise the CLI discovers the origin from `sitePorts` in `config.json`, trying ancestor anchors first, shortest first. `listen` is an observer unless you pass `--handler`. The handler slot is exclusive, one program per file, and it also keeps HTML Clay watching the file while no tab is open on it, so an edit made while you are away is still versioned and still appears when you come back. `--handler` on `listen` takes that slot without running any child command. `send --type` must start with `wire/`. The payload is read from stdin and must be valid JSON: ```bash printf '%s' '{"instruction":"make the intro shorter"}' | htmlclay wire send ~/notes/page.htmlclay \ --type wire/request \ --text "make the intro shorter" ``` It prints the server's acknowledgement. `where` prints one JSON line: ```json {"file":"/Users/you/notes/page.htmlclay","origin":"http://127.0.0.1:52341","anchor":"/Users/you/notes","state":"live"} ``` `anchor` is the literal string `--port` when `--port` was passed. `state` is `live` or `refused`, and a refused answer carries a `status` field. Exit codes: | Exit | Means | |---|---| | 0 | fine | | 1 | bad arguments, unknown subcommand, or a path that cannot be resolved | | 2 | HTML Clay is not running, or has never served this file's folder | | 3 | the address is held by the recovery page, so nothing is open at this origin | | 4 | no site is serving that file, so open it first | | 5 | another program holds the handler slot | | 6 | refused (403, 429, 503) | | 7 | the send was accepted and nothing was attached to take it | Frames go to stdout, one JSON object per line. Everything else goes to stderr. ### The handler contract `wire serve -- ` runs `` once per request. **In:** the whole request envelope as JSON on stdin, plus two environment variables. ```json { "v": 1, "type": "wire/request", "id": "request-id", "from": "page", "file": "/absolute/path/to/page.htmlclay", "text": "make the intro shorter", "payload": { "instruction": "make the intro shorter", "target": "#intro" } } ``` | Variable | Value | |---|---| | `HTMLCLAY_WIRE_FILE` | absolute, symlink-resolved path of the file to edit | | `HTMLCLAY_WIRE_ID` | this request's id | **Out:** every line the program prints on stdout becomes a `wire/status` frame the page can display. Exiting `0` becomes `wire/done`; any other exit code becomes `wire/error`. **Cancel:** the process is sent `SIGTERM` (on Windows there is no deliverable SIGTERM, so it is killed). A handler that wants to finish its write can trap it. After the cancel signal the child gets 5 seconds before exec forces it down. **On the CLI's own stderr:** the handler's stderr, each line prefixed with the short request id (the first 8 characters), and then one line per request saying how it ended: `done`, `error: …`, or `cancelled`. That last line matters when something goes wrong, because a request that ends with no terminal frame looks exactly like one whose frame was posted and lost downstream, and the two have nothing in common. The line is printed after the frame goes out, so if the line is there, the CLI did its part. A request refused before the handler ever ran says so on the same line: `refused: …`, or `already running; ignoring the repeat`. At most 8 requests run concurrently in one `wire serve`. Past that a request is refused with a terminal frame rather than queued, so the page stops spinning. The program edits the file directly. There is no "return the new HTML" path, on purpose: the file is the state, and anything that wrote HTML back through the wire would be a second, competing writer. ### Request states and timeouts | State | Means | |---|---| | `sent` | posted, waiting for the program to take it | | `acked` | the program has it, and every status line rearms the clock | | `landing` | the program finished writing, the page is waiting to see the bytes | | `done` | the change is on the page | | `error` | the program failed, nothing was attached, or it went silent | | `cancelled` | you took it back | **`done` means visible, not merely finished.** The program's exit says the file is written; the page then waits up to 4 seconds for live sync to deliver it before reporting done. A page with no `sync` plugin still reports done, just later. Page-side timeouts, so a stuck program cannot hang the page: 15 seconds to acknowledge, then 120 seconds of silence. **Every frame rearms the clock**, so a program that prints what it is doing can work for as long as it likes. A silent one gets two minutes. This is why both of the example handlers print while they work. The CLI's own stream has a 90 second idle timeout, and the server sends a keepalive comment every 25 seconds, so three missed keepalives end the stream and it reconnects. ### The page API `clay.wire` exists only when the plugin is loaded, so check for it. ```js clay.wire.send(payload, opts) // returns { id, state, done, cancel() } clay.wire.cancel(id) // true if it was still open clay.wire.get(id) // one snapshot, or undefined clay.wire.list() // every request this page knows about clay.wire.isBusy() // is anything in flight clay.wire.on(fn) // subscribe; returns its own unsubscribe ``` `opts` takes `{ id, type, text }`, all optional. `text` is a plain line for a program that wants one without parsing the payload. `id` lets you supply your own request id; reusing a live one is refused as an outcome rather than thrown. `type` defaults to `wire/request`. A snapshot is `{ id, type, state, text, error, startedAt }`. `send` returns immediately. `handle.done` resolves with the final snapshot and **never rejects**: a failed request is an outcome to render, not an exception to catch. `clay.wire.on(fn)` passes the snapshot as the first argument and, as the second, the inbound frame when one caused the update. Today that means `wire/status` and nothing else. `state.text` is sticky across later states, so the frame is the only way to tell a new status line from the same line arriving again. The DOM event `clay:wire-state` carries the same snapshot on `document`, for code that would rather not hold a subscription. **Sending saves the page first.** The program is about to read the file, so it has to read what you are looking at. Autosave is then suspended until the request ends, so the page cannot write over the program mid-edit. Both are automatic. ### Status UI must never become document content Everything visible in a malleable page can be serialized, so wire status UI has to be built at runtime and marked before it is connected: ```js const status = document.createElement("p"); status.setAttribute("clay", "no-save no-snapshot no-watch"); document.getElementById("ask").after(status); clay.wire.on(state => { status.textContent = state.error || state.text || state.state; }); ``` **Never author a `no-save` node into the file by hand.** A node marked `no-save` is left out of the document that gets written, so the first save deletes it permanently. Injecting it at runtime means it comes back on every load without ever becoming saved state. Some hosts read the attribute form rather than the `clay` list, so set both when you want to be portable: ```js for (const t of ["no-save", "no-snapshot", "no-watch"]) node.setAttribute(t, ""); ``` ### The HTTP protocol underneath Two routes, both under `/_/wire/`. ``` GET /_/wire/subscribe?file=[&role=handler] POST /_/wire/send ``` `GET /_/wire/subscribe` returns `text/event-stream` with `Cache-Control: no-store`, `Connection: keep-alive`, `X-Accel-Buffering: no`. Each frame is `id: ` then `data: `. The keepalive is a bare comment line. `Last-Event-ID` (header, or a `lastEventId` query parameter) resumes and replays retained terminal frames past that sequence; a fresh subscription replays nothing. A browser never names a path: its file comes from the page's own URL, sent as the `Page-URL` header or a `page-url` query parameter, and any supplied `file` is discarded. A local process has no page, so it names the absolute path, which is then validated for containment in the home directory and must already be registered with this site. `POST /_/wire/send` requires `Content-Type: application/json`. The body is the envelope shown under the handler contract: seven fields, no more. `v`, `file`, and `from` are stamped by the server and any client-supplied value is discarded, which is why a browser cannot address another file by writing a different path into its envelope. `from` is exactly one of `"process"` or `"page"`. `payload` is opaque raw JSON that the server never inspects. The response is: ```json {"ok": true, "delivered": 1, "observers": 0} ``` `delivered` counts handler queues only. It is how a page distinguishes "nobody was listening" from a timeout, and it is what `wire send` turns into exit code 7. Frame types in use: `wire/request`, `wire/ack`, `wire/status`, `wire/done`, `wire/error`, `wire/cancel`. The router validates only that a type starts with `wire/` and is at most 64 bytes; it does not enforce a closed set. `wire/done` and `wire/error` are the only two the server itself treats specially, as terminal frames. The first terminal frame for an id wins, and a later one does not overwrite it. Errors from `POST /_/wire/send`, all `{"ok":false,"error":"…"}`: | Status | Message | |---|---| | 415 | `expected application/json` | | 413 | `wire frame too large` | | 400 | `could not read body`, `invalid JSON`, `invalid type`, `invalid id` | | 404 | `unknown file` | Errors from `GET /_/wire/subscribe` are plain text: `403 Forbidden` (guard refused, or a browser asked for the handler role), `404 Not Found`, `409 Conflict` (handler slot taken), `429 Too Many Requests` (too many observers), `503 Service Unavailable` (shutting down, or version history could not be opened for the handler). Caps: request body 1 MiB, status `text` truncated at 4096 bytes, request id 128 bytes, 8 observers per file, 32 retained terminal frames per file for 5 minutes, 8 MiB of retained terminal frames hub-wide. ### What keeps the wire safe **A local process runs as you, so it needs no secret.** The wire tells a browser from a program by attestation: a browser sends `Sec-Fetch-Site` or `Origin` on every request, including every `EventSource`, and cannot forge either. A local process sends neither. That is the entire classifier. There is no token to leak and no header to copy. **A page may only reach its own file.** Requests are addressed by the file's absolute path, and a page can only name the file it was served from. `Sec-Fetch-Site: same-site` is rejected rather than admitted: HTML Clay serves one loopback origin per project tree, and every one is same-site with every other, so admitting it would let one project's page drive another project's wire. **Only a program may be a handler.** A page can send and observe. It cannot claim the handler slot, so an open tab cannot impersonate your agent. That refusal happens before the file is even resolved, so it reveals nothing about which paths exist. **Nothing but text crosses.** Requests and status lines are small JSON frames. The content is in the file, where you can read it, diff it, and put it in version control. The 1 MiB body cap is set low deliberately so that "HTML never rides the wire" fails loudly rather than drifting. **Every write is versioned.** Attaching a handler turns version history on for the file and takes a baseline backup first, so an agent's own first write is not the earliest state history remembers. If the file cannot be read for that baseline, the attach is refused rather than run unversioned. An edit made by a program is backed up before it lands, exactly like one made by the page, whether or not a tab was open at the time. **What the wire does not decide is whether the work is safe to do.** The request payload and the file's own contents are inputs to a process running with your permissions. When the file came from someone else, treat both as untrusted instructions rather than as a task description, and do not attach an agent to a document merely because the channel is local. ### Answering in words Status lines are how a handler says what it is doing. They are also how it says something back to the person who asked, if the page knows to look for one. This is a convention between the page and the handler, not something HTML Clay parses. The convention worth copying is a single line that parses as a JSON object carrying a `redpen` key: ```json {"redpen":1,"reply":"Cut the second clause and moved the date into the caption."} ``` | Field | Meaning | |---|---| | `redpen` | the marker, value `1`. Required, and conventionally the first key | | `reply` | the text, plain. A blank line starts a paragraph | | `append` | optional `true`: concatenate onto the reply so far instead of replacing it | Every other line stays progress. The marker is what stops a handler that pipes `jq` or an HTTP response to stdout from having its tool output read as the agent's own words. Put `redpen` first, because a line cut at the size limit keeps its head, and that head is the only thing a reader can use to tell a truncated reply from a line that was never meant to be one. Three rules that are easy to get wrong: - **Budget the reply at about 3,500 bytes** after JSON escaping. The line cap is 4096 bytes and it is enforced twice, in the CLI's reader and again in the server. Longer replies split across several lines with `append: true`. - **Print the reply before you exit.** Frames for a request that has already finished are dropped silently, so a reply printed after the process ends is simply lost. - **The last non-append line wins**, which makes printing the same reply twice free. The status lane is lossy on both sides, so a handler that prints its reply when it has the answer and again just before exit halves the chance of losing it. Hand-escaping JSON in bash is how people ship broken handlers, so let `jq` do it: ```bash echo "rewriting the intro" REPLY=$(node rewrite.js "$HTMLCLAY_WIRE_FILE") printf '{"redpen":1,"reply":%s}\n' "$(jq -Rs . <<<"$REPLY")" ``` A page that holds a conversation sends a follow-up as another ordinary request. The payload convention is that **`said` is always the newest message**, so a handler that only reads `payload.said` keeps working with no change; the exchanges so far ride alongside it as `thread`, a list of `{you, agent}` pairs, appearing only from the second message onwards. Both keys live inside `payload`, which the server never inspects. ### A complete working page This is the file from https://htmlclay.com/agent-editing, verbatim in substance. Save it with a `.htmlclay` extension and open it before attaching a handler. ```html Robot notes

Robot notes

  • Lines the robot adds will show up here.

This part is yours. Click and type.

``` A handler with nothing clever in it, to prove the loop: ```bash #!/bin/bash echo "working on it" NOW=$(date "+%H:%M:%S") LINE="
  • The robot was here at $NOW.
  • " perl -pi -e "s||$LINE\n |" "$HTMLCLAY_WIRE_FILE" echo "added a line at $NOW" ``` ```bash htmlclay wire serve robot-notes.htmlclay -- bash robot.sh ``` An AI handler is the same three rules with a different program. This one prints a heartbeat every five seconds so the silence timeout never fires, and sends the agent's own chatter to the terminal rather than the page: ```bash #!/bin/bash REQUEST=$(cat) claude --permission-mode acceptEdits -p "The file at $HTMLCLAY_WIRE_FILE is an HTML page I have open on my screen. This request came from inside the page: $REQUEST. Make the change it asks for by editing that file directly. Leave everything else in the file exactly as it is, and do not create any other files." 1>&2 & agent=$! while kill -0 "$agent" 2>/dev/null; do echo "thinking" sleep 5 done wait "$agent" ``` ```bash htmlclay wire serve robot-notes.htmlclay -- bash agent.sh ``` `acceptEdits` lets that example write without asking again. Use a stricter permission mode when each edit should be reviewed. Where your file goes: an AI agent reads your file and sends what it reads to its own provider, under your own account with them. HTML Clay sends nothing anywhere. It starts the program you named and gets out of the way. The agent runs with your permissions, in the folder you started it from. On Windows the app and the wire both work; the example handlers are bash scripts. Write the same three rules in PowerShell or any language: read stdin, print progress, edit the file named by `HTMLCLAY_WIRE_FILE`, exit 0. ### Things that will trip you up **Open the file before attaching.** A file registers with HTML Clay on its first document navigation, and the wire finds files through that registration. Attaching first exits 4 with `no HTML Clay site is serving this file; open it first`. **One program per file.** A second attach while the first is running exits 5. Stop the old one with Ctrl+C. **Keys decide what survives.** If someone is editing one part of the page while a program rewrites another, live sync merges the two by matching elements. Give the regions a program will touch a stable `data-id` or `id`, or a structural change has nothing to match and quietly does not appear until the next reload. **A reply printed after the work is done is thrown away.** Frames are dropped for a request that has already finished, and exiting finishes it. Print anything you want the page to read, then exit. **The first save after a program's edit warns you.** It reports that the file changed outside this tab and that your version was saved with the previous one in Backups. Nothing is lost, and the page's own save carries the program's edit forward. Known wart. A runnable end-to-end harness, with a comment UI, a program on the other end, and five traces that assert the whole loop against the shipped app, lives at `plans/htmlclay/wire-e2e/` in the Hyper workspace. ## Reading a file as JSON Any `.html` or `.htmlclay` file HTML Clay will serve can also be read as JSON, using the same extraction rules as the Hyperclay platform. Two ways in, and each ignores the other's input. You supply the rules, and the document's own rules tag is ignored: ```bash curl 'http://127.0.0.1:52341/notes.htmlclay?data={title:"h1",items:".todo[]"}' ``` The file supplies its own, and any `?data=` on that URL is ignored: ```bash curl 'http://127.0.0.1:52341/_/api/notes.htmlclay' ``` The second reads a tag the page publishes: ```html

    Hello

    • one
    • two
    ``` ```json {"title":"Hello","items":[{"text":"one","done":"yes"},{"text":"two","done":"no"}]} ``` `data-rules-version` must be exactly `"1"`; anything else is a `400`. The tag is found by `script[data-rules-name~="api"]`, so the attribute is a space-separated token list and a script may publish several names. The first match wins. The token must match `^[a-zA-Z0-9_-]+$`. ### Rule syntax Rules are relaxed JSON: unquoted keys, single-quoted strings, trailing commas, and bare selectors are all fine. Comments are not. Strict JSON is tried first and used as-is when it parses. | Rule | Result | |---|---| | `"sel"` | trimmed text of the first match, or `null` when nothing matches | | `"sel[]"` | list of every match's trimmed text. Empty stays `[]`, never `null` | | `"sel@name"` | property or attribute `name` of the first match | | `"@name"` | property or attribute `name` of the context node itself | | `"."` | trimmed text of the context node | | `["sel", {…}]` | one object per match, the shape evaluated with that match as context | Nesting is capped at 20 levels; the 21st fails. ### Which selectors are accepted The selector gate is an allowlist, not a denylist, and it refuses rather than guesses whenever the two engines would read a construct differently. A refusal is a `400` that names the construct and the reason. Accepted pseudo-classes: `:not`, `:has`, `:nth-child`, `:nth-last-child`, `:nth-of-type`, `:nth-last-of-type`, `:first-child`, `:last-child`, `:first-of-type`, `:last-of-type`, `:only-child`, `:only-of-type`, `:root`, `:first`, `:last`, `:eq`, `:nth`, `:contains`, `:checked`. Refused pseudo-classes, each with its own reason: `:matches`, `:matchesown`, `:containsown`, `:haschild`, `:lang`, `:enabled`, `:disabled`, `:empty`, `:focus`, `:target`, `:gt`, `:lt`, `:even`, `:odd`, `:is`, `:where`, `:scope`, `:parent`, `:header`, `:selected`, `:button`, `:submit`, `:text`, `:password`, `:radio`, `:file`, `:image`, `:reset`, `:input`, `:link`, `:visited`, `:hover`, `:active`. Pseudo-elements (`::before` and friends) are refused too, as are CSS comments and `:has()` whose argument starts with a combinator. `:first`, `:last`, `:eq`, `:nth`, `:contains`, and `:checked` are post-filters, so they are legal only at the very end of a selector, and at most one positional per comma group. `li:first span` is refused for that reason. Attribute clauses accept the operators `=`, `~=`, `|=`, `^=`, `$=`, `*=`, and `!=`. The regular-expression operator `[attr#=…]` is refused, so is the `s` flag in `[a=b s]`, so is any other flag, and so is an empty value with `^= $= *= != ~=`, because the two engines disagree about what it means. A successful data response carries `Content-Type: application/json; charset=utf-8`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`. `@name` reads a DOM property when the name is in the property allowlist, and otherwise the literal attribute. The properties, which shadow any same-named attribute: `textContent`, `innerText`, `innerHTML`, `outerHTML`, `value`, `checked`, `selected`, `disabled`, `readOnly`, `type`, `tagName`, `nodeName`, `nodeType`, `nodeValue`, `childElementCount`, `id`, `className`, `classList`, `baseURI`, `offsetWidth`, `offsetHeight`, `clientWidth`, `clientHeight`, `scrollWidth`, `scrollHeight`, `dataset`, `currentSrc`, `duration`, `paused`, `title`, `documentURI`, `contentType`. `href`, `src`, and `action` are deliberately absent and read as plain attributes, so the round trip stays byte-stable. One asymmetry that is real and easy to hit: the property branch keeps an empty string, the attribute branch turns it into `null`. So `@className` on `class=""` is `""`, while `@data-x` on `data-x=""` is `null`. `sel@name[]` is not supported by either engine. HTML Clay refuses it with a `400` naming the form that works: `["sel", {"name": "@name"}]`. ### What a data request is, and is not A data request is a different response shape on the normal read path, not a second way into the filesystem. It runs the same home-directory, hidden-file, trusted-root, permission and real-path checks as an ordinary GET, in the same order, and returns the same refusal. Specifically it: - cannot read a file an ordinary request could not read - creates no editing session: no save token, no edit-mode cookie, no identity, no version - strips any save token an external writer left on disk before extraction, so a capability cannot be read back out - returns `Cache-Control: no-store` - sends no CORS headers - accepts only `.html` and `.htmlclay` documents - is read-only and has no mutating counterpart **The absence of CORS headers is deliberate.** Only a program you run yourself, such as `curl`, or a page on this same origin can read it. A page served by another local server on your machine cannot. Bare `/_/api` is a `400`, not an index. A non-`.html`/`.htmlclay` path is a `404` on the `/_/api` route, and on the query route the `?data=` parameter is ignored and the file is served normally. Error bodies are `{"error": "...", "message": "...", "details": "...", "example": "?data={title:\"h1\",items:\".item\"}"}`. ### Where this differs from the Hyperclay platform Each row was measured against the platform's own engine rather than assumed. | Behavior | HTML Clay | Platform | |---|---|---| | A selector the two engines read differently (`:is`, `:empty`, `:gt`, `:matches`, CSS comments, the `[a=b s]` flag) | refused with a reason | answered, sometimes differently | | A positional that is not last, such as `li:first span` | refused | answered | | Any broken selector | `400` | `400` or `500`, depending on the parser's wording | | `@type` on an element | the `type` attribute | `"tag"`, the internal node type | | `@readOnly` | the real value | always `false` | | `sel@href[]` | refused, naming the array form that works | silently `[]` | | A repeated `?data=` parameter | the first one wins | `500` | | Caching | none | five minutes | | CORS | none | enabled | The full ledger, with the measurement behind every row, is `internal/dataapi/testdata/selector-parity.json` in the repository. ## Versions and backups Every write keeps a restorable previous version, including writes made by a program over the wire. Retention is the union of two rules: **the newest 20 versions are always kept, plus every version from the last 60 days.** A version is captured at five moments: when an explicitly opened file is first served, before a first save, on every incoming save, before an old version is restored, and when another program changes the file on disk. Versions live under the config directory, in `/htmlclay/versions`. The tray menu item is called Backups and opens that folder in the file manager. Each version is a complete, uncompressed `.html` file whose name is a readable local timestamp (`2026-08-27-14-03-52-431-0400.html`), readable without HTML Clay and readable after HTML Clay is gone. Saving a 1 MB file 20 times therefore uses about 20 MB. Other behavior worth knowing: - **Opening is read-only.** A file you open and never save stays byte for byte unchanged. - **Opening leaves no permanent copy.** A first-open snapshot under a freshly minted identity that no save ever makes durable is deleted after 7 days. - **Consecutive identical versions collapse.** The same content is never stored twice in a row. - **History follows the document.** Rename or move a `.htmlclay` file and its history follows, because the identity is the `documentid` attribute on ``. A copy gets a fresh identity, so the copy's edits never mix with the original's. Plain `.html` files are the exception: their history is keyed by location, so moving one leaves its history behind. - **An outside edit is backed up before your save wins.** The save succeeds and the previous bytes remain in Backups. - **A save replaces the whole file.** Temp file, flush, rename over the target. On macOS and Linux the directory is flushed too; on Windows that final directory flush is skipped. ## Trusted folders and the security boundary Two rules carry the whole design: 1. **Only files you choose can be saved.** That choice takes two forms: a file you opened yourself, and any HTML Clay file inside a folder you have trusted. Everything else, a file reached by a link, an iframe, or a typed URL, is served read-only and never gets a save token. 2. **A page can only read inside the folder you opened it from.** A request for anything outside that folder pauses and asks you, in a dialog HTML Clay draws itself, to widen reads to one named folder. One sentence: a page can only change itself. A trusted folder is the one exception, and inside it every HTML Clay file can change every other, forever, including files that arrive later. Trust only a folder you control completely. Opening one file explicitly gives its page read access to its own folder, so relative CSS, scripts, images and links work, and gives that one document a save token. A trusted folder is broader and persistent: it gets one stable localhost origin, every `.htmlclay` file inside it can auto-register as editable including files added later, relative links work across the project, browser storage stays on one origin, and bookmarks survive restarts while the remembered port is still free. Always refused, trusted or not: anything outside your home folder, dotfiles and dot-directories such as `.env`, `.git`, and `.ssh`, HTML Clay's own settings and version history, and directory listings. A refusal is a single fixed response that names no path and does not depend on whether the file exists, so a page cannot use refusals to work out which files you have. Symlinks are resolved and the real path of the opened file descriptor is checked before any bytes are served. Save tokens are per-file, 256 bits, and never written to the log. Save and restore both replace the file atomically rather than truncating in place. There are three ways to trust a folder, and the two page routes have different rules. Getting this backwards is the usual mistake, so it is worth stating exactly. The personal folders in question are the top-level names `Desktop`, `Documents`, `Downloads`, `Library`, `Movies`, `Music`, `Pictures`, `Public`, plus the sync roots `Dropbox`, `Google Drive`, `Nextcloud`, `Sync`, `Box`, and `OneDrive`. `OneDrive` matches by the start of the name, so a work account's `OneDrive - Contoso` is covered. When Windows folder backup has moved `Desktop`, `Documents`, or `Pictures` inside a sync root, those are covered there too. Matching is case-folded and follows symlinks, so a `Documents` that points at an external drive is still recognized. 1. **The tray's "Trust a Folder…" picker** consults none of the rules below, because choosing a folder from a picker is already a deliberate act. It warns once before letting you pick a personal folder, then does it. 2. **The read-permission dialog's "Trust this folder" button**, on a page that asked to read outside its folder. The page steers which folder gets named here, by choosing which files it asks for, so the rule is the strict one: a personal folder, **anything inside one**, and any ancestor of one are all refused, and the button is simply not shown. The practical cost is real: if your projects live under `~/Documents` or `~/Desktop`, this route will never offer to trust them. 3. **A page asking to trust its own folder**, either from the read-only banner or from a script. The folder is pinned to the requesting file's own directory, so a page cannot inflate it, and the rule is the looser one: a personal folder itself and any ancestor that would swallow one are refused, but a folder **inside** a personal folder is allowed. `~/Documents/GitHub/myproject` is trustable this way; `~/Documents/GitHub` itself is refused, because it would take the whole checkout tree. Both page routes raise a native dialog naming the requesting file and the folder, which is always and only the folder that file actually sits in. The banner's button presents a single-use, server-minted code that resolves to the exact file that page was served from; the code expires in ten minutes, dies on first use, and a Deny quiets that folder for the rest of the session. The dialog also has an "Allow Once" choice, which widens reading only, never writing, and is forgotten when you quit. Untrusting genuinely ends the capability: the folder's origin closes, files it auto-registered lose their save tokens immediately, their live-sync streams close, and a save from a page that was already open is re-checked at write time. Files you had opened yourself survive on a new address of their own. Each trusted folder is pinned to the folder's identity on disk: device and inode on macOS and Linux, volume serial and file id on Windows. A folder deleted and replaced stops granting anything, its address stops being served, and it shows in the tray as "missing or replaced" rather than silently covering the newcomer. Approving the dialog again re-pins it to the folder now on disk, which is the one way back. Windows gained that fingerprint later than the other two platforms. An entry recorded before it did carries an empty pin, which means the path is the whole of that entry's identity; it keeps working and is pinned to the directory currently there on the next launch. Note that `SECURITY.md` and the features page still describe Windows folder trust as path-only. The code has moved past them: `internal/platform/dirid_windows.go` implements `DirIdentity`, `internal/config/config.go` backfills a pin for older entries, and `internal/trust/policy.go` compares it on every platform. HTML Clay is a local bridge, not a sandbox for untrusted HTML. The browser still sandboxes the page as a web page, and the page can still run JavaScript and make network requests like any other page. The known limitations are enumerated in `SECURITY.md` rather than left to be discovered. The two that most change how you should build: - **A trusted folder trusts everything in it.** A hostile HTML file placed inside one can read that whole tree and send it somewhere, and it can open its siblings in visible tabs, lift their save tokens, and overwrite those files too. A worm is possible; because tokens ride only on real navigations, it is not invisible. Only trust folders where you control the HTML and JavaScript. - **The Linux and Windows permission prompts are beta.** They are implemented and reviewed but not yet exercised on real machines. They fail closed: if a dialog cannot run, access is denied. On Linux the prompts are drawn by `zenity`, falling back to `kdialog`; with neither installed every prompt fails closed, and the app says so at startup, in the log, in a notification, and in a permanent tray row. The folder picker also falls back to the XDG desktop portal (`org.freedesktop.portal.FileChooser`), which needs no helper binary. Also true, and load-bearing when you reason about the bridge: the server listens on loopback, so any process running as your user can reach `127.0.0.1:`. Saving requires the per-file token, but the top-level serve route returns a currently-open file by path with no token, so a malicious local process, or a page you already have open, can read other open documents. This is inherent to the localhost-bridge model. HTML Clay serves only files you have explicitly opened, and nothing is exposed off the machine. ## Live sync When a document loads the ClayJS `sync` plugin, tabs of the same file stay in step through the server. Tabs in edit mode exchange unsaved DOM snapshots on the `live` lane. View-mode tabs follow the durable document on the `saved` lane, which the server updates after an accepted save or a settled file-system change. Identifiable dirty regions, the caret, and scroll position survive an incoming file change. This works between tabs and windows on one machine. **It is not multiplayer sync between computers.** Live sync is also how a program's edit reaches an open page. A text editor, git command, sync client, or wire handler can rewrite the file; HTML Clay waits until the bytes stop changing, versions the file, and updates the open page in place with no reload. Half-written files stay off screen, because an outside change is shown only after repeated reads return the same bytes. Your own save does not echo back, because the watcher suppresses the exact hash it just wrote. The relay passes the same same-origin check as every other mutating route. ## Files on disk Configuration lives at `/htmlclay/config.json`, where `` is `~/Library/Application Support` on macOS, `~/.config` on Linux, and `%APPDATA%` on Windows. The log is `/htmlclay/htmlclay.log`, rotated at 10 MB. Versions are in `/htmlclay/versions`. ```json { "startOnLogin": false, "sitePorts": { "/Users/you/projects/notes": 54321 }, "workspaceFolders": [ { "path": "/Users/you/projects/notes", "identity": "16777229:224754543" } ] } ``` `sitePorts` remembers the port each origin was served on, keyed by its anchor folder, and is what the `htmlclay wire` CLI reads to find an origin. The trusted-folder list keeps the on-disk key `workspaceFolders` from the version that introduced it; renaming the key would make older configs fail to parse, and the corrupt-config path would then reset every other setting. `identity` is the folder's fingerprint at the moment you trusted it. An entry recorded before Windows could fingerprint a directory has no `identity`; it keeps working and gains one on the next launch. A single instance is enforced with a Unix socket, or TCP on Windows. Additional launches forward their file paths to the running instance and exit. ## The system tray The app has no main window. The tray menu holds: an update row that appears when a new version is available, Open Example File, Backups, a Trusted Folders submenu (a "Trust a Folder…" row plus one row per trusted folder, clicking a row stops trusting it), a Start on Login checkbox, and Quit. Start on Login is a LaunchAgent on macOS, an autostart desktop entry on Linux, and a registry key on Windows. HTML Clay checks for a new version once at startup, against `https://download.htmlclay.com/htmlclay-release-info.json`. It sends no file contents, and nothing else leaves your machine. ## Limits and non-goals State these plainly rather than discovering them. - **Not a web server and not a host.** Nothing is exposed off the machine. The server binds `127.0.0.1` only. If you want the same file online, that is what https://hyperclay.com is for. - **Not cloud storage, an account system, or a deployment service.** - **Not multiplayer.** Live sync is between tabs and windows on one machine, not between computers. - **Not a general filesystem API.** A page saves through its own token and reads only inside approved roots. - **It does not make arbitrary HTML editable.** Renaming a file changes which app opens it. The editor, the persistence rules and the save behavior all come from page code such as ClayJS. - **Nothing outside your home folder opens at all**, and dotfiles, dot-directories, HTML Clay's own settings, and its version history are always refused. - **No script is injected into your page.** HTML Clay adds a save token, a durable id, and one cookie on the way out and strips its additions before anything reaches disk. - **The save route has exactly one body shape.** Plain document text. A JSON body is a `415`, not a guess. - **The data API sends no CORS headers**, on purpose, so no other origin can read your files. - **A page cannot be a wire handler**, and one file has one handler at a time. - **No chromeless app window.** Files always open in the system default browser, in an ordinary tab. - **The Windows and Linux permission prompts are beta**, and fail closed when no dialog can be drawn. - **Windows skips the final directory flush** on save; macOS and Linux do it. - **Bad fit for**: anything that needs a server, a database, multiple simultaneous remote users, files larger than 50 MB per save, uploads larger than 25 MB, or files outside your home directory. ## Building the app from source Go 1.26 or later. Linux and Windows build as pure Go; macOS builds use cgo for the system tray and Finder integration. ``` cmd/htmlclay/ The executable. main.go starts and stops it, sites.go owns the site registry, folders.go the trusted-folder list, open.go hands a file to the browser, recovery.go the remembered-port page, wire_cli.go the `htmlclay wire` CLI internal/server/ HTTP server, request handlers, security middleware, the wire, live sync internal/session/ Token generation, file to token mapping, held read roots internal/trust/ Which folders may be trusted, and on whose say-so internal/dataapi/ Reading a document as JSON, with its vendored conformance corpus internal/versions/ Save history internal/browser/ Opening a URL in the system's default browser internal/htmlutil/ Inject and strip savetoken and documentid on the tag internal/config/ Settings in the OS config dir internal/platform/ Single-instance lock, Start on Login, file associations, native dialogs internal/tray/ System tray icon and menu internal/logging/ File logger with 10 MB rotation internal/update/ Version check against download.htmlclay.com packaging/ Per-OS packaging assets and build scripts website/ htmlclay.com, deployed from this repo ``` Three things about the repository are easy to trip over: - **The website deploys itself on every push to `main`.** htmlclay.com is a Cloudflare Worker fed by a git integration, not by CI and not by a manual `wrangler deploy`. There is no deploy command to run, and adding one only races the integration. - **Download links are stamped after CI, not during the version bump.** `scripts/stamp-website.js` writes the version into `website/index.html`, anchored on the `data-version`, `data-mac-dmg`, and `data-sha256` attributes. Stamping during the bump would publish links to a dmg CI has not uploaded yet. - **`htmlclay-release-info.json` on R2 is not a website file.** It is the feed the in-app update checker polls, and its URL is compiled into every shipped binary. Removing it would silently and permanently break update checks for installs already in the wild. ## License **First Million Stays Yours License, version 1.0.** Copyright Hyperspace Systems LLC. SPDX identifier `LicenseRef-First-Million-Stays-Yours-1.0`, becoming `MIT` after the conversion date. | Free under | Above it | Becomes MIT | |---|---|---| | US $1,000,000 per calendar year from products built on it | 3% of the excess | 18 months after that version's release | - Free to use, for anyone, for anything. Under the line you owe nothing and there is nothing to pay, report, register, or notify. Keep the copyright notice in copies and you are finished with the license. - The fee applies only to products and services you supply that contain the software, derive from it, or provide its functionality to others by running it, and only to receipts above US $1,000,000 in a calendar year, aggregated across entities under common control. Where the software is one component of something larger, only the share fairly attributable to it counts. - Crossing the line never makes the first million payable, never reaches back into an earlier year, and never ends the license. - **Every released version becomes plain MIT on a date printed in its own LICENSE file.** For 1.7.0, released 2026-08-20, that date is 2028-02-20. The conversion is automatic, unconditional, and irrevocable, and binds successors and assigns. It also happens immediately if there is no public release and no security response for 12 consecutive months, or if the licensor ceases operations without a successor that assumes the license. - Each version's terms are locked at its release and never change. Revised terms apply only to future versions. - **Not open source by the OSD**, and the project does not blur that. It is source available: read every line, build from it, modify it, self-host it. It becomes open source, plain MIT, on the printed date, per version. - A free Coverage Certificate settles any question about whether a specific offering is covered: send a written description to license@hyperclay.com, and silence for 30 days accepts it as submitted. **ClayJS and the page layer are MIT-0, forever.** Everything that runs inside or alongside your HTML files is MIT No Attribution, never fenced. **The malleable HTML file specification is CC0.** Plain answers and payment: https://hyperclay.com/host-program. Questions: license@hyperclay.com. The binding text is `LICENSE` in the repository; the standing commitments are `STABILITY.md`. ## Links - Site: https://htmlclay.com - Get started: https://htmlclay.com/get-started - Features: https://htmlclay.com/features - Agent editing: https://htmlclay.com/agent-editing - Downloads and checksums: https://htmlclay.com/#downloads - Update feed: https://download.htmlclay.com/htmlclay-release-info.json - Source: https://github.com/panphora/htmlclay - Wire protocol: https://github.com/panphora/htmlclay/blob/main/docs/wire.md - Security model: https://github.com/panphora/htmlclay/blob/main/SECURITY.md - Stability commitments: https://github.com/panphora/htmlclay/blob/main/STABILITY.md - ClayJS, the in-page library: https://clayjs.com and https://clayjs.com/llms.txt - The file format specification: https://malleablehtmlfile.com - The hosted platform: https://hyperclay.com - Security reports: david@storylog.com