# clashtml Agent Docs ## clashtml Route Alias Hosted clashtml keeps the `/api/agent/*` and `/share/html` compatibility routes. The reusable `clashtml` surface is mounted in parallel at: - `POST /documents` - `GET /documents/:slug/state` - `GET /documents/:slug/snapshot` - `POST /documents/:slug/ops` - `POST /documents/:slug/presence` - `GET /documents/:slug/events/pending` - `POST /documents/:slug/events/ack` - `GET /documents/:slug/bridge/state` - `GET /documents/:slug/bridge/marks` - `POST /documents/:slug/bridge/comments` - `POST /documents/:slug/bridge/suggestions` - `POST /documents/:slug/bridge/rewrite` - `POST /documents/:slug/bridge/presence` - `POST /documents/:slug/images` — upload an image (editor); body `{ "data": "", "alt": "..." }` → `{ "url": "/documents/:slug/images/:key" }`. Reference the `url` in an ``/`
` via Edit V2. - `GET /documents/:slug/images/:key` — serve an image (read-gated). ## Which Editing Method Should I Use? clashtml has three editing approaches. **Pick one — don't mix them.** | Goal | Method | Endpoint | |------|--------|----------| | **Add/replace/insert a few lines** (recommended) | Edit V2 (block-level) | `GET /snapshot` → `POST /edit/v2` | | **Simple text replacement** | Structured edit | `POST /edit` | | **Replace entire document** | Rewrite | `POST /ops` with `rewrite.apply` | | **Add a comment** | Ops | `POST /ops` with `comment.add` | **Start with Edit V2** for most tasks. It uses stable block refs, handles concurrent edits cleanly, and returns clean HTML without internal annotation wrappers. `suggestion.add` now matches against annotated documents correctly and preserves stable anchors, but `edit/v2` is still the better default for programmatic content changes. `rewrite.apply` is still disruptive. Avoid it if anyone might have the document open: hosted environments block rewrites while live authenticated collaborators are connected, and `force` is ignored there. ## I Just Received A clashtml Link No browser automation is required. Use HTTP directly (for example, `curl` or your tool's `web_fetch`). If you received a shared link like: http://localhost:4000/d/?token= You can discover the API and read the document in one step using **content negotiation** on that same URL. Fetch JSON (recommended): curl -H "Accept: application/json" "http://localhost:4000/d/?token=" Fetch raw HTML: GET http://localhost:4000/d/?token=&format=html The JSON response includes: - `html` (document content) - `_links` (state, ops, docs) - `agent.auth` hints (how to use the token) ### Quick copy/paste flow (token already in the shared URL) ```bash SHARE_URL='http://localhost:4000/d/?token=' TOKEN='' SLUG='' curl -H "Accept: application/json" "$SHARE_URL" curl "$SHARE_URL&format=html" curl -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: your-agent" "http://localhost:4000/documents/$SLUG/state" ``` ## Auth: Token From URL If a URL contains `?token=`, treat it as an access token: - Preferred: `Authorization: Bearer ` - Also accepted: `x-share-token: ` ## Edit Via Ops (Comments, Suggestions, Rewrite) Use: POST /documents//ops `by` controls authorship. Presence is explicit-only: send `X-Agent-Id: ` (or `agentId` in the JSON body) when you want the agent to appear in presence. Add a comment: curl -X POST "http://localhost:4000/documents//ops?token=" \ -H "Content-Type: application/json" \ -H "X-Agent-Id: your-agent" \ -d '{"type":"comment.add","by":"ai:your-agent","quote":"text to anchor","text":"comment body"}' Suggest a replace: curl -X POST "http://localhost:4000/documents//ops?token=" \ -H "Content-Type: application/json" \ -H "X-Agent-Id: your-agent" \ -d '{"type":"suggestion.add","by":"ai:your-agent","kind":"replace","quote":"old text","content":"new text"}' Create and immediately apply a suggestion: curl -X POST "http://localhost:4000/documents//ops?token=" \ -H "Content-Type: application/json" \ -H "X-Agent-Id: your-agent" \ -d '{"type":"suggestion.add","by":"ai:your-agent","kind":"replace","quote":"old text","content":"new text","status":"accepted"}' Rewrite the whole document: curl -X POST "http://localhost:4000/documents//ops?token=" \ -H "Content-Type: application/json" \ -H "X-Agent-Id: your-agent" \ -d '{"type":"rewrite.apply","by":"ai:your-agent","content":"

New document

Fresh body.

"}' ## Edit Via Structured Operations (Append, Replace, Insert) For surgical edits without rewriting the entire document, use the `/edit` endpoint: POST /documents//edit All requests require `Content-Type: application/json` and auth via `Authorization: Bearer `. The body must include an `operations` array (max 50 ops) and a `by` field for authorship. If you want presence, also send `X-Agent-Id: ` or `agentId` in the body. ### Append to a section Add content at the end of a named section (matched by heading text): curl -X POST "http://localhost:4000/documents//edit" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -H "X-Agent-Id: your-agent" \ -d '{ "by": "ai:your-agent", "operations": [ {"op": "append", "section": "Brandon", "content": "

Feb 16, 2026

New brainstorm idea here.

"} ] }' The `section` value is matched against heading text (e.g., `"Brandon"` matches `

Brandon

`). ### Replace text Find and replace a specific string in the document: curl -X POST "http://localhost:4000/documents//edit" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -H "X-Agent-Id: your-agent" \ -d '{ "by": "ai:your-agent", "operations": [ {"op": "replace", "search": "old text to find", "content": "new replacement text"} ] }' ### Insert after text Insert content after a specific anchor string: curl -X POST "http://localhost:4000/documents//edit" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -H "X-Agent-Id: your-agent" \ -d '{ "by": "ai:your-agent", "operations": [ {"op": "insert", "after": "anchor text to find", "content": "

Content to insert after the anchor.

"} ] }' `insert` only supports `after`. Payloads using `before` are rejected with `INVALID_OPERATIONS`. ### Multiple operations You can combine operations in a single request (applied in order): curl -X POST "http://localhost:4000/documents//edit" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -H "X-Agent-Id: your-agent" \ -d '{ "by": "ai:your-agent", "operations": [ {"op": "append", "section": "Dan", "content": "

New idea from Dan.

"}, {"op": "replace", "search": "(placeholder)", "content": "Actual content here."} ] }' ### Response A successful response includes: { "success": true, "slug": "", "updatedAt": "", "collabApplied": true } - `collabApplied: true` means the edit was pushed into the live collab session (connected viewers see it in real time). - `presenceApplied` is only `true` when you also supplied explicit agent identity via `X-Agent-Id`, `agentId`, or `agent.id`. - If the document changed since you last read it, you may get a `409 STALE_BASE` error — re-fetch state and retry. Collab convergence fields: - `collab.status` is render-authoritative (`confirmed` when the ProseMirror/Yjs fragment converged). - `collab.fragmentStatus` tracks fragment convergence (`confirmed|pending`). - `collab.htmlStatus` tracks SQL HTML projection convergence (`confirmed|pending`). - `collabApplied` follows `fragmentStatus` (not HTML projection status). ### Optimistic locking (required for `/edit`) Pass `baseUpdatedAt` (from a prior state response) to detect concurrent edits: {"by": "ai:your-agent", "baseUpdatedAt": "2026-02-16T...", "operations": [...]} If the document's `updatedAt` doesn't match, you'll get a `409` with `retryWithState` pointing to the state endpoint. ## Update Title Metadata Use: PUT /documents//title Example: curl -X PUT "http://localhost:4000/documents//title" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"title":"Updated document title"}' Discovery: - `GET /documents//state` includes `_links.title` and `agent.titleApi`. ## Style The Document (Per-Document CSS) Every document has a CSS channel next to its HTML. It is stored exactly as you write it; renderers sanitize and scope it under `.clashtml-canvas` at apply time, so you can style the document but never the app chrome. The rendered document canvas is viewer-theme-independent: it always uses a light baseline (matching the export), and the app's light/dark toggle never restyles it — only the chrome and source panes. To give the document its own dark mode, add `@media (prefers-color-scheme: dark) { ... }` (follows the viewer's OS) or `html[data-theme="dark"] { ... }` (follows the in-page toggle) to the document CSS; both scope to the canvas. The `prefers-color-scheme` form also applies in the self-contained export; `html[data-theme]` does not. Write plain selectors (`h2 { ... }`, `.cols { ... }`) — the scope prefix is added automatically. Selectors that already start with `.clashtml-canvas` are left as-is, and `:root`/`html`/`body` are rewritten to the canvas scope, so any of those forms styles the canvas root itself (page width, grid columns, padding). Read the current stylesheet (any share token role): curl -H "Authorization: Bearer " "http://localhost:4000/documents//css" Replace the stylesheet (editor or owner credentials): curl -X PUT "http://localhost:4000/documents//css" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"css":"h1 { letter-spacing: -0.02em; } .lead { font-size: 1.25rem; color: #555; }"}' The response looks like: { "ok": true, "dropped": [] } `dropped` is advisory: it lists what renderers will strip when they apply your CSS (for example `removed @import` or `position: fixed demoted to sticky`). The stored text is still verbatim. Renderers drop `@import`, external `url()` references, `expression()`/`behavior`, and demote `position: fixed` to `sticky`. Payloads over 200000 characters are rejected with `CSS_TOO_LARGE`. You can also seed CSS at creation time: curl -X POST "http://localhost:4000/documents" \ -H "Content-Type: application/json" \ -d '{"title":"Styled doc","html":"

Hello

Intro.

","css":".lead { font-size: 1.25rem; }"}' Discovery: - `GET /documents//state` includes the `css` field plus `_links.css` and `agent.cssApi`. ### Document model: what the rendered editor preserves The HTML channel stores what you send almost verbatim, but the rendered editor re-parses it through a stricter schema: - Supported top-level blocks: `p`, `h1`–`h6`, `blockquote`, `pre`, `ul`, `ol`, `table`, `hr`, `div`, `section`, `figure`. - `article`, `aside`, `header`, `footer`, and `nav` are unwrapped and their `class`/`style` lost. - Table cells (`td`/`th`) hold inline content only; block elements inside a cell are hoisted out of the table. Write paths reject such content with `TABLE_CELL_BLOCK_CONTENT`. Never use tables for layout. - For columns, use one `div` wrapper with `section` children plus document CSS: ```html

Main

...

Side

  • ...
``` ```css .cols { display: grid; grid-template-columns: 2fr 1fr; gap: 2rem } ``` After editing, confirm the result with `GET /documents//rendered` (see below) instead of trusting `ok: true`. ### Fonts A curated, self-hosted font library (27 open-license families: sans, serif, slab, mono, display) is preloaded on every document page. Set `font-family` in the document CSS — no `@font-face` required, and unused families cost nothing (fonts download lazily): .article h1 { font-family: 'Playfair Display', serif; } .article p { font-family: 'Lora', serif; } List available families and weights: `GET /fonts/catalog.json` (use the `family` value verbatim). HTML exports inline the used fonts automatically, so exported documents keep their typography offline. ### Styling hooks and scroll effects `class` and `style` attributes survive on `p`, `h1`–`h6`, `blockquote`, `div`, `section`, and `span` (inline `style` is sanitized with the same declaration rules as the stylesheet). Block elements also accept scroll effects via `data-effect="reveal|parallax|typewriter|counter"`, tuned with `data-effect-variant` (reveal: `fade|slide-up|slide-left`), `data-effect-delay` (ms), `data-effect-duration` (ms), and `data-effect-speed` (parallax: `-1..1`; typewriter: chars/sec). Effects animate for read-only viewers. Example block you can insert with any edit method:

Launch metrics

42,000 users

## Export A Document `GET /d/?token=&format=export` downloads the document as one self-contained HTML file: document HTML, sanitized scoped CSS, and the used self-hosted fonts inlined as data: URIs. The file opens from disk with no server and no network. ## Verify Rendering (GET /documents//rendered) A write returning `ok: true` means the content was stored, not that it renders the way you intended. This endpoint answers "what will viewers actually see?": curl -H "Authorization: Bearer " "http://localhost:4000/documents//rendered" The response includes: - `canonicalHtml`: what the HTML channel stores. - `renderedHtml`: the same content after the rendered editor's schema normalizes it — this is what viewers get. - `changedByEditor`: true when the editor restructures your HTML. - `warnings`: human-readable notes on structures the editor will change or drop (unwrapped elements, table-cell content, ...). - `css.source` / `css.applied` / `css.dropped`: the stylesheet as authored, as renderers actually apply it (sanitized and scoped), and what gets stripped. Call it after your edits land; if `warnings` is non-empty or `renderedHtml` lost structure you wanted, fix the document before reporting success. The link is also advertised as `_links.rendered` and `agent.renderedApi` in `GET /state`. ## Previewing Your Work (Screenshots + Print PDF) `/rendered` reports schema fidelity; these endpoints show you real rendered pixels, so you can see overflow, bad spacing, and broken print pagination before a human does. Both accept any read-level credential (the same auth as `GET /state`) and are advertised as `agent.previewWebApi` / `agent.previewPrintApi` in `GET /state`. ### Web preview (PNG) `GET /api/agent//preview/web` returns a full-page `image/png` screenshot of the published reader view — the same self-contained export a reader downloads (no editor chrome, no comment highlights). curl -H 'x-share-token: ' -o preview.png 'http://localhost:4000/api/agent//preview/web?width=375' - `width` (default `1280`, clamped 320–3840): CSS-px viewport width. Device presets: mobile `375` (×812), tablet `768` (×1024), desktop `1280`. - `height` (optional, clamped 320–3840): when present, capture only what fits on screen on load instead of the full page (e.g. `width=375&height=812` = the mobile above-the-fold view). - Full-page captures taller than 16,000 px are clipped; the response then carries `X-Preview-Truncated: true`. ### Print preview (PDF) `GET /api/agent//preview/print` returns a true paginated `application/pdf` render — real page breaks, margins, and page count, which a screenshot cannot show. curl -H 'x-share-token: ' -o preview.pdf 'http://localhost:4000/api/agent//preview/print?paper=a4' - `paper` (default `letter`; also `a4`, `legal`): fallback page size. A document's own `@page` CSS rules always win. - Backgrounds are printed, so the preview looks like the document, not a fax. ### The recommended loop: edit → preview → refine 1. Write or edit content (`/edit/v2`, `/ops`, CSS via `PUT /css`). 2. `GET /preview/web?width=375` and `width=1280` — look at the images. Check overflow, spacing, contrast, and that the layout works at both sizes. 3. If the document is meant to print, `GET /preview/print` and check pagination (headings orphaned at page bottoms, tables split mid-row). 4. Fix what looks wrong and preview again before reporting success. Notes: - Previews render the current canonical state — the same source `GET /state` reflects — so the loop is consistent with your edits. - All external network requests are blocked during rendering. An `` shows as a broken placeholder — honest feedback that the doc depends on external resources. Upload images through the document image flow instead; those are inlined and render fine. - Renders are expensive: a dedicated rate bucket allows ~12 renders/min per agent per document (separate from the 120/min edit bucket), and hard-timeout at 15 s (`504 PREVIEW_TIMEOUT`). ## Edit V2 (Block IDs + Revision Locking) Use v2 for top-level block edits with stable block IDs and revision-based optimistic locking. ### Get a snapshot GET /documents//snapshot Example: curl -H "Authorization: Bearer " "http://localhost:4000/documents//snapshot" The response includes `revision` and an ordered `blocks` array with deterministic refs (`b1`, `b2`, ...). ### Apply edits POST /documents//edit/v2 Example: curl -X POST "http://localhost:4000/documents//edit/v2" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -d '{ "by": "ai:your-agent", "baseRevision": 128, "operations": [ { "op": "replace_block", "ref": "b3", "block": { "html": "

Updated paragraph.

" } }, { "op": "insert_after", "ref": "b3", "blocks": [{ "html": "

New Section

" }] } ] }' On success, the response includes the new `revision`, a `snapshot` payload, and a `collab` status. If your `baseRevision` is stale, you'll receive `STALE_REVISION` plus the latest snapshot for retry. v2 convergence fields: - `collab.status` remains compatibility status (`confirmed|pending`) and is fragment-authoritative. - `collab.fragmentStatus` and `collab.htmlStatus` expose render-vs-projection split directly. - `202` is only expected when fragment convergence is pending. Precondition contract for v2: - `baseRevision` is required. - `baseUpdatedAt` is not accepted on `/edit/v2`. Idempotency guidance: - Send `Idempotency-Key` for mutation requests (`X-Idempotency-Key` is also accepted for compatibility). - `/edit/v2` examples include this header because block-level retries are common in automation. Mutation contract discovery: - Read `contract.mutationStage` from `GET /documents//state` to detect Stage A/B/C rollout. - `contract.idempotencyRequired` and `contract.preconditionMode` summarize current requirements. Common mutation contract error codes: - `IDEMPOTENCY_KEY_REQUIRED`: mutation request omitted idempotency key in required stage. - `IDEMPOTENCY_KEY_REUSED`: same key reused with a different payload hash. - `BASE_REVISION_REQUIRED`: stage requires `baseRevision` and request did not provide it. - `LIVE_CLIENTS_PRESENT`: rewrite blocked because active authenticated collab clients are connected. Use `retryWithState` to refresh state, confirm `connectedClients === 0`, and if `forceIgnored=true` do not retry with `force` in hosted environments. This response is retryable and includes `reason` + `nextSteps`. - `REWRITE_BARRIER_FAILED`: rewrite safety barrier failed before mutation; no rewrite was applied. This response is retryable and includes `reason` + `nextSteps`; retry with bounded exponential backoff and jitter. ## Presence And Event Polling Poll for changes: GET /documents//events/pending?after=&limit=100 Ack processed events (editor/owner): POST /documents//events/ack Body: {"upToId": , "by": "ai:your-agent"} ## Archived Desktop Workflow This repo is web-first. Desktop-native workflows are outside the public surface scope and should be treated as separate implementation work. ## Projection Guardrails And QA Operational metrics: - `projection_guard_block_total{reason,source}` - `projection_drift_total{reason,source}` - `projection_repair_total{result,reason}` - `projection_chars_bucket{source,le}` Staging soak (live browser viewers + repeated `/edit` + `/edit/v2`): SHARE_BASE_URL=https://clashtml-web-staging.up.railway.app \ SOAK_DURATION_MS=300000 \ npx tsx scripts/staging-collab-projection-soak.ts ## Create A New Shared Doc If you need to create a share from scratch, use: POST /documents This is the canonical public create route. Hosted clashtml still accepts `POST /api/share/html` as a compatibility alias: curl -X POST "http://localhost:4000/api/share/html" \ -H "Content-Type: application/json" \ -d '{"title":"My Document","html":"

Hello

First draft.

"}' Legacy create routes like `/api/documents` are internal/legacy and may be warned or disabled on hosted environments. ## Recommended Workflow: Adding Content To An Existing Doc This is the most reliable way to add a line, row, or section to an existing document: ### Step 1: Get the snapshot curl -H "Authorization: Bearer " "http://localhost:4000/documents//snapshot" This returns clean HTML per block (no internal annotation wrappers) plus stable `ref` identifiers and a `revision` number. ### Step 2: Find the right block Look through the `blocks` array for the block you want to edit or insert near. Each block has: - `ref`: stable identifier (e.g., `b3`) - `html`: the clean HTML content of that block - `type`: block type (e.g., `paragraph`, `heading`, `table`) ### Step 3: Apply your edit curl -X POST "http://localhost:4000/documents//edit/v2" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -d '{ "by": "ai:your-agent", "baseRevision": 128, "operations": [ { "op": "insert_after", "ref": "b3", "blocks": [{ "html": "

New content here.

" }] } ] }' ### Step 4: Handle conflicts If you get `STALE_REVISION`, the response includes the latest snapshot — re-read the blocks and retry. ## Troubleshooting ### `PATHOLOGICAL_GROWTH_BLOCKED` on a write The growth guard limits how much a single write can grow the document relative to its current size. The 422 response includes `details` (current size, growth multiplier) and a `hint` with the allowed budget. Split the content into sequential smaller writes — the budget grows with the document, so each write can be larger than the last. Filling a blank or near-blank document with a normal-sized article is allowed in one write. ### `INVALID_UTF8` on a write Your payload contained the Unicode replacement character (�): the HTTP client sent a non-UTF-8 request body (common on Windows shells, where em-dashes and curly quotes are cp1252 bytes) and the original characters were destroyed before the server saw them. Re-send the request with a UTF-8 encoded body, or use ASCII-safe HTML entities (`—` `–` `’` `“` `”` `…`) for all non-ASCII characters. ### `TABLE_CELL_BLOCK_CONTENT` on a write You put block elements (headings, paragraphs, lists, ...) inside `td`/`th`. The rendered editor hoists them out of the table, so the write is rejected up front. Use the `div` + `section` + CSS grid recipe from the document model section instead of table layouts. ### `ANCHOR_NOT_FOUND` on `/edit` replace or insert The `/edit` endpoint searches for your `search` or `after` text in the document. If the document was previously edited by agents, it may contain internal `` wrappers. The search now automatically falls back to matching against clean text (with wrappers stripped), so this should be rare. If it still fails, the text genuinely doesn't exist in the document — re-read state and verify. ### `LIVE_CLIENTS_PRESENT` on `rewrite.apply` `rewrite.apply` is blocked when authenticated collaborators are connected. Outside hosted environments you can pass `"force": true`, but on hosted environments `force` is ignored. If you still prefer the safer path: 1. Use `/edit` or `/edit/v2` instead (they work with live clients). 2. Wait for clients to disconnect (poll `/state` and check `connectedClients`). ### Suggestion anchors not matching `suggestion.add` now resolves quotes against clean text even when the stored HTML contains internal `` annotations. If you still get `ANCHOR_NOT_FOUND`, re-read state and verify the quote text genuinely exists. ### Document content looks corrupted after suggestion reject cycles Repeated suggest/reject cycles on annotated documents now preserve stable suggestion anchors so the document text should remain unchanged. If you still see unexpected content drift, re-read the document with `?format=html` and report the exact request/response pair. ### `COLLAB_SYNC_FAILED` errors Edits via the API can fail when a browser has the document open with an active Yjs collab session. The `/edit` and `/edit/v2` endpoints handle this gracefully, but `rewrite.apply` does not. If you hit this, retry after a short delay or use `/edit`/`/edit/v2` instead.