{
  "openapi": "3.1.0",
  "info": {
    "title": "cnvs.app HTTP API",
    "version": "1.0.0",
    "summary": "Read and discover cnvs.app boards over plain HTTP.",
    "x-requestBodyLimit": {
      "bytes": 5242880,
      "note": "Single-request body cap (5 MiB) enforced via Content-Length AND during the streamed body read. Chunked / header-less clients cannot bypass the cap. HTTP 413 (REST) / JSON-RPC -32000 code:payload_too_large (MCP)."
    },
    "x-cors": {
      "allowOrigin": "*",
      "allowMethods": ["GET", "POST", "DELETE", "OPTIONS"],
      "allowHeaders": ["Content-Type", "Mcp-Session-Id", "If-None-Match", "X-Board-Key"],
      "note": "Every public endpoint (/api/boards/..., /json/..., /svg-preview/..., /mcp) returns permissive CORS headers and handles OPTIONS preflight. The board ID is the access credential when the board is unlocked; locked boards additionally require the X-Board-Key header. X-Import-Token is intentionally absent: it is a server-to-server credential, so browser clients cannot send it and belong on the standard per-IP rate tier."
    },
    "x-rateLimits": {
      "standard": {
        "restMcpPerBoard": { "requests": 60, "windowSeconds": 10, "per": "boardId" },
        "boardCreate": { "requests": 5, "windowSeconds": 60, "per": "clientIp", "enforcement": "Cloudflare Rate Limiting binding; permissive and eventually consistent per edge location" }
      },
      "infrastructure": {
        "credential": "valid X-Import-Token (server-to-server only)",
        "restMcpPerBoard": { "requests": 600, "windowSeconds": 10, "per": "boardId" },
        "boardCreate": { "requests": 50, "windowSeconds": 60, "per": "infrastructureToken", "perNote": "Keyed by a one-way digest of the token VALUE (the credential never enters limiter keys or telemetry). There is a single import-token secret, so all trusted callers share this one bucket — it is not a per-integration allowance.", "enforcement": "Cloudflare Rate Limiting binding; permissive and eventually consistent per edge location" }
      },
      "webSocket": { "requests": 60, "windowSeconds": 10, "per": "boardId", "infrastructureTokenChangesTier": false },
      "security": { "lockUnlockVerify": { "requests": 5, "windowSeconds": 60, "per": "clientIp", "infrastructureTokenChangesTier": false } },
      "scope": "Browser WebSocket frames are rate-limited inside the per-board BoardServer Durable Object — single-writer and strong. REST and MCP are rate-limited in whichever Worker isolate serves the request; because Cloudflare routes traffic across many isolates (regions, colos, warm/cold starts), a caller distributing load effectively gets N × the advertised cap. Treat REST/MCP as a soft per-isolate ceiling, not a strong global guarantee. X-Import-Token raises only the REST/MCP and board-create tiers; it does not bypass hard caps or board access locks.",
      "response": {
        "rest": { "status": 429, "headers": { "Retry-After": "<seconds>" }, "body": { "code": "rate_limited", "retryAfterSeconds": 10 } },
        "mcp": { "jsonRpcCode": -32000, "data": { "code": "rate_limited", "retryAfterSeconds": 10 } },
        "ws": { "body": { "type": "error", "code": "rate_limited", "retryAfterSeconds": 10 } }
      },
      "note": "Strong for browser WS (DO-enforced), soft for REST/MCP (isolate-local; bypassable via distribution). Cloudflare's platform-level limits remain the hard wall. Live values in /quotas.json."
    },
    "x-accessLock": {
      "modes": ["write", "all"],
      "keyFormat": "8 chars [a-z0-9] (6-char legacy keys also accepted)",
      "channel": {
        "rest": "X-Board-Key header",
        "mcp": "X-Board-Key header OR `access_key` tool argument",
        "ws": "Sec-WebSocket-Protocol subprotocol `cnvs-key.<code>` (server echoes it back in the 101 response). `?key=<code>` query parameter is accepted as a deprecated fallback but leaks the key into access logs."
      },
      "endpoints": {
        "lock": "POST /api/boards/{id}/lock",
        "unlock": "POST /api/boards/{id}/unlock",
        "verify": "POST /api/boards/{id}/verify-key"
      },
      "rejection": {
        "rest": { "status": 401, "body": { "code": "board_locked", "lockMode": "write|all" } },
        "mcp": { "jsonRpcCode": -32001, "data": { "code": "board_locked", "lockMode": "write|all" } },
        "ws": { "status": 401, "note": "Upgrade rejected for read-locked boards; write-locked allows the upgrade but server drops mutation frames with type=error code=board_locked." }
      },
      "note": "By default boards are open (anyone with the ID can read + write). When locked, mode `write` only requires the key for mutations; `all` requires it for both reads and writes. There is no recovery — losing the key loses the board."
    },
    "x-quotasManifest": { "url": "/quotas.json", "note": "Machine-readable single source of truth for limits. Cache ≤ 5 min." },
    "x-fieldLimits": {
      "maxAuthorChars": 80,
      "maxThumbnailBytes": 8000,
      "textWidth": { "min": 160, "max": 4096 },
      "columnWidth": { "min": 200, "max": 480, "default": 280 },
      "maxLaneTitleChars": 200,
      "maxColumnTitleChars": 200,
      "maxTaskNameChars": 500,
      "maxAssigneeChars": 200
    },
    "x-operationLimits": {
      "maxRecolorItems": 500,
      "maxBatchOps": 500,
      "wsContinuation": { "maxFrames": 500, "idleMs": 500 }
    },
    "x-boardImportLimits": {
      "maxItems": 2550,
      "maxBatchStatements": 320,
      "maxBoundParamsPerStatement": 100,
      "countedCollections": ["texts", "lines", "images", "columns", "tasks", "lanes"],
      "maxLaneTitles": 10,
      "laneTitleRule": "Each lane title must reference a lane used by a column created in the same request.",
      "infrastructureToken": "X-Import-Token is server-to-server. A valid token selects the infrastructure REST/MCP and board-create rate tiers; it never bypasses hard caps, validation, storage, board access locks, or Cloudflare platform limits."
    },
    "description": "cnvs.app is a free, no-signup real-time collaborative whiteboard. This is the public HTTP surface — endpoints that let any client (AI agent, curl, browser) read AND mutate board state without speaking MCP or WebSocket.\n\n**For AI agents**: if you can speak MCP (Model Context Protocol), prefer `https://cnvs.app/mcp` — it bundles reads, mutations, live notification subscriptions and the `wait_for_update` long-poll tool into one session. See `/llms.txt` and `/.well-known/mcp.json` for MCP details.\n\n**If you cannot use MCP**, this REST API gives you equivalent capabilities: read a board (`GET /json/{id}`), see a schematic preview (`GET /svg-preview/{id}`), add or update text / strokes / images / links (`POST /api/boards/{id}/{kind}`), move a text node (`POST /api/boards/{id}/texts/{id}/move`), or delete any item (`DELETE /api/boards/{id}/{kind}/{itemId}`). On `todo`-mode boards the kanban surface is also fully reachable: `POST /api/boards/{id}/mode`, `/columns`, `/tasks`, `/tasks/{id}/move`, `/column-width`, `/lanes`, plus `DELETE` on columns and tasks. 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.\n\n**Auth model**: by default no API keys or tokens — the board ID is the access credential and anyone who knows the ID can read and write. Boards can OPTIONALLY be PIN-locked: mode `write` keeps reads public but requires the `X-Board-Key` header (8 chars [a-z0-9]) for mutations; mode `all` requires the header for both reads and writes. WebSocket upgrades carry the key in the `Sec-WebSocket-Protocol` subprotocol (`cnvs-key.<code>`). Rejected requests respond `401 Bearer board_locked` with `{code, locked, lockMode}` — see `responses.BoardLocked`. Lock management: `POST /api/boards/{id}/lock {mode}` (returns the key once), `POST /api/boards/{id}/unlock`, `POST /api/boards/{id}/verify-key`. There is no recovery — a lost key locks the board for everyone. Keep board URLs private for sensitive content regardless.\n\n**Author tagging**: every mutation accepts an optional `author` string. Human edits from the browser are tagged `user:<uuid>`; MCP edits default to `ai:claude`; REST edits default to `ai:rest`. Charset `[A-Za-z0-9:_\\-.]`, max 80 chars. AI-authored items are visually distinguished in the SVG preview (purple border). The `author` tag is IMMUTABLE: once an item has been created with a given author, moves/edits by other collaborators never relabel it. A separate `last_updated` timestamp tracks when the row last changed directly. Exception: when a text/image with anchored strokes is moved, those strokes' `points` are rewritten server-side to follow the anchor, but their `last_updated` is NOT bumped — the node owns the gesture, and this preserves reload z-order so the moved node renders on top of its anchored line. Use the node's timestamp (or the board-level max) as the freshness signal in that case.\n\n**Rate tiers**: standard REST/MCP callers get 60 requests per 10 seconds per board ID; a valid server-to-server `X-Import-Token` gets 600/10s. Browser WebSockets always remain 60/10s and are enforced strongly inside the per-board BoardServer Durable Object. REST/MCP enforcement is a soft Worker-isolate-local ceiling. Board creation is 5/60s/IP standard or 50/60s/infrastructure token; lock security remains 5/60s/IP. Exceeding returns `429` (REST) or JSON-RPC error `-32000` (MCP). Live values in `/quotas.json`.\n\n**Per-board quotas** (keeping this service free and predictable; applied identically to browser, REST and MCP): max **500 text nodes** (100 000 chars each), max **50 images** (≤ ~900 kB per image, ≤ 10 MB total), max **2000 strokes**. On `todo`-mode boards: max **200 columns** (≤ 20 per row × ≤ 10 rows), max **1000 tasks**, **20 000 chars** per task content. Exceeding any quota returns HTTP `413` (REST) or JSON-RPC `-32000` (MCP) with `{ code: \"quota_exceeded\", kind, detail }` naming which limit was hit.",
    "contact": { "name": "cnvs.app", "url": "https://cnvs.app/about" },
    "license": { "name": "Public service — no account required", "url": "https://cnvs.app/about" }
  },
  "servers": [
    { "url": "https://cnvs.app", "description": "Production" }
  ],
  "tags": [
    { "name": "Boards", "description": "Create, read and delete boards." },
    { "name": "Items", "description": "Add / update / move / delete draw-board items (texts, strokes, images, links). Mirrors MCP tools one-to-one." },
    { "name": "Kanban", "description": "Todo-mode kanban board: mode switch, columns, tasks, row (lane) titles, and shared column width. Mirrors the MCP kanban tools one-to-one (`set_board_mode`, `create_column`, `create_task`, `move_task`, `set_lane`, `set_column_width`, …)." },
    { "name": "AI-facing", "description": "Endpoints designed for LLM consumption — schematic SVG previews and clean JSON snapshots." },
    { "name": "Discovery", "description": "Service-level discovery documents for AI agents and MCP clients." }
  ],
  "paths": {
    "/api/boards": {
      "post": {
        "tags": ["Boards"],
        "summary": "Create a new board",
        "description": "Allocates a fresh UUID and returns a board immediately reachable at `https://cnvs.app/#{id}`. A bare POST creates a `draw` board. Optional `{mode:\"todo\", template}` creates a kanban board with `kanban`, `sprint`, or `bugs` starter columns.\n\n**Atomic board import**: optional `content`, `autolayout`, `lock`, and `author` pre-fill and optionally PIN-lock the board in the same transaction; see `BoardImportContent`. The combined content arrays accept at most **2550 entries**, lane titles at most **10**, and the D1 batch at most **320 statements**. Template columns do not count as input. In practice the 5 MB request-body cap (`/quotas.json` → `perRequest.maxBodyBytes`) is the binding limit for image-heavy imports, long before 2550 entries. Each lane index may be named at most once (a duplicate is `400 invalid_item` on the second entry, not a last-write-wins collapse), and `tasks[].done` must be a real boolean. All content is validated first; any invalid item or quota error creates nothing. Draw items require draw mode; columns/tasks/lanes require todo mode. A response containing `imported` confirms support.\n\n**Autolayout** fills missing draw-item coordinates deterministically. **Lock at create** returns the plaintext 8-char `access_key` once. **Author** is 1–80 chars and defaults to `ai:import`; every item may also carry its OWN `author`, which is preserved as the item's creator instead of being overwritten by the importer's tag. **Item ids are always server-minted** and returned in `ids` (per kind, in input order); declare a write-only `sourceId` on texts/images to have `lines[].anchors` endpoints remapped onto the minted ids. Embed readable boards at `https://cnvs.app/?embed=1#{id}`.\n\n**Rate tiers**: standard callers get 5 creates/60s/IP. A valid server-to-server `X-Import-Token` selects 50 creates/60s/infrastructure token and 600 REST/MCP requests/10s/board. WebSockets remain 60/10s/board and lock/unlock/verify-key remain 5/60s/IP. The token never bypasses board access locks, import/field/operation/storage/body limits, validation, or Cloudflare platform limits. Invalid or unconfigured tokens return `403 invalid_import_token`.",
        "operationId": "createBoard",
        "parameters": [
          {
            "name": "X-Import-Token",
            "in": "header",
            "required": false,
            "schema": { "type": "string" },
            "description": "Optional server-to-server credential. When it matches the operator-set `IMPORT_TOKEN` Worker secret it selects the infrastructure tiers: 600 REST/MCP requests/10s/board and 50 board creates/60s/infrastructure token (constant-time comparison; never logged). WebSockets stay at 60/10s/board and lock/unlock/verify-key stay at 5/60s/IP. It does not bypass board access locks, the 2550-item/320-statement import caps, validation, per-item/per-board quotas, request-body cap, or Cloudflare platform limits. Invalid or unconfigured token → `403 invalid_import_token`. Omit for standard tiers."
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "mode": { "type": "string", "enum": ["draw", "todo"], "description": "Initial board mode. Defaults to `draw`." },
                  "template": { "type": "string", "enum": ["kanban", "sprint", "bugs"], "description": "Starter column set when `mode` is `todo` and `content.columns` is empty/absent. Defaults to `kanban`." },
                  "content": { "$ref": "#/components/schemas/BoardImportContent" },
                  "autolayout": { "type": "boolean", "description": "Draw mode only: deterministically place texts/images that omit x/y (masonry grid; explicit coordinates are never touched). Without it, items missing coordinates are rejected `400 invalid_item`. No-op in todo mode." },
                  "lock": { "type": "string", "enum": ["write", "all"], "description": "PIN-lock the board atomically at create time. `write` = public reads, key-holders write; `all` = key required for everything. The plaintext key is returned ONCE as `access_key` — there is no recovery." },
                  "author": { "type": "string", "description": "Board-level author tag, inherited by every created item that does not carry its own `author` (e.g. `ai:plai`). 1-80 chars of `[A-Za-z0-9:_\\-.]`. Defaults to `ai:import`. Per-item `author` wins where present — see `BoardImportContent`." }
                }
              },
              "examples": {
                "todoTemplate": {
                  "summary": "Kanban board from a template (legacy shape)",
                  "value": { "mode": "todo", "template": "sprint" }
                },
                "importDraw": {
                  "summary": "Import a draw board with autolayout + lock",
                  "value": {
                    "content": {
                      "texts": [
                        { "content": "# Q3 plan", "color": "red" },
                        { "content": "flowchart TD\n A[Idea] --> B[Ship]", "diagram": true }
                      ],
                      "lines": [ { "points": [[100, 500], [400, 500]], "color": "blue" } ]
                    },
                    "autolayout": true,
                    "lock": "write",
                    "author": "ai:plai"
                  }
                },
                "importAnchorsAndAuthors": {
                  "summary": "Restore a board: per-item authors + sourceId anchor remapping",
                  "value": {
                    "content": {
                      "texts": [
                        { "sourceId": "t1", "x": 100, "y": 100, "content": "Idea", "author": "user:8f1c2d3e" },
                        { "sourceId": "t2", "x": 500, "y": 100, "content": "Ship", "author": "ai:claude" }
                      ],
                      "lines": [
                        {
                          "points": [[200, 120], [480, 120]],
                          "anchors": { "start": { "kind": "text", "id": "t1", "dx": 100, "dy": 20 }, "end": { "kind": "text", "id": "t2", "dx": -20, "dy": 20 } },
                          "author": "user:8f1c2d3e"
                        }
                      ]
                    },
                    "lock": "write",
                    "author": "ai:plai"
                  }
                },
                "importTodo": {
                  "summary": "Import a kanban board with columns + tasks",
                  "value": {
                    "mode": "todo",
                    "content": {
                      "columns": [ { "title": "Backlog" }, { "title": "Doing", "color": "blue" }, { "title": "Done" } ],
                      "tasks": [
                        { "columnIndex": 0, "name": "Ship import", "description": "One-call board create", "priority": "H", "due_date": "2026-08-01" },
                        { "columnIndex": 2, "name": "Celebrate", "done": true }
                      ],
                      "lanes": [ { "lane": 0, "title": "Sprint 12" } ],
                      "colWidth": 320
                    },
                    "author": "ai:plai"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New board created.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreateBoardResponse" },
                "examples": {
                  "plain": { "value": { "id": "0a01ba29-bc15-4880-b135-ec47e33d95b2", "mode": "draw" } },
                  "imported": { "value": { "id": "0a01ba29-bc15-4880-b135-ec47e33d95b2", "mode": "draw", "imported": { "texts": 2, "lines": 1, "images": 0, "columns": 0, "tasks": 0, "lanes": 0 }, "ids": { "texts": ["4d1f8c02-1a11-4f1d-9d3a-6f7c0e2b1a55", "9c2b7e64-3f0a-4d5c-8a21-2e6b9f4c7d03"], "lines": ["c1a9f5d8-77b2-4e63-90aa-5b1c8d2f6e47"], "images": [], "columns": [], "tasks": [] }, "access_key": "k3v9x2ab" } }
                }
              }
            }
          },
          "400": {
            "description": "Import validation failed — NOTHING was created (not even the board row). `{error: \"invalid_item\", kind, index, reason}` names the first offending item; `{error: \"quota_exceeded\", kind, reason}` a quota the content would exceed; `{error: \"content_mode_mismatch\"}` a content/mode disagreement; `{error: \"invalid_content\", field, reason}` a malformed top-level field (content / lock / author / colWidth).\n\nTwo `invalid_item` cases are easy to hit when replaying a hand-edited or third-party file: a `tasks[].done` that is not a real boolean (a coerced `\"false\"` would be stored as COMPLETED), and a `lanes` array that names the same lane index twice (collapsing it would overwrite the first entry's `author`).",
            "content": {
              "application/json": {
                "examples": {
                  "text": { "value": { "error": "invalid_item", "code": "invalid_item", "kind": "texts", "index": 3, "reason": "content is required (non-empty string)" } },
                  "taskDone": { "value": { "error": "invalid_item", "code": "invalid_item", "kind": "tasks", "index": 1, "reason": "done must be true or false (or omitted)" } },
                  "duplicateLane": { "value": { "error": "invalid_item", "code": "invalid_item", "kind": "lanes", "index": 2, "reason": "lane 0 is named twice in this import — each lane may have at most one title entry" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "403": {
            "description": "An `X-Import-Token` header was presented but does not match the `IMPORT_TOKEN` secret (or the secret is unset). Distinct from the missing-header path, which is simply rate-limited.",
            "content": {
              "application/json": {
                "example": { "error": "Invalid import token", "code": "invalid_import_token" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/todo": {
      "get": {
        "tags": ["Boards"],
        "summary": "Open a fresh kanban board (browser redirect)",
        "description": "Human-friendly entry point for sharing a new todo/kanban board. Creates a fresh board already in `todo` mode (starter columns seeded server-side) and responds with `302 Found` to `https://cnvs.app/#<id>` — a clean URL you can paste into chat. Optional query `template` (`kanban` — default — / `sprint` / `bugs`) selects the starter column set, same as `POST /api/boards { mode: \"todo\", template }`.\n\nShares the `POST /api/boards` per-IP create cap (5 / 60s per `CF-Connecting-IP`).",
        "operationId": "openTodoBoard",
        "parameters": [
          {
            "name": "template",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["kanban", "sprint", "bugs"] },
            "description": "Starter column set. Defaults to `kanban`."
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the new board at `/#<id>`.",
            "headers": {
              "Location": { "schema": { "type": "string" }, "description": "Absolute URL `https://cnvs.app/#<boardId>`." },
              "Cache-Control": { "schema": { "type": "string" }, "description": "`no-store`." }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "head": {
        "tags": ["Boards"],
        "summary": "Open a fresh kanban board (redirect probe)",
        "description": "Same as `GET /todo` but without a body — useful for link checkers.",
        "operationId": "openTodoBoardHead",
        "responses": {
          "302": { "description": "Redirect to the new board at `/#<id>`." },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/draw": {
      "get": {
        "tags": ["Boards"],
        "summary": "Open a fresh draw board (browser redirect)",
        "description": "Human-friendly entry point for sharing a new draw board. Creates a fresh board in `draw` mode and responds with `302 Found` to `https://cnvs.app/#<id>` — a clean URL you can paste into chat. Optional query `seed` (`mermaid-flowchart` / `mermaid-mindmap`) pre-populates the canvas with an example Mermaid diagram; an absent or unknown `seed` yields a plain blank draw board.\n\nShares the `POST /api/boards` per-IP create cap (5 / 60s per `CF-Connecting-IP`).",
        "operationId": "openDrawBoard",
        "parameters": [
          {
            "name": "seed",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["mermaid-flowchart", "mermaid-mindmap"] },
            "description": "Example diagram to seed. Absent or unknown ⇒ blank board."
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the new board at `/#<id>`.",
            "headers": {
              "Location": { "schema": { "type": "string" }, "description": "Absolute URL `https://cnvs.app/#<boardId>`." },
              "Cache-Control": { "schema": { "type": "string" }, "description": "`no-store`." }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "head": {
        "tags": ["Boards"],
        "summary": "Open a fresh draw board (redirect probe)",
        "description": "Same as `GET /draw` but without a body — useful for link checkers.",
        "operationId": "openDrawBoardHead",
        "responses": {
          "302": { "description": "Redirect to the new board at `/#<id>`." },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/lock": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Boards"],
        "summary": "Lock board (set/change mode)",
        "description": "Apply an access lock to the board. On an unlocked board: server generates an 8-char [a-z0-9] key, stores its SHA-256 hash, and returns the plaintext key ONCE. On an already-locked board: caller must supply the existing key via `X-Board-Key`; only the mode is updated.\n\n**There is no recovery.** Losing the key loses the board.",
        "operationId": "lockBoard",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "mode": { "type": "string", "enum": ["write", "all"], "description": "`write` = anyone can read, only key-holders can mutate. `all` = key required for both read and write." } },
                "required": ["mode"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Lock applied. `key` is present only on first lock (server-generated).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "mode": { "type": "string", "enum": ["write", "all"] },
                    "key": { "type": "string", "pattern": "^(?:[a-z0-9]{6}|[a-z0-9]{8})$", "description": "Plaintext access key — returned once. Server only stores SHA-256." }
                  },
                  "required": ["mode"]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "403": { "description": "Existing key not supplied or incorrect on a re-lock." },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "description": "Concurrent lock-state change; the row rotated between SELECT and UPDATE. Reload and retry." },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/unlock": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Boards"],
        "summary": "Remove lock",
        "description": "Clears the lock so the board returns to public read+write. Requires the current key via `X-Board-Key`. No-op if already unlocked.",
        "operationId": "unlockBoard",
        "responses": {
          "200": { "description": "Board unlocked.", "content": { "application/json": { "schema": { "type": "object", "properties": { "mode": { "type": "null" } } } } } },
          "403": { "description": "Missing or invalid X-Board-Key." },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "description": "Concurrent change; reload and retry." }
        }
      }
    },
    "/api/boards/{boardId}/verify-key": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Boards"],
        "summary": "Verify an access key",
        "description": "Pure verification — does NOT change board state. Used by the browser modal to test a freshly-typed code before persisting it client-side. Returns `{ok: true|false}`.",
        "operationId": "verifyBoardKey",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": { "key": { "type": "string", "pattern": "^(?:[a-z0-9]{6}|[a-z0-9]{8})$" } },
                "required": ["key"]
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Verification result.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } }, "required": ["ok"] } } } },
          "400": { "description": "Board is not locked." },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/api/boards/{boardId}": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "get": {
        "tags": ["Boards"],
        "summary": "Read board (browser shape)",
        "description": "Returns the raw board contents as served to the browser client. Differs from `/json/{id}` in two ways: (1) no `boardId` wrapper — just `{texts, lines, images}`; (2) `images[].dataUrl` carries the FULL base64 payload (not elided). For AI consumption prefer `/json/{id}` — it mirrors the MCP `get_board` shape and elides large image payloads.\n\n**Security note**: like every endpoint in this spec this is served with `Access-Control-Allow-Origin: *`, so any cross-origin page that knows the board id can fetch the full payload (including every `images[].dataUrl`). The board id is the access credential — keep board URLs private for sensitive content, or use `/json/{id}` if you want a lighter snapshot.",
        "operationId": "getBoardLegacy",
        "responses": {
          "200": {
            "description": "Either a live snapshot (`{texts, lines, images}`) or a tombstone marker (`{deleted: true, deleted_at}`) if the board was erased within the last 30 days.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/BoardBrowserSnapshot" },
                    {
                      "type": "object",
                      "properties": {
                        "deleted": { "type": "boolean", "enum": [true] },
                        "deleted_at": { "type": "string", "description": "SQLite-formatted UTC timestamp." }
                      },
                      "required": ["deleted", "deleted_at"]
                    }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["Boards"],
        "summary": "Soft-delete board",
        "description": "Marks the board as deleted and wipes all texts / strokes / images. The ID is tombstoned for 30 days (requests return 404 during that window); after 30 days the ID is eligible for reuse via `open_board`. A `board_erased` broadcast is pushed to any connected WebSocket clients so browsers refresh immediately.",
        "operationId": "deleteBoard",
        "responses": {
          "200": {
            "description": "Board deleted.",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } }, "required": ["ok"] }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/texts": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Items"],
        "summary": "Create or update a text node",
        "description": "Mirrors the MCP `add_text` tool. Creates a NEW text node with a fresh UUID, OR updates an existing node if you pass its `id` (preferred over creating duplicates). Content supports cnvs markup (Markdown-ish, including `[]` / `[ ]` / `[x]` task-list checkboxes that render as clickable circles and toggle in source on click) and Mermaid diagrams — when using Mermaid, the ENTIRE content must be a single ```mermaid fenced block (one diagram per node). Set `postit: true` for a yellow sticky-note style.",
        "operationId": "addText",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AddTextRequest" },
              "examples": {
                "simpleSticky": {
                  "value": { "x": 100, "y": 200, "content": "# Hello from REST!", "postit": true }
                },
                "mermaid": {
                  "value": { "x": 400, "y": 200, "content": "```mermaid\nflowchart LR\n  A --> B\n  B --> C\n```", "width": 320 }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Text created or updated.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddTextResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/{kind}/{itemId}/move": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        { "name": "kind", "in": "path", "required": true, "description": "Item kind. Use the same plural form as the creation endpoint: `texts`, `links`, `strokes` (or the alias `lines` — matches the JSON snapshot key), or `images`.", "schema": { "type": "string", "enum": ["texts", "links", "strokes", "lines", "images"] } },
        { "$ref": "#/components/parameters/ItemIdPath" }
      ],
      "post": {
        "tags": ["Items"],
        "summary": "Move any item",
        "description": "Unified move endpoint — works for every item kind. For `texts` / `links` / `images` the top-left lands at (x, y); for `strokes` the whole point array is translated so the bbox top-left sits at (x, y) (no need to re-send the point list). The creator's `author` tag is preserved (immutable after creation) so a move by a collaborator does NOT relabel who originally authored the item.",
        "operationId": "moveItem",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MoveTextRequest" },
              "example": { "x": 320, "y": 480 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Moved.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "id": {"type":"string"}, "x": {"type":"number"}, "y": {"type":"number"} } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/strokes": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Items"],
        "summary": "Draw a freehand stroke",
        "description": "Mirrors the MCP `draw_stroke` tool. Stroke width is fixed at 3 px; `color` is a NAME, not hex (case-insensitive): `auto`/`black`/omitted for theme-aware ink, or `red`, `blue`, `green`, `orange`, `yellow`, `pink`, `purple`, `maroon`, `brown`, `gray`, `lightgray`, `teal`, `sage`, `sky`, `lavender` — anything else (including a literal hex) silently clamps to `auto`. Accepts points as nested `[[x,y],...]`, flat `[x1,y1,x2,y2,...]`, or a JSON string of either — the server normalises.",
        "operationId": "addStroke",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AddStrokeRequest" },
              "example": { "points": [[100,100],[200,150],[300,180]], "color": "red" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stroke drawn.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "id": {"type":"string"}, "pointCount": {"type":"integer"}, "color": {"type":"string"}, "bbox": {"type":"object","properties":{"x":{"type":"integer"},"y":{"type":"integer"},"width":{"type":"integer"},"height":{"type":"integer"}}}, "author": {"type":"string"} } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/images": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Items"],
        "summary": "Paste an image onto the board",
        "description": "Mirrors the MCP `add_image` tool. `dataUrl` must be a `data:image/(png|jpeg|gif|webp|svg+xml);base64,...` string, ≤ ~900 kB. Hosted URLs are NOT accepted — if you have a URL, fetch the bytes yourself and re-encode as a data URL. Strongly recommended: also pass `thumbDataUrl` (≤8 kB, ~64 px) — it gets embedded into SVG previews so other AI viewers see an actual image instead of a placeholder box.\n\nExample (upload a 1×1 PNG):\n```\ncurl -X POST https://cnvs.app/api/boards/<id>/images \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"x\":200,\"y\":300,\"width\":80,\"height\":80,\"dataUrl\":\"data:image/png;base64,iVBORw0KGgo...\"}'\n```\nFor a hosted image, convert first:\n```\nBASE64=$(curl -sL https://example.com/pic.png | base64)\ncurl -X POST https://cnvs.app/api/boards/<id>/images \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"x\\\":200,\\\"y\\\":300,\\\"width\\\":400,\\\"height\\\":300,\\\"dataUrl\\\":\\\"data:image/png;base64,$BASE64\\\"}\"\n```",
        "operationId": "addImage",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AddImageRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Image placed.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "id":{"type":"string"},"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number"},"height":{"type":"number"},"hasThumbDataUrl":{"type":"boolean","description":"Whether a thumbnail was stored alongside the full image. Governs whether `/svg-preview` inlines the image or shows a placeholder."},"author":{"type":"string"} } } } }
          },
          "400": { "description": "Bad data URL, too large, or invalid dimensions.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/links": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Items"],
        "summary": "Drop a URL capsule",
        "description": "Mirrors the MCP `add_link` tool. Renders as a clickable pill showing the hostname. Use this instead of POST /texts when the node is just a link — the capsule styling signals clickability to humans.",
        "operationId": "addLink",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AddLinkRequest" },
              "example": { "x": 100, "y": 400, "url": "https://cnvs.app/about" }
            }
          }
        },
        "responses": {
          "200": { "description": "Link placed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "id":{"type":"string"},"x":{"type":"number"},"y":{"type":"number"},"url":{"type":"string"},"author":{"type":"string"},"kind":{"type":"string","enum":["link"]} } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/mode": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Kanban"],
        "summary": "Set board mode (draw / todo)",
        "description": "Mirrors the MCP `set_board_mode` tool. A board is either `draw` (the default infinite-canvas whiteboard) or `todo` (a kanban task board with columns and cards). **The mode is switchable WHENEVER 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 a board that once held content but is now cleared becomes switchable again, and you can flip `draw` ↔ `todo` freely until the first stroke/text/image or task lands. Calling this on a board that still holds real content returns `400 { code: \"board_not_empty\" }`. Switching an empty board to `todo` seeds the starter columns; pass an optional `template` (`kanban` — the default — / `sprint` / `bugs`) to choose the column set, returned in the `columns` array.",
        "operationId": "setBoardMode",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["mode"],
                "properties": {
                  "mode": { "type": "string", "enum": ["draw", "todo"], "description": "`draw` = infinite-canvas whiteboard (default). `todo` = kanban task board." },
                  "template": { "type": "string", "enum": ["kanban", "sprint", "bugs"], "description": "Starter column set when switching to `todo`. Defaults to `kanban`. Ignored for `draw`." }
                }
              },
              "example": { "mode": "todo", "template": "sprint" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mode set.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "mode": { "type": "string", "enum": ["draw", "todo"] }, "columns": { "type": "array", "items": { "$ref": "#/components/schemas/Column" } } }, "required": ["ok", "mode"] } } }
          },
          "400": {
            "description": "Invalid mode, or the board still holds real content (`code: board_not_empty`) so the mode can't change until it's cleared.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Board mode can only be changed while the board is empty of real content.", "code": "board_not_empty" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/column-width": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Kanban"],
        "summary": "Set the shared kanban column width",
        "description": "Board-level setting: the pixel width every kanban column shares. Only valid on a `todo`-mode board. Mirrors the WebSocket `set_column_width` frame and the MCP `set_column_width` tool; exists mainly so a snapshot restore can recreate the saved width. The value is clamped server-side to the [200, 480]px range. Read the current value as `colWidth` from `GET /json/{id}`.",
        "operationId": "setColumnWidth",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["width"],
                "properties": { "width": { "type": "number", "description": "Column width in px. Clamped to [200, 480]." } }
              },
              "example": { "width": 280 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Width set (after clamping).",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "width": { "type": "number" } }, "required": ["ok", "width"] } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/lanes": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Kanban"],
        "summary": "Set or clear a kanban row (lane) title",
        "description": "Board-level setting: the optional title of a kanban row, keyed on the integer `lane` index (a lane is not its own entity, just the index columns carry). Only valid on a `todo`-mode board. Mirrors the WebSocket `lane_upsert` frame and the MCP `set_lane` tool; exists mainly so a snapshot restore can recreate saved row names. An empty `title` clears the row name. Read current lane titles from the `lanes[]` array of `GET /json/{id}`.",
        "operationId": "upsertLane",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["lane"],
                "properties": {
                  "lane": { "type": "integer", "minimum": 0, "description": "Row index the title applies to." },
                  "title": { "type": "string", "description": "Row name. Empty / omitted clears it." },
                  "author": { "type": "string", "description": "Optional author tag; defaults to `ai:rest`. Immutable after the row is first named." }
                }
              },
              "example": { "lane": 0, "title": "Sprint 1" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Row title set or cleared.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "lane": { "type": "integer" }, "title": { "type": "string" } }, "required": ["ok", "lane", "title"] } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/columns": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Kanban"],
        "summary": "Create or update a kanban column",
        "description": "Mirrors the MCP `create_column` / `update_column` tools. Only valid on a `todo`-mode board. Creates a NEW column with a fresh UUID, OR updates an existing column in place if you pass its `id`. Max 200 columns per board (≤ 20 per row, ≤ 10 rows).",
        "operationId": "addColumn",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AddColumnRequest" },
              "examples": {
                "create": { "value": { "title": "Backlog", "sort": 0 } },
                "update": { "value": { "id": "c0a1...", "title": "In review", "sort": 2 } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Column created or updated.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Column" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/columns/{itemId}": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        { "$ref": "#/components/parameters/ItemIdPath" }
      ],
      "delete": {
        "tags": ["Kanban"],
        "summary": "Delete a kanban column",
        "description": "Mirrors the MCP `delete_column` tool. Deletes the column AND every task inside it. Discover column ids via `GET /json/{id}` (the `columns[]` array).",
        "operationId": "deleteColumn",
        "responses": {
          "200": { "description": "Deleted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "id": { "type": "string" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/tasks": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "post": {
        "tags": ["Kanban"],
        "summary": "Create or update a kanban task",
        "description": "Mirrors the MCP `create_task` / `update_task` tools. Only valid on a `todo`-mode board. Creates a NEW task with a fresh UUID, OR updates an existing task in place if you pass its `id`. Max 1000 tasks per board, 20 000 chars per task content. In the snapshot a task's `content` is an opaque JSON string (e.g. `{\"description\":...}`) — treat it as a blob. It may also carry an optional `boards` array of attached cnvs board ids (`{\"boards\":[\"<id>\",...]}`), set when a user drags boards from their Recent rail onto the task.\n\nOver MCP there are extra task tools without a dedicated REST path: `create_tasks` (bulk-create an array in one call), `query_tasks` (server-side filter by assignee / priority / done / overdue / due-window), and `export_tasks` (the same output as `GET tasks.csv` / `tasks.md`). `due_date` must be an ISO 8601 date (`YYYY-MM-DD` or full date-time); non-ISO values are rejected `400 { field: \"due_date\" }`.",
        "operationId": "addTask",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AddTaskRequest" },
              "examples": {
                "minimal": { "value": { "column_id": "c0a1...", "name": "Ship kanban docs" } },
                "full": { "value": { "column_id": "c0a1...", "name": "Ship kanban docs", "description": "Update llms.txt + openapi", "due_date": "2026-06-30", "priority": "H", "assignee": "lukasz", "sort": 0 } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Task created or updated.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Task" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/QuotaExceeded" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/tasks/{itemId}/move": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        { "$ref": "#/components/parameters/ItemIdPath" }
      ],
      "post": {
        "tags": ["Kanban"],
        "summary": "Move a kanban task",
        "description": "Mirrors the MCP `move_task` tool. Moves a task into a (possibly different) column and sets its sort position within that column.",
        "operationId": "moveTask",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["column_id", "sort"],
                "properties": {
                  "column_id": { "type": "string", "description": "Destination column id." },
                  "sort": { "type": "number", "description": "Sort position within the destination column." }
                }
              },
              "example": { "column_id": "c0a2...", "sort": 1 }
            }
          }
        },
        "responses": {
          "200": { "description": "Moved.", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "column_id": { "type": "string" }, "sort": { "type": "number" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/tasks/{itemId}": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        { "$ref": "#/components/parameters/ItemIdPath" }
      ],
      "delete": {
        "tags": ["Kanban"],
        "summary": "Delete a kanban task",
        "description": "Mirrors the MCP `delete_task` tool. Deletes a single card. Discover task ids via `GET /json/{id}` (the `tasks[]` array).",
        "operationId": "deleteTask",
        "responses": {
          "200": { "description": "Deleted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "id": { "type": "string" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/tasks.csv": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        {
          "name": "download",
          "in": "query",
          "required": false,
          "description": "Set to `1` to receive `Content-Disposition: attachment` instead of the default `inline`. Only the literal `1` opts in \u2014 the default is deliberately unchanged so existing readers (curl, API explorers, agents) keep displaying the document rather than downloading it.",
          "schema": { "type": "string", "enum": ["1"] }
        }
      ],
      "get": {
        "tags": ["Reads"],
        "summary": "Export tasks as CSV",
        "description": "Export a `todo`-mode board's columns + tasks as CSV — one row per task, RFC-4180 quoted. Header: `lane,column,name,priority,assignee,due_date,done,description`. `lane` is the title of the swimlane (row) the task's column sits in, falling back to the lane's 1-based row number (`1`, `2`, …) when that lane has no title. Mirrors the MCP `export_tasks` tool with `format: \"csv\"`. A `draw` board (no columns) still returns 200 with just the header row, so callers don't have to branch on mode first.",
        "operationId": "exportTasksCsv",
        "responses": {
          "200": { "description": "CSV document.", "content": { "text/csv": { "schema": { "type": "string" }, "example": "lane,column,name,priority,assignee,due_date,done,description\r\nBackend,To do,Ship docs,H,lukasz,2026-06-30,false,Update llms.txt" } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/tasks.md": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        {
          "name": "download",
          "in": "query",
          "required": false,
          "description": "Set to `1` to receive `Content-Disposition: attachment` instead of the default `inline`. Only the literal `1` opts in \u2014 the default is deliberately unchanged so existing readers (curl, API explorers, agents) keep displaying the document rather than downloading it.",
          "schema": { "type": "string", "enum": ["1"] }
        }
      ],
      "get": {
        "tags": ["Reads"],
        "summary": "Export tasks as Markdown",
        "description": "Export a `todo`-mode board's columns + tasks as a Markdown checklist grouped by column. Done cards render as `[x]`, open as `[ ]`; priority / assignee / due_date ride inline as trailing tags. Mirrors the MCP `export_tasks` tool (default `format: \"markdown\"`). A `draw` board (no columns) still returns 200 with just the board heading.",
        "operationId": "exportTasksMarkdown",
        "responses": {
          "200": { "description": "Markdown document.", "content": { "text/markdown": { "schema": { "type": "string" }, "example": "# Task board <id>\n\n## To do\n- [ ] Ship docs  _(priority: H, @lukasz, due 2026-06-30)_\n" } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/{kind}/{itemId}": {
      "parameters": [
        { "$ref": "#/components/parameters/BoardIdPath" },
        { "name": "kind", "in": "path", "required": true, "description": "Item kind. `links` is an alias for `texts` (links live in the same table — delete via either path). `lines` is an alias for `strokes` (matches the `/json` snapshot key — use either spelling).", "schema": { "type": "string", "enum": ["texts", "links", "strokes", "lines", "images"] } },
        { "$ref": "#/components/parameters/ItemIdPath" }
      ],
      "delete": {
        "tags": ["Items"],
        "summary": "Delete an item by id",
        "description": "Mirrors the MCP `erase` tool. `kind` MUST match the item type; unknown kinds are rejected with HTTP 400 (no silent targeting of the wrong table). `links` is an alias for `texts` since they live in the same table — you may delete a link via either path.",
        "operationId": "deleteItem",
        "responses": {
          "200": { "description": "Deleted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": {"type":"boolean"}, "id": {"type":"string"}, "kind": {"type":"string"} } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/boards/{boardId}/wait": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "get": {
        "tags": ["AI-facing"],
        "summary": "Long-poll: block until the next edit",
        "description": "REST equivalent of the MCP `wait_for_update` tool. Blocks until the next debounced edit burst lands on this board, or `timeout_ms` elapses. Designed for AI clients without MCP push notifications: call it after your turn is done, refresh with `GET /json/{id}` (using the returned ETag) when it resolves with `updated: true`. Resolves ~3 s after the edit burst settles (same debounce as push notifications).",
        "operationId": "waitForBoardUpdate",
        "parameters": [
          { "name": "timeout_ms", "in": "query", "required": false, "description": "Milliseconds to block before giving up. Clamped to [1000, 55000]; default 25000.", "schema": { "type": "integer", "minimum": 1000, "maximum": 55000, "default": 25000 } }
        ],
        "responses": {
          "200": {
            "description": "Either an edit landed (`updated: true`) or the timeout elapsed (`timedOut: true`). Returned ETag matches what `GET /json/{id}` would produce right now — save it and send back as `If-None-Match` on the next read to skip the 200 body when nothing has changed.",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "boardId": {"type":"string"}, "updated": {"type":"boolean"}, "timedOut": {"type":"boolean"}, "etag": {"type":"string"} }, "required": ["boardId","updated","timedOut","etag"] } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/json/{boardId}": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "get": {
        "tags": ["AI-facing"],
        "summary": "Board snapshot (MCP get_board shape)",
        "description": "Full structured JSON snapshot of a board, identical in shape to the MCP `get_board` tool. Includes every text node's COMPLETE content (Mermaid source stays intact inside the `content` field), every stroke's point array, and image metadata. Heavy image payloads (>8 kB base64) are elided (`dataUrl: null`); tiny images (small SVGs, icon-sized PNGs) ride along inline so AI clients can render them without a second round-trip.\n\nThe `boardId` can be supplied as a trailing path segment OR as `?board=...` query param — the query form accepts a raw UUID, a hash URL like `https://cnvs.app/#<id>`, or any URL whose path tail is the id.\n\n**Caching for pollers**: every response carries a weak `ETag` header derived from the board's last-updated timestamp + per-kind item counts. Send it back as `If-None-Match` and unchanged boards short-circuit to `304 Not Modified` (empty body, no rate-limit burn). `HEAD` is also supported for cheap existence/freshness probes.",
        "operationId": "getBoardJson",
        "parameters": [
          { "name": "If-None-Match", "in": "header", "required": false, "description": "Weak ETag from a previous response. Unchanged boards respond `304 Not Modified`.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Board snapshot.",
            "headers": {
              "ETag": { "description": "Weak ETag. Reuse with `If-None-Match` to poll cheaply.", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BoardSnapshot" }
              }
            }
          },
          "304": { "description": "Unchanged since the supplied `If-None-Match`. Empty body." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "head": {
        "tags": ["AI-facing"],
        "summary": "Cheap existence + freshness probe",
        "description": "Same headers as `GET /json/{boardId}` but zero body. Returns 200 with ETag if the board exists, 404 otherwise.",
        "operationId": "headBoardJson",
        "responses": {
          "200": { "description": "Board exists; ETag returned.", "headers": { "ETag": { "schema": { "type": "string" } } } },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/json": {
      "get": {
        "tags": ["AI-facing"],
        "summary": "Board snapshot (via query param)",
        "description": "Same as `/json/{boardId}` but with the id in the query string. Convenient when you have a full cnvs URL and don't want to strip the hash: `GET /json?board=https://cnvs.app/#<id>`.",
        "operationId": "getBoardJsonQuery",
        "parameters": [
          { "name": "board", "in": "query", "required": true, "description": "Raw board UUID, hash URL (`https://cnvs.app/#<id>`), or bare id. Alias: `id`.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Board snapshot.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BoardSnapshot" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/svg-preview/{boardId}": {
      "parameters": [{ "$ref": "#/components/parameters/BoardIdPath" }],
      "get": {
        "tags": ["AI-facing"],
        "summary": "Schematic SVG preview",
        "description": "Compact schematic render of the board — a few kB of plain SVG text, directly consumable by multimodal LLMs as an image. Shows texts as labeled rectangles, strokes as polylines with exact world coordinates, and images as placeholder boxes (or tiny thumbnails if provided). Mermaid blocks currently render as a `[mermaid diagram]` placeholder — fetch `/json/{id}` for the raw source. AI-authored items are rendered with a purple border so the viewer can tell apart AI and human contributions.",
        "operationId": "getSvgPreview",
        "responses": {
          "200": {
            "description": "SVG preview.",
            "content": {
              "image/svg+xml": {
                "schema": { "type": "string", "format": "binary" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/svg-preview": {
      "get": {
        "tags": ["AI-facing"],
        "summary": "Schematic SVG preview (via query param)",
        "description": "Same as `/svg-preview/{boardId}` but with the id in the query string.",
        "operationId": "getSvgPreviewQuery",
        "parameters": [
          { "name": "board", "in": "query", "required": true, "description": "Raw board UUID, hash URL, or bare id. Alias: `id`.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "SVG preview.", "content": { "image/svg+xml": { "schema": { "type": "string", "format": "binary" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "401": { "$ref": "#/components/responses/BoardLocked" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/mcp": {
      "get": {
        "tags": ["Discovery"],
        "summary": "MCP server discovery",
        "description": "Returns a small JSON document describing the MCP endpoint (name, version, protocol). For actual MCP interaction, POST JSON-RPC 2.0 requests to this same URL, or open an SSE stream with `Accept: text/event-stream` plus the `Mcp-Session-Id` returned by `initialize` — a stream request without that header is rejected. Streams are recycled on an idle TTL (5 min) and a hard lifetime cap (15 min); reconnect with the same session id and your subscriptions carry over. Send `DELETE /mcp` with the session id when you are done. See `/llms.txt` for full tool reference.",
        "operationId": "mcpDiscovery",
        "responses": {
          "200": { "description": "MCP discovery document.", "content": { "application/json": { "schema": { "type": "object" } } } },
          "400": { "description": "SSE stream requested without a valid `Mcp-Session-Id` header.", "content": { "application/json": { "schema": { "type": "object" } } } }
        }
      }
    },
    "/.well-known/mcp.json": {
      "get": {
        "tags": ["Discovery"],
        "summary": "MCP `.well-known` document",
        "description": "Service-level discovery per the `.well-known` convention: server URL, protocol version, tool list, resource URIs, capabilities. For AI agents that scan `.well-known/` for available integrations.",
        "operationId": "wellKnownMcp",
        "responses": {
          "200": { "description": "Discovery document.", "content": { "application/json": { "schema": { "type": "object" } } } }
        }
      }
    },
    "/llms.txt": {
      "get": {
        "tags": ["Discovery"],
        "summary": "llms.txt (LLM-facing site guide)",
        "description": "Human- and LLM-readable description of cnvs.app including the full MCP tool/resource reference, client config snippets for Claude Desktop / Claude Code, rate limits, and author tagging conventions.",
        "operationId": "llmsTxt",
        "responses": {
          "200": { "description": "Plain text.", "content": { "text/plain": { "schema": { "type": "string" } } } }
        }
      }
    },
    "/quotas.json": {
      "get": {
        "tags": ["Discovery"],
        "summary": "Machine-readable limits manifest",
        "description": "Single source of truth for per-request body caps, per-board quotas, board-import limits and rate-limit windows. Values are emitted from server constants and active configuration. Response is `Cache-Control: public, max-age=300`.",
        "operationId": "quotasJson",
        "responses": {
          "200": {
            "description": "Quotas / limits manifest.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "perRequest": { "type": "object", "properties": { "maxBodyBytes": { "type": "integer" } } },
                    "perBoard": {
                      "type": "object",
                      "properties": {
                        "maxTexts": { "type": "integer" },
                        "maxTextContentChars": { "type": "integer" },
                        "maxImages": { "type": "integer" },
                        "maxImageBytesTotal": { "type": "integer" },
                        "maxStrokes": { "type": "integer" },
                        "maxColumns": { "type": "integer" },
                        "maxColumnsPerLane": { "type": "integer" },
                        "maxLanes": { "type": "integer" },
                        "maxTasks": { "type": "integer" },
                        "maxTaskContentChars": { "type": "integer" }
                      }
                    },
                    "boardImport": {
                      "type": "object",
                      "properties": {
                        "maxItems": { "type": "integer", "const": 2550 },
                        "maxBatchStatements": { "type": "integer", "const": 320 },
                        "countedCollections": { "type": "array", "items": { "type": "string" } },
                        "counting": { "type": "string" },
                        "maxLaneTitles": { "type": "integer", "const": 10 },
                        "laneTitleRule": { "type": "string" },
                        "infrastructureToken": { "type": "string" }
                      }
                    },
                    "fieldLimits": {
                      "type": "object",
                      "properties": {
                        "maxAuthorChars": { "type": "integer", "const": 80 },
                        "maxThumbnailBytes": { "type": "integer", "const": 8000 },
                        "textWidth": { "type": "object", "properties": { "min": { "type": "integer", "const": 160 }, "max": { "type": "integer", "const": 4096 } } },
                        "columnWidth": { "type": "object", "properties": { "min": { "type": "integer", "const": 200 }, "max": { "type": "integer", "const": 480 }, "default": { "type": "integer", "const": 280 } } },
                        "maxLaneTitleChars": { "type": "integer", "const": 200 },
                        "maxColumnTitleChars": { "type": "integer", "const": 200 },
                        "maxTaskNameChars": { "type": "integer", "const": 500 },
                        "maxAssigneeChars": { "type": "integer", "const": 200 }
                      }
                    },
                    "operationLimits": {
                      "type": "object",
                      "properties": {
                        "maxRecolorItems": { "type": "integer", "const": 500 },
                        "maxBatchOps": { "type": "integer", "const": 500 },
                        "wsContinuation": { "type": "object", "properties": { "maxFrames": { "type": "integer", "const": 500 }, "idleMs": { "type": "integer", "const": 500 } } }
                      }
                    },
                    "rateLimits": {
                      "type": "object",
                      "description": "Standard and X-Import-Token infrastructure REST/MCP tiers, plus unchanged WebSocket and access-lock security limits.",
                      "properties": {
                        "standard": { "type": "object" },
                        "infrastructure": { "type": "object" },
                        "webSocket": { "type": "object" },
                        "security": { "type": "object" },
                        "tokenPolicy": { "type": "string" }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "tags": ["Discovery"],
        "summary": "This OpenAPI spec",
        "description": "Self-reference. The OpenAPI 3.1 spec you're reading now.",
        "operationId": "openapiJson",
        "responses": {
          "200": { "description": "OpenAPI document.", "content": { "application/json": { "schema": { "type": "object" } } } }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "BoardIdPath": {
        "name": "boardId",
        "in": "path",
        "required": true,
        "description": "Board identifier. Charset `[A-Za-z0-9-]`, max 64 characters. Usually a UUID but `open_board` accepts any conforming string.",
        "schema": { "type": "string", "pattern": "^[A-Za-z0-9-]{1,64}$", "example": "0a01ba29-bc15-4880-b135-ec47e33d95b2" }
      },
      "ItemIdPath": {
        "name": "itemId",
        "in": "path",
        "required": true,
        "description": "Item id (text / stroke / image). Discover via `GET /json/{id}`.",
        "schema": { "type": "string" }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid input (missing or malformed board id).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "Board does not exist or was deleted within the 30-day tombstone window.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "BoardLocked": {
        "description": "Board is access-locked and the request did not carry a valid key. `lockMode` tells the client whether to prompt for a code or just disable write UI.",
        "headers": {
          "WWW-Authenticate": { "schema": { "type": "string" }, "description": "`Bearer realm=\"cnvs board\", error=\"board_locked\"`." }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": { "type": "string" },
                "code": { "type": "string", "enum": ["board_locked"] },
                "locked": { "type": "string", "enum": ["read", "write"], "description": "`read` when lockMode=all, `write` when lockMode=write." },
                "lockMode": { "type": "string", "enum": ["write", "all"] }
              },
              "required": ["error", "code", "lockMode"]
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limit exceeded. Standard REST/MCP: 60 requests/10s/board; valid X-Import-Token infrastructure tier: 600/10s/board; browser WebSocket always 60/10s/board. REST/MCP enforcement is soft per-isolate; WebSocket enforcement is strong per Durable Object.",
        "headers": {
          "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait before retrying." }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": { "type": "string" },
                "code": { "type": "string", "enum": ["rate_limited"] },
                "retryAfterSeconds": { "type": "integer" }
              },
              "required": ["error", "code"]
            }
          }
        }
      },
      "QuotaExceeded": {
        "description": "Per-board quota exceeded (too many texts, images, strokes, or total image bytes). Response body names the specific limit that was hit.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/QuotaError" },
            "examples": {
              "tooManyImages": {
                "value": { "error": "This board already has 50 images; the cap is 50. Delete one first.", "code": "quota_exceeded", "kind": "images_per_board" }
              },
              "tooManyBytes": {
                "value": { "error": "Total image bytes on this board would be 10.3 MB; the cap is 10 MB. Upload a smaller image or erase existing ones.", "code": "quota_exceeded", "kind": "image_bytes_per_board" }
              }
            }
          }
        }
      }
    },
    "securitySchemes": {
      "BoardKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Board-Key",
        "description": "8-char [a-z0-9] access key (6-char legacy keys also accepted). Required for write operations on any locked board, and for ALL operations on a board locked with mode=all. Obtain via POST /api/boards/{id}/lock. Browsers carry the key in localStorage under `cnvs:board_key:<id>`."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string", "description": "Human-readable error message." }
        },
        "required": ["error"]
      },
      "QuotaError": {
        "allOf": [
          { "$ref": "#/components/schemas/Error" },
          {
            "type": "object",
            "properties": {
              "code": { "type": "string", "enum": ["quota_exceeded"] },
              "kind": {
                "type": "string",
                "enum": [
                  "texts_per_board",
                  "text_content_chars",
                  "images_per_board",
                  "image_bytes_per_board",
                  "strokes_per_board",
                  "columns_per_board",
                  "tasks_per_board",
                  "task_content_chars",
                  "import_items",
                  "lanes"
                ]
              }
            },
            "required": ["code", "kind"]
          }
        ]
      },
      "CreateBoardResponse": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "Freshly allocated board UUID. Open at `https://cnvs.app/#{id}` — or `https://cnvs.app/?embed=1#{id}` for the minimal-chrome iframe embed view." },
          "mode": { "type": "string", "enum": ["draw", "todo"], "description": "The created board's mode." },
          "columns": { "type": "array", "items": { "$ref": "#/components/schemas/Column" }, "description": "Todo boards only: the created columns (from `content.columns`, or the template seed)." },
          "imported": {
            "type": "object",
            "description": "Present whenever `content` was provided (even all-zero) — the feature-detection signal for board import. Per-kind counts of rows created FROM `content`; template-seeded columns are NOT counted.",
            "properties": {
              "texts": { "type": "integer" },
              "lines": { "type": "integer" },
              "images": { "type": "integer" },
              "columns": { "type": "integer" },
              "tasks": { "type": "integer" },
              "lanes": { "type": "integer" }
            }
          },
          "ids": {
            "type": "object",
            "description": "Present whenever `content` was provided, alongside `imported`. The server-minted ids for the imported rows: index `i` of each array is the id created for `content.<kind>[i]`, in the order the caller supplied them. Item ids are ALWAYS minted server-side — a caller may not choose them (ids are globally unique and the item write paths upsert on id, so an honoured caller id could overwrite rows on another board). `columns` is `[]` when the columns came from the `template` seed (those are already returned in full in `columns`). There is no `lanes` array — lane rows have no synthetic id, they are keyed by `(board_id, lane)`.",
            "properties": {
              "texts": { "type": "array", "items": { "type": "string" } },
              "lines": { "type": "array", "items": { "type": "string" } },
              "images": { "type": "array", "items": { "type": "string" } },
              "columns": { "type": "array", "items": { "type": "string" } },
              "tasks": { "type": "array", "items": { "type": "string" } }
            }
          },
          "access_key": { "type": "string", "description": "Only when `lock` was requested: the plaintext board key (8 chars a-z0-9), returned ONCE. There is no recovery." }
        },
        "required": ["id", "mode"]
      },
      "BoardImportContent": {
        "type": "object",
        "description": "Initial board content applied atomically at create time (`POST /api/boards` `content` field / MCP `create_board`). The combined input arrays may contain at most 2550 entries (counted exactly as supplied — entries are never merged); template-seeded columns do not count as input. The D1 batch is capped at 320 statements (multi-row INSERTs sized against D1's 100-bound-parameters-per-statement limit). In practice the 5 MB request-body cap bites first on image-heavy imports, well before 2550 entries.\n\n**Per-item `author`.** Every item in `texts` / `lines` / `images` / `columns` / `tasks` / `lanes` accepts an optional `author` (1–80 chars of `[A-Za-z0-9:_\\-.]`, trimmed — the same rule as the REST/WS paths). Absent or null inherits the body-level `author` (default `ai:import`); an invalid value is `400 {error:\"invalid_item\", kind, index, reason}`. This exists because an item's `author` is its CREATOR and is never rewritten, so restoring someone else's board must be able to carry the original authorship instead of stamping everything as the importer.\n\n**`sourceId` (texts and images).** Optional string, 1–128 chars, unique across the whole import. WRITE-ONLY: it is never stored and never becomes the row id — item ids are always server-minted and returned in the response's `ids`. Its only effect is anchor resolution: a `lines[i].anchors.start.id` / `.end.id` matching a declared `sourceId` is rewritten to that item's minted id. An anchor id matching nothing is left verbatim and renders as a free stroke end.\n\n**Strict value checks.** `tasks[].done` must be a real boolean (or null/omitted); a truthy stand-in like `\"false\"` is `400 {error:\"invalid_item\", kind:\"tasks\", index, reason}` rather than being coerced into a COMPLETED card. `lanes` may name each lane index at most once; a second entry for the same index is `400 {error:\"invalid_item\", kind:\"lanes\", index, reason}` on that second entry, since collapsing it would overwrite the first entry's `author`.\n\nDraw kinds (texts/lines/images) require draw mode; kanban kinds (columns/tasks/lanes/colWidth) require `mode: \"todo\"` — a mismatch is `400 content_mode_mismatch`. All standard per-board quotas also apply (live values in /quotas.json).",
        "properties": {
          "texts": {
            "type": "array",
            "maxItems": 500,
            "items": {
              "type": "object",
              "properties": {
                "x": { "type": "number", "description": "Optional with `autolayout: true`, required otherwise." },
                "y": { "type": "number", "description": "Optional with `autolayout: true`, required otherwise." },
                "content": { "type": "string", "description": "Required, non-empty. cnvs markup + Mermaid supported." },
                "color": { "type": "string", "description": "Named ink only (red/blue/green/…); anything else clamps to auto." },
                "width": { "type": "number", "description": "Explicit width in px (160–4096)." },
                "postit": { "type": "boolean" },
                "diagram": { "type": "boolean" },
                "kind": { "type": "string", "enum": ["text", "link"], "description": "`link` renders the content as a URL capsule. Defaults to `text`." },
                "author": { "type": "string", "description": "Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\\-.]`). Omit to inherit the body-level `author`." },
                "sourceId": { "type": "string", "maxLength": 128, "description": "Optional caller-side handle, unique across the import. Write-only: never stored, never the row id — it only lets `lines[].anchors` endpoints be remapped to this text's server-minted id." }
              },
              "required": ["content"]
            }
          },
          "lines": {
            "type": "array",
            "maxItems": 2000,
            "description": "Freehand strokes. `strokes` is accepted as an alias key (when both are present, `lines` wins).",
            "items": {
              "type": "object",
              "properties": {
                "points": { "description": "Nested `[[x,y],...]`, flat `[x1,y1,...]`, or a JSON string of either. Required, non-empty." },
                "color": { "type": "string" },
                "anchors": { "description": "Optional `{start?, end?}` endpoint anchors (same shape as POST /strokes) — each endpoint is `{kind:'text'|'image', id, dx, dy}`; an endpoint failing that shape is dropped, and anchors with neither endpoint left collapse to null. An endpoint `id` matching a `sourceId` declared by a text/image in this same import is rewritten to that item's minted id; an id matching nothing is kept verbatim and renders as a free end." },
                "author": { "type": "string", "description": "Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\\-.]`). Omit to inherit the body-level `author`." }
              },
              "required": ["points"]
            }
          },
          "images": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "object",
              "properties": {
                "x": { "type": "number", "description": "Optional with `autolayout: true`, required otherwise." },
                "y": { "type": "number", "description": "Optional with `autolayout: true`, required otherwise." },
                "dataUrl": { "type": "string", "description": "`data:image/(png|jpeg|gif|webp|svg+xml);base64,...`, ≤ ~900 kB. Required — hosted URLs are not accepted." },
                "width": { "type": "number" },
                "height": { "type": "number" },
                "thumbDataUrl": { "type": "string", "description": "Optional ≤8 kB raster thumbnail for SVG previews." },
                "author": { "type": "string", "description": "Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\\-.]`). Omit to inherit the body-level `author`." },
                "sourceId": { "type": "string", "maxLength": 128, "description": "Optional caller-side handle, unique across the import. Write-only: never stored, never the row id — it only lets `lines[].anchors` endpoints be remapped to this image's server-minted id." }
              },
              "required": ["dataUrl", "width", "height"]
            }
          },
          "columns": {
            "type": "array",
            "maxItems": 200,
            "description": "Todo mode. Array order = sort order. When empty/absent on a todo board, the `template` columns are seeded instead (and `tasks[].columnIndex` indexes into those).",
            "items": {
              "type": "object",
              "properties": {
                "title": { "type": "string" },
                "lane": { "type": "integer", "description": "Row index (≥ 0). Defaults to 0." },
                "color": { "type": "string", "enum": ["red", "blue", "green"], "description": "Optional title color; anything else → default." },
                "author": { "type": "string", "description": "Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\\-.]`). Omit to inherit the body-level `author`." }
              },
              "required": ["title"]
            }
          },
          "tasks": {
            "type": "array",
            "maxItems": 1000,
            "items": {
              "type": "object",
              "properties": {
                "columnIndex": { "type": "integer", "description": "Index into `content.columns` (or the template-seeded columns when `content.columns` is empty/absent)." },
                "name": { "type": "string" },
                "description": { "type": "string", "description": "Stored inside the task's opaque `content` JSON, same as the UI." },
                "due_date": { "type": "string", "description": "ISO 8601 date." },
                "priority": { "type": "string", "enum": ["H", "M", "L"] },
                "assignee": { "type": "string" },
                "done": { "type": "boolean", "description": "Completion flag. Must be a REAL boolean — a truthy stand-in such as the string `\"false\"` or `0` is rejected `400 {error:\"invalid_item\", kind:\"tasks\", index, reason:\"done must be true or false (or omitted)\"}` and nothing is created (coercing `\"false\"` would persist the card as COMPLETED). `null` or omitted means not done." },
                "color": { "type": "string", "description": "Card color, stored inside the task's opaque `content` JSON alongside `description`." },
                "author": { "type": "string", "description": "Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\\-.]`). Omit to inherit the body-level `author`." }
              },
              "required": ["columnIndex", "name"]
            }
          },
          "lanes": {
            "type": "array",
            "maxItems": 10,
            "description": "Row (lane) titles for multi-row kanban layouts. At most 10 entries; each `lane` must be used by a column created in this request. Each lane index may appear AT MOST ONCE — a second entry for the same `lane` is rejected `400 {error:\"invalid_item\", kind:\"lanes\", index, reason}` (the `index` is that of the SECOND entry) rather than collapsing last-write-wins, because collapsing would let the later entry overwrite the first namer's `author`, and an item's author is its creator and is never rewritten. Every entry still counts separately toward both the 10-lane-title and 2550-total-input-entry limits, and the 10-entry quota error is reported before the duplicate check. Lanes get no id — they are keyed by `(board_id, lane)`, so they have no entry in the response's `ids`.",
            "items": {
              "type": "object",
              "properties": {
                "lane": { "type": "integer", "description": "Row index (≥ 0)." },
                "title": { "type": "string" },
                "author": { "type": "string", "description": "Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\\-.]`). Omit to inherit the body-level `author`." }
              },
              "required": ["lane", "title"]
            }
          },
          "colWidth": { "type": "number", "description": "Shared kanban column width in px, clamped to [200, 480]." }
        }
      },
      "BoardSnapshot": {
        "type": "object",
        "description": "Full board state as returned by `/json/{id}` and the MCP `get_board` tool. Same shape as the `cnvs://board/{id}/state.json` MCP resource.",
        "properties": {
          "boardId": { "type": "string" },
          "mode": { "type": "string", "enum": ["draw", "todo"], "description": "Board mode. `draw` (default) = infinite-canvas whiteboard; `todo` = kanban task board. Switchable whenever the board is empty of real content (drawings + tasks); empty/seeded columns don't count, so a cleared board can switch again." },
          "texts": { "type": "array", "items": { "$ref": "#/components/schemas/TextNode" } },
          "lines": { "type": "array", "items": { "$ref": "#/components/schemas/StrokeLine" } },
          "images": { "type": "array", "items": { "$ref": "#/components/schemas/ImageNode" } },
          "columns": { "type": "array", "description": "Kanban columns (present / non-empty on `todo`-mode boards).", "items": { "$ref": "#/components/schemas/Column" } },
          "tasks": { "type": "array", "description": "Kanban tasks (present / non-empty on `todo`-mode boards).", "items": { "$ref": "#/components/schemas/Task" } }
        },
        "required": ["boardId", "texts", "lines", "images"]
      },
      "BoardBrowserSnapshot": {
        "type": "object",
        "description": "Browser-client shape returned by `/api/boards/{id}`. Image payloads are NOT elided here.",
        "properties": {
          "texts": { "type": "array", "items": { "$ref": "#/components/schemas/TextNode" } },
          "lines": { "type": "array", "items": { "$ref": "#/components/schemas/StrokeLine" } },
          "images": { "type": "array", "items": { "$ref": "#/components/schemas/ImageNodeFull" } }
        },
        "required": ["texts", "lines", "images"]
      },
      "TextNode": {
        "type": "object",
        "description": "A draggable, editable text block. Content supports cnvs markup (Markdown-ish) and Mermaid diagrams. When a node contains Mermaid, its entire content is one ```mermaid fenced block (one diagram per node).",
        "properties": {
          "id": { "type": "string" },
          "x": { "type": "number", "description": "World x (+x right)." },
          "y": { "type": "number", "description": "World y (+y DOWN, standard SVG)." },
          "content": { "type": "string", "description": "Raw text content (up to 100 000 chars). May include Markdown, Mermaid, emoji." },
          "color": { "type": ["string", "null"], "description": "Resolved ink value stored on the node — the palette hex for a named color (e.g. `red` → `#ff3b30`) or the CSS variable `var(--text-color)` for theme-aware ink. Writes accept only the color NAMES, never raw hex." },
          "width": { "type": ["number", "null"], "description": "Explicit wrapping width in px, or null for auto." },
          "postit": { "type": "integer", "enum": [0, 1], "description": "1 renders as a yellow sticky note." },
          "kind": { "type": "string", "enum": ["text", "link"], "description": "`text` is a free-form markdown/Mermaid node; `link` is a URL capsule rendered as a clickable pill. Both live in the same table — `kind` lets machine clients distinguish them without guessing from content." },
          "author": { "type": ["string", "null"], "description": "Author tag: `user:<uuid>` for browser edits, `ai:<label>` for MCP edits. IMMUTABLE after creation — subsequent moves / edits by other collaborators do NOT change this value." },
          "last_updated": { "type": "string", "description": "UTC timestamp." }
        },
        "required": ["id", "x", "y", "content"]
      },
      "StrokeLine": {
        "type": "object",
        "description": "A freehand stroke — an ordered list of world-coordinate points.",
        "properties": {
          "id": { "type": "string" },
          "points": {
            "type": "array",
            "description": "Array of `[x, y]` pairs in board world coordinates.",
            "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
          },
          "color": { "type": ["string", "null"] },
          "author": { "type": ["string", "null"] },
          "last_updated": { "type": "string" }
        },
        "required": ["id", "points"]
      },
      "ImageNode": {
        "type": "object",
        "description": "Image metadata. Full `dataUrl` is NOT included in this shape — fetch `/api/boards/{id}` if you need raw pixels.",
        "properties": {
          "id": { "type": "string" },
          "x": { "type": "number" },
          "y": { "type": "number" },
          "width": { "type": "number" },
          "height": { "type": "number" },
          "thumbDataUrl": { "type": ["string", "null"], "description": "Tiny preview embedded in SVG previews (≤8 kB)." },
          "author": { "type": ["string", "null"] },
          "last_updated": { "type": "string" }
        },
        "required": ["id", "x", "y", "width", "height"]
      },
      "ImageNodeFull": {
        "allOf": [
          { "$ref": "#/components/schemas/ImageNode" },
          {
            "type": "object",
            "properties": {
              "dataUrl": { "type": "string", "description": "Full `data:image/...;base64,...` payload. Can be up to ~900 kB per image." }
            }
          }
        ]
      },
      "AuthorTag": {
        "type": "string",
        "description": "Author tag, charset `[A-Za-z0-9:_\\-.]`, max 80 chars. Defaults to `ai:rest` for REST mutations. Use `ai:<your-label>` (e.g. `ai:claude`, `ai:mybot`) so humans can tell AI edits apart.",
        "pattern": "^[A-Za-z0-9:_\\-.]{1,80}$"
      },
      "AddTextRequest": {
        "type": "object",
        "required": ["x", "y", "content"],
        "properties": {
          "id": { "type": "string", "description": "Optional stable id. Pass an existing id to UPDATE that node in place; omit to CREATE a new one with a fresh UUID." },
          "x": { "type": "number", "description": "World x (+x right)." },
          "y": { "type": "number", "description": "World y (+y DOWN, standard SVG)." },
          "content": { "type": "string", "description": "Raw content (up to 100 000 chars). Supports cnvs markup (Markdown-ish) and Mermaid (one diagram per node — entire content must be a single ```mermaid fenced block)." },
          "color": { "type": "string", "enum": ["auto", "black", "red", "blue", "green", "orange", "yellow", "pink", "purple", "maroon", "brown", "gray", "lightgray", "teal", "sage", "sky", "lavender"], "description": "Ink color NAME — same mental model as clicking the ink picker. Case-insensitive. `auto` / `black` / omitted → theme-aware. `red` / `blue` / `green` and the extended palette (`orange`, `yellow`, `pink`, `purple`, `maroon`, `brown`, `gray`, `lightgray`, `teal`, `sage`, `sky`, `lavender`) → emphasis colors. Custom hex codes silently clamp to `auto` so AI writes never end up invisible on dark mode." },
          "width": { "type": ["number", "null"], "description": "Explicit width in px (160–4096), or null for auto-fit." },
          "postit": { "type": "boolean", "description": "Renders as a yellow sticky note when true." },
          "author": { "$ref": "#/components/schemas/AuthorTag" }
        }
      },
      "AddTextResponse": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "x": { "type": "number" },
          "y": { "type": "number" },
          "content": { "type": "string" },
          "postit": { "type": "boolean" },
          "author": { "type": "string" }
        },
        "required": ["id", "x", "y", "content"]
      },
      "MoveTextRequest": {
        "type": "object",
        "required": ["x", "y"],
        "properties": {
          "x": { "type": "number" },
          "y": { "type": "number" },
          "author": { "$ref": "#/components/schemas/AuthorTag" }
        }
      },
      "AddStrokeRequest": {
        "type": "object",
        "required": ["points"],
        "properties": {
          "id": { "type": "string" },
          "points": {
            "description": "Ordered points. Accepts `[[x,y],[x,y],...]`, flat `[x1,y1,x2,y2,...]`, or a JSON string of either.",
            "oneOf": [
              { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } },
              { "type": "array", "items": { "type": "number" } },
              { "type": "string" }
            ]
          },
          "color": { "type": "string", "enum": ["auto", "black", "red", "blue", "green", "orange", "yellow", "pink", "purple", "maroon", "brown", "gray", "lightgray", "teal", "sage", "sky", "lavender"], "description": "Ink color NAME — same options as for text. Case-insensitive. Custom hex is rejected-with-clamp, not accepted literally." },
          "author": { "$ref": "#/components/schemas/AuthorTag" }
        }
      },
      "AddImageRequest": {
        "type": "object",
        "required": ["x", "y", "width", "height", "dataUrl"],
        "properties": {
          "id": { "type": "string" },
          "x": { "type": "number" },
          "y": { "type": "number" },
          "width": { "type": "number", "description": "Displayed width in board px." },
          "height": { "type": "number", "description": "Displayed height in board px." },
          "dataUrl": {
            "type": "string",
            "description": "Data URL: `data:image/(png|jpeg|gif|webp|svg+xml);base64,<payload>`. Max ~900 kB total. Hosted URLs NOT accepted — fetch + base64-encode first.",
            "pattern": "^data:image/(png|jpeg|gif|webp|svg\\+xml);base64,"
          },
          "thumbDataUrl": {
            "type": ["string", "null"],
            "description": "Optional tiny thumbnail (≤8 kB JPEG/PNG/WebP, ~64 px on long edge). Embedded into SVG previews so other AI viewers see the image instead of a placeholder."
          },
          "author": { "$ref": "#/components/schemas/AuthorTag" }
        }
      },
      "AddLinkRequest": {
        "type": "object",
        "required": ["x", "y", "url"],
        "properties": {
          "id": { "type": "string" },
          "x": { "type": "number" },
          "y": { "type": "number" },
          "url": { "type": "string", "format": "uri" },
          "author": { "$ref": "#/components/schemas/AuthorTag" }
        }
      },
      "Column": {
        "type": "object",
        "description": "A kanban column on a `todo`-mode board.",
        "properties": {
          "id": { "type": "string" },
          "title": { "type": "string" },
          "sort": { "type": "number", "description": "Left-to-right order of the column." },
          "lane": { "type": "integer", "default": 0, "description": "Row index for multi-lane layout. Defaults to 0 (top row)." },
          "color": { "type": ["string", "null"], "enum": ["red", "blue", "green", null], "description": "Column title color — one of `red` / `blue` / `green`, or null for the default." },
          "author": { "type": ["string", "null"] },
          "last_updated": { "type": "string", "description": "UTC timestamp." }
        },
        "required": ["id", "title"]
      },
      "Task": {
        "type": "object",
        "description": "A kanban task (card) on a `todo`-mode board.",
        "properties": {
          "id": { "type": "string" },
          "column_id": { "type": "string", "description": "Column the card currently lives in." },
          "name": { "type": "string", "description": "Card title." },
          "content": { "type": ["string", "null"], "description": "Opaque JSON string blob, e.g. `{\"description\":...}`. Treat as a blob — do not parse application meaning out of it. Max 20 000 chars." },
          "due_date": { "type": ["string", "null"], "description": "ISO 8601 due date, or null." },
          "priority": { "type": ["string", "null"], "enum": ["H", "M", "L", null], "description": "High / Medium / Low, or null." },
          "assignee": { "type": ["string", "null"] },
          "done": { "type": "boolean", "description": "Whether the task is completed (checked off). Defaults to false." },
          "sort": { "type": "number", "description": "Order within the column." },
          "author": { "type": ["string", "null"] },
          "last_updated": { "type": "string", "description": "UTC timestamp." }
        },
        "required": ["id", "column_id", "name"]
      },
      "AddColumnRequest": {
        "type": "object",
        "required": ["title"],
        "properties": {
          "id": { "type": "string", "description": "Pass an existing id to UPDATE that column; omit to CREATE a new one." },
          "title": { "type": "string" },
          "sort": { "type": "number", "description": "Left-to-right position. Defaults to the end if omitted on create." },
          "lane": { "type": "integer", "default": 0, "description": "Optional row index for multi-lane layout. Defaults to 0 (top row)." },
          "color": { "type": "string", "enum": ["red", "blue", "green"], "description": "Optional column title color — one of `red` / `blue` / `green`. Omit for the default." }
        }
      },
      "AddTaskRequest": {
        "type": "object",
        "required": ["column_id", "name"],
        "properties": {
          "id": { "type": "string", "description": "Pass an existing id to UPDATE that task in place; omit to CREATE a new one." },
          "column_id": { "type": "string", "description": "Column the card belongs to." },
          "name": { "type": "string", "description": "Card title." },
          "description": { "type": "string", "description": "Optional free-text body. Stored inside the task's opaque `content` JSON blob (≤ 20 000 chars)." },
          "due_date": { "type": "string", "description": "Optional ISO 8601 due date." },
          "priority": { "type": "string", "enum": ["H", "M", "L"], "description": "Optional High / Medium / Low priority." },
          "assignee": { "type": "string", "description": "Optional assignee label." },
          "done": { "type": "boolean", "description": "Mark the task completed (checked off). Defaults to false on create; omit on update to leave unchanged." },
          "sort": { "type": "number", "description": "Optional order within the column." },
          "author": { "$ref": "#/components/schemas/AuthorTag" }
        }
      }
    }
  },
  "externalDocs": {
    "description": "cnvs.app / MCP reference",
    "url": "https://cnvs.app/llms.txt"
  },
  "x-mcp": {
    "endpoint": "https://cnvs.app/mcp",
    "wellKnown": "https://cnvs.app/.well-known/mcp.json",
    "transport": "streamable-http",
    "note": "This REST API covers reads and mutations one-to-one with MCP. MCP additionally provides live `notifications/resources/updated` subscriptions and the `wait_for_update` long-poll tool."
  }
}
