- HTML Clay
- Docs
- Host reference
- Server endpoints and save protocol
Server endpoints and save protocol
The routes the HTML Clay server exposes, how a save is written to disk, and the technical shape of the app behind them.
HTML Clay is a local HTTP server that bridges the browser sandbox and your file system. Every folder it serves answers on one loopback port at a hostname of its own, and the page it hands to your browser saves through the routes under the /_/ marker.
How the bridge works#
User double-clicks a .htmlclay file
-> OS launches HTML Clay (registered handler for .htmlclay)
-> App picks the file's origin: the trusted folder containing it, else its own folder
-> App serves the folder at its own hostname on the app's one port, bound on 127.0.0.1 and
::1, and mints a session token
-> App opens the system default browser at http://htmlclay<n>.localhost:1212/<relative path>
-> Server injects savetoken into the <html> tag on the way out
-> Page edits, saves: POST /_/save/{token} with the whole document as the body
-> Server writes to a temp file, fsyncs, renames it over the target
Facts that matter when you are reasoning about this:
- The server binds loopback only,
127.0.0.1and::1, never0.0.0.0, and validates theHostheader. - One origin per folder. A trusted folder is served at its own hostname on the app’s one port, and each folder a loose file was opened from gets a hostname of its own. Two folders are therefore two browser origins and cannot read each other.
- The port is fixed per computer, 1212 unless taken; the hostname is fixed per folder. The port
is chosen on the first launch and bound again at every startup, before any file is opened, and the
hostname is chosen once per folder and never changes. Both live in
config.json, so a bookmarked address still answers after a restart.52341throughout this file is illustrative; the addresses are not. - An address HTML Clay remembers but is not currently serving holds no permissions and serves no
files. A top-level navigation to a
.htmlclayfile that a trusted folder still covers is redirected to that folder’s current address. Anything else gets a fixed recovery page that never echoes the requested path, and an address whose folder was untrusted keeps that page for the rest of the run.
Three host additions ride on a document navigation, and none of them is a script:
savetokenon the root<html>element, the per-file save capability. It is 32 cryptographically random bytes as unpadded URL-safe base64, lives for the lifetime of the process, and is redacted from the log. It is injected only whenSec-Fetch-Destisdocumentor absent, so a backgroundfetch()of a sibling file gets the bytes it is allowed to read and no token.documentidon.htmlclaydocuments, a UUID that is the durable identity behind version history. It reaches disk on the page’s first save, and it is what makes history follow a renamed or moved file.
Both names are recent. Before the save protocol settled on savetoken and documentid, HTML
Clay called them htmlclaytoken and htmlclayid, and it still handles both, permanently.
The token is injected under both names carrying the same value, and stripped under both on save. A document reads this attribute by name in its own inline script and no update can reach that script, so serving one name only would silently stop every older document from saving. One value under two names is safe where two credentials would not be: a reader takes the first name it recognises and gets the same token.
The id is injected under the current name only. Nothing frozen reads it — it is this host’s
version-history key, and the clients that touch it know both spellings — so serving both would
put two ids in every saved file for no reader. A file already carrying htmlclayid keeps its
history, and serving re-stamps it as documentid, so it migrates on its next save.
Write new pages against savetoken and documentid.
isAdminOfCurrentResource=true, a host-only cookie that tells ClayJS edit mode is available. It is set with noDomainattribute, so it stays with the hostname that served the page: a cookie spelledDomain=localhostwould be sent to every*.localhosthostname on the machine. Safari is the browser that has historically treated cookies onlocalhostnames differently, so it is the one to check by hand if edit mode ever fails to appear. The response also carriesCache-Control: no-store, so a 304 after a restart cannot hand back a dead token.
The save token and the read-only banner are stripped from the body on every save, so neither reaches disk. Opening a file changes nothing. The token, the cookie and the initial identity exist only in the served response; a file you open and never save stays byte for byte unchanged.
HTML Clay always opens the system default browser (open on macOS, xdg-open on Linux,
cmd /c start on Windows), in an ordinary tab. App Mode, an older chromeless window backed by a
private Chromium profile, has been removed. A launch that finds the old chrome-profile
directory under its config dir deletes it and posts a notification saying so. Do not write
instructions that assume a chromeless app window, and note there is no flag or setting to bring
it back.
A minimal self-saving file#
It’s just HTML. Here’s a minimal example — a page with editable text and a save button:
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>My Note</title>
</head>
<body>
<h1 contenteditable>Edit me</h1>
<button id="save">Save</button>
<script>
document.getElementById('save').addEventListener('click', async () => {
const html = '<!DOCTYPE html>\n' + document.documentElement.outerHTML;
const token = document.documentElement.getAttribute('savetoken');
const res = await fetch('/_/save/' + token, {
method: 'POST',
headers: { 'Content-Type': 'text/html' },
body: html
});
if (res.ok) document.title = 'Saved!';
});
</script>
</body></html>
The key line is fetch('/_/save/' + token, ...) — that’s the save call. HTML Clay injects a savetoken attribute into the <html> tag when serving the file, and the page reads it to know where to save. The token is a cryptographic session identifier that maps to the file on disk.
Before the save protocol settled on savetoken, HTML Clay called the attribute htmlclaytoken. It serves both names carrying the same token, and strips both on save, so a document written against the old name goes on saving forever. Read savetoken in anything new.
Server endpoints#
Content is served at the top level; actions live under the /_/ marker, matching the Hyperclay platform convention. The save endpoint takes the document as a plain-text body. That is the one shape the format defines for it, so a JSON body is refused with 415 rather than guessed at, and anything a save needs to say beyond the document travels in a header.
| Method | Route | Purpose |
|---|---|---|
GET | /{path} | serve a file, with savetoken injected on document navigations |
GET | /_/read/{token} | raw file contents |
POST | /_/save/{token} | write the document back to disk, atomically |
POST | /_/upload/{token} | store a file in the uploads library |
GET | /_/uploads/{folder}/{name} | serve a stored upload, no token needed |
GET | /_/meta | what this host supports, no token needed |
GET | /_/meta/{token} | the same, plus metadata for one file |
GET | /_/versions/{token} | list saved versions |
GET | /_/version/{token}/{name} | read one version |
POST | /_/restore/{token}/{name} | restore one version |
GET | /_/sync | live-sync SSE stream (also GET /_/live-sync/stream) |
POST | /_/sync | live-sync snapshot or document relay (also POST /_/live-sync/save) |
POST | /_/open-request | ask to widen reads to a named folder |
POST | /_/workspace-request/{token} | ask to trust the requesting file’s own folder |
GET | /tailwindcss/{name}.css | Tailwind stylesheet compiled from the requesting document (or {name}.html, then {name}.htmlclay) |
GET | /_/api/{path} | read the document as JSON, using caller data rules, or the rules the file publishes |
POST | /_/api/{path} | write JSON using caller data rules or the embedded mapping, content only |
POST | /{path}?data={…} | write JSON using caller rules, content only |
GET | /{path}?data={...} | read the document as JSON, using rules you supply |
GET | /_/wire/subscribe | wire SSE stream |
POST | /_/wire/send | send one wire frame |
Every mutating route except the wire is wrapped in a same-origin guard: it requires
Sec-Fetch-Site: same-origin, and if Origin is present it must equal http://<Host>. The wire
is deliberately outside that guard, because it admits local processes, which attest neither
header.
Two global gates sit in front of everything. The Host header must be a
htmlclay<n>.localhost:<port> name the app issued, or one of the bare loopback spellings
127.0.0.1:<port>, localhost:<port> or [::1]:<port>, which get a fixed page saying nothing is
served there. Any request with Sec-Fetch-Site: cross-site is refused unless it is an eligible
top-level navigation from an origin this process serves. Two folders’ hostnames are cross-site to
each other in every browser, so a link from one folder to another works under the default referrer
policy, and a page that sets no-referrer makes its cross-folder links fail closed. Both failures
are 403 Forbidden in plain text.
A read refused for being outside the page’s permitted folder answers 403 with
Content-Type: application/problem+json, the header X-HTMLClay-Error: read-access-required,
and this body, which names no path and does not depend on whether the file exists:
{"type":"about:blank","title":"Read access required","status":403,
"detail":"This page requested a file outside its permitted folder; access was not granted."}
POST /_/save/{token}#
The body is the whole document as text. That is the one body shape this route has.
- A JSON
Content-Typeis refused with415and{"ok":false,"msg":"/_/save takes the document as text, not JSON.","msgType":"error","code":"unsupported-type"}. A JSON body is never guessed at. - An empty body is
400 {"ok":false,"error":"empty body"}. - A body with no
<html>tag is400 {"ok":false,"error":"body is not an HTML document"}. - Over 50 MB is
413 {"ok":false,"error":"body too large (max 50MB)"}. - An invalid or expired token is
401 {"ok":false,"error":"invalid token"}. - Success is
{"ok":true,"msg":"Saved","msgType":"success","etag":"…"}. - When the file changed on disk since this server last wrote it, the save still succeeds and the
body is
{"ok":true,"msg":"<name> had been changed outside this tab. Your version was saved; the previous one is in Backups.","msgType":"warning","etag":"…"}.
Conditional saves. Send If-Match carrying the stamp you last saw and the save is refused
rather than allowed to overwrite a newer copy:
{"ok":false,"code":"conflict","msgType":"error","etag":"…",
"msg":"<name> changed since you last loaded it. Your version was not saved.",
"changedBy":"another-tab"}
with status 412, and nothing written: not the file, not a version, not the file’s identity. The
etag in the refusal is the current one, so a client can reconcile without a second request.
changedBy names what moved the document, and appears only when this host actually knows. It says
another-tab when the bytes on disk are ones this process wrote itself, which on a desktop host
means you, in another tab or on another device. A change made by anything else — an editor, a
script, an agent, a sync client — is indistinguishable from here, so the field is omitted
rather than guessed at.
A save with no If-Match is the plain last-write-wins save and behaves exactly as it always has,
so nothing already saved is affected by this capability existing.
The stamp is sha256 of the bytes stored, hex, first 16 characters, and it is the same
computation hyperclay uses, so a document that moves between the two hosts keeps a stamp both
recognise. Stored, not sent: a save arrives carrying a save token that is stripped before the
write, so the stamp describes the file rather than the request. If-Match: * matches any document
that has bytes; a field naming no stamp this host issued is refused rather than ignored.
GET /_/meta/{token}#
{
"spec": 1,
"extensions": ["conditional", "data-read", "data-write", "sync", "upload"],
"path": "notes.htmlclay",
"absolutePath": "/Users/you/Desktop/notes.htmlclay",
"name": "notes.htmlclay",
"size": 3122,
"lastModified": "2026-08-27T18:04:11Z",
"documentid": "…",
"htmlclayid": "…",
"document": {
"etag": "…",
"upload": { "allowed": true, "maxBytes": 26214400 }
}
}
document is the per-file half of the answer, and where a client seeds the stamp it later sends
as If-Match. It is the only part of a discovery answer a host ever withholds. Its upload block
is what makes the announced upload capability usable: a client that finds no block does not
upload and does not probe the route to find out, so the cap is reported rather than discovered by
a refusal.
htmlclayid is the pre-spec spelling of documentid and carries the same value. It is permanent.
Every earlier HTML Clay answered here with that name, and this route exists for a document that has
no other way to learn about itself, so its own inline script is the reader that would have
hardcoded it.
path is always forward-slashed, so a client can build a URL from it. absolutePath keeps the
platform’s own separators, because it names a file on disk rather than a route.
GET /_/meta#
{ "spec": 1, "extensions": ["conditional", "data-read", "data-write", "sync", "upload"] }
The same answer without the per-file half, and it takes no token. spec and extensions
describe the host, so they are identical for every file it serves and nothing in them belongs to
any one person; the protocol forbids gating that behind a credential, because the caller is a
page a person just loaded and this is the only place a client may learn what a host supports.
Ask here when the page carries no save token, which on this host means any file served read-only. A client that gets no answer treats the host as save-only, which is a correct thing to be, so discovery failing never costs a save.
GET /_/versions/{token} and restore#
{"ok": true, "name": "notes.htmlclay",
"versions": [{"name": "2026-08-27-14-03-52-431-0400.html", "time": "…", "seq": 0, "size": 3122}]}
Newest first. GET /_/version/{token}/{name} returns that version’s raw bytes as
text/html; charset=utf-8, and {name} must be exactly one generated filename or the answer is
400 invalid version name.
POST /_/restore/{token}/{name} answers {"ok":true,"msg":"Restored <name>","msgType":"success"}.
Before an old version replaces the current file, HTML Clay backs up what is there now; if that
copy cannot be made, the restore is refused with
500 current file cannot be read, so no safety backup is possible. A version over 50 MB is
413, and one that is not a complete HTML document is 400.
POST /_/upload/{token}#
multipart/form-data with exactly one part named file.
{"ok": true, "msg": "Uploaded", "msgType": "success",
"uploads": [{"name": "photo-a3f19c0d5e7b2a914c6f08d1e3b7a522.jpg", "url": "/_/uploads/assets-notes/photo-a3f19c0d5e7b2a914c6f08d1e3b7a522.jpg", "bytes": 84210}]}
The file lands in the computer’s uploads library, ~/htmlclay/uploads/, in a folder named
after the document, so notes.htmlclay uploads into ~/htmlclay/uploads/assets-notes/. The
folder name is the document’s stem lowercased, with every run of other characters turned into
one dash. The stored name is the submitted stem, a dash, at least the first 32 hex characters of
the content’s SHA-256, and the extension. That tail lengthens only on a real collision between
different content, so identical bytes reuse one file and different bytes never overwrite each
other.
The returned url is a host path, percent-encoded per segment. Every HTML Clay port answers
GET /_/uploads/... from the library, so the link keeps working when the document is renamed or
moved, and Hyperclay Local and hyperclay.com use the same form. The route serves stored files
only, never a folder listing, and needs no token, so any page on this computer can read an
upload whose name it knows; the long hash tail is what keeps names unguessable. A document opened
straight from disk shows these files broken. Export as zip packages every linked upload and
rewrites the links to uploads/... in the zipped copy.
Limit 25 MB (413, code: "too-large"). Active content is refused with 415 and
code: "unsupported-type": .html, .htm, .xhtml, .htmlclay, .js, .mjs, .cjs,
.xml, .xht, .xsl, .xslt. SVG is accepted and served inert.
Technical deep dive#
Architecture#
HTML Clay is a Go application with a simple architecture: a localhost HTTP server that bridges the browser sandbox with the filesystem.
User double-clicks .htmlclay file
→ OS launches HTML Clay (registered handler for .htmlclay)
→ App picks the file's origin: the trusted folder containing it, else its own folder
→ App serves the folder at its own hostname on the app's one port, and mints a session token
→ App opens the default browser at http://htmlclay<n>.localhost:1212/
→ Browser loads file from the loopback server
→ User edits, hits save
→ JS reads savetoken, calls POST /_/save/{token}
→ Server writes changes back to disk
The app’s one port is bound at startup, on both loopback addresses, before a file is opened, and
every folder answers at its own hostname on it. If another program holds the port at launch, a
dialog names it and offers to quit it, move HTML Clay to a new port, or quit. If trusting an
enclosing folder changes the active origin, an ordinary remembered 127.0.0.1:<port> address
redirects an eligible top-level document navigation to the folder’s current hostname while its old
port is free. The old listener serves no files and grants no permissions. Missing trust, a failed
folder identity check, unsafe paths, and non-navigation requests keep the recovery page. Explicitly
revoked addresses remain on that page for the rest of the run.
Folder trust does not migrate browser storage between origins, and forgotten or evicted records are not permanent bookmark records.
Programs#
Programs → Configure an Open File… lists the programs a document declares for files already registered, including files opened from a bookmark. Selecting a row shows its current status, chooses the executable, and then asks for explicit document-only or any-document approval. Helpers receive protocol requests on stdin; launchers that only open pages are not helpers. Cancel or deny leaves bindings unchanged. A stored valid binding is restored on reload or restart without another approval. This menu is generic; Search’s specific contract belongs in its own README.
Reading a file as JSON#
Any .html or .htmlclay file HTML Clay will serve can also be read as JSON, using the same
extraction rules as the Hyperclay platform. Two ways in:
# You supply the rules
curl --get 'http://htmlclay.localhost:1212/notes.htmlclay' \
--data-urlencode 'data={title:"h1",items:".todo[]"}'
# The file supplies its own, from a tag inside it
curl 'http://htmlclay.localhost:1212/_/api/notes.htmlclay'
Replace htmlclay with the hostname in your tab’s address bar; the port is the same for every
folder. Browsers and curl resolve *.localhost to loopback by themselves. Other programs ask
the system resolver, which macOS answers, Linux answers with systemd-resolved, and Windows usually
does not (PowerShell’s Invoke-RestMethod fails with “remote name could not be resolved”). A
program can skip the hostname entirely: 127.0.0.1:1212/_/api/<path> reaches whichever open folder
serves that file, for any caller that is not a browser (no Origin, no Sec-Fetch-Site):
Invoke-RestMethod http://127.0.0.1:1212/_/api/Documents/notes/notes.htmlclay
The path is relative to your home folder, the same path the address bar shows. If no open folder
contains the file, the answer is a 404 No open folder; open the file in HTML Clay first.
The second reads a tag the page publishes:
<script data-rules-name="api" data-rules-version="1">
{title: "h1", items: [".todo", {text: ".", done: "@data-done"}]}
</script>
Rules are relaxed JSON: unquoted keys and bare selectors are fine. A rule is a CSS selector, with
sel[] for every match’s text, sel@attr for an attribute, and [sel, {…}] for one object per
match.
A data request reads exactly what a normal request reads, and nothing more. It runs the same
permission checks in the same order, asks the same folder-access question, and returns the same
refusal. It never creates an editing session: no save token, no edit-mode cookie, and no version
snapshot. There is no CORS header on either route, so only a program you run yourself, such as
curl, or a page on this same site can read it.
Where this differs from the platform#
Deliberate, and each one measured against the platform’s own engine rather than assumed.
| Behavior | HTML Clay | Platform |
|---|---|---|
| A selector the two engines read differently | refused with a reason, e.g. :is, :empty, :gt, :matches, CSS comments, the [a=b s] flag | answered, sometimes differently |
A positional that is not last, e.g. li:first span | refused | answered |
| Any broken selector | 400 | 400 or 500, depending on the wording of the parser’s message |
@type on an element | the type attribute | "tag", the internal node type |
@readOnly | the real value | always false |
sel@href[] | refused, naming the array form that works | silently [] |
A repeated ?data= parameter | 400 | 500 |
| Caching | none | five minutes |
| CORS | none | enabled |
The full ledger, with the measurement behind every row, is in
internal/dataapi/testdata/selector-parity.json: one list of constructs that must match the platform exactly,
and one of constructs HTML Clay refuses, each recorded with the answer the platform gives so the cost
of the refusal is written down rather than guessed at.
Writing a file as JSON#
Caller supplied rules work on both URL forms, for GET and POST. No embedded tag is required:
curl --get 'http://htmlclay.localhost:1212/soup.htmlclay' \
--data-urlencode 'data={title:h1}'
curl --request POST \
'http://htmlclay.localhost:1212/soup.htmlclay?data=%7Btitle%3Ah1%7D' \
--header 'Content-Type: application/json' \
--data '{"title":"Lunch"}'
Replace htmlclay with the hostname in your tab’s address bar; the port is the same for every
folder. The same requests work with /_/api/soup.htmlclay?data=%7Btitle%3Ah1%7D, and with .html
files.
Supplied rules replace the embedded mapping for that request, even if the tag is malformed.
They are never merged with it or saved into the file. Without data, /_/api/ uses the
embedded api tag as before. An empty, malformed, or repeated data parameter returns
400; it never falls back to the tag. POST bodies remain strict JSON, with no rules envelope.
The response uses the same rules as the request. GET and POST return the source file’s
ETag; send it as If-Match to refuse a stale write with 412.
POST JSON to the same /_/api/ address and HTML Clay writes it into the file through the page’s own
api rules tag. The body has the same shape a GET returns:
curl -X POST 'http://htmlclay.localhost:1212/_/api/notes.htmlclay' \
-H 'Content-Type: application/json' \
-d '{"title":"Groceries","items":[{"text":"eggs","done":"no"}]}'
- Content only. Text is written as text, never parsed as HTML. The write is refused (
400 Write refused, nothing written) when it would set anon*handler,style,srcdoc, a save or region marker, or ajavascript:,vbscript:ordata:URL, when it targets anything inside<script>,<style>,<template>,<iframe>or the head’s metadata tags, and for@innerHTMLand@outerHTML. - Strict keys. A key the selected rules do not define, or a rule whose selector matches nothing,
is
400 Write rejectedwith the offending names. A key you leave out is left alone, andnullclears a text value. Lists grow by cloning their first row, or acms-templateseed when empty. - Only the changed elements are rewritten. Every other byte of the file, line endings and quoting included, stays exactly as it was. The splice is proven by reparsing, and when it cannot be proven the whole document is rendered instead.
- It lands like a save. Version history records it, open tabs update live, and a body that
changes nothing writes nothing. The answer is the fresh extraction with an
ETag; the GET faces send the sameETag, and sending it back asIf-Matchturns a write over a changed file into a412. - Who can write. A program you run yourself, such as
curlor an agent, needs nothing extra. A browser page must send the target file’s save token in aSave-Tokenheader, so a page cannot write a sibling it has not been handed the token for. The file must be one HTML Clay has open, or an HTML Clay file inside a trusted folder; anything else is403 Not writable. Bodies areapplication/json(415otherwise), at most 1 MB.
Package structure#
cmd/htmlclay/ The executable. main.go starts and stops it, sites.go owns the site registry,
folders.go the trusted-folder list, open.go hands a file to the browser,
recovery.go the remembered-port page, wire_cli.go the `htmlclay wire` subcommand
internal/server/ HTTP server, request handlers, security middleware
internal/session/ Cryptographic token generation, file↔token mapping, held read roots
internal/trust/ The rules about which folders may be trusted, and on whose say-so
internal/dataapi/ Reading a document as JSON, with its vendored conformance corpus
internal/versions/ Save history: every write keeps a restorable previous version
internal/tailwind/ Builds a document's Tailwind stylesheet with the bundled Tailwind CLI
internal/browser/ Opening a URL in the system's default browser
internal/htmlutil/ Inject/strip savetoken and documentid attributes in <html> tag
internal/config/ Persist settings to OS config dir (~/Library/Application Support, ~/.config, %APPDATA%)
internal/platform/ Single-instance enforcement (Unix socket / TCP on Windows), Start on Login, file associations
internal/tray/ System tray icon and menu
internal/logging/ File-based logger with 10MB rotation
internal/update/ Version check against htmlclay.com
packaging/ Per-OS packaging. packaging_test.go sits here rather than at the root: it
pins the couplings between these assets and the code that reads them
packaging/macos/ macOS .app bundle build script, Info.plist, codesigning
packaging/linux/ Desktop entry, MIME type registration, icons, install and uninstall scripts
packaging/windows/ Icon and version resources (winres.json), zip README, fallback registration script
packaging/icons/ The icon source set and the generator that renders every platform's icons
website/ htmlclay.com, deployed from this repo
Security model#
- Localhost only — The server binds its one port on
127.0.0.1and::1, serves each folder at its own*.localhosthostname, validates theHostheader, and rejects cross-site requests (Sec-Fetch-Site: cross-site). Mutating routes (save, restore, live sync, permission requests) additionally requireSec-Fetch-Site: same-originand a matchingOrigin. Two folders’ hostnames are cross-site to each other, so a link between folders works under the default referrer policy and fails closed from a page that setsno-referrer. - Read-only by default, two ways to editable — A linked or typed
.htmlclayfile serves read-only with a banner offering to trust its folder (a single-use server-minted nonce plus a native dialog). A trusted folder auto-registers its.htmlclayfiles as editable on real navigations, with no prompts. Save tokens are injected only into document navigations, never into background fetches. - 256-bit session tokens — Each opened file gets a cryptographically random token. The read, save, and meta endpoints (under
/_/) require a valid token; the top-level file-serving route only resolves paths that match an already-open file. Tokens are redacted from the log and live for the lifetime of the process (there is no per-file expiry); on a single-user desktop this is fine, since they never leave loopback. - Path traversal prevention — All file paths are validated as relative and within the user’s home directory. Symlinks are resolved before validation.
- Atomic writes — Files are written to a temp file first, then renamed into place, preventing corruption on crash.
- Single instance — A Unix socket (or TCP on Windows) ensures only one server runs at a time. Additional launches forward their file paths to the running instance.
- Local trust boundary — The bridge listens on loopback, so any process running as your user can reach the app’s port, at
127.0.0.1:<port>or at any*.localhosthostname it serves. Saving requires the per-file token, but the top-level serve route returns a currently-open file by path with no token, so a malicious local process (or a page you already have open) can read other open documents. This is inherent to the localhost-bridge model: htmlclay only serves files you have explicitly opened, and nothing is exposed off the machine.
Configuration#
Stored at <os-config-dir>/htmlclay/config.json (~/Library/Application Support on macOS, ~/.config on Linux, %APPDATA% on Windows):
{
"startOnLogin": false,
"port": 1212,
"siteSlugs": {
"/Users/you/projects/notes": "htmlclay1"
},
"retiredSlugs": [
"htmlclay"
],
"nextSiteNumber": 3,
"sitePorts": {
"/Users/you/projects/notes": 54321
},
"workspaceFolders": [
{ "path": "/Users/you/projects/notes", "identity": "16777229:224754543" }
]
}
port is the one port every folder is served on, chosen on the first launch and kept. siteSlugs
remembers the hostname each folder answers at, keyed by its anchor folder, so an address survives a
restart: the label is chosen once and never changes. Labels are htmlclay, then htmlclay1,
htmlclay2, and so on, in the order folders are first served, and nextSiteNumber is the next one.
retiredSlugs are labels that were once issued and then given up, which are never issued again.
sitePorts is the legacy record from the per-folder-port model, kept only so an old
127.0.0.1:<port> bookmark redirects to the folder’s hostname while that port is free. Nothing new
is ever written to it.
aiEdit holds the AI Editing switch and is absent until you change something. "enabled": false
turns AI editing off. "default" names the agent used when a request names none (claude, fable
or codex, or one of your own). "engines" adds your own agents, each a command as an argument
list, where an argument containing {prompt} receives the prompt; without one, the prompt arrives
on standard input. For example "aiEdit": { "engines": { "echo": ["sh", "-c", "cat"] } } makes
@echo send the prompt back unchanged.
The trusted-folder list keeps the on-disk key workspaceFolders from the version that introduced it.
Renaming the key would make older configs fail to parse, and the corrupt-config path would then reset
every other setting. identity is the folder’s fingerprint at the moment you trusted it: volume UUID
and inode on macOS, device and inode on Linux, volume serial and file id on Windows. A folder replaced
since then stops granting anything rather than covering the newcomer. An entry recorded before Windows
could fingerprint a directory has no identity; it keeps working and gains one on the next launch.
System tray#
The app lives in the system tray with controls for:
- Trusted Folders: trust one through a folder picker, or click a row to stop trusting it
- Opening the example file and the backups folder
- Toggling Start on Login (LaunchAgent on macOS, autostart desktop entry on Linux, registry key on Windows)
- Turning AI Editing on or off (on by default)
- A notification when a new version is available (click to open the download page)
Building from source#
Requires Go 1.26+. Linux and Windows build as pure Go (no system libraries needed); macOS builds use cgo for the system tray and Finder integration.
To install the binary straight from the module:
go install github.com/panphora/htmlclay/cmd/htmlclay@latest
That gives you the tray app, the local server, and the htmlclay wire CLI. On Windows it also gives you the .htmlclay double-click association, which the binary registers itself on launch. On macOS and Linux that association lives outside the binary, in the .app bundle and the freedesktop MIME entry the packaged builds install, so download a build from htmlclay.com if you want double-click to work.
To build from a checkout instead:
# Build the binary
make build
# Build the binary with the Tailwind compiler bundled (downloads the pinned
# Tailwind release once; `make build` leaves it out and /tailwindcss/ answers
# an empty stylesheet)
make build-tailwind
# Run tests
make test
# Build macOS .app bundle
make dist-macos
# Build Linux binary (prints install steps; CI assembles the tarball)
make dist-linux
# Build Windows executable
make dist-windows
# Clean build artifacts
make clean
Platform support#
Supports macOS, Linux, and Windows. Each platform has build scripts and OS integration assets in packaging/. The internal/browser/, internal/platform/, and internal/tray/ packages use platform-specific build files (_darwin.go, _linux.go, _windows.go).
File associations. macOS reads them from the .app bundle’s Info.plist. Linux gets them from the
freedesktop MIME database, which packaging/linux/install.sh writes into ~/.local/share with no sudo.
Windows writes them itself, under HKCU\Software\Classes, on every launch, so a binary that moved
keeps a working association.
Undoing it. htmlclay.exe --unregister on Windows removes the file associations and the Start on
Login entry, then exits; it runs before the single-instance lock, so it works whether or not the app is
running. bash uninstall.sh in the Linux tarball removes the binary, the desktop entry, the MIME
package, the icons, and the autostart entry. Neither touches the config directory, which holds the
trusted-folder list and every saved version.
Linux permission prompts are drawn by zenity, falling back to kdialog. With neither installed
the app says so at startup, in the log, in a notification, and in a permanent tray row, because every
prompt then fails closed. The folder picker also falls back to the XDG desktop portal
(org.freedesktop.portal.FileChooser), which needs no helper binary, so trusting a folder still works
on a machine with neither tool.
Releasing#
./scripts/release.sh --minor # or --major, --patch (the default), or --version=1.9.0
That bumps the version in cmd/htmlclay/main.go, tags and pushes, triggers the CI release workflow (test on three platforms, sign, notarize, upload to R2), publishes the website, and installs the new build into /Applications.
Three things about this repo are easy to trip over.
The website deploys itself on every push to main. htmlclay.com is a Cloudflare Worker fed by a git integration, not by CI and not by a manual wrangler deploy. Any push to main redeploys website/ about 25 seconds later. There is no deploy command to run, and adding one only races the integration.
Download links are stamped after CI, not during the version bump. scripts/stamp-website.js writes the version into website/index.html, anchored on the data-version and data-mac-dmg attributes, so new spots on the page need no change to the script. Stamping during the bump would auto-deploy links to a dmg that CI has not uploaded yet, leaving them broken for the few minutes a build takes.
htmlclay-release-info.json on R2 is not a website file. It is the feed the in-app update checker polls, and its URL is compiled into every shipped binary (internal/update/update.go). Removing it would silently and permanently break update checks for installs already in the wild.
To reinstall the current release locally without cutting a new one:
bash scripts/install-local.sh # version from cmd/htmlclay/main.go
bash scripts/install-local.sh 1.1.0 # or an explicit version