llms.txt
If you are an agent putting a canvas on a canvas-drop instance, start here. The
instance serves this page and its companions (Overview, Quickstart, Capabilities,
SDK overview, Deploy API, Runtime API, Error codes) as one plain-text file at
{base}/llms.txt, meant to be dropped straight into context. It is
public: served on the instance's base host ahead of the sign-in gateway, so you
can read the contract before you hold any credential. {base} is the instance
origin (a fresh local instance is http://localhost:3000).
There are three ways in. Pick by what you hold.
| You hold | Use | Reach |
|---|---|---|
A per-canvas secret key (cd_...) |
Deploy API at {base}/v1/canvases/{id}/... |
that one canvas: deploy, read back, roll back, unpublish |
| An MCP-capable host | MCP at {base}/mcp (OAuth 2.1, no key to paste) |
every canvas the signed-in account owns or edits; 49 tools |
| Code running inside a canvas page | Browser SDK at {base}/sdk/v1.js, global canvasdrop |
the six fixed primitives for that canvas: KV, files, AI, identity, realtime, Connections |
Two verbs recur below. Publish turns the editor draft into an immutable
version. Deploy (the Deploy API, deploy_canvas, the staged upload) publishes
directly with no draft step. Both create a new version at the same URL; the last
10 versions are kept.
Deploy with a key
- Get a canvas and its key. A person creates the canvas on the dashboard's
Create page with Use the API, or you call
create_canvasover MCP. Either mints the canvas plus a one-time secret key, shown once. - PUT a ZIP with
index.htmlat its root:
curl -fsS -X PUT "{base}/v1/canvases/{id}/deploy" \
-H "Authorization: Bearer $CANVAS_KEY" \
--data-binary @site.zip
# 200 {"outcome":"published","url":"...","version":7,"versionId":"01J…","releaseId":null,"publicationToken":"9f2c…","fileCount":12,"totalBytes":348201,"warnings":[]}
- Verify through the server, not the URL:
curl -fsS "{base}/v1/canvases/{id}/files" -H "Authorization: Bearer $CANVAS_KEY"
# 200 {"version":7,"fileCount":12,"files":[{"path":"index.html","size":1204,"mime":"text/html","hash":"..."}]}
curl -fsS "{base}/v1/canvases/{id}/files?path=index.html" \
-H "Authorization: Bearer $CANVAS_KEY" | sha256sum
# the live file's raw bytes; compare the hash with what you shipped
The deploy publishes a live version immediately. {id} is the canvas id, not the
slug. The Deploy API host is CANVAS_DROP_API_BASE_URL, which defaults to the
instance base URL and can differ from the dashboard host; create_canvas and the
dashboard both return the exact endpoints for the canvas, so never guess the host.
The key is verified per canvas. A missing or unknown key, including a key for an
archived, disabled, or deleted canvas, answers 401 {"error":"unauthorized"}; a key
for a different canvas answers 403. Validation failures answer {"code", "message","path"}: 400 on the ZIP path with EMPTY_DEPLOY, TOO_MANY_FILES,
FILE_TOO_LARGE, CANVAS_TOO_LARGE, INVALID_ZIP, INVALID_PATH,
ZIP_SLIP_REJECTED, or ZIP_BOMB_REJECTED; a ZIP body over the canvas cap is
refused before it is read with 413 CANVAS_TOO_LARGE. Deploys, staged
begin/finalize, and rollbacks are throttled at 10 per minute per canvas
(429 {"error":"rate_limited"} with Retry-After).
Companion routes, same Bearer key:
| Route | Purpose |
|---|---|
GET /v1/canvases/{id} |
{id, slug, url, title, status, publicationState, accessMode, currentVersionId, publicationToken, currentVersion: {id, number, releaseId, createdAt} | null} (accessMode: restricted | whole_org | public_link — who else can open it beyond the people-and-teams list) |
GET /v1/canvases/{id}/versions |
{versions: [{id, number, source, status, createdBy, createdAt, fileCount, totalBytes, releaseId, current}]} |
GET /v1/canvases/{id}/files |
the live manifest as JSON; ?path= returns that file's raw bytes; 404 NOT_PUBLISHED before the first deploy |
POST /v1/canvases/{id}/rollback |
body {"version": 6}; makes that ready version current and returns {url, version}; 404 when no ready version has that number |
POST /v1/canvases/{id}/unpublish |
back to Draft: {url, publicationState: "draft", currentVersionId: null}; 409 CANNOT_UNPUBLISH when not published |
For large or repeat deploys, the staged flow sends only changed blobs:
POST /v1/canvases/{id}/uploads with {"manifest":[{"path","hash","size"}]}
(sha256 hex) returns {uploadId, missingHashes}; PUT /v1/canvases/{id}/uploads/{uploadId}/blobs/{hash} with the raw bytes of each
missing blob returns 204; POST /v1/canvases/{id}/uploads/{uploadId}/finalize
returns the same DeployResult. A session lives 15 minutes and finalizes once.
Staged errors use the same {code, message} shape at a mapped status: size caps
413; an unknown or foreign uploadId 404 UPLOAD_HANDLE_INVALID;
UPLOAD_ALREADY_FINALIZED and UPLOAD_IN_PROGRESS 409; UPLOAD_EXPIRED,
UPLOAD_MISSING_BLOB, BLOB_HASH_MISMATCH, INVALID_MANIFEST 400.
Limits: 100 MB per canvas, 25 MB per file, 2 000 files. Full contract: Deploy API.
Two publishers, one canvas. When a local tool and a CI job can both ship the same
build, add ?releaseId=<opaque build identity>&expectedPublicationToken=<the publicationToken you read back> to PUT .../deploy (or the same two fields in the
staged begin/finalize bodies). Outcomes: 200 outcome:"already_current" (your release
is live already; nothing created), 409 PUBLICATION_CHANGED (the publication changed
since you read the token; body carries current), 409 RELEASE_NOT_CURRENT (your
release exists only in history; roll back to it or ship a new release), else a normal
published result with the new token. Every publication change — any deploy, an editor
publish, a rollback, an unpublish — rotates the token and never reuses a value. Read
back before deploying and treat each conflict as a reassess signal, not a retry; Canvas
Drop does not know which commit is newest. Recipe and examples:
Coordinate two publishers.
Connect over MCP
Add {base}/mcp to an MCP-capable host. First use runs OAuth 2.1 against
canvas-drop itself (RFC 8414/9728 discovery, Dynamic Client Registration, PKCE
S256), sends you through the instance's normal org sign-in, and returns a 1 h
access token plus a rotating refresh token. Every call re-checks that the account
is still active. Transport is Streamable HTTP, stateless; calls are limited to 120
per minute per account and request bodies to 110 MiB. An instance with
CANVAS_DROP_MCP=off has no /mcp endpoint at all.
A first session, as tool calls:
whoami {} -> { id, email, name, orgs, teams, isGuest }
create_canvas { "title": "Retro board" } -> { id, slug, url, apiKey, deploy, ... } apiKey is returned once
deploy_canvas { "id": "<id>", "files": [{ "path": "index.html", "content": "<h1>Hi</h1>" }] }
-> { outcome: "published", url, version: 1, versionId, releaseId: null, publicationToken, fileCount: 1, totalBytes, warnings: [] }
get_canvas_file { "id": "<id>", "path": "index.html" }
-> { version, path, size, mime, hash, encoding: "utf8", content }
Scope and roles. Tools act on the canvases the account owns or edits. A canvas
you hold no role on reads as canvas not found, for admins too. Each tool has a
minimum role: any (identity, lists, create, teams), editor (everything on a
canvas), or owner (delete_canvas, transfer_canvas). An editor calling an
owner-only tool, or setting guestAiEnabled / guestAiCap through update_canvas,
gets OWNER_ONLY: ...; get_canvas echoes the list as ownerOnlyActs: ["delete", "transfer", "guest_ai"]. id parameters are canvas ids (team tools take team
ids), never slugs. Results are JSON in a text content block; failures are
isError: true with the text CODE: message. An admin-disabled canvas stays
readable but every mutation fails DISABLED: ...; an archived canvas refuses
deploy and publish with NOT_ACTIVE.
| Group | Tools |
|---|---|
Identity, lists, create (any) |
whoami, list_canvases, list_shared_canvases, create_canvas, clone_canvas |
Read (editor) |
get_canvas, list_versions, get_canvas_file, get_canvas_usage, list_access, search_people |
Deploy (editor; publishes live immediately) |
deploy_canvas, begin_deploy, add_files, finalize_deploy — optional releaseId / expectedPublicationToken on deploy_canvas, begin_deploy, finalize_deploy coordinate two publishers (see Deploy with a key) |
Lifecycle (editor unless marked) |
rollback_canvas, unpublish_canvas, delete_version, preview_version_prune, prune_versions, archive_canvas, unarchive_canvas, delete_canvas (owner), transfer_canvas (owner) |
Settings (editor) |
update_canvas, set_capabilities, set_canvas_slug, set_canvas_preview, regenerate_deploy_key; list_canvas_connections reads the admin-granted profiles |
Sharing (editor) |
grant_access, invite_to_canvas, revoke_access, set_access_role |
Draft loop (editor) |
get_draft, read_draft_file, write_draft_file, delete_draft_file, rename_draft_file, publish_draft, restore_draft |
Teams (any) |
list_teams, create_team, rename_team, delete_team, add_team_member, remove_team_member, cancel_team_invite, list_team_members |
Working notes:
- Typical flow:
create_canvas, deploy, verify.create_canvasreturns the canvas view, the one-timeapiKey, and adeployblock (apiBase,zipUpload,staged.begin/stageBlob/finalize,readback, and a ready-to-runcurlwith the key filled in). A new canvas is empty and Restricted (privatein the API); its URL serves content only after a deploy. - Prefer
curlfor bytes.deploy_canvastakes exactly one ofzipBase64orfiles: [{path, content, encoding?}](utf8default, orbase64) and, likeadd_files, inlines file content into the model context. When you can run shell commands, use thedeployblock's curl, whole ZIP or staged; keepdeploy_canvasfor a small first publish without a shell. - Verify through the server, not the URL. The live URL is behind org sign-in: a
signed-out GET is redirected to login (
oidc) or answered401(proxy,dev) unless the canvas is on thepublic_linkrung. Check the returned{version, fileCount},list_versions, thereadbackURL, orget_canvas_file(nopathlists the manifest; apathreturns the content asutf8orbase64; over 256 KiB it returnstruncated: trueinstead). list_canvasesreturns owned and edited canvases, each withrole("owner"or"editor") andowner; theroleparameter (ownedoredited) narrows.queryis a forgiving filter over title, description, tags, and slug (case, accent, and whitespace insensitive; multiple words AND).tagsis any-match.sortisupdated(default),created,title, orpopular(views over the last 30 days).limitdefaults to 50, max 100.list_shared_canvaseslists canvases you can open but do not manage: a direct grant, a listed team share, or a listed whole-org share.- Draft loop:
write_draft_file,delete_draft_file, andrename_draft_filetakeexpectedHash(the file's currenthashfromget_draft/read_draft_file, or"none"for a path you expect absent); a mismatch failsDRAFT_CONFLICTwith the current hash and last writer. WithoutexpectedHashthe write still failsDRAFT_CONFLICTwhen a different user wrote the file last, so two editors never overwrite each other silently.write_draft_filewithcreate: truerefuses an existing path (PATH_EXISTS).publish_draftsnapshots the draft into a live version and returns{version, versionId, fileCount, totalBytes}. update_canvasfields:title(max 200),description(max 2000, ornull),tags(max 20, each max 50 chars; one set serves list filtering and the gallery),access,discoverability(link_onlyby default;listedshows a Team or Whole-org canvas in Shared and makes a Whole-org canvas gallery-eligible),teamIds,password(ornullto clear),sharedExpiresAt(unix ms, ornull),spaFallback,previewMode(autooroff;set_canvas_previewwith an image setscustom),galleryListed,galleryTemplatable, and the owner-onlyguestAiEnabled/guestAiCap. Refusals you will meet:SHARE_REQUIRES_PUBLISH(sharing needs a published canvas),ORG_REQUIRED,PUBLIC_LINKS_DISABLED(instance switch off),PUBLIC_NOT_ALLOWED(the owner may not publish publicly),PUBLIC_LINK_OWNER_GATED(an editor asked forpublic_linkon such a canvas),TEAM_REQUIRED/TEAM_FORBIDDEN.set_capabilitiestakesbackendEnabled,kv,files,ai,realtime,authoring.- People:
list_accessentries arevieweroreditor; the owner also getstransferCandidates.grant_accesstakes exactly one ofemailorteamIdplusrole; a new email ispendinguntil its first verified sign-in through the instance's identity provider; legacy guests are viewers only (GUEST_VIEWER_ONLY).set_access_rolechanges an entry's role.transfer_canvastakes a user id (never an email) of an existing editor (NOT_ELIGIBLEotherwise) and returns{ok, canvas, previousOwnerEditor, publicLinkReverted}: the previous owner keeps editor access while their account is active, and apublic_linkrung reverts when the new owner lacks the entitlement. - Tenancy:
whoamireturnsorgs,teams, andisGuest(true only when an org boundary is configured and you belong to none).create_canvas.orgId: omit to default to your only org, passnullfor a personal canvas; an org you do not belong to failsORG_FORBIDDEN. Under an active org boundary thewhole_orgrung needs an org-homed canvas (ORG_REQUIRED). - Versions:
list_versionscarries adownloadUrlper version ({base}/mcp/canvases/{id}/versions/{n}/download, a ZIP fetched with the same MCP access token as a Bearer).delete_versionremoves a non-current version only (CURRENT_VERSIONotherwise).
Parameters and return shapes for every tool: MCP server.
Browser SDK inside a canvas
<script src="/sdk/v1.js"></script>
<script type="module">
const me = await canvasdrop.me(); // { id, email, name, avatarUrl, kind, canvasRole, permissions }
await canvasdrop.kv.user.set("last-visit", Date.now());
const views = await canvasdrop.kv.user.increment("visits"); // 1 on the first call, then 2, 3, ...
</script>
One global, window.canvasdrop; there is no cd alias and no version property.
Zero config: the slug and API base are read from the page URL (/c/{slug}/ in
path mode, {slug}.{host} in subdomain mode), every call goes to
{apiBase}/v1/c/{slug}/... with the session cookie, and no key ever reaches the
page. The root-relative src resolves on the canvas's own origin in both modes.
The canvas must have Backend switched on (see Capabilities below); /sdk/v1.js
sits behind the same sign-in as the canvas.
me()returns{ id, email, name, avatarUrl, kind, canvasRole, permissions }.kindis"member";"guest"appears only for retained legacy guest sessions, since new Add person grants materialize as signed-in users.kv(shared) andkv.user(per viewer, keyed server-side) have the same five methods:get(key)returns the value ornull;set(key, value)stores any JSON exceptnull;delete(key)is idempotent;list({ prefix?, cursor?, limit? })returns{ entries: [{ key, value }], nextCursor }(limit1 to 1000, default 100);increment(key, by = 1)returns the new number and failsNOT_NUMERICon a non-numeric value. Key max 512 bytes, value max 64 KiB, 10 000 shared and 1 000 per-user keys per canvas.files:upload(file)returns{ id, name, size, url }with an absoluteurl;list()returns[{ id, name, size, mime, createdAt }];delete(id);url(id)is synchronous. 25 MiB per file, 1 GiB per canvas.ai:chat(messages, { model, system?, maxTokens? })returns{ text, usage: { inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens }, cost };stream(messages, options)returns anAsyncIterable<string>of text deltas with no usage.modelis required and must be on the instance allowlist (MODEL_NOT_ALLOWED). Messages are{ role: "user" | "assistant", content }; the system prompt goes inoptions.system;maxTokensdefaults to 1024, cap 8192. The provider key stays server-side.realtime.channel(name)returns a handle withpublish(event, data)(fire and forget, buffered while reconnecting),subscribe(handler)wherehandlerreceives{ event, data, from: { id, name, canvasRole } }and the call returnsvoid,unsubscribe()(clears every handler on the channel),presence()resolving to[{ id, name }],onPresence,onJoin,onLeave, andclose(). One shared socket per page with automatic reconnect; 30 connections per canvas, 16 KiB per message. There is no generic.on(...).connections.fetch(profile, path, init?)makes a bounded server-side request through an admin-granted profile.pathmust be root-relative;init.methodmust be one the admin selected. The profile fixes one exact HTTPS DNS origin and adds write-only protected headers last. Upstream 4xx/5xx remain normalResponseobjects; platform policy failures throw. Public-link viewers remain static-only.canvases(theauthoringcapability, off by default per canvas and per instance):publish,update,list,revokelet a signed-in viewer create and manage a share canvas from a page.
Full signatures and types: SDK overview. Raw routes: Runtime API.
Sharing and access
One access rung per canvas, set on the Share tab or with update_canvas.access.
Access is evaluated on every request, so a revoke, an expiry, or a role change
takes effect on the next request; a canvas you may not open reads as 404.
The people-and-teams list (grant_access / revoke_access) always applies: a viewer or
editor row, or membership of a granted team, opens the canvas at every access value.
access (General access) says who else can:
access |
Who else can open it |
|---|---|
private |
nobody beyond the owner, the editors, and the list (Restricted). specific_people and team are legacy aliases of this value — accepted, stored, treated identically |
whole_org |
any signed-in org member; discoverability: "listed" shows it in Shared for them and makes it gallery-eligible |
public_link |
anyone with the link, while the instance switch is on and the owner may publish publicly (canPublishPublic); static only for everyone except the owner and editors: every primitive answers 403 STATIC_ONLY |
An editor grant, direct or through an editor-role team, also manages the canvas. Editors
skip the password gate and the share expiry. A password lock answers
403 PASSWORD_REQUIRED on the runtime API until the viewer passes the gate; a
share expiry (sharedExpiresAt) turns other viewers away with
404 SHARE_EXPIRED once it passes. Details: Sharing & access.
Capabilities
backendEnabled is off by default. With it on, kv, files, ai, and
realtime default on and authoring defaults off; each toggles independently.
Effective rule: identity = backend; kv = backend && capKv; files = backend && capFiles; ai = backend && capAi && provider key configured; realtime = backend && capRealtime && CANVAS_DROP_REALTIME=on (the default); authoring = backend && capAuthoring && CANVAS_DROP_AUTHORING=on (default off).
An off feature answers 403 {"code":"CAPABILITY_DISABLED","capability":"kv", "backendEnabled":false,"reason":"backend_off"|"feature_off"|"operator_disabled", "hint":"..."}; the SDK throws CapabilityDisabledError. Toggle from the canvas's
Backend tab, PATCH /api/canvases/{id}/capabilities, or set_capabilities.
Details: Capabilities.
Errors
Every failure carries a stable string code; branch on it, not on message text.
The SDK throws CanvasdropError with .code and .status, plus six subclasses:
NotAuthenticatedError (any 401), NotFoundError (any 404),
CapabilityDisabledError (403 CAPABILITY_DISABLED, with .hint),
QuotaExceededError (QUOTA_EXCEEDED, GUEST_AI_CAP, KEY_LIMIT, and every 413
size code), PublishFailedError (502, from canvasdrop.canvases.publish, with
the new canvas .id), and UpdatePartialError (502, from canvasdrop.canvases.update
when the settings saved but the bundle deploy failed, with .stage and .current).
Every other code arrives as a plain CanvasdropError.
kv.get returns null for a missing key instead of throwing.
Codes you will meet most on the runtime API: 401 {"error":"unauthorized"} (no
session; oidc redirects to login instead), NOT_FOUND / ARCHIVED /
OWNER_ONLY / SHARE_EXPIRED 404, DISABLED 403, PASSWORD_REQUIRED 403,
STATIC_ONLY 403, CAPABILITY_DISABLED 403, CROSS_CANVAS_FORBIDDEN /
CROSS_SITE_FORBIDDEN 403, MODEL_NOT_ALLOWED 403, INVALID_BODY 400,
KEY_TOO_LARGE / VALUE_TOO_LARGE / FILE_TOO_LARGE 413, KEY_LIMIT 409,
NOT_NUMERIC 409, QUOTA_EXCEEDED 429 (409 on files), CONNECTION_LIMIT 429,
RATE_LIMITED 429, AI_STREAM_TRUNCATED / AI_UPSTREAM_ERROR 502. Full table:
Error codes.
Rate limits
Defaults; each is an env var the operator can change.
| Surface | Default | Keyed by | Env var |
|---|---|---|---|
Deploy API (deploy, uploads begin, finalize, rollback) |
10/min | canvas | CANVAS_DROP_RATELIMIT_DEPLOY_PER_MIN |
Runtime API /v1/c/{slug}/... |
120/min | user + canvas | CANVAS_DROP_RATELIMIT_CANVAS_API_PER_MIN |
Runtime AI /v1/c/{slug}/ai/... |
10/min | user | CANVAS_DROP_RATELIMIT_AI_PER_MIN |
| Runtime Connections | 60/min per actor/profile; 600/min per profile | actor + canvas + profile; profile | CANVAS_DROP_CONNECTIONS_ACTOR_PER_MIN, CANVAS_DROP_CONNECTIONS_PROFILE_PER_MIN |
MCP /mcp |
120/min | account | CANVAS_DROP_RATELIMIT_CANVAS_API_PER_MIN |
For a packaged, installable version of this guidance, see the Agent skill.
Runtime permission contract
canvasdrop.me() exposes server-derived canvasRole and permissions.
Shared kv.set/delete/increment and shared file mutations require owner/editor.
Use kv.user for private preferences and submissions.get/set/delete(collection)
for the caller's own vote/form; submissions.list/remove/clear require owner/editor.
Do not use client-supplied author IDs or shared counters for viewer votes.
Private attachments use files.upload(file, { scope: "submission" }).
AI and Connections default to owner/editor audiences; opt viewers in using
set_capabilities fields aiAudience / connectionsAudience: "viewers".
Unconfigured realtime shared publishing requires owner/editor; participants: channels allow
attributed viewer messages, visible to all subscribers. Role denials are
PERMISSION_DENIED and never disappear by hiding UI controls.
For cleanup use preview_version_prune then prune_versions with the exact
returned numbers and expectedVersionIds; never infer bytes recovered from an estimate.
For multiple authored items use kv.collection(name) after configuring the resource
through set_capabilities.runtimePolicy. Five data/file presets: personal,
submissions, contributions, managed, collaborative. Default modes initialize newly
added resources; existing policies remain explicit. expectedRuntimePolicy must
match get_canvas.runtimePolicyRevision (initially null), or saving fails with
POLICY_CONFLICT. Author identity is immutable and server-derived. Read/create/update/
delete/increment can be customized; mutations also require read. File attachments
use {collection, recordId} and inherit the record's rights. Named channels configure
subscribe/publish/seePresence/participatePresence, and Connections configure audience
and methods. me().resources exposes effective rights. Full schema and examples:
/docs/sdk/permissions.