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

Source: https://htmlclay.com/docs/server/

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.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](https://hyperclay.com) 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:

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

```
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](https://hyperclay.com) platform. Two ways in:

```bash
# 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.

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

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

```bash
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

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

```bash
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](https://htmlclay.com) if you want double-click to work.

To build from a checkout instead:

```bash
# 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

```bash
./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
bash scripts/install-local.sh          # version from cmd/htmlclay/main.go
bash scripts/install-local.sh 1.1.0    # or an explicit version
```
