# The Malleable HTML File Specification **Version 1, draft.** July 2026. This specification is public domain (CC0). You can copy it, change it, and ship it. --- ## The one-line version A malleable HTML file is a plain `.html` file that saves itself: the page serializes its own DOM and POSTs it to one endpoint, and the host writes it back to disk. The file is the app, the DOM is the database, and any server that implements one route can host it. --- ## The problem The web browser is the best application platform ever shipped. It is on every machine, it is sandboxed, and it has powerful APIs for drawing, storage, media, and networking. A useful personal tool, a checklist, a tracker, a diary, a small database, fits comfortably in a few hundred kilobytes of HTML. But an HTML file cannot save itself. And every existing way around that is hostile to normal people: - **Native apps** must be signed and notarized per platform, or the operating system actively warns users away: "unidentified developer," SmartScreen, quarantine dialogs. Email and chat services refuse to carry executables at all. You cannot hand someone an app the way you hand them a document. - **Terminal software** asks the recipient to curl an installer and run a server by hand. Fine for developers. A dead end for everyone else. - **Browser file APIs** (the File System Access API) work only in Chromium browsers and sit behind permission prompts. A document format cannot require a specific browser and a permission ceremony. - **Self-modifying executables** are the tempting dead end. A binary that rewrites itself invalidates its own code signature on every save, so it can never be signed, and no mail system will deliver it anyway. So personal software gets pushed onto platforms: someone else's server, someone else's account system, someone else's subscription. The tool stops being yours. ## The idea Split the problem in two, and keep the halves separate: **The document stays plain HTML.** One file. Its DOM is its storage: state lives in elements, attributes, and text. It travels through email, chat, and USB sticks. It is small, readable, and unscary. Nothing about it needs signing, because it is not an executable. **The host is any HTTP server that can do two things:** serve the file, and accept one POST that overwrites it. A host can be twenty lines of script, a signed desktop app, or a hosted platform. Hosts are interchangeable. The file does not care which one it is sitting on. Saving is the whole trick: the page clones its own DOM, cleans it up, serializes it, and POSTs it to the host. The host writes the bytes over the file. From then on, the file *is* its own latest state. Open it anywhere and your data is already inside. The document is mutable and the runtime is not. That is the property no single-file executable can ever have, and it is why this format can be both powerful and safe to receive. ## The specification Four terms: - **Document**: the HTML file. Both the file at rest and the artifact a save writes. - **Host**: the program serving the document and accepting saves. - **Snapshot**: the full serialized state of the live page, before anything is stripped for saving. - **Save**: the POST that carries a document to the host. ### 1. The document A document is a single HTML file. Its durable state lives in the DOM, so that serializing the DOM captures everything worth keeping. A document should: - keep state in markup (elements, attributes, text), not only in JavaScript variables, - write any state that lives outside markup (form field values, for example) into the DOM before a snapshot is taken, - mark purely ephemeral interface elements so they can be stripped (section 2), - work when opened as a static file: a malleable document degrades to a read-only document, never to a broken one. Two boundaries every document lives within: - **Everything durable lives inside the root `` element.** Serialization is the doctype plus the root element, so comments or processing instructions outside `` do not survive a save. - **The format is HTML5.** A saved document always begins with ``, whatever doctype the file started with. ### 2. The snapshot and the document A **snapshot** is the string: ``` "" + documentElement.outerHTML ``` taken from a prepared clone of the live DOM, never from the live DOM itself. Preparing the clone means, in order: 1. **Clone** the root element, without disturbing the live page. 2. **Sync**: copy live form state into markup (`value`, `checked`, `selected` as attributes) so it survives serialization. Password and file inputs are never written into markup. 3. **Run the page's own pre-snapshot hooks**, so a document can reshape its clone before capture. 4. **Strip `[no-snapshot]` elements**: things that exist only on the live page and should never leave it. 5. **Strip extension debris**: password managers and grammar checkers inject elements and attributes into pages. In a format where the file is the database, that junk would be saved into the document forever. Remove it from every snapshot. The **document to save** is the snapshot taken one step further: run the page's save-time hooks, then strip `[no-save]` elements, then serialize. The two markers do different jobs. `no-save` content is real page state that never reaches disk, but it still travels to other live editors during sync (section 10). `no-snapshot` content never leaves the live page at all. Both markers work on elements. For state that is not an element, the rule is simpler and worth saying once: **serialization captures attributes, not JavaScript properties.** `outerHTML` writes out what `setAttribute` put on an element and nothing else. So live-only state belongs in a property (`el.myState = value`), a module variable, or a `WeakMap` keyed by the element, and it will never reach disk without any marker at all. Put the same value in an attribute or in `dataset`, which is attributes, and it is saved forever. This is the whole trick to keeping a document's durable state and its working state apart. The document must be a complete, valid, standalone HTML file. Whatever the host writes to disk is what a person gets when they download it, so the document is the file. This section names every step; the exact normative algorithm, with per-control form rules and required ordering, lives in the companion page [The snapshot algorithm](snapshot-algorithm.txt). The conformance fixtures (section 11) are the arbiter of exact bytes. ### 3. The save The client sends: | Part | Value | |---|---| | Route | `POST /_/save`, same origin the document was served from | | Body | the document, as text. Always text, never JSON: this route has exactly one body shape | | Header | `Document-URL`: the document's full URL, so a host serving many files knows which one to write | | Credentials | cookies are included; hosts may authenticate with them | The host responds: | Case | Response | |---|---| | Accepted | HTTP 2xx with JSON `{ "msg": "Saved" }` | | Refused or failed | HTTP non-2xx, with JSON `{ "msg": "why" }` when the host itself answers | The status code is authoritative. `msg` is a short human-readable string, always rendered as text, never as HTML. A response may carry two optional fields: `code`, a machine-readable reason from a small open registry (`unauthorized`, `forbidden`, `not-found`, `too-large`, `invalid-document`, `conflict`, `read-only`), and `etag`, the version stamp of what the host stored (section 6). Clients must tolerate fields they don't know, and must not assume a JSON body on failure: proxies and gateways answer with HTML error pages on a host's behalf, so a client reads the status first and treats the body as a bonus. That is the entire wire protocol. A complete host in about twenty lines of Express: ```js 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) => { 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*/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" }); }); app.listen(4600); ``` Still minimal, but honest: it refuses cross-origin saves, stays inside its folder, and sanity-checks the body. A real host adds authorization (this one trusts anyone on your machine), atomic writes, and version history. ### 4. The host A host must: - serve documents over HTTP (localhost counts), - implement the save route and write the body to the document's file, **bytes exactly as sent**, - identify the target document from the request (a token in the path, the request's origin, or the `Document-URL` header), never from the body, - refuse to write outside its designated folder (reject path traversal), - validate the request's origin on every save (section 8), - decide authorization itself, per target. A client's edit mode is interface, not security. Whether a save is accepted is always the host's decision, whether by cookie, by token, or by "this is localhost on your own machine." And the decision is about *this* document, not just this user (section 8). A host should: - write atomically (write to a temp file, then rename over the original), so a crash mid-save never destroys the document, - keep version history (a copy of the previous state on every save), because a file that overwrites itself deserves an undo; every reference host does at least one of these two, - cap the accepted body size (the reference hosts allow between 5 and 50 megabytes), - refuse a body that is not a complete HTML document with a top-level `` element. A host must not transform the bytes it stores, with two exceptions. It strips the ephemeral attributes it injected when serving, so its own machinery never lands in a person's file (section 9). And a host that declares the `format` capability (section 5) may reformat the HTML on save, and only for a document that asks for it. **Formatting is opt-in, per document.** A document asks with ``. The attribute is read by value, not by presence: any other value, or no attribute at all, means the host stores the bytes exactly as sent. A host that does not declare `format` ignores the attribute entirely. It is opt-in because reformatting buys less than it appears to. A browser's serializer already preserves the author's own indentation, blank lines, and comments, and it is a fixed point: serialize a document, reload it, serialize it again, and the bytes are identical. What a browser normalizes is syntax, once, on first save (quoting attribute values, lowercasing tag names, closing implied elements). A host that reformats on top of that is not rescuing a person's file from machine output, it is replacing that person's formatting with its own, and its output and the browser's will disagree forever. So the default is to leave the bytes alone, and a document that genuinely wants house formatting says so. When a host reformats, the `etag` it returns describes the stored bytes, not the sent ones, so clients stay honest about what is on disk. Serving has the same discipline, and it has one hard rule: **serving must never rewrite the stored file.** Reading a document does not change it. A host may inject spec-defined attributes on the root element at serve time, but the injection exists only in the bytes it sends to the browser, never in the bytes on disk. Two kinds are defined, and they differ only in what happens on the way back: - **Ephemeral.** The host strips it before writing, so it never exists in the stored file. The save token of section 9 is the only one. - **Durable identity.** The host injects it when the document lacks one, and then simply stores whatever the client sends back. It reaches the file because the client saved it there, not because the host rewrote a file it was only asked to read. Section 9 defines the one such attribute. The distinction matters because a host that writes identity into a file at serve time has made opening a document a mutation, which breaks both this rule and any client comparing what it holds against what is on disk. ### 5. The host's namespace: `/_/` Paths under `/_/` belong to the host, never to documents. A document must not depend on `/_/` paths as content, and a host never serves a document from them. This specification claims only a few routes there (`/_/save`, `/_/meta`, and the sync route in section 10); a host is free to add its own routes under the same prefix. The prefix exists so host machinery and document URLs can never collide. One of those routes is how a client learns what a host can do: ``` GET /_/meta (with the Document-URL header when the host serves many files) ``` ```json { "spec": 1, "extensions": ["conditional", "sync", "format"], "document": { "etag": "a1b2c3" } } ``` - `spec` is the specification version the host implements. - `extensions` lists the capabilities the host actually supports, by name. - `document`, when present, describes the document named by `Document-URL` (its current `etag`, and whatever else the host wants to expose). A `404` means a bare core host: plain saves only, and that is fully conforming. **Clients must not infer a host's capabilities any other way.** Not from the hostname, not from the port, not from guesswork. Discovery is deliberately out-of-band: a host never has to modify a person's document just to describe itself. Clients are strict about what counts as an answer and forgiving about what to do when they don't get one. A response counts only if it is a 2xx carrying a JSON object with a numeric `spec`. Anything else, a 404, an HTML error page from a proxy, a redirect, a body that will not parse, is treated as a bare core host, and the client saves normally. Discovery failing must never cost a person their save. A conformance suite is held to the opposite standard: a host that answers `/_/meta` with anything other than a valid capability document or a clean 404 **fails**, because in testing a malformed answer is a defect, not a shrug. ### 6. Saves that don't collide The core save is **last write wins**, and the specification says so plainly: if two copies of a document save in sequence, the later save replaces the earlier one, whole file, no questions asked. For a single person on a single device, that is exactly right. The `conditional` capability closes the gap for everything else. A host that advertises it: - returns `etag` (a version stamp of the stored bytes) with every save response and in `/_/meta`, - accepts an `If-Match` header carrying the stamp the client last saw, - and, when the stamps disagree, refuses with `412` and `{ "code": "conflict" }`, writing nothing. The client's page can then say "this file changed since you opened it" instead of silently destroying the newer copy. A host that advertises `conditional` must honor it: accepting `If-Match` and ignoring it would tell clients they are protected when they are not. ### 7. Clients Any code that takes snapshots and performs saves is a client. The reference client is [clayjs](https://clayjs.com), but nothing here requires it. A client must: - allow only one save in flight at a time, and **coalesce rather than drop**: if a save is requested while one is in flight, save again when it completes, so the latest state always eventually persists, - check the response status before parsing the body, and survive a non-JSON error body, - render `msg` as text, never as HTML. A client should: - treat a timeout as *indeterminate*, not as failure: the write may have landed after the client gave up. When the host supports `conditional`, reconcile against `/_/meta` before retrying, - surface `msg` from every response to the person saving. ### 8. Security A malleable document is arbitrary JavaScript by design. That is the format's power, and it is also its threat model. The person *receiving* a file is safe because the browser sandbox is doing its job. The *host* is the party that needs rules: - **Validate the origin of every save.** A cross-origin POST with a `text/plain` body is a CORS "simple request": without this check, any web page a person visits could silently save garbage into a document on their localhost host. Check the `Origin` header (or reject `Sec-Fetch-Site: cross-site`). Requiring the `Document-URL` header helps too, since custom headers force a preflight, but that is defense in depth, not the defense. - **Authorization is per-target.** "Is this person logged in" is not enough on a host that serves many documents; the question is "may this request write *this* document." The `Document-URL` header is untrusted input, chosen by the page: resolve it inside the host's own namespace, canonicalize it, and never derive a raw filesystem path from it. - **Documents from different owners get different origins.** Same-origin pages can call each other's save routes with credentials attached. A host serving documents it does not fully trust must isolate each trust domain on its own origin; this is why multi-tenant platforms put each tenant on its own subdomain. - **Local hosts bind to the loopback interface** and validate the `Host` header, because DNS rebinding is real. ### 9. Extensions These are optional. A minimal document and a minimal host interoperate without any of them. Exactly three of them are **capabilities**, announced by name in `/_/meta`, because a client cannot discover them any other way and behaves differently when they are present: `conditional`, `sync`, and `format`. That list is the whole registry; a host must not invent new names for the extensions below, which need no announcement. Save tokens announce themselves by the attribute appearing in the served document. The save trigger is a header a client sends and a host may ignore. The `.htmlclay` extension is an operating-system convention, not a wire feature. - **Save tokens**: a host injects a per-file token as a `savetoken` attribute on `` when serving; clients that find one save to `POST /_/save/{token}` instead. Carrying the token in the path means the same credential works for `fetch` and `EventSource` alike. This is one of only two attributes a host may inject (the other is `documentid`, below), and it exists for a specific job that has no easy alternative: on a local host every file shares one origin, so a cookie cannot bind a browser tab to the one file it is allowed to save. The document itself is the only channel that is per-file and per-tab. The bounds are strict: injection happens at serve time only, only as an attribute on the root element, and the host strips it before writing, so the stored file is never modified and the token never persists. A host that refuses even serve-time injection can carry the token in the URL it opens instead; the trade is that the credential then lives in browser history and in anything that URL gets pasted into. - **Document identity**: a host that wants version history to follow a document rather than its path injects a `documentid` attribute on `` when serving a document that does not already carry one. It is a durable identity attribute in the sense of section 4: injected at serve time, never written to disk by the host, and persisted only when the client's own save carries it back. The job it solves is real. Keying history to a file path means renaming or moving a document orphans its history, and copying a document silently merges the copy's history into the original's. An identity carried inside the file survives both, because it travels with the file. The cost is that identity is only as durable as the first save: until then it exists only in the host, so a host that keeps anything of its own against that identity before the first save, a snapshot of the file as first opened, for instance, must be able to reach or discard it later. A host that finds the same identity on two different paths is looking at a copy, and should assign the copy a fresh one so the two histories stay apart. - **The `.htmlclay` extension**: a malleable document is plain HTML either way, but files named `.htmlclay` can be registered to a desktop host, so double-clicking one opens it served and saveable. - **The save trigger**: a save may carry a `Save-Trigger` header saying what caused it. The value set is open; `user` (a human gesture) and `auto` (autosave or script) are defined, and a host treats unknown values as `auto`. The signal is advisory, never security: document code can say anything. Hosts use it to guard the file against background scripts clobbering data a person never touched. - **Conditional saves** (`conditional`): defined in section 6. - **Formatting** (`format`): defined in section 4. - **Live sync** (`sync`): defined in section 10. ### 10. Live sync *This section is informative in v1; the sync wire protocol will be a companion specification.* Live sync is the extension most worth wanting and the hardest to build alone: the same document open on two devices, or by two people, staying continuously identical. It is what makes a malleable file stop feeling like a clever trick and start feeling like real software, like a Google Doc. The core protocol doesn't change. What changes is how you think about the page. There are two artifacts, and the spec has already named them: - **The document** is the durable one: ephemera stripped, safe for anyone, updated when a save lands. Viewers follow the document. On each accepted save, the host pushes the new state to every open view-mode tab, and each page morphs to match. - **The snapshot** is the working session: the full state, including edit controls and `no-save` content that will never reach disk, updated continuously as the person works. Editors follow the snapshot: your other device, or a collaborator in edit mode, sees changes without waiting for a save. Viewers follow the document; editors follow the snapshot. Two audiences, two cadences. Keeping them separate is why an editor's toolbar never flashes onto a reader's screen, and why a reader only ever sees states the author chose to keep. On the wire: a host that advertises `sync` accepts relay messages at its own route, `POST /_/sync`, as JSON. A message carries one of the two artifacts, and the field name says which audience it is for: ```json { "snapshot": "..." } ``` ```json { "document": "..." } ``` A `snapshot` is fanned out to the other editors; a `document` is fanned out to the viewers. **`/_/sync` never writes to disk. Saving happens only at `/_/save`, ever.** This is the rule that keeps the two routes honest, and it is worth stating flatly because the tempting design is to let one message both persist and fan out. Saving is a deliberate act with one route, one shape, and one set of consequences: version history, authorization, the whole discipline of section 4. Relaying is continuous, cheap, and safe to lose. A host that persisted a relay message would be writing to a person's file on a cadence they never asked for, through a path that was never designed to be a write. So a host that advertises `sync` must treat every `/_/sync` body as ephemeral, no matter which field it carries. The two routes are therefore independent, not alternatives. A client that is live syncing still saves through `/_/save` exactly like every other client, and `/_/save` never changes shape. A client that has not discovered `sync` never sends JSON anywhere. The usual flow needs no client relay at all for viewers: when a save lands at `/_/save`, the host itself pushes the new document out to the open view-mode tabs. A client posts `document` to `/_/sync` only when it wants viewers updated without a save behind it. The moving parts are small: a host that fans out updates (the reference uses Server-Sent Events: `livesync-hyperclay` on npm) and a client that applies incoming HTML to the open page without a reload, preserving focus, cursor, and half-typed input (`hyper-morph` on npm). Live sync ships in clayjs as a plugin; read it before writing your own. ### 11. Conformance **A conforming document** keeps its state in the DOM, inside ``, and marks its ephemera per section 2. **A conforming host** implements section 3's route, section 4's must-list, and section 8. Everything in `/_/meta` is optional; a host that omits it is a bare core host. **A conforming client** produces snapshots and documents per section 2 (exact bytes per the companion algorithm and fixtures), and follows section 7's must-list. Any half can be written in an afternoon, in any language, with no dependency on the other side's implementation. The conformance fixtures (sample documents, expected output bytes, and a host-test page you point at your own server) are the arbiter when prose and practice disagree. The fixtures are indexed at [malleablehtmlfile.com/fixtures/manifest.json](https://malleablehtmlfile.com/fixtures/manifest.json), and the host-test page you point at your own server is [malleablehtmlfile.com/host-test.html](https://malleablehtmlfile.com/host-test.html). When prose and fixtures disagree, report it as a bug in the prose. ## Why this shape Every rule above comes from one requirement: **a self-saving document must be mailable, unscary, and small.** - Mailable rules out executables: mail and chat systems refuse them. - Unscary rules out unsigned apps and permission ceremonies: the OS should have nothing to warn about, and it doesn't, because opening an HTML file is safe by design. - Small rules out bundled runtimes: the browser is already on the machine, so the file only needs to carry itself. Keeping the runtime and the document separate is what makes all three possible at once. The runtime (a host) can be signed once and never change. The document can change constantly and never need signing. ## The invitation This specification is open and it is small on purpose. Build a host in Go, Rust, Python, PHP, or a shell script. Build a client with no library at all: `fetch("/_/save", { method: "POST", body: documentText })` is a working start. Put documents on a Raspberry Pi, a shared folder, a company intranet, or a public platform. Fork the conventions if your needs differ. The point is a kind of software that anyone can receive, read, change, and pass on. An HTML file has View Source built in: every malleable document teaches how it was made. Data never leaves the file, so it never gets trapped in a platform. If every host on earth disappeared, the files would still open, still readable, still yours. Software used to be something you could hand to a friend. It can be again. --- Canonical: [malleablehtmlfile.com/specification.txt](https://malleablehtmlfile.com/specification.txt) · Normative companion: [The snapshot algorithm](snapshot-algorithm.txt) · Both in one file: [llms.txt](https://malleablehtmlfile.com/llms.txt) *Reference implementations: [clayjs](https://clayjs.com) is the client library. [htmlclay](https://htmlclay.com) is a signed desktop host for `.htmlclay` files. [Hyperclay Local](https://hyperclay.com) is a desktop host with device sync. [hyperclay.com](https://hyperclay.com) is a hosted platform. The same file moves between all of them unchanged.*