Calls without X-API-Key need no key. Calls with it need a paid plan — get a key.
Get an API key
Free tier, no form to fill in. Signing in shares your email address with us — we
store it to create your key and to recognise you if you sign in with another
provider. See our Privacy Policy and
Terms.
openapi: 3.2.0
info:
title: Cnvs App Kanban API
version: 1.0.0
contact:
name: cnvs.app
url: https://cnvs.app/about/
license:
name: Public service — no account required
url: https://cnvs.app/about/
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-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.
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-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-quotasManifest:
url: /quotas.json
note: Machine-readable single source of truth for limits. Cache ≤ 5 min.
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-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-webSocketErrors:
note: 'Every WebSocket error frame is `{type: "error", code, message, originType, originId}` and is sent ONLY to the socket whose mutation failed — never broadcast. `originType`/`originId` echo the frame that caused it.'
codes:
rate_limited: Rejection — per-board WS budget exceeded (see x-rateLimits). Nothing was written.
board_locked: Rejection — board is write-locked and this socket has no valid key (see x-accessLock). Nothing was written.
quota_exceeded: Rejection — a per-board quota would be exceeded; carries `kind`. Nothing was written.
invalid_payload: Rejection — the frame failed validation; carries `field`. Nothing was written.
server_error: 'NOT a rejection — the mutation threw server-side (e.g. a D1 storage reset mid-write). The throw may occur before, during or after the write commits, so the outcome is unknown: the change may be fully stored, partly stored, or absent. Re-read the board (GET /json/{id}) and reconcile; do not assume local state is correct, and do not blindly undo the change.'
description: 'Operations tagged Kanban across 2 of this provider''s published API definitions: cnvs-app-openapi.json, cnvs-app-openapi.yml. Each path carries the servers of the definition it was published in.'
servers:
- url: https://cnvs.app
description: Production
tags:
- 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`, …).'
paths:
/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'
servers:
- url: https://cnvs.app
description: Production
/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'
servers:
- url: https://cnvs.app
description: Production
/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'
servers:
- url: https://cnvs.app
description: Production
/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'
servers:
- url: https://cnvs.app
description: Production
/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'
servers:
- url: https://cnvs.app
description: Production
/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":["",...]}`), set when a user drags boards from their Recent rail onto the task.
Over 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: alex
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'
servers:
- url: https://cnvs.app
description: Production
/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'
servers:
- url: https://cnvs.app
description: Production
/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'
servers:
- url: https://cnvs.app
description: Production
components:
schemas:
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
Error:
type: object
properties:
error:
type: string
description: Human-readable error message.
required:
- error
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.
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
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
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}$
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'
responses:
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
BadRequest:
description: Invalid input (missing or malformed board id).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
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
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
NotFound:
description: Board does not exist or was deleted within the 30-day tombstone window.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
parameters:
ItemIdPath:
name: itemId
in: path
required: true
description: Item id (text / stroke / image). Discover via `GET /json/{id}`.
schema:
type: string
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
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>`.
externalDocs:
description: cnvs.app / MCP reference
url: https://cnvs.app/llms.txt
x-refined-from:
- cnvs-app-openapi.json
- cnvs-app-openapi.yml
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.