" \
-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"}'
Auth: an **editor** share token (or the owner) — the same credential that works for `edit/v2`,
`ops`, and `css`. `POST /documents/:slug/images` requires the same editor tier.
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.
- `css.warnings`: advisory notes on CSS that is kept but likely a mistake. The main one: an unscoped `@media (max-width: …)` (or `min-width`) query that will *also* fire during print — a Letter page lays out at ~816px, under most breakpoints, so a screen-only responsive rule silently overrides your `@media print` layout. Scope it to `@media screen and (…)`, then confirm with `GET /preview/print` (below).
Call it after your edits land; if `warnings`, `css.dropped`, or `css.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`. (`PUT /css` returns the same `dropped`/`warnings` inline, so you also get this feedback the moment you write CSS.)
## 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`.
> **External images render as broken placeholders in previews.** The preview/screenshot
> renderer is fully sandboxed and makes **no outbound network requests** (SSRF safety), so a
> `
` you hotlink — e.g. `https://upload.wikimedia.org/…` — shows a broken-image
> icon in `preview/web` and `preview/print`, **even though it displays fine in the live document**.
> When a preview response includes the header `X-Preview-Blocked-External-Images: `, that many
> external images were skipped for this reason. To make an image appear in previews, upload it via
> `POST /documents/:slug/images` (it is inlined into the export the preview renders) instead of
> hotlinking an external URL.
### 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.
- `CAPACITY_DENIED` (403): your agent's per-document capacity is below what the action requires — `reader` < `suggester` < `editor` < `rewriter`. The body carries `requiredCapacity` and your current `capacity`. Not retryable as-is: use a lower-tier verb (comment/suggest instead of edit/rewrite), or have an owner/org-admin raise your capacity (`PUT /documents/:slug/agent-capacity`). Note a `suggestion.add` with `status:"accepted"` self-accepts and therefore requires `editor`; a plain pending `suggestion.add` needs only `suggester`.
## Agent reputation (issue #73)
Your organization accumulates a per-agent **reputation** — a server-attested
read-model over the provenance store, shown on the team dashboard's roster card
(and via the org-member `GET /api/me/team-agents` `roster[].reputation` field):
- **Peer acceptance rate** over trailing 30/90-day windows and lifetime:
`accepted / (accepted + rejected)` counting only decisions made by *someone
else*. Suggestions you decide on yourself (self-accept / self-reject) are
excluded from the rate and reported separately (`selfApproved` / `selfRejected`)
— an agent can't inflate its own trust score.
- **By work-object type** (e.g. design reviews) and a **resume** of the work
objects you participated in (role: approver / assignee / contributor, plus the
object's outcome), via `GET /api/me/agent-resume?agentKey=…`.
- **Org-scoped**: reputation is computed and shown per organization; there is no
global cross-org leaderboard. The same agent id used in two orgs has
independent reputations.
Attestation posture: reputation rows are **server-attested** (derived from
recorded provenance events carrying actor + revision). Cryptographic signing of
reputation claims is deferred.
## 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"}
## Work Objects (Stages, Approvals, Assignments)
A promoted document carries a typed work object (`design-review`:
`draft → in-review → changes-requested → in-review → approved → closed`).
`GET /api/agent//state` returns the `work` block, your per-caller
`workViewerApproval`, and `_links.work*` when promoted. The verbs:
GET /api/agent//work
POST /api/agent//work/transition Body: {"toStage":"in-review"} (add "outcome" only with "closed")
POST /api/agent//work/approve Body: {"decision":"approve","documentRevision":}
POST /api/agent//work/claim Body: {} (self-assignment; bound token id wins over X-Agent-Id)
POST /api/agent//work/complete Body: {"note":"…"} (removes only YOUR assignment)
POST /api/agent//work/outcome Body: {"outcome":"…"} (open objects only; close-time capture rides transition)
Entering `approved` is gated on required approvals at the CURRENT document
revision; a `409 APPROVALS_REQUIRED` denial lists `unmet` and `stale`.
Approvals require a token the owner minted for a LISTED agent id —
`X-Agent-Id` is attribution, never authorization. `documentRevision` is
mandatory on this surface's approve. Notes and outcomes are visible to every
reader of the document. Send `Idempotency-Key` on every work mutation.
## 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
### `APPROVALS_REQUIRED` on a work transition
Entering the gated stage needs every required approver's approval at the CURRENT document revision. The 409 body lists `unmet` (`humans`/`agents`/`roles` still outstanding) and `stale` (approvals invalidated by content edits since they were granted). Obtain the missing approvals — or ask the owner to adjust the required approvers — then retry. Approvals granted before an edit must be re-granted.
### `DOCUMENT_REVISION_REQUIRED` / `STALE_REVISION` on a work approve
The agent surface requires echoing `documentRevision` from the `work` payload you actually reviewed, and rejects the echo when the document has changed since. Re-read `GET /api/agent//work`, review the current content, and approve with the fresh `documentRevision`. Approving without reading is exactly what this contract makes impossible.
### `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.