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