{
  "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"],
      "note": "Every public endpoint (/api/boards/..., /json/..., /svg-preview/..., /mcp) returns permissive CORS headers and handles OPTIONS preflight. Board id is the access credential; no credentials mode."
    },
    "x-rateLimit": {
      "requests": 60,
      "windowSeconds": 10,
      "per": "boardId",
      "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.",
      "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": "Sliding window. 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-quotasManifest": { "url": "/quotas.json", "note": "Machine-readable single source of truth for limits. Cache ≤ 5 min." },
    "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}`). 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**: no API keys or tokens. The board ID is the access credential; anyone who knows the ID can read and write. Keep board URLs private for sensitive content.\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.\n\n**Rate limit**: 60 requests per 10 seconds per board ID. Browser WebSockets are rate-limited inside the per-board BoardServer Durable Object — strong. REST and MCP are rate-limited in the Worker isolate that serves the request — soft: because Cloudflare distributes traffic across many isolates, a caller spreading load effectively gets N × the advertised cap. Combined WS + HTTP throughput on a hot board can exceed the nominal 60/10s. 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**. 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 board items (texts, strokes, images, links). Mirrors MCP tools one-to-one." },
    { "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, inserts an empty board row, and returns the id. The board is immediately reachable at `https://cnvs.app/#{id}` (browser) and through every other endpoint in this API.\n\n**Per-IP rate limit**: this endpoint is capped at 5 creates per 60s per client IP (keyed on the `CF-Connecting-IP` edge header). Exceeding returns `429` with `Retry-After: 60` and body `{ code: \"rate_limited\", retryAfterSeconds: 60 }`. Humans rarely trip this; MCP clients typically reuse a single board across a session so it also doesn't pinch legit AI use.",
        "operationId": "createBoard",
        "responses": {
          "200": {
            "description": "New board created.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreateBoardResponse" },
                "example": { "id": "0a01ba29-bc15-4880-b135-ec47e33d95b2" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/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" },
          "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" },
          "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) 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" },
          "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" },
          "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` accepts any CSS color. 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": "#ff0000" }
            }
          }
        },
        "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" },
          "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" },
          "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" },
          "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" },
          "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" },
          "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" },
          "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" },
          "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" },
          "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" },
          "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`. See `/llms.txt` for full tool reference.",
        "operationId": "mcpDiscovery",
        "responses": {
          "200": { "description": "MCP discovery document.", "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 and the rate-limit window. Values are emitted straight from the server constants so this document cannot drift from actual enforcement. 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" }
                      }
                    },
                    "rateLimit": {
                      "type": "object",
                      "properties": {
                        "requests": { "type": "integer" },
                        "windowSeconds": { "type": "integer" },
                        "per": { "type": "string" },
                        "enforcement": { "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" } } }
      },
      "RateLimited": {
        "description": "Per-board rate limit exceeded (60 requests / 10 s; strong for browser WS, soft per-isolate for REST/MCP).",
        "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" }
              }
            }
          }
        }
      }
    },
    "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"
                ]
              }
            },
            "required": ["code", "kind"]
          }
        ]
      },
      "CreateBoardResponse": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "Freshly allocated board UUID. Open at `https://cnvs.app/#{id}`." }
        },
        "required": ["id"]
      },
      "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" },
          "texts": { "type": "array", "items": { "$ref": "#/components/schemas/TextNode" } },
          "lines": { "type": "array", "items": { "$ref": "#/components/schemas/StrokeLine" } },
          "images": { "type": "array", "items": { "$ref": "#/components/schemas/ImageNode" } }
        },
        "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": "CSS color, or the CSS variable `var(--text-color)`." },
          "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"], "description": "Ink color NAME — same mental model as clicking the ink picker. Case-insensitive. `auto` / `black` / omitted → theme-aware. `red` / `blue` / `green` → 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"], "description": "Ink color NAME — same five 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" }
        }
      }
    }
  },
  "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."
  }
}
