# cnvs.app ## ⚠️ Quick start for AI agents (read this first) **Board URLs contain a `#` fragment which is CLIENT-SIDE routing only — the server never sees it.** If you're using curl, a headless browser, or any HTTP client: `https://cnvs.app/#` looks like `/` to the server and returns the app shell, NOT the board. This trips up most agents on their first try. Correct flow: 1. **Get the board id** — strip it from after the `#` in the URL, or from a shared snippet. Ids look like `ad53466d-e55e-4886-924d-5dcf861c25c1`. 2. **Read the board** → `GET /json/` (JSON snapshot, ETag-aware) **AND** `GET /svg-preview/` (SVG render — do both on strokes / images, because JSON is numbers and shapes live in the preview). 3. **React to live changes** → install the [`cnvs-whiteboard`](https://cnvs.app/cnvs-whiteboard/SKILL.md) + [`mcp-listen`](https://cnvs.app/mcp-listen/SKILL.md) Agent Skills (they push MCP notifications as in-chat wake-ups). REST fallback without the skills: `GET /api/boards//wait?timeout_ms=25000` long-poll. 4. **Write** → `POST /api/boards//{texts|links|strokes|images}` (and `/move`, `DELETE`). REST is universal — every MCP tool has a 1:1 REST mirror, and REST writes are simpler and don't require an MCP client. 5. **Create a fresh board** → `POST /api/boards` → returns `{id}`. If you want the canonical collaborator guide as an Agent Skill, read [`/cnvs-whiteboard/SKILL.md`](https://cnvs.app/cnvs-whiteboard/SKILL.md). --- > cnvs.app is a free, no-signup, real-time collaborative whiteboard in the browser. Open a URL, share it, draw and write together instantly. No accounts, no teams, no paywall. Ships a Model Context Protocol (MCP) endpoint so AI assistants can collaborate on the same live board as human users. cnvs.app is built for the simplest possible use case: two or more people on a call who want to sketch, write or brainstorm on the same surface. You open the site, a board is created, you copy the URL, you share it. Anyone who opens the URL joins the same canvas live. There is no signup, no login, no team setup, no board limit, no session token — just a URL and an infinite sheet of paper. The project is positioned as a minimalist alternative to Miro, FigJam, MURAL, Excalidraw and tldraw. Miro / FigJam / MURAL require accounts and paid plans for team use. Excalidraw's free tier is limited to a single scene. tldraw.com requires every participant to sign in before they can join a shared room. cnvs.app requires none of that. The stack is a Cloudflare Worker with Durable Objects for WebSocket-based real-time sync. Data is deleted permanently when a board is erased or after 30 days of inactivity. "Inactivity" is defined strictly as no snapshot reads — `GET /api/boards/` (browser load), `GET /json/` (REST) or MCP `get_board`. Writes, SVG/PNG thumbnails, unfurl bots and long-poll waits do NOT reset the retention timer; only a real read of the board content does. No AI training on user content, no activity logging, no ads, no trackers. **PWA / offline read-only.** cnvs.app ships as an installable Progressive Web App. A service worker (`/sw.js`) precaches the full app shell (HTML/JS/CSS/vendor/icons) and network-first-caches every board URL (`/api/boards/`, `/json/`, `/svg-preview/`) the browser actually loads. Offline users see a banner, keep full read access to previously-visited boards (browsing, switching via the recent-boards sidebar, pan/zoom/select), and have all write paths disabled (draw, text create/edit/type, paste, image drop, colour picker, eraser, postit, undo, new canvas, delete, move, resize, touch duplicate/delete). The WebSocket pauses until the browser `online` event and then reconnects. Deleted boards (server responds 404/410 or DELETE from the same browser) are evicted from the offline cache so erased content cannot be revived from a stale snapshot. The board cache is versionless so shipping new app builds never wipes a user's offline boards. Agents do not need to know about the service worker — it only affects the browser UI; REST and MCP continue to go straight to the origin. ## Pages - [Home](https://cnvs.app/): Opens a new board and gives you a shareable URL immediately. - [About](https://cnvs.app/about): Full product description, feature list, competitor comparison, pricing, FAQ, MCP docs. ## Colors — named only (no custom hex from API / MCP) Think of it like clicking an ink-picker button in the UI: you pick a *name*, not a hex value. The REST API and MCP tools accept only the color names in the table below (case-insensitive; `auto` and its alias `black` resolve to the same theme-aware ink); anything else — including a valid hex like `#ff3b30` — silently clamps to `auto` / theme-aware ink. This guarantees AI writes stay visible on both themes and look like something a human-UI user could produce. **Don't push custom hex codes** through `color`; they're normalised away. Accepted values on `texts.color`, `strokes.color`, and the move / link endpoints: | input (case-insensitive) | resolves to | meaning | |---|---|---| | `auto` / `""` / `black` / omitted / null | `var(--text-color)` | theme-aware ink (default). `black` is an alias for auto so it adapts to dark mode instead of painting black-on-black. | | `red` | `#ff3b30` | red, for emphasis | | `blue` | `#007aff` | blue | | `green` | `#34c759` | green | | `orange` | `#ff9500` | orange | | `yellow` | `#ffcc00` | yellow | | `pink` | `#ff69b4` | pink | | `purple` | `#af52de` | purple | | `maroon` | `#8b1e3e` | maroon / burgundy | | `brown` | `#a2845e` | brown | | `gray` | `#636366` | dark gray | | `lightgray` | `#aeaeb2` | light gray | | `teal` | `#0d9488` | teal / sea green | | `sage` | `#99d98c` | pastel green | | `sky` | `#a2d2ff` | pastel blue | | `lavender` | `#cdb4db` | pastel lavender | | anything else (custom hex, named HTML colors, etc.) | `var(--text-color)` | silently clamped — treat as a no-op | Same rule across every transport — REST, MCP and the browser WebSocket all accept only these named colors (`auto`/`black` plus `red`, `blue`, `green`, `orange`, `yellow`, `pink`, `purple`, `maroon`, `brown`, `gray`, `lightgray`, `teal`, `sage`, `sky`, `lavender`; a handful of retired names — `mint`, `cyan`, `indigo`, `violet`, `rose` — still resolve as legacy aliases but are no longer offered in the UI). In the browser, `red`/`blue`/`green` are the always-visible swatches and the rest live behind the desktop-only "multicolor" (5th) ink slot. Older cached browser clients that still send hex silently clamp to auto on their next draw until they reload. ## Coordinate system + vocabulary - **Infinite 2D canvas in CSS pixels.** `+x` goes right, `+y` goes DOWN (standard SVG / web, NOT mathematical). Origin is `(0, 0)` top-left; there are no negative caps but a typical board sits inside roughly `(0, 0) - (2000, 2000)` before panning starts feeling stretched. Spawn new items anywhere in that range; use `get_preview` / `GET /svg-preview/` to find empty space before placing. - **Dimensions.** Text nodes auto-size to content between ~200–400 px wide and ~40–80 px per short line; pass explicit `width` (160–4096) to force wrapping. Images render at the `width` / `height` you post — scale them to look good next to text (don't post a 2000×2000 image next to 40-px stickers). Strokes don't have a width/height; they're a freehand list of `[x, y]` world points. - **Gaps.** Leave 20–40 px of padding between adjacent nodes — otherwise the browser UI's drag handles overlap. - **`lines` vs `strokes`.** The JSON snapshot at `/json/` returns `lines[]` (matches the DB table `wbrd_lines`), while the action endpoint is `POST /api/boards//strokes` (matches the user action "draw a stroke"). Both names refer to the same entity. The REST path also accepts `/api/boards//lines` as a no-op alias if you prefer the JSON-key spelling. ## Recommended AI workflow (listen via MCP, act via REST) For AI agents that want to be a *live collaborator* on a board (react to human edits in real time, not just one-shot "go do X"), the empirically fastest loop is **hybrid — MCP for listening, REST for writing**: 1. **Subscribe via MCP** to `cnvs://board//state.json`. The server pushes `notifications/resources/updated` over SSE within ~3 s of every edit. This is the only way to get a push (no REST webhook). In Claude Code the ready-made [`mcp-listen`](https://cnvs.app/mcp-listen/SKILL.md) skill (wired up by the primary [`cnvs-whiteboard`](https://cnvs.app/cnvs-whiteboard/SKILL.md) skill) wraps this into `Monitor` so each push becomes an in-chat notification that re-invokes the model. 2. **React via REST** to `POST /api/boards//{texts,strokes,images,links}` (or `/move`, `DELETE`). Simpler to compose than threading through an MCP tool call, no per-request session management, same validator/broadcast path server-side. Every mutation is still visible to all MCP subscribers ~3 s later. 3. **Filter self-echoes**: the skill's `--ignore-author-prefix "ai:"` suppresses notifications caused by any `ai:*` author (including your own writes), so your REST mutations don't wake your own listener. **Always fetch `/svg-preview/` (not just `/json/`) when the triggering item is a `line` or an `image`.** The JSON snapshot carries numbers (point arrays, bounding boxes, image dimensions); a multimodal model reading those numbers can tell "47-point red stroke in bbox (323,1771)-(585,2066)" but has no idea it's a **heart**, a signature, a lightning bolt or illegible scribble. The human drew something *for you to see* — seeing only coordinates is functionally blindness. One extra `GET /svg-preview/` per non-trivial edit is cheap and closes this gap. For `kind: "text"` without Mermaid, JSON `content` is already enough. Why hybrid beats pure-MCP: - **Universality**: REST works for any client with outbound HTTP — no install, no SDK, no configuration. MCP requires installing a client (Claude Desktop, Claude Code, Cline, Goose, custom agent, …) and its session management. Every AI agent that can shell out or `fetch()` can use the REST surface. - **Listening**: MCP push is the only option for real-time — REST has no webhook, only `GET /api/boards//wait` long-poll (useful as fallback when MCP isn't available in the client). - **Writing**: REST avoids the JSON-RPC envelope, session header bookkeeping and tool-call round-trip the MCP SDK adds. For an agent constructing payloads from natural language, `curl -X POST ... -d '{json}'` is cognitively cheaper and fully stateless. - **No tool-call slots wasted**: MCP tool calls consume one of the model's per-turn tool-call slots. REST via shell doesn't. You CAN use pure MCP (tool calls for writes) and it works, but it's slower end-to-end per cycle and requires MCP-capable client tooling. The REST endpoints mirror every MCP tool 1:1 — `add_text` ↔ `POST /texts`, `move` ↔ `POST /{kind}/{id}/move`, `erase` ↔ `DELETE /{kind}/{id}`, etc. Pick the transport that fits your agent's runtime; the HYBRID pattern (MCP-listen + REST-write) is strictly best when both are available. ## HTTP API (for AI agents that cannot speak MCP) If your runtime can't load MCP servers, cnvs.app exposes HTTP endpoints that cover reads AND mutations. Every mutation goes through the same server-side validator as MCP and WebSocket mutations and is broadcast live to all connected browsers within ~100 ms. ### Reads - `GET /json/{boardId}` — full structured JSON snapshot (same shape as MCP `get_board`; includes Mermaid source inside text `content`; each text node carries a `kind: "text" | "link"` field). Also supports `?board=`. **Every response carries a weak `ETag`** — send it back as `If-None-Match` on follow-up polls and unchanged boards respond `304 Not Modified` (zero-body, no rate-limit burn). - `HEAD /json/{boardId}` — same headers as `GET` (including `ETag`), zero body. Cheap existence / freshness probe. - `GET /svg-preview/{boardId}` — compact schematic SVG preview (same as MCP `get_preview`; a few kB of plain SVG, consumable as an image by multimodal LLMs). Also supports `?board=`. - `GET /api/boards/{boardId}` — browser-shape snapshot (images include full base64 payload — heavier than `/json`). - `GET /api/boards/{boardId}/wait?timeout_ms=25000` — **long-poll**: blocks until the next debounced edit burst, or timeout. REST equivalent of MCP `wait_for_update`. Returns `{updated, timedOut, etag}`. Use this instead of polling `/json` in a tight loop — zero wasted requests. ### Boards - `POST /api/boards` — create a new board, returns `{id, mode}`. Optional body `{ mode?: "draw" | "todo", template? }` — `mode: "todo"` seeds a kanban board (`template` ∈ kanban/sprint/bugs) and the response adds `columns`. Also accepts the board-import fields (`content`, `autolayout`, `lock`, `author`) — see "Board import" below. - `GET /draw` — human-friendly browser entry point: creates a fresh draw board and `302`-redirects to `/#`. Optional `?seed=mermaid-flowchart|mermaid-mindmap` opens the board seeded with an example Mermaid diagram; an unknown or absent `seed` yields a blank board. Shares the `POST /api/boards` per-IP create cap. - `GET /todo` — human-friendly browser entry point: creates a fresh kanban board and `302`-redirects to `/#`. Optional `?template=kanban|sprint|bugs` selects the starter column set (default `kanban`). Shares the `POST /api/boards` per-IP create cap. - `DELETE /api/boards/{boardId}` — soft-delete the board (ID tombstoned 30 days). ### Board import (create a pre-filled board in one call) `POST /api/boards` accepts four additional optional fields — absent, the endpoint behaves exactly as above (fully backward compatible): ```jsonc { "mode": "draw" | "todo", // existing "template": "kanban"|"sprint"|"bugs", // existing "content": { // initial board content, applied atomically at create time "texts": [{ "x"?, "y"?, "content", "color"?, "width"?, "postit"?, "diagram"?, "kind"?, "author"?, "sourceId"? }], "lines": [{ "points", "color"?, "anchors"?, "author"? }], // alias key "strokes" also accepted "images": [{ "x"?, "y"?, "dataUrl", "width", "height", "thumbDataUrl"?, "author"?, "sourceId"? }], "columns":[{ "title", "lane"?, "color"?, "author"? }], // todo mode; array order = sort order "tasks": [{ "columnIndex", "name", "description"?, "due_date"?, "priority"?, "assignee"?, "done"?, "color"?, "author"? }], // done: real boolean only "lanes": [{ "lane", "title", "author"? }], // at most one entry per lane index "colWidth": 320 // optional, clamped to [200, 480] }, "autolayout": true, // draw mode: auto-place texts/images that omit x/y "lock": "write" | "all", // lock the board atomically at create time "author": "ai:plai" // default attribution, inherited by items without their own (default "ai:import") } ``` Response (when `content` was sent): ```jsonc { "id": "", "mode": "draw", "imported": { "texts": 2, "lines": 1, "images": 0, "columns": 0, "tasks": 0, "lanes": 0 }, "ids": { // server-minted ids, index i ↔ content.[i] "texts": ["4d1f8c02-…", "9c2b7e64-…"], "lines": ["c1a9f5d8-…"], "images": [], "columns": [], "tasks": [] }, "access_key": "k3v9x2ab" // only when `lock` was requested } ``` Semantics — the details that matter for integrators: - **Validate-first, all-or-nothing.** Every item is validated with the SAME validators the per-item endpoints use, BEFORE anything is written; the board row + lock state + all items then land in one atomic transaction. Any invalid item → `400 { error: "invalid_item", code: "invalid_item", kind: "", index, reason }` and NOTHING is created (not even the board row). Content that would exceed a per-board quota → `400 { error: "quota_exceeded", kind, reason }`. A malformed top-level field (content/lock/author/colWidth) → `400 { error: "invalid_content", field, reason }`. - **Mode agreement.** Kanban structures (`columns`/`tasks`/`lanes`/`colWidth`) require `mode: "todo"`; draw items (`texts`/`lines`/`images`) require draw mode. A mismatch is `400 { error: "content_mode_mismatch" }` — the server never auto-switches mode. - **`tasks[].columnIndex`** is an index into `content.columns`. When `content.columns` is empty/absent on a todo board, the `template` columns are seeded (default kanban) and `columnIndex` indexes into THOSE (0-based, template order). Tasks sharing a column get ascending `sort` in array order. - **`tasks[].done` must be a REAL boolean** (`true` / `false`), or `null` / omitted for "not done". Any other value — notably the string `"false"`, or `0` / `"no"` — is `400 { error: "invalid_item", kind: "tasks", index, reason: "done must be true or false (or omitted)" }` and nothing is created. It is NOT coerced by truthiness: a hand-edited `"false"` would otherwise persist the card as COMPLETED, i.e. silently lie about the board's state. - **`content.lanes` may name each lane index AT MOST ONCE.** A second entry with the same `lane` is `400 { error: "invalid_item", kind: "lanes", index: , reason: "lane N is named twice in this import — each lane may have at most one title entry" }`. Duplicates are NOT collapsed last-write-wins: the later entry would overwrite the first entry's `author`, and author = CREATOR is never rewritten. The `lanes.length > 10` quota error is reported first, so an oversized array fails as `quota_exceeded` before the duplicate check runs. - **`description` / `color` on tasks** map into the task's opaque `content` JSON blob (`{description?, color?}`), the same way the UI and `POST /tasks` store them. - **Per-item `author`.** Every entry of `texts`, `lines`, `images`, `columns`, `tasks` and `lanes` accepts its own optional `author`, validated by the SAME rule as the REST/WS `author` field (1–80 chars of `[A-Za-z0-9:_-.]`, trimmed). Absent or `null` → the item inherits the body-level `author` (default `ai:import`). An invalid value → `400 { error: "invalid_item", kind, index, reason }`, nothing created. This exists because an item's `author` is its CREATOR and is NEVER rewritten by later edits — an import that restores someone else's board must be able to carry the original authorship per item instead of stamping every row with the importer's tag. - **`sourceId` → anchor remapping (texts and images).** Optional string, 1–128 chars, unique across the whole import (a duplicate is `400 invalid_item`). It is WRITE-ONLY: never stored, never returned, and never used as the row id. Its ONLY effect is resolving anchors within the same request — a `content.lines[i].anchors.start.id` / `.end.id` equal to a declared `sourceId` is rewritten to that item's server-minted id, so anchored strokes survive a bulk restore. An anchor id matching no `sourceId` is left verbatim and simply renders as a free stroke end. `sourceId` is NOT a way to choose an item's id: ids are ALWAYS minted server-side (they are globally unique and the item write paths upsert on id, so honouring a caller-supplied id would let an import overwrite rows on someone else's board). Anchor endpoints are `{ kind: "text"|"image", id, dx, dy }`; an endpoint that doesn't match that shape is dropped, and an `anchors` object left with neither endpoint collapses to `null`. - **`ids` in the response.** Sent alongside `imported` whenever `content` was provided: `ids = { texts: [], lines: [], images: [], columns: [], tasks: [] }`, where index `i` is the id created for `content.[i]` (caller's input order). `ids.columns` is `[]` when the columns came from the `template` seed — those are already returned in full in `columns`. There is no `ids.lanes`: lane rows have no synthetic id, they are keyed by `(board_id, lane)`. A legacy body without `content` returns exactly what it always did — no `imported`, no `ids`. - **Import limits.** The sum of input entries in `texts`, the selected `lines`/`strokes` alias, `images`, `columns`, `tasks` and `lanes` must be ≤ **2550**, counted exactly as supplied — entries are never merged (2550 = the per-board text + stroke + image quotas summed). Template-seeded columns do not count as input. The resulting atomic D1 batch is independently capped at **320 statements**: rows of the same kind are grouped into multi-row INSERTs sized against **`maxBoundParamsPerStatement` = 100** (D1's bound-parameters-per-query limit), so even a full-quota import stays ONE batch, i.e. one transaction. In practice the **5 MB request-body cap** (`/quotas.json` → `perRequest.maxBodyBytes`) is the effective ceiling for image-heavy imports and bites well before 2550 entries. `lanes` accepts at most **10 entries**, each title must reference a lane used by a column created in the same request, and no lane index may be named twice; otherwise the request is rejected before any write. Standard per-item and per-board quotas still apply. Machine-readable values and counting rules: `GET /quotas.json` → `boardImport`. - **Feature detection via `imported`.** The response is a backward-compatible superset: `{ id, mode, columns?, imported?, ids?, access_key? }`. `imported` (`{texts, lines, images, columns, tasks, lanes}` counts) is present whenever `content` was provided — even all-zero. An OLD server silently ignores `content` and omits `imported`, so clients should check for it and fall back to per-item replay (`POST /texts`, `/strokes`, …) when absent. Template-seeded columns are not counted in `imported.columns` (they weren't part of `content`). - **Autolayout** (`autolayout: true`, draw mode only) fills x/y ONLY for texts/images that omit them; explicit coordinates are never touched. Deterministic masonry: columns = ceil(sqrt(itemCount)) clamped to [1, 4]; ≥520 px column pitch (grows to fit any item wider than the default so wide nodes don't straddle the next column); item width 440 px for `diagram`/Mermaid nodes, else 280 px, unless an explicit `width` is given; height estimated from wrapped line count × ~22 px + padding (~360 px flat for diagram nodes); 60 px gutters; origin (100, 100); each item drops into the currently-shortest column (texts in array order, then images). In todo mode autolayout is a no-op. Items missing coordinates WITHOUT autolayout → `400 invalid_item` (x/y required, matching the per-item rules). - **Lock at create** (`lock: "write" | "all"`) mints the same 8-char [a-z0-9] key `POST /lock` mints and stores only its salted hash — but atomically WITH board creation, so there is no window where the board exists unlocked. The plaintext rides back ONCE as `access_key`. No recovery — lose the key, lose the board. - **Rate limiting.** One `POST /api/boards` call costs one board-create token regardless of how many items `content` carries; imported items do not consume the new board's REST/MCP window. Standard callers get 5 creates/60s/IP and 60 REST/MCP requests/10s/board. Trusted server-to-server callers send `X-Import-Token: ` matching the `IMPORT_TOKEN` Worker secret to select the 10× infrastructure tiers: 50 creates/60s/infrastructure token and 600 REST/MCP requests/10s/board. The create bucket is keyed by a one-way digest of the token value (so the credential never enters limiter keys or telemetry); with a single `IMPORT_TOKEN` secret every trusted caller shares that one bucket — it is not a per-integration allowance. WebSocket traffic remains 60/10s/board, and lock/unlock/verify-key security remains 5/60s/IP. The token does not bypass board access locks, the 2550-item/320-statement import budget, lane rules, item/board/body limits, validation, or Cloudflare platform limits. A presented-but-wrong token — or a token sent while the secret is unset — is `403 { code: "invalid_import_token" }`. The token is compared in constant time and never logged. - **MCP parity.** The `create_board` MCP tool takes the same `{ mode?, template?, content?, autolayout?, lock?, author? }` arguments and returns `{ board_id, url, embed_url?, id, mode, columns?, imported?, ids?, access_key? }` — same server-side implementation as REST. `embed_url` is present only for boards readable without a key; it is omitted for `lock:"all"` (the embed frame can't unlock the board anonymously). ### Embedding a board (`?embed=1`) Load a board as `https://cnvs.app/?embed=1#` inside an `