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
<html>element. Serialization is the doctype plus the root element, so comments or processing instructions outside<html>do not survive a save. - The format is HTML5. A saved document always begins with
<!DOCTYPE html>, whatever doctype the file started with.
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:
- Clone the root element, without disturbing the live page.
- Sync: copy live form state into markup (
value,checked,selectedas attributes) so it survives serialization. Password and file inputs are never written into markup. - Run the page's own pre-snapshot hooks, so a document can reshape its clone before capture.
- Strip
[no-snapshot]elements: things that exist only on the live page and should never leave it. - 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 12) 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, payment-required, not-found, too-large, unsupported-type, 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 a couple dozen lines of Express:
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
const root = process.cwd();
const app = express();
app.use(express.static(root));
app.use(express.text({ type: "*/*", limit: "10mb" }));
app.post("/_/save", async (req, res) => {
try {
const origin = req.get("Origin");
if (origin && new URL(origin).host !== req.get("Host"))
return res.status(403).json({ msg: "Cross-origin save refused", code: "forbidden" });
const page = decodeURIComponent(new URL(req.get("Document-URL")).pathname);
const target = path.join(root, path.normalize(page.endsWith("/") ? page + "index.html" : page));
if (!target.startsWith(root + path.sep))
return res.status(403).json({ msg: "Outside the folder", code: "forbidden" });
if (!/^\s*<!doctype html>/i.test(req.body))
return res.status(422).json({ msg: "Not a complete HTML document", code: "invalid-document" });
await fs.writeFile(target, req.body);
res.json({ msg: "Saved" });
} catch {
res.status(500).json({ msg: "Couldn't save" });
}
});
app.listen(4600);
Still minimal, but honest: it refuses cross-origin saves, stays inside its folder, sanity-checks the body, and answers rather than dying when a write fails or a header arrives malformed. That last one is not politeness. An unhandled rejection inside an async route ends the process on current Node, so a host without it is one bad save away from being down for every document it serves. 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-URLheader), 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
<html>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 <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:
- 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, /_/upload, the sync route in section 10, and the wire routes in section 11); 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", "upload"], "document": { "etag": "a1b2c3" } }
specis the specification version the host implements.extensionslists the capabilities the host actually supports, by name.document, when present, describes the document named byDocument-URL(its currentetag, and whatever else the host wants to expose).
The answer has two scopes. spec and extensions describe the host: they are the same for every document it serves, and nothing in them belongs to any one person. A host must not gate discovery more tightly than the documents it serves. Gate it tighter and a host strands its own clients: the caller is the page a person just loaded, often anonymously, and /_/meta is the only place a client is permitted to learn what a host supports.
The document block is the other scope, and the only part of the answer a host ever withholds. Anything genuinely per-document, which today means the etag, lives there. A host withholds it by omission: for a caller who may not see the document, it answers without the block, exactly as it would for a document that does not exist, so /_/meta can never be used to probe for documents a caller cannot see. Even an etag is worth gating: it is a fingerprint of the current bytes, so hand one out for a document someone cannot read and it proves the document exists, ticks on every save for anyone who polls, and confirms any guess at the contents. A client reads a missing document block as the absence of a version stamp, nothing more: not an error, not a failed discovery, and the rest of the answer still stands.
A 404 means no discovery is on offer, and a client treats that as a bare core host: plain saves only, which is fully conforming. A server that has never heard of this specification answers the same way, and that costs nothing, because a client that discovers nothing saves plainly and finds out from the save. What a 404 never describes is one document, so a host must not answer 404 for a per-document reason, to hide a private document, for instance. The client then downgrades to plain saves and silently loses every capability the host actually offers. Withholding belongs in the document block, never in the status code. 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-Matchheader carrying the stamp the client last saw, - and, when the stamps disagree, refuses with
412and{ "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.
A refusal may also carry changedBy, naming what moved the document out from under the caller:
{ "msg": "This document changed since you last loaded it.", "code": "conflict", "changedBy": "another-tab" }
The registered values are another-tab (the same person, in another tab or on another device), another-person, and an-agent. Only the host can know this, and a client must never try to work it out for itself. It changes what the page says and often what the person does: being told it was your own second tab is a different situation from being told a colleague has overwritten you, and the second reading of the first case is alarming for no reason.
A host that cannot tell omits the field, which is the common case and fully conforming: a plain filesystem write carries no author at all. A host must not guess, because a confident wrong attribution is worse than none. A client that receives no value says something that is true whoever it was, and treats an unrecognized value the same way, so the list can grow without breaking documents already saved.
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:
- 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
msgas 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/_/metabefore retrying, - surface
msgfrom 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/plainbody 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 theOriginheader (or rejectSec-Fetch-Site: cross-site). Requiring theDocument-URLheader 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-URLheader 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
Hostheader, because DNS rebinding is real.
9. Extensions
These are optional. A minimal document and a minimal host interoperate without any of them.
Exactly six 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, format, upload, wire, and scoped-stylesheet. 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
savetokenattribute on<html>when serving; clients that find one save toPOST /_/save/{token}instead. Carrying the token in the path means the same credential works forfetchandEventSourcealike.A host that mints tokens also answers
GET /_/meta/{token}, the same discovery document as section 5 with the document resolved from the token instead of from aDocument-URLheader. The token is frequently the only identity the document has: a host that serves its documents sandboxed gives them an opaque origin, so they hold no cookie, present nothing a host recognises, and receive the answer any stranger would. Without this route such a document can save and can learn what the host supports, but can never learn anything about itself, including whether this person may upload to it and how large a file the host will take, which is exactly the information a client needs before it offers someone an upload. A host that injects tokens answers this route; a host that does not mint tokens has no use for it.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.The same bounds apply to the relay in section 10, because a snapshot fanned out to another editor is bytes leaving one browser for another. A client strips the token from what it relays and refuses one that arrives; a host strips it from every frame it fans out. A token is minted for one person and one file, so a tab that adopts somebody else's token saves as them, and goes on saving after its own access ends, because revoking a person's access cannot reach a credential issued to another. The same rule read backwards matters just as much: a frame that has been stripped of a token must not remove the receiving tab's own, or a live sync silently costs that tab the ability to save.
Document identity: a host that wants version history to follow a document rather than its path injects a
documentidattribute on<html>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
.htmlclayextension: a malleable document is plain HTML either way, but files named.htmlclaycan be registered to a desktop host, so double-clicking one opens it served and saveable.The save trigger: a save may carry a
Save-Triggerheader saying what caused it. The value set is open;user(a human gesture) andauto(autosave or script) are defined, and a host treats unknown values asauto. 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.Scoped stylesheets (
scoped-stylesheet): a host that generates a stylesheet per document, rather than serving one file every document links to, may rewrite that one link element in a saved document so it addresses this document's own stylesheet. Nothing else in the document may change.It is the same shape as
formatand belongs in the registry for the same reason: section 4 says stored bytes are the bytes sent, so a host that quietly edits a link while announcing nothing has told every client its documents are stored verbatim when they are not. The opt-in is the link itself. A document that does not carry one is never touched, and a document whose link is already correct is rewritten to the value it already had, so in the steady state the transform is a no-op and its visible effect is repairing the link after a document is renamed or moved.A client that reads this name knows the bytes it sent and the bytes now on disk may differ in that one attribute, which matters to anything comparing them: a client holding its own copy for a conditional save, or computing what changed, would otherwise see a document it never wrote.
Live sync (
sync): defined in section 10.The agent channel (
wire): defined in section 11.Uploads (
upload): a host that advertisesuploadstores files a document wants to reference instead of embedding.POST /_/upload (or POST /_/upload/{token}, on a host that injects one) Content-Type: multipart/form-data Document-URL: the document's full URLThe file rides in a part named
file. A host must accept that shape; it may accept others.The destination belongs to the host. It derives where the bytes land from the document the request names, and a document never names a folder. The
Document-URLheader is untrusted input, resolved and canonicalized inside the host's own namespace exactly as section 8 requires of a save.A host that accepts an upload:
- chooses the stored name itself. The name the client sent is a suggestion and never a path: the host takes its last segment and derives from it a name that is free. Nothing is ever overwritten, and a taken name is never an error. Deriving that name from a hash of the content is the recommended way, because then the same bytes sent twice store one file, and an upload retried after a timeout creates no duplicate.
- refuses anything it would serve as a document, answering
415with{"code": "unsupported-type"}. Documents are written through/_/save, which carries authorization, version history, and the whole discipline of section 4. An upload lane that could write one is a second way in with none of it. - keeps an upload from becoming active content on the origin its documents run on. This is the same threat as the one above rather than a matter of taste: where every document shares one origin, an uploaded page can read another document's injected save token out of a response and then save as that document. Serving every stored upload with
X-Content-Type-Options: nosniff, and serving anything that would otherwise execute withContent-Disposition: attachment, is enough. An image still renders through<img>, and a direct navigation downloads it instead of running it. - authorizes the upload exactly as it authorizes a save of the document named, including by save token, and validates the request's origin, for the reason section 8 gives. A
multipart/form-dataPOST is a CORS simple request, so an unguarded upload route is reachable from any page the person happens to be visiting. - caps the size it accepts, answering
413with{"code": "too-large"}, and reports that cap in/_/meta.
The answer is
200with the stored files:{ "msg": "Uploaded 1 file", "uploads": [ { "name": "avatar-7f3a91.webp", "url": "assets-notes/avatar-7f3a91.webp", "bytes": 84213 } ] }urlmust resolve to the stored file when used verbatim as an attribute value inside the document named byDocument-URL. That is the whole contract on its form. A host serving one document at one address should return a document-relative URL, because that is the form that survives the document being downloaded, moved, or rehosted alongside its files. A host serving one document at several addresses must return an absolute one. Refusals follow section 3: the status is authoritative, andcodemay beunsupported-type,too-large,payment-required, or any other the registry names.Whether a particular caller may upload to a particular document is a per-document fact, so it lives in the
documentblock of/_/meta(or of/_/meta/{token}, on a host that mints tokens) and is withheld by omission like everything else there:{ "document": { "etag": "a1b2c3", "upload": { "allowed": true, "maxBytes": 104857600 } } }A client that discovers no
uploadcapability, or nouploadobject in the document block, does not upload, and does not probe the route to find out. A document with no host at all is not an error case. A file opened from disk cannot upload anything, and embedding the bytes is the right behavior there, because inline bytes are the only bytes that travel with a file nobody is serving.
10. Live sync
This section is informative in v1. A host may implement live sync as written here, implement it differently, or leave it out entirely and still conform, because sync is an optional capability like every other. It is written down anyway, in this much detail, because the alternative is reading somebody's application source to find out what the other half of the connection expects.
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-savecontent 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.
One address, two directions. A host that advertises sync serves both halves at the same route, which is the whole of the live sync wire:
POST /_/sync a page sends an update
GET /_/sync a page receives updates
Sending is a JSON body carrying 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. The message may also carry sender, an opaque per-client string the host copies onto every frame it fans out, which is how a page recognises and drops its own update coming back. The document is named the way a save names it, with the Document-URL header of section 3.
Receiving is a long-lived text/event-stream response, opened by EventSource in practice. Two things follow from that, and they are the only reasons this direction does not look exactly like the other one. EventSource cannot set a header, so the document is named by a document-url query parameter instead, and a save token rides in the query string as well. And EventSource reconnects on its own, so a host should expect a client to reappear rather than treat a dropped connection as a departure.
GET /_/sync?document-url=https://example.com/notes.html&lane=live
lane selects the audience: live for editors, which carries pre-strip snapshots, and saved for viewers, which carries whole documents. A host that omits the parameter should default to live. The two lanes share one channel per document, and a host authorizes them differently: the saved lane carries what the file already contains, so it is gated like a read, while the live lane carries another editor's unsaved working state, so it is gated like a write.
Each update is one unnamed event whose data is a JSON object:
data: {"html":"<!DOCTYPE html>...","sender":"c3f1a9","seq":41}
htmlis the whole artifact, never a fragment or a diff.senderis whatever the sending client set, or a host-chosen string likefile-watcherwhen the host itself is the origin. A client compares it against its own id and ignores its own frames.seqincreases across the frames a host sends, so a client can notice it missed one.
Lines beginning with : are comments. A host sends them as a keepalive so an idle connection is not reaped by an intermediary, and they carry nothing a client needs to read.
/_/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 relay body as ephemeral, no matter which field it carries.
The relay and the save route 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 opens either half of this route.
A snapshot is the sending tab's state, and a few attributes on the root element are not state at all: the save token of section 9, and whatever the client writes there to track its own save status and mode. Those belong to one response and one tab. A client drops them from what it relays, and leaves its own alone when a frame arrives without them. Section 9 gives the full rule and the reason it runs in both directions.
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 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. The agent channel
This section is informative in v1, on the same terms as section 10. A host may implement the agent channel as written here, implement it differently, or leave it out entirely and still conform, because wire is an optional capability like every other. It is written down in this much detail because both halves are usually written by different people, and the alternative is reading somebody's application source to find out what the other end expects.
A malleable document can be edited by the person who opened it. The agent channel is how it gets edited by a program that person is talking to: a coding agent, a script, a build step, running on the same machine, asked to change this document while its author watches. The page says what it wants. A process does the work. The result arrives back through the paths that already exist.
The channel never carries the document and never writes it. It carries a request and the progress of that request, and nothing else. The process changes the file the way anything else changes a file, through /_/save or through the filesystem it already has, and the open page learns about it the way it learns about any other change, through live sync or a reload. This is the same rule section 10 states for the relay, for the same reason: a route designed to carry a sentence should never become a second way to write someone's document, with none of section 4's discipline behind it.
What the channel adds is the part neither side can build alone: a page finding out that an agent is listening, addressing it without knowing where it runs, and watching it work.
Two roles, told apart by what the browser attests. A page is a document open in a browser. A process is anything else on the machine. A host distinguishes them from the fetch metadata the browser sends and cannot be persuaded to omit:
- A caller that sends neither
Sec-Fetch-SitenorOriginis a process. A browser always sends at least one on a request it makes from a page. - A caller whose
Sec-Fetch-Siteissame-origin, or which sends a matchingOriginand noSec-Fetch-Site, is a page. - Anything else is refused with
403.
same-site is refused rather than admitted, because on a local host every loopback origin is same-site with every other, so admitting it would let any port on the machine speak as a page. Origin is checked only when it is present, never required, because browsers omit it on same-origin GET requests including the one EventSource makes.
One handler per document. A process may ask to be the handler, the one endpoint that answers requests for this document. A host grants that to at most one caller at a time and refuses a second with 409. Every other subscriber, page or process, is an observer: it receives frames and answers nothing. A page is never granted the handler role. Refuse it before resolving the document, so the refusal cannot double as a test for which files exist.
One address, two directions, the shape section 10 already established:
GET /_/wire/subscribe receive frames for one document
POST /_/wire/send send one frame
Naming the document works the way saving does, and differs by role. A page names it with the Document-URL header of section 3, or, on the subscribe route, a page-url query parameter, because EventSource cannot set a header. A process names an absolute filesystem path in a file parameter. A host must never accept a path from a page, on either route: it resolves the page's own URL inside its own namespace, exactly as section 8 requires of a save, and ignores any file the page sent. A path from a process is admitted only if it is already a document this host serves. Anything else, from either role, is 404.
role=handler on the subscribe request asks for the exclusive slot:
GET /_/wire/subscribe?file=/Users/me/notes/plan.htmlclay&role=handler
GET /_/wire/subscribe?page-url=http://127.0.0.1:7890/notes/plan.htmlclay
One frame shape, for everything. A frame is a JSON object:
{ "v": 1, "type": "wire/status", "id": "8f2c41", "from": "process",
"file": "/Users/me/notes/plan.htmlclay", "text": "rewriting the third section" }
typeis required, beginswire/, and is short. The set is open: a host routes on the prefix and never on the whole name, so two implementations can add a type without asking anyone.idis required and opaque. It ties every frame of one request together. A host never parses it, never namespaces it, and never assigns it.textis optional and is meant for a person to read. A host that finds it too long truncates it rather than refusing the frame, because losing a progress message is better than failing a request over the length of its description.payloadis optional and is never interpreted by the host.fromandfileare stamped by the host and never read from the caller. This is the rule the channel's integrity rests on: a page that could setfromwould speak as an agent to its own document's other tabs, and a page that could setfilewould address a document it was never granted. Overwrite both, always, whatever the caller sent.vis the frame version, stamped by the host.
Six types are defined. A page sends wire/request to ask for something and wire/cancel to withdraw it. A handler answers wire/ack when it has picked the request up, wire/status as often as it likes while working, and exactly one of wire/done or wire/error when it stops. wire/done and wire/error are terminal, and terminality is a property of the type alone, so a host can recognise the end of a request in an implementation it has never seen.
Receiving is a long-lived text/event-stream, one unnamed event per frame, with the SSE id field carrying a sequence number that increases across everything the host sends. Lines beginning with : are keepalive comments and carry nothing to read.
Delivery is honest about what it guarantees. Progress frames are best effort: a lost wire/status is repaired by the next one. Terminal frames are retained, one per request id, so a page that was not connected when the agent finished can still learn how it finished. The first terminal frame for an id wins and a later one is ignored, so an agent that reports success and then crashes does not overwrite its own answer.
A reconnecting client replays by sending Last-Event-ID, and receives every retained terminal frame above that sequence. A connection that presents no id replays nothing, deliberately. A page that reloaded has no memory of the request it cancelled a moment earlier, so replaying into it would resurrect an outcome the person already dismissed.
Retention is bounded in three ways, and a host should bound it all three ways: a cap on retained frames per document, an age after which one is dropped, and a ceiling on the total across every document. The third is the one that is easy to miss. A page may address any document on its own origin, so per-document caps alone let a loop over a folder pin the maximum for every file at once. Nothing survives a restart, and nothing needs to.
A send is answered with how many handlers received it.
{ "ok": true, "delivered": 1, "observers": 2 }
delivered counts handlers only. Counting every subscriber would make a page's own observer stream, which receives a copy of what it just sent, register as delivery, so a page could never tell "an agent has this" from "I am talking to myself". This one number is how a page discovers there is an agent at all: delivered: 0 means nothing is attached, and the page can say so instead of waiting out a timeout. It answers about this moment, which is the only thing worth knowing about a peer that comes and goes, and it costs one round trip. A host may also announce wire in /_/meta, which separates "this host has no channel" from "no agent is attached right now"; the reference host does not, and a client that finds no announcement should still try, because for this capability the send response is the real answer.
What the handler role grants beyond frames. Taking the slot is the moment a host learns that something other than a person is about to write this file, and two things should follow. The host opens version history for the document, seeding a baseline of the current bytes, so the agent's very first write is recoverable rather than being the change that has nothing to compare against. And the host keeps watching the file for as long as the handler is attached, even with no tab open, because an agent writing to a closed document is exactly the case a watcher tied to open tabs would miss. Both should be refcounted or otherwise safe to hold twice, since a handler reconnecting overlaps its own teardown.
If the host cannot seed that baseline for a document that exists, it should refuse the attach rather than accept it, because an agent editing a file whose history could not be opened writes changes nobody can undo.
Clients. A page that sends a request must:
- save first, and let the save finish, so the agent reads the document the person is looking at rather than the one they last saved,
- suspend autosave for the length of the request, so the page does not write over the agent's edit while it is being made,
- treat
wire/doneas the agent finishing, not as the change appearing. The edit arrives separately, through live sync or a reload, and a page that resolves ondonewill report success before anything is on screen. Wait for the change, with a short bound so a document with no live sync still settles.
A page should also time out twice, because the two silences mean different things: a request that is never acknowledged has no agent behind it, and a request acknowledged and then silent has an agent that stopped. And it should treat a request as finished once, ignoring every frame that arrives for an id it has already closed.
Security, in addition to section 8:
- Require a JSON content type on the send route, answering
415otherwise. This is not a courtesy:application/jsonis not a CORS simple content type, so requiring it forces any cross-origin attempt into a preflight the host declines. Without it, any page in the browser could post frames into a document on the person's own machine. - Cap the body, and answer
413rather than reading it. - Refuse the handler role to pages, before the document is resolved.
- Refuse hidden and host-internal paths the same way the serving routes do.
What this does not solve, stated plainly because a reader will otherwise assume it does:
- A browser too old to send
Sec-Fetch-Siteattests nothing on a same-origin request and is read as a process. Such a page can take the handler slot and lie to other tabs on its own origin. It gains no ability to read or write any file, and cannot reach another origin's documents. No header closes this, because a same-origin request may set any header without a preflight; only a secret the page cannot read would, which is a larger change than the exposure is worth. - A wire is addressing, not isolation. A page may open a channel on any document its own origin serves, which is the same trust boundary section 8 already draws around saving. Documents that should not reach each other belong on different origins.
- A dropped handler is not an error. When a handler's connection ends, requests in flight are simply never answered, and the page's own timeouts are what end them. A host may synthesize a terminal frame for each open request instead, and that is better behaviour, but a client must not depend on it.
12. 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 host that advertises a capability is held to that capability's rules, and the host-test page checks them: announcing upload and then refusing every file is worse than never announcing it, because a client skips its own fallback on the strength of the announcement.
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.
- 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 name is free too. "Malleable HTML" and "malleable HTML file" are generic terms for the format described here. No one owns them, no one will register them as a trademark, and you need no permission to use them for your file, your host, your library, or your writing about any of it. The reference implementations listed below carry their own product names, which belong to whoever made them; the name of the format belongs to everyone.
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.