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:

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:

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:

Two boundaries every document lives within:

2. The snapshot and the document

A snapshot is the string:

"<!DOCTYPE html>" + 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. 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:

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*<!doctype html>/i.test(req.body))
    return res.status(422).json({ msg: "Not a complete HTML document", code: "invalid-document" });
  await fs.writeFile(target, req.body);
  res.json({ msg: "Saved" });
});

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:

A host should:

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 <html formathtml="true">. 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:

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)
{ "spec": 1, "extensions": ["conditional", "sync", "format"], "document": { "etag": "a1b2c3" } }

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:

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, but nothing here requires it. A client must:

A client should:

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:

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.

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:

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:

{ "snapshot": "<!DOCTYPE html>..." }
{ "document": "<!DOCTYPE html>..." }

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 <html>, 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, and the host-test page you point at your own server is 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.

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 · Normative companion: The snapshot algorithm · Both in one file: llms.txt

Reference implementations: clayjs is the client library. htmlclay is a signed desktop host for .htmlclay files. Hyperclay Local is a desktop host with device sync. hyperclay.com is a hosted platform. The same file moves between all of them unchanged.