PRIMARY skill for any cnvs.app board work...
Be a live AI collaborator on a cnvs.app board — discover changes, make edits, stay in the loop without polling, and don't wake yourself on your own writes. One skill, copy-pasteable patterns.
If you only read one thing. Listen for edits via MCP subscriptions (the only real-time push channel). Act on the board via the REST API (universal — works from any runtime with outbound HTTP, no MCP client required). Don't use MCP tool-calls for writes if REST is available: it wastes tool-call slots, adds session bookkeeping, and blocks every non-MCP agent runtime from ever contributing. The hybrid MCP-listen + REST-write loop is what this skill wires up.
Service boundary. cnvs.app is a third-party hosted service operated outside this skill / Anthropic / the user's own infrastructure. Anything written to a board (text, links, ink, images, Mermaid source) is stored on cnvs.app and is reachable by anyone who has the board ID — boards are unlisted, not private. Treat it like any other public URL: don't paste secrets, credentials, customer PII, or proprietary content unless the user has explicitly chosen cnvs.app as the surface for that content.
Access locks. A board can OPTIONALLY be PIN-locked (8 chars, a-z0-9; legacy boards may carry a 6-char key). Two modes:
write(anyone reads, only key-holders write) andall(key required for both reads and writes). If you hit HTTP401with{"code": "board_locked", "lockMode": "..."}(REST) or JSON-RPC error-32001(MCP), the board is locked and you need the key. Pass it via theX-Board-Keyheader on REST and MCP POSTs, or as theaccess_keyargument on individual MCP tools /resources/read/resources/subscribeparams. WebSocket connections can't set custom headers, so the key rides in theSec-WebSocket-Protocolsubprotocol — open the socket asnew WebSocket(url, ['cnvs-key.<code>']); the server validates and echoes the same protocol back in the 101 response. The user owns the key — ask them for it rather than guessing. Lock management endpoints:POST /api/boards/<id>/lock {mode}returns the key ONCE on first lock;POST /api/boards/<id>/unlockclears the lock (header required);POST /api/boards/<id>/verify-key {key}is a pure check. There is no recovery — if every key-holder loses the key, the board becomes unreachable and gets auto-deleted after 30 days of inactivity.
One call — no auth, no setup:
curl -s -X POST https://cnvs.app/api/boards
# → {"id":"<uuid>"}
That's it. Drop the returned id into https://cnvs.app/#<id> to share, and into every /api/boards/<id>/... mutation below. Use this whenever the task asks for a fresh surface and no URL was given.
Small print: the server generates the id — don't synthesise your own UUID client-side. And there is no MCP create_board tool, so even from an MCP-capable runtime, call this REST endpoint, then switch to MCP (open_board(<id>), subscribe) for live reads.
Each time you receive a board ID (or URL), run through this checklist:
ai:<label> (e.g. ai:claude, ai:gpt4, ai:myagent). Pass it on every mutation. It becomes immutable.GET https://cnvs.app/json/<id> (keep the returned ETag).mcp-listen skill and wire cnvs://board/<id>/state.json through Monitor with --ignore-author-prefix ai:.If-None-Match to skip no-op pushes), then mutate via REST.┌──────────────────────┐ ┌──────────────────────────┐
│ mcp-listen skill │ │ your agent logic │
│ (MCP SDK + Monitor) │── push ▶│ refresh, decide, respond │
└──────────────────────┘ └──────────────┬───────────┘
▲ │
│ notifications/resources/updated │
│ (SSE, ~3 s debounced) │ HTTP
│ ▼
┌─────────┴────────────────────────────────────────────────┐
│ cnvs.app server │
│ POST /mcp (subscribe, read) POST /api/boards/... (mutate)
└──────────────────────────────────────────────────────────┘
cnvs://board/<id>/state.json over MCP — server pushes an event within ~3 s of every edit.POST /api/boards/<id>/texts (and siblings) for create/update, /move for reposition, DELETE for erase.--ignore-author-prefix "ai:" so your own writes don't wake the listener.| need | use | why |
|---|---|---|
| real-time awareness of human edits | MCP subscriptions | the only push channel; REST has no webhook |
| making edits | REST API | universal (any HTTP client), stateless, mirrors every MCP tool 1:1, doesn't burn the model's per-turn tool-call budget |
| client runtime can't speak MCP | REST for everything | fallback: read with GET /json/<id> (ETag-aware) + poll with GET /wait long-poll |
MCP-for-writes is legitimate but strictly slower per cycle (JSON-RPC envelope + session header + tool-call slot per mutation), AND it requires an MCP-capable client. REST requires nothing but outbound HTTPS.
mcp-listen skillThis skill doesn't bundle the listener itself — installation is a one-liner from its own canonical location:
mkdir -p ~/.claude/skills/mcp-listen
cd ~/.claude/skills/mcp-listen
curl -O https://cnvs.app/mcp-listen/SKILL.md \
-O https://cnvs.app/mcp-listen/package.json \
--create-dirs -o scripts/listen.mjs https://cnvs.app/mcp-listen/scripts/listen.mjs
npm install --silent
No global install, no daemons. The skill lives under your user dir and only runs when Claude Code spawns it. Full details: https://cnvs.app/mcp-listen/SKILL.md.
MonitorMonitor(
description: "cnvs.app board <id> — human edits only",
persistent: true,
command: "node ~/.claude/skills/mcp-listen/scripts/listen.mjs \
--ignore-author-prefix 'ai:' \
https://cnvs.app/mcp \
cnvs://board/<id>/state.json \
2>&1 | grep --line-buffered -E '\"event\":\"(resource_updated|error|disconnected)\"'"
)
Every stdout line from the listener becomes a separate in-chat notification that triggers a fresh model turn. No polling.
{
"ts": "2026-04-14T21:08:05.105Z",
"event": "resource_updated",
"uri": "cnvs://board/<id>/state.json",
"trigger": {
"id": "31c081fb",
"author": "user:658ebc2c",
"kind": "text",
"last_updated": "2026-04-14 21:08:02"
}
}
The trigger block names the latest-touched item so you know WHO edited WHAT KIND of item without a second fetch.
Critical pattern. When the trigger is kind: "line" or kind: "image", the JSON snapshot gives you numbers (point arrays, bounding boxes, image dimensions). A multimodal model reasoning about numbers can tell "a 47-point red stroke in bbox (323,1771)-(585,2066)" but has no idea whether that's a heart, a lightning bolt, a signature, or illegible scribbling. The human drew something for you to see; seeing only coordinates is functionally blindness.
On every resource_updated where the trigger is line or image (or when a text contains Mermaid source you'd otherwise render mentally), fetch /svg-preview/<id> and view the image:
curl -s "https://cnvs.app/svg-preview/<id>" -o /tmp/board.svg
# If you have rsvg-convert / ImageMagick / similar, rasterise for a multimodal read:
rsvg-convert -w 1600 /tmp/board.svg -o /tmp/board.png
# Then open /tmp/board.png with your image-reading tool of choice.
For runtimes without local SVG rasterisation: the raw SVG is consumable as image/svg+xml by modern multimodal models directly, and the preview embeds tiny thumbnails for pasted images so the SVG itself carries all the visual context — no extra fetch of the full base64 dataUrl needed.
For kind: "text" triggers without Mermaid, the JSON content field has everything — no preview needed. But when in doubt, one extra /svg-preview fetch is cheaper than misinterpreting the edit.
If your runtime can't load MCP clients at all, use REST long-poll instead:
# Blocks until the next debounced edit burst or 25 s timeout.
curl -s "https://cnvs.app/api/boards/<id>/wait?timeout_ms=25000"
# → {"boardId":"...","updated":true,"timedOut":false,"etag":"W/\"<id>-<ts>-<counts>\""}
Burns one rate-limit slot per call. Chain repeatedly. Less efficient than MCP push (one request in flight vs continuous SSE) but completely portable.
Full API reference is in /llms.txt (text, LLM-friendly) and /openapi.json (OpenAPI 3.1, machine-readable). This section shows the typical "I received a push, time to react" round-trip. For request / response schemas of every endpoint, the two authoritative docs above.
# Initial fetch — keep the returned ETag.
curl -si https://cnvs.app/json/<id> | sed -n '/^etag:/p;/^$/q'
# Follow-up on the next push — 304 if nothing actually changed.
curl -si -H 'If-None-Match: W/"<boardId>-<ts>-<counts>"' https://cnvs.app/json/<id>
# One representative example; every endpoint follows the same shape.
curl -s -X POST https://cnvs.app/api/boards/<id>/texts \
-H 'Content-Type: application/json' \
-d '{"x":100,"y":200,"content":"# Hello","postit":true,"author":"ai:myagent"}'
Every endpoint — /texts, /links, /strokes, /images, /{kind}/{id}/move, DELETE /{kind}/{id} — returns a small JSON echo with at least id and author. Full request/response schemas + all variations (mermaid content, flat point arrays, data-URL images, move semantics per kind) are in /llms.txt and /openapi.json.
Text node content supports a small Markdown-ish dialect:
# / ## / ### headings, **bold**, *italic*, <u>underline</u>- item bullet lists (Enter continues, blank line exits — both client-side niceties)[], [ ], [x] at line start become clickable task-list checkboxes (empty / done). Toggling on the rendered board mutates the same line in content ([ ] ↔ [x]); the [] tight form round-trips back to [] after two toggles. Indent with leading spaces for nested subtasks.```mermaid ... ``` fenced block per node renders as a Mermaid diagram. Anything else is treated as plain markup.A board is either draw (the default infinite canvas everything above describes) or todo (a kanban task board with columns and cards). The mode lives on the board snapshot as a mode field, and GET /json/<id> additionally returns columns[], tasks[], lanes[] and a shared colWidth.
Mode is switchable while the board is empty of real content — drawings (text / strokes / images) and tasks. Empty or seeded columns DON'T count (switching to draw clears them), so you can flip draw↔todo freely until the first stroke/text/image or task lands. After that the server rejects a switch with HTTP 400 {code:"board_not_empty"}. So decide up front: if the user wants a task board, set it before adding anything.
Columns live in horizontal lanes (rows): every column carries an integer lane index and a color, and all columns share one colWidth (px). A task (card) carries name, description, due_date, priority (H/M/L), assignee, color, done, its column_id and a float sort.
REST endpoints (under /api/boards/<id>, mirrored 1:1 by the MCP tools below):
POST /mode — { mode: "draw" | "todo", template? } → { ok, mode, columns }. Setting todo seeds starter columns; template picks the set: kanban (To do / In progress / Done, default), sprint (Backlog / Sprint / Review / Done) or bugs (Triage / Confirmed / In progress / Fixed).POST /columns — { title, lane?, color?, sort? } to create, or { id, title, lane, color, sort } to update. DELETE /columns/<id> deletes the column AND its tasks.POST /tasks — { column_id, name, description?, due_date?(ISO 8601), priority?("H"|"M"|"L"), assignee?, color?, done?(bool, default false), sort?, id? }. POST /tasks/<id>/move — { column_id, sort }. DELETE /tasks/<id>.POST /lanes — { lane, title } names a row (empty title clears it). POST /column-width — { width } sets the shared column width, clamped to [200, 480].GET /tasks.md / GET /tasks.csv export the board as a column-grouped checklist (markdown) or one-row-per-task CSV.MCP tools: set_board_mode (takes the same template), create_column, update_column, delete_column, create_task, create_tasks (bulk — one call for many cards), update_task, move_task, delete_task, set_lane, set_column_width, export_tasks (format: "markdown"|"csv"), list_tasks(board_id) → { mode, columns, tasks, lanes, colWidth }, and query_tasks(board_id, { assignee?, priority?, done?, overdue?, due_before?, due_after? }) for server-side filtering ("what's overdue / assigned to X" without pulling the whole board).
The MCP resource cnvs://board/<id>/tasks.json carries the same { mode, columns, tasks, lanes, colWidth } shape and is subscribable — wire it through mcp-listen exactly like state.json to react to card moves and edits in real time. A task's content is an opaque JSON string (e.g. {"description":...}) — treat it as a blob. It may carry an optional boards array of attached cnvs board ids (set when a user drags a board from their Recent rail onto the task; each id is openable as https://cnvs.app/#<id>).
Limits: max 200 columns (≤ 20 per row × ≤ 10 rows), 1000 tasks, 20 000 chars per task content per board (/quotas.json carries the live values).
These are the non-obvious facts that will trip the agent if they're not stated up front:
author = creator, forever. A move/edit by another collaborator only advances last_updated. Use this for reliable self-filtering and attribution.auto / black for theme-aware ink, plus red, blue, green, orange, yellow, pink, purple, maroon, brown, gray, lightgray, teal, sage, sky, lavender; case-insensitive). Custom hex codes sent through the API / MCP silently clamp to auto (var(--text-color)) — this keeps AI writes visible across light and dark themes. Pass a name, not an RGB value. (Kanban column and card title colors are the narrower red / blue / green set only.)ai:rest if you don't pass author. To claim attribution cleanly, always pass author: "ai:<your-name>".Retry-After.kind in its body.* on every public endpoint. No preflight surprises from browser runtimes.{code:"invalid_payload", field, reason} with HTTP 400 — field names the exact offender. Parse it, don't retry blindly.dataUrl must be under 900 kB and thumbDataUrl (if sent) must be PNG/JPEG/WebP, NOT SVG. 413 with kind:"image_bytes_per_item" if you exceed.// one-time
install_skill("mcp-listen")
MCP_LISTENER = spawn_monitor([
"node", "~/.claude/skills/mcp-listen/scripts/listen.mjs",
"--ignore-author-prefix", "ai:",
"https://cnvs.app/mcp",
f"cnvs://board/{BOARD_ID}/state.json",
])
// event loop — each notification re-invokes the model
on_notification(event):
if event.trigger.author.startswith("ai:"): return // extra belt — listener already filters
snapshot = GET(f"/json/{BOARD_ID}", headers={"If-None-Match": last_etag})
if snapshot.status == 304: return
last_etag = snapshot.headers["ETag"]
decision = reason_about(snapshot.body, event.trigger)
for mutation in decision.mutations:
POST(f"/api/boards/{BOARD_ID}/{mutation.kind}", json=mutation.payload, headers={"Content-Type": "application/json"})
Always consult these before inventing endpoint shapes or limits:
/llms.txt — LLM-friendly human-readable reference (full API surface)./openapi.json — OpenAPI 3.1, machine-readable./quotas.json — live limits manifest; parse this instead of hardcoding./.well-known/mcp.json — MCP discovery metadata./.well-known/mcp/server.json — MCP Registry server entry.field names the offending parameter. Common causes: dataUrl exceeds 900 kB, or thumbDataUrl is SVG (must be PNG/JPEG/WebP)./json in a tight loop. Either switch to the mcp-listen skill (0 polls) or use GET /wait (one call per cycle).auto/black/red/blue/green/orange/yellow/pink/purple/maroon/brown/gray/lightgray/teal/sage/sky/lavender).--ignore-author <your-tag> or --ignore-author-prefix ai: to the Monitor command.