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 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:

A host should:

A host must not transform the bytes it stores. Two kinds of change are allowed, and nothing else. It strips the ephemeral attributes it injected when serving, so its own machinery never lands in a person's file (section 9). And it may make the one change a capability it declares in /_/meta defines, within the bounds that capability sets: format (section 9) reformats a document that asks for it, scoped-stylesheet (section 9) rewrites the single link element it names. A change no declared capability accounts for is a violation of this rule, however much it improves the file, because a client compares what it sent against what the host says it stored.

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, /_/upload, the sync routes 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" } }

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:

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.

The receipts capability closes the one gap conditional leaves open: a save whose outcome the client never learned. A request that times out leaves two things unknown at once, and a client cannot tell them apart. Did its own write land after it gave up, and did somebody else write in the meantime. Both of the answers a client can reach alone are wrong. Assuming the write was its own means adopting a stamp for bytes it may never have seen and overwriting them on its next save, silently, which is the loss conditional exists to prevent. Assuming it was not means refusing a save that was never in conflict, which on an unreliable connection is nearly every time. changedBy cannot settle it either: its finest value names another tab of the same person, and the question here is not who wrote but whether this exact save is what wrote.

A host that advertises receipts also advertises conditional, and:

A host reports saveId only after confirming the stored bytes still carry the paired etag. Every surface that reports it already computes the etag it is answering with, so this costs one comparison, and it is what makes the capability safe by construction rather than by diligence: a write that arrives by any other route, a text editor, a version restore, a sync pull, an agent on the filesystem, changes the bytes and therefore the etag, and the pair stops matching without anything having to notice it. There are no hooks to forget. A host whose pair is missing, stale, or lost to a restart omits saveId and is fully conforming; the client then behaves as it would against a host with no receipts at all. Losing this state costs smoothness and never costs safety, which is why it may live in memory.

A save carrying no Save-ID still replaces the pair, with an id-less one, so a remembered id can never come to describe bytes newer than its own. A host must never mint or guess an id; it only ever echoes one a client sent. A Save-ID identifies one save attempt and never a person, a tab, or a version, and it is never a credential: a host must not accept one as authorization for anything, and must not reveal one to a caller it would not show the etag to, since it lives in the same withheld block for the same reason.

The rule this exists to let a client follow:

A client adopts a stamp after a timeout only when the host has proved that the currently stored bytes are the result of that save, or are byte equivalent to that result. Every recovery write still has to satisfy the original If-Match.

Which resolves into two moves and no guesses. If saveId is the id this save carried, the host has attested that what it stores came from this save's own body, so the client adopts the etag and the save is done, with nothing to show anybody. The id must match that one attempt and not merely be an id the client has used before: a client that accepts any of its own recent ids reads the honest answer about an earlier save as an answer about this one, and a request that never left the browser then reports itself as saved, with the client's baselines advanced and the bytes on no disk anywhere. That is the loss this capability exists to prevent, arrived at through the capability itself. Otherwise the client sends the save again, carrying the original If-Match, and lets the host settle it: if nothing was written the stamp still matches and the save simply goes through; if the client's own write did land after all, the stamp is stale and the refusal carries that write's own receipt, which is the late-duplicate case below; and if somebody else wrote, the same refusal carries their bytes and is the genuine conflict, the one case a person needs to be shown.

A client may compare the stored etag against the one it held and skip a re-send it knows will be accepted, but it must not read a difference there as proof of a foreign write. A host that has lost its pair, to a restart or to its own eviction, reports no saveId for bytes the client really did write, which is indistinguishable from somebody else's write by the etag alone. Only the conditional re-send tells them apart, and it is safe precisely because it cannot write over anything the client has not seen. For the same reason, an absent saveId settles nothing: a client may stop treating a save's outcome as unknown when the host names a DIFFERENT id, which is positive proof that another save wrote what is stored, but not merely because a host advertising receipts returned none. A client that treats absence as proof tells a person their own timed-out save was somebody else's change.

The re-send carries the original If-Match, not whatever stamp the client holds by then. The two are different values more often than it looks: a client that also implements section 10 adopts the stamp of any disk-sourced frame it applies, so the value can move while the save is unresolved, on account of the very write the client is trying to find out about or a later one. Re-sending under the newer stamp is a write conditional on a version this save was never judged against, and the host accepts it, replacing bytes the person has just been shown. A save that carried no If-Match has nothing to re-send under and must not be re-sent at all: an unconditional recovery write has no comparison to refuse it.

One refusal is worth naming separately, because it can arrive at any time and not only after a timeout: a 412 whose saveId the client recognises as its own is the host saying that the client's own earlier save is what moved the document out from under this one. A late duplicate does this. The client adopts that refusal's etag and sends again, rather than reporting a conflict against itself.

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 seven 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, receipts, 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.

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:

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.

A snapshot may also carry etag, and this is how the version stamp of section 6 reaches the other editors. A tab that has just saved knows the stamp the host returned; the other editors do not, and until they learn it their next conditional save is refused over a change they have no quarrel with. So the saving tab relays its snapshot again with the stamp attached, and the host copies the field onto the frames it fans out, unread. Only the editor lane carries it: a viewer makes no saves and has no use for a version to answer for.

A receiver adopts a stamp only as part of applying the content that stamp describes, and a stamp must never travel on its own. This is the one rule the field has, and everything about its shape follows from it. A stamp says the host stores this version. Adopting it says, additionally, that this page holds those bytes, and only the second claim makes the next save safe. A frame with no content is no evidence for it, and a page that took one anyway could pass If-Match while still displaying the older document and overwrite a save it never received. A receiver that cannot apply the content, because it is holding a local edit the incoming version cannot merge with, keeps the stamp it had and takes an honest refusal later.

A host that relays a stamp it computed itself, rather than one a client attached, is making the same claim on that client's behalf without the standing to make it: it cannot know which relayed snapshot belongs to which save.

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 documents are named in the query string instead, and a save token rides there 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.

One stream carries any number of subscriptions. A subscription is one document on one lane, and the query lists them, one s parameter each:

GET /_/sync?s=live:0:https%3A%2F%2Fexample.com%2Fnotes.html&s=saved:41:https%3A%2F%2Fexample.com%2Findex.html

Each value is lane:since:document-url, split on its first two colons, the URL percent-encoded as any query value is. lane selects the audience: live for editors, which carries pre-strip snapshots, and saved for viewers, which carries whole documents. since is the last seq the client saw on that subscription, and 0 for one with no past. 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.

A host caps how many subscriptions one stream may carry, and answers 400 to a request above the cap, with no subscriptions, or with a value it cannot parse. A subscription the caller may not follow, because the document is not there or is not theirs, is not a reason to refuse the stream. The host answers it inside the stream, below, so a client following many documents is never cut off from all of them over one.

The stream opens with one cursor event per subscription, in the order the query named them:

event: cursor
data: {"sub":0,"seq":41}

event: cursor
data: {"sub":1,"seq":0,"resync":true}

event: cursor
data: {"sub":2,"error":"not-found"}

sub is the subscription's index in the query. seq is where the host resumes it. When since was above zero and the host still holds every frame after it, those frames follow the cursor and resync is absent: the client missed nothing. When the host cannot prove that, because the frames have aged out or the file was replaced underneath, it says "resync":true, and the client's repair is to fetch the served document again, the same repair it already needs for a change too large to send. error names a subscription the host will not serve, and not-found is the one value defined; a client drops that subscription and keeps the others.

Each update is one event named by its subscription, s0 for the first in the query, s1 for the second, with the frame's seq as the event id and a JSON object as its data:

event: s0
id: 42
data: {"html":"<!DOCTYPE html>...","sender":"c3f1a9","seq":42}

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.

One stream per origin, and none for a page nobody can see. A browser allows about six connections to one origin at a time, and a stream holds one for as long as it is open, so a client that opens a stream per tab stops loading at the seventh tab, saves included. A host reached over HTTP/2 does not have this problem, because HTTP/2 carries many streams on one connection, but a client cannot tell which kind of host it has, so the rule is the same for every host: hold one stream per origin, and put every open page's subscriptions on it. That is what the list above is for. A client without a way to share still does the next best thing. It closes a hidden page's stream a few seconds after the page is hidden and reopens it from since when the page is visible again, and the cursor says whether the gap was covered.

The worker. Sharing a stream across tabs needs code that outlives any one of them, and the browser's shape for that is a SharedWorker: one instance per origin, every tab connected to it through a message port. A SharedWorker script must come from the page's own origin, which is the host, so a client library loaded from anywhere else cannot bring its own. A host that advertises sync therefore serves one:

GET /_/sync/worker.js

The script holds the origin's single stream, rebuilds it from the query whenever a subscription is added or dropped (each surviving subscription resumes from its own since, so a rebuild loses nothing), and talks to pages over their ports in the messages below. Every message is a JSON-able object with v set to 1; a message without it is ignored. A client creates the worker under the name clay-sync, so two client libraries on one origin share one worker rather than holding two streams.

A page sends Meaning
{"v":1,"type":"subscribe","document":"https://…","lane":"live","since":0} Follow this document on this lane, from since
{"v":1,"type":"unsubscribe"} Stop following; closing the port does the same
{"v":1,"type":"visible"} / {"v":1,"type":"hidden"} The page's visibility, so the worker can hold frames for a hidden page
{"v":1,"type":"ping"} Liveness; the worker answers pong
The worker sends Meaning
{"v":1,"type":"cursor","seq":41,"resync":false} The subscription's position, as the stream's cursor reports it
{"v":1,"type":"frame","data":"{…}"} One frame for this page; data is the string the host sent
{"v":1,"type":"status","state":"open"} The shared stream's state: open, connecting or closed
{"v":1,"type":"gone"} The worker will not carry this subscription: the host answered not-found, or the entry was malformed or past the host's cap
{"v":1,"type":"pong"} Answer to ping

A hidden page receives no frames while hidden. The worker keeps the latest frame per subscription and delivers it when the page reports visible, and to a page that joins a subscription other pages already hold, so a tab opened into a session in progress starts from the current state. A client that cannot run the worker, because the browser has no SharedWorker or the script fails to load, falls back to a stream of its own with the hidden-page rule above. It never falls back because the shared stream dropped: reconnecting is the worker's job.

/_/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 and serves the worker (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:

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" }

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:

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:

What this does not solve, stated plainly because a reader will otherwise assume it does:

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.

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.