HTML Clay
  1. HTML Clay
  2. Docs
  3. Host reference
  4. 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#

plaintext
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.1 and ::1, never 0.0.0.0, and validates the Host header.
  • 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. 52341 throughout 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 .htmlclay file 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:

  • savetoken on 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 when Sec-Fetch-Dest is document or absent, so a background fetch() of a sibling file gets the bytes it is allowed to read and no token.
  • documentid on .htmlclay documents, 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 no Domain attribute, so it stays with the hostname that served the page: a cookie spelled Domain=localhost would be sent to every *.localhost hostname on the machine. Safari is the browser that has historically treated cookies on localhost names differently, so it is the one to check by hand if edit mode ever fails to appear. The response also carries Cache-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:

HTML
<!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.

MethodRoutePurpose
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/_/metawhat 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/_/synclive-sync SSE stream (also GET /_/live-sync/stream)
POST/_/synclive-sync snapshot or document relay (also POST /_/live-sync/save)
POST/_/open-requestask to widen reads to a named folder
POST/_/workspace-request/{token}ask to trust the requesting file’s own folder
GET/tailwindcss/{name}.cssTailwind 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/subscribewire SSE stream
POST/_/wire/sendsend 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:

JSON
{"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-Type is refused with 415 and {"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 is 400 {"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:

JSON
{"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}#

JSON
{
  "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#

JSON
{ "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#

JSON
{"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.

JSON
{"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.

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

Shell
# 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):

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

HTML
<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.

BehaviorHTML ClayPlatform
A selector the two engines read differentlyrefused with a reason, e.g. :is, :empty, :gt, :matches, CSS comments, the [a=b s] flaganswered, sometimes differently
A positional that is not last, e.g. li:first spanrefusedanswered
Any broken selector400400 or 500, depending on the wording of the parser’s message
@type on an elementthe type attribute"tag", the internal node type
@readOnlythe real valuealways false
sel@href[]refused, naming the array form that workssilently []
A repeated ?data= parameter400500
Cachingnonefive minutes
CORSnoneenabled

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:

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

Shell
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 an on* handler, style, srcdoc, a save or region marker, or a javascript:, vbscript: or data: URL, when it targets anything inside <script>, <style>, <template>, <iframe> or the head’s metadata tags, and for @innerHTML and @outerHTML.
  • Strict keys. A key the selected rules do not define, or a rule whose selector matches nothing, is 400 Write rejected with the offending names. A key you leave out is left alone, and null clears a text value. Lists grow by cloning their first row, or a cms-template seed 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 same ETag, and sending it back as If-Match turns a write over a changed file into a 412.
  • Who can write. A program you run yourself, such as curl or an agent, needs nothing extra. A browser page must send the target file’s save token in a Save-Token header, 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 is 403 Not writable. Bodies are application/json (415 otherwise), at most 1 MB.

Package structure#

plaintext
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.1 and ::1, serves each folder at its own *.localhost hostname, validates the Host header, and rejects cross-site requests (Sec-Fetch-Site: cross-site). Mutating routes (save, restore, live sync, permission requests) additionally require Sec-Fetch-Site: same-origin and a matching Origin. 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 sets no-referrer.
  • Read-only by default, two ways to editable — A linked or typed .htmlclay file 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 .htmlclay files 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 *.localhost hostname 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):

JSON
{
  "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:

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

Shell
# 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#

Shell
./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:

Shell
bash scripts/install-local.sh          # version from cmd/htmlclay/main.go
bash scripts/install-local.sh 1.1.0    # or an explicit version