# canvas-drop — llms.txt Agent-readable reference for deploying and extending canvases. See {base}/docs for the full docs. ## Overview canvas-drop canvas-drop is an open-source, self-hostable platform where members of an organization deploy and share small web artifacts — canvases . A canvas is just static files (HTML, CSS, JS, images). Drop them in and they're live at a URL your colleagues can open. The constraint is the product: a small set of primitives, done well, instead of a general-purpose hosting platform. Canvases run with no build step and no secrets in the page, and they gain backend capability only through five primitives exposed by a zero-config browser SDK. Status: v1 is feature-complete and hardening toward a public release. The Docker image, one-command compose, MCP server, and examples have all shipped. The remaining ops/packaging work (M10) is proving the backup/restore round-trip, a single-VPS load test, and a colleague IAP pilot. See Self-hosting → Install . What you can build Prototypes, dashboards, demos, microsites, small games, internal tools — anything that communicates better as a working artifact than as a screenshot or a slide. How it fits together Publish a canvas four ways: paste a single index.html , drag a folder of files, upload a .zip , or call the deploy API with a per-canvas key. Agents can ship without a human in the loop, either over that HTTP API or through the built-in MCP server at {base}/mcp . You can also edit in the browser and Publish from the draft. Add backend capability with the browser SDK at {base}/sdk/v1.js : key–value storage, file storage, the signed-in viewer's identity ( me() ), AI, and realtime. The owner opts a canvas into backend (off by default), then toggles kv , files , ai , and realtime independently; me() is on whenever backend is on. Version & roll back. Every publish is an immutable version (last 10 kept); one-click Make current switches the served version. Share the URL on a per-canvas access rung: private (owner only), specific_people (named org members and email-invited guests), whole_org (any signed-in member with the link), or public_link (anyone with the link — admin-gated per owner, and static-only). Layer on a per-canvas password or a share expiry, and opt into the gallery to let colleagues browse it. Where to go next New here? Start with the Quickstart . Building a canvas with a backend? Read the SDK overview . Running your own instance? See Self-hosting → Install . An AI agent? Read /llms.txt and the agent skill . Examples and URLs in these docs use {base} (your instance's base URL) and localhost placeholders. Substitute your own instance's address. ## Quickstart Quickstart Stand up a local instance and publish your first canvas. The commands below get you a logged-in instance on localhost with no external services. Run an instance locally The zero-config default is path + sqlite + local + dev auth — no external services, no proxy, signed in as a dev user. git clone https://github.com//canvas-drop.git cd canvas-drop pnpm install cp .env.example . env pnpm dev pnpm dev loads .env once and runs the server ( tsx watch ) and the dashboard ( vite ) together. In dev the dashboard runs at http://localhost:5173 (the Vite dev server, with HMR), which proxies API/auth/v1 requests to the Hono server on :3000 . (In production the Hono server serves the built SPA on :3000 .) SQLite lives at ./data/canvasdrop.db and uploaded files at ./data/storage . To stop: pnpm dev:stop . To restart: pnpm dev:restart . In dev auth mode you're signed in as dev@example.com (override with CANVAS_DROP_DEV_USER_EMAIL / CANVAS_DROP_DEV_USER_NAME ). dev mode is rejected when NODE_ENV=production — see Configuration and Self-hosting → Install for proxy / oidc auth, Postgres, and S3, all of which are config swaps, not code changes. Create a canvas From the dashboard, click Create canvas . Four ways to publish: Paste HTML — a single index.html , created and published in one step. Files or folder — drag files or a folder; relative paths are kept at the canvas root. Upload ZIP — upload an archive; it's extracted server-side. Use the API — get a slug and a per-canvas key for programmatic deploys. Each canvas gets a slug and a URL. The slug is a random, unguessable name by default ( quiet-otter-x7k2… ); you can type your own in the Slug field when creating a canvas, or change it later under Settings → Change slug (leave it empty for a fresh random one). In path mode the URL is {base}/c/{slug}/ (e.g. http://localhost:3000/c/{slug}/ ); in subdomain mode it's {slug}.{base} (e.g. {slug}.canvases.example.com ). Add some content A canvas is static files. The simplest canvas is one index.html : < html > < body > < h1 > Hello from my canvas No build step runs on the server — what you deploy is what's served. Give it a backend (optional) Add the SDK for storage, identity, and more, with no keys in the page: < script src = "/sdk/v1.js" > < script > ( async () => { const me = await canvasdrop. me (); await canvasdrop. kv . increment ( "views" ); })(); The owner enables Backend (and the specific primitive — kv, files, ai, realtime) in the canvas's Backend tab first. Identity ( me() ) is on whenever Backend is on. See the SDK overview . Publish & share Open the canvas, edit in the Editor tab, then Publish to snapshot an immutable version and point the canvas URL at it. Roll back or switch the current version from the Versions tab. Set who can open it in the Share tab: the access ladder runs Private (owner + admins only, the default) → Specific people (an email allowlist, including email-invited guests) → Whole org (any signed-in member with the link) → Public link (anyone with the link; admin-gated, static files only — primitives are refused for public visitors). On top of any rung you can add a password gate or a share expiry. Once a version is published you can also list the canvas in the gallery. Deploying as an agent Agents deploy over HTTP with a per-canvas API key — no dashboard session needed. A deploy is live: it publishes a version directly with no draft loop. See the Deploy API and /llms.txt . ## Capabilities Capabilities A canvas is static by default. To give it backend behaviour, the owner turns on capabilities on the canvas's Backend tab. They are off until you enable them, and any SDK call to a feature that is off throws a CapabilityDisabledError (code CAPABILITY_DISABLED ). Turn on the backend Open the canvas, go to the Backend tab. Under Backend , switch Enable backend on. This is the master switch, off by default. With the backend on, toggle the features you need: KV , Files , AI , Realtime . Each is independent. Identity has no toggle. me() is available exactly when the backend is on; the tab shows it as "Always on" once you enable the backend. The five primitives Capability What it gives the canvas SDK Key–value Shared and per-viewer JSON storage, atomic increment kv Files Upload, list, serve files files Identity The signed-in viewer's id, email, name, avatar identity AI Server-side model calls, no provider key in the page ai Realtime Ephemeral pub/sub + presence realtime When a feature is effective A feature is effective — usable from the SDK — only when every applicable condition is true: Feature Effective when Identity ( me() ) Backend is on KV Backend on and KV toggle on Files Backend on and Files toggle on AI Backend on and AI toggle on and the operator has configured an AI provider key Realtime Backend on and Realtime toggle on and the operator has enabled realtime for the instance KV and Files have no operator-level switch: your two toggles are the whole story. AI and Realtime each carry an extra operator gate, so a feature you've turned on can still report as off if the instance isn't set up for it. When that happens, the toggle stays on but the tab labels it "Disabled by your administrator for this instance" — so you can see why a feature you've enabled is still off. Public links are static-only If a canvas is shared as a public link (the public_link access rung, anyone with the link), every primitive is inert for public visitors. The Backend tab shows a warning that the canvas serves static files only, and the server refuses backend calls from those visitors with STATIC_ONLY (status 403). The backend still works for you and for signed-in org members; use a more restricted access rung if the canvas needs a backend for everyone. What happens when a feature is off The SDK throws at call time: try { await canvasdrop. kv . set ( "count" , 1 ); } catch (err) { // err is a CapabilityDisabledError, err.code === "CAPABILITY_DISABLED" } See error codes for the full list. Why off by default No secrets ever live in canvas files. Capabilities are server-enforced per request from the signed-in session — the canvas can ask, but the server decides. Turning a capability on is a deliberate choice the owner makes per canvas. Cloning a canvas as a template starts with the backend off : clones are static-first, and the new owner opts back in to whatever they need. ## Overview Browser SDK You wrote a static canvas and now you want it to remember things, store files, greet the signed-in viewer, call a model, or sync between tabs. The browser SDK gives your canvas those five backend capabilities — KV (key-value storage), files, AI, identity ( me() ), and realtime — with no build step and no secrets in the canvas . Identity comes from the signed-in session; the canvas is identified by its own URL. Add it to a canvas Drop in one script tag, then call the global: < script src = "/sdk/v1.js" > < script > const me = await canvasdrop. me (); await canvasdrop. kv . set ( "last-viewer" , me. name ); const total = await canvasdrop. kv . increment ( "views" ); The script tag defines the single global window.canvasdrop (that is the only global name — there is no cd alias). The SDK auto-detects the canvas slug and the API base from the page's location: Path mode — a path like /c/{slug}/… is matched; the slug is the segment after /c/ , and the API base is the page's own origin. Subdomain mode — otherwise, the slug is the first label of the hostname ( {slug}.{base} ), and the API base is the rest of the host. Every request goes to {apiBase}/v1/c/{slug}/… with credentials: "include" , so identity rides the existing session cookie. You never pass the slug or any key yourself. Enable the capability first The canvas owner must turn on Backend (and the specific feature) in the canvas's Backend tab. A method whose capability is off throws a CapabilityDisabledError . See Capabilities . The surface canvasdrop.me() — the signed-in viewer ( { id, email, name, avatarUrl, kind } , where kind is "member" or "guest" ). canvasdrop.kv — get / set / delete / list / increment , shared plus per-viewer ( canvasdrop.kv.user ). canvasdrop.files — upload / list / delete / url . canvasdrop.ai — server-side model calls: chat and streaming stream . canvasdrop.realtime — channel(name) for pub/sub + presence. Errors Every failure throws an error extending CanvasdropError , which carries a string .code and a numeric .status . Four typed subclasses are thrown directly — NotAuthenticatedError (401), NotFoundError (404), CapabilityDisabledError (403), and QuotaExceededError (429/409/413, the spend/rate and size-limit failures — the *_TOO_LARGE codes surface here too) — and everything else surfaces as the base CanvasdropError . Switch on .code to handle the rest. See the error code reference . try { await canvasdrop. kv . increment ( "votes" ); } catch (err) { if (err. code === "CAPABILITY_DISABLED" ) { // ask the owner to enable KV in the Backend tab } else { throw err; } } If you import the SDK package as a module instead of using the global script, the error classes (and createClient ) are exported for instanceof checks. ## Deploy API Deploy API Ship a canvas over HTTP — from CI, a script, or an AI agent — with no dashboard session . Authenticate with the canvas's secret key as a Bearer token. A key operates only on its own canvas, and every response is machine-readable so an agent can repair and retry without a human. # Deploy a ZIP and confirm it's live, end to end. curl -fsS -X PUT "{base}/v1/canvases/{id}/deploy" \ -H "Authorization: Bearer $CANVAS_KEY " \ --data-binary @site.zip Auth: Authorization: Bearer cd_... — the canvas secret key, not a session cookie. It is shown once at creation and stored hashed (SHA-256). This path takes no cookies and no CORS, and is distinct from the session-cookie auth the Runtime API and browser SDK use. Base path: {base}/v1/canvases/{id} . {id} is the canvas id (not the slug). What is {base} ? The host serving this API — CANVAS_DROP_API_BASE_URL , which defaults to the instance base URL. In subdomain mode the API is usually fronted on its own host (e.g. https://api.example.com ), separate from the canvas hosts ( {slug}.example.com ) — so don't assume it equals the dashboard host. You don't have to guess it: create_canvas (over MCP ) returns the exact, ready-to-run curl endpoints for the canvas, and the create flow shows them too. Method Path Purpose PUT /v1/canvases/{id}/deploy Publish a live version from an archive body POST /v1/canvases/{id}/uploads Open a staged upload from a manifest PUT /v1/canvases/{id}/uploads/{uploadId}/blobs/{hash} Stage one file's bytes POST /v1/canvases/{id}/uploads/{uploadId}/finalize Publish from the staged upload GET /v1/canvases/{id} Canvas metadata GET /v1/canvases/{id}/versions List versions GET /v1/canvases/{id}/files Read back the live version (verify a deploy) POST /v1/canvases/{id}/rollback Restore a prior ready version POST /v1/canvases/{id}/unpublish Take the canvas back to Draft Deploy a version PUT {base}/v1/canvases/{ id }/deploy Authorization: Bearer cd_... Content-Type: application/zip The raw request body is ingested as a ZIP archive (put index.html at its root); the body is always read with the ZIP reader regardless of Content-Type . A new version is created and the canvas points at it. Deploys via this key are attributed to the canvas owner and audited as source: "api" . # Ships static files only. To use the browser SDK, first enable Backend + # the capabilities you need (kv, files, ai, realtime) in the canvas's Backend tab — # the deploy key can't toggle them. curl -X PUT "{base}/v1/canvases/{id}/deploy" \ -H "Authorization: Bearer $CANVAS_KEY " \ --data-binary @site.zip Success — 200 ( DeployResult , stable shape): { "url" : "" , "version" : 7 , "fileCount" : 12 , "totalBytes" : 348201 , "warnings" : [ "index.html may contain a canvas API key — remove it before deploying" ] } url — the canvas's public URL. version — the new version number. fileCount / totalBytes — files and bytes written. warnings[] — non-fatal notices (e.g. a file MIME-downgraded to text/plain , or the deploy-time key-lint flagging a file that may embed a canvas API key). Warnings do not fail the deploy. Limits: 100 MB/canvas, 25 MB/file, 2000 files. The body is also capped before buffering at 110 MB (canvas cap + 10 MB); an over-limit body returns 413 { "code": "CANVAS_TOO_LARGE" } . An empty body returns 400 { "code": "EMPTY_DEPLOY" } . Rate limit: deploy and rollback are throttled at 10/min per canvas (keyed after the key is verified). Over-limit returns 429 { "error": "rate_limited" } with a Retry-After header. Staged upload (large or incremental) PUT .../deploy sends the whole archive in one request. For large canvases — or when you re-deploy often and want to send only what changed — use the three-step staged flow. Bytes go straight to the server (never base64'd through an agent's context), and content-addressing means an unchanged file is never re-uploaded. 1 — begin. Send the full manifest: each file's path , hash (sha256 hex of its bytes), and size . POST {base}/v1/canvases/{ id }/uploads Authorization: Bearer cd_... Content-Type: application/json { "manifest" : [ { "path" : "index.html" , "hash" : "" , "size" : 1234 } ] } Returns the handle and the subset of hashes the server doesn't already have: { "uploadId" : "up_..." , "missingHashes" : [ "" , "..." ] } 2 — stage each missing blob (raw bytes; the path is irrelevant here — blobs are content-addressed by {hash} ): PUT {base}/v1/canvases/{ id }/uploads/{uploadId}/blobs/{ hash } Authorization: Bearer cd_... 204 on success. The bytes must hash to {hash} (else 400 BLOB_HASH_MISMATCH ), the blob is capped at 25 MB ( 413 FILE_TOO_LARGE ), and a handle minted for a different canvas is rejected 404 UPLOAD_HANDLE_INVALID (no existence leak). 3 — finalize to publish a version from the staged manifest: POST {base}/v1/canvases/{ id }/uploads/{uploadId}/finalize Authorization: Bearer cd_... Returns the same DeployResult shape as PUT .../deploy . The handle is single-use and short-lived (15 min). A finalize before every blob is staged returns 400 UPLOAD_MISSING_BLOB and can be retried after staging the rest — the handle is consumed only on a successful publish. Attributed to the owner, audited as source: "upload" . Get a canvas GET {base}/v1/canvases/{ id } Authorization: Bearer cd_... Returns { id, slug, url, title, status, publicationState, currentVersionId } , so an agent can confirm a canvas is live without interpreting status + currentVersionId itself. List versions GET {base}/v1/canvases/{ id }/versions Authorization: Bearer cd_... Returns the canvas's versions, newest first. Verify a deploy (read back the live version) The canvas's public URL is access-controlled — a keyed/curl agent can't fetch it to check what shipped (an unauthenticated request gets a login page). Use this instead. The key only works on its own canvas, so it's an owner-scoped read. GET {base}/v1/canvases/{ id }/files Authorization: Bearer cd_... With no query, returns the live version's manifest: { "version" : 7 , "fileCount" : 3 , "files" : [ { "path" : "index.html" , "size" : 1280 , "mime" : "text/html; charset=utf-8" , "hash" : "9f86d0…" } ] } Add ?path= to get one file's raw bytes (the body is the file itself, with its Content-Type and an ETag of the content hash) — pipe it straight to a checksum to confirm the bytes match what you deployed: curl -fsS "{base}/v1/canvases/{id}/files?path=index.html" \ -H "Authorization: Bearer $CANVAS_KEY " | sha256sum 404 NOT_PUBLISHED if the canvas has no live version; 404 NOT_FOUND if the path isn't in the live manifest. (The MCP get_canvas_file tool is the identity-scoped equivalent; it inlines content up to 256 KiB and returns hash-only metadata above that — this HTTP read-back has no size cap since you stream the body.) Roll back POST {base}/v1/canvases/{ id }/rollback Authorization: Bearer cd_... Content-Type: application/json { "version" : 5 } Restores a prior ready version and points the canvas back at it. Rollback shares the 10/min-per-canvas rate limit with deploy. A target version that doesn't exist or isn't ready returns 404 { "code": "INVALID_PATH" } ; a missing or non-numeric version field returns 400 { "code": "INVALID_PATH" } ; and if the target was pruned between selection and the swap you get 409 { "code": "VERSION_UNAVAILABLE" } (refresh and retry). See Errors . Unpublish POST {base}/v1/canvases/{ id }/unpublish Authorization: Bearer cd_... Takes the canvas back to Draft : the public URL goes offline and any live realtime sockets are dropped, while the draft and version history are kept. Re-publish later with PUT .../deploy or by rolling back to a kept version. Unpublishing a canvas that isn't currently published returns 409 { "code": "CANNOT_UNPUBLISH" } . Errors Validation failures return a stable code so agents can repair from the body. DeployError validation failures on deploy are 400 : { "code" : "" , "message" : "..." , "path" : "" } Codes: EMPTY_DEPLOY , TOO_MANY_FILES , FILE_TOO_LARGE , CANVAS_TOO_LARGE , ZIP_SLIP_REJECTED , ZIP_BOMB_REJECTED , INVALID_ZIP , INVALID_PATH , PATH_EXISTS , VERSION_UNAVAILABLE , CANNOT_UNPUBLISH . See Error codes for the full table. Staged-upload codes: INVALID_MANIFEST ( 400 ), UPLOAD_HANDLE_INVALID ( 404 — unknown / wrong-owner / wrong-canvas handle, no existence leak), UPLOAD_EXPIRED ( 400 ), UPLOAD_ALREADY_FINALIZED / UPLOAD_IN_PROGRESS ( 409 ), UPLOAD_MISSING_BLOB ( 400 ), BLOB_HASH_MISMATCH ( 400 ), INVALID_ENCODING ( 400 ). Rollback reuses some of these codes at non- 400 statuses: INVALID_PATH at 404 when there's no ready version of that number, and VERSION_UNAVAILABLE at 409 when the target was pruned during the swap. Unpublish returns CANNOT_UNPUBLISH at 409 when the canvas isn't currently published. The read-back ( GET …/files ) returns NOT_PUBLISHED at 404 when the canvas has no live version, and NOT_FOUND at 404 when ?path= names a file that isn't in the live manifest. Auth, size, and rate-limit failures use their own shapes: Status Body Cause 400 { "code": "EMPTY_DEPLOY", ... } empty request body on deploy 401 { "error": "unauthorized" } missing or unknown Bearer key 403 { "error": "unauthorized" } key resolves to a different canvas than {id} 413 { "code": "CANVAS_TOO_LARGE", ... } body over the size cap 429 { "error": "rate_limited" } over 10/min for this canvas ( Retry-After header) A key used against a canvas it does not own returns 403 , not 404 . ## Runtime API Runtime API The runtime API is what the browser SDK calls from inside a canvas. Reach for the SDK first — it builds these requests, handles the SSE/WebSocket wire formats, and maps errors to typed exceptions. Use this reference when you need the raw routes: debugging, a non-JS client, or to know exactly what a primitive returns. All routes live under {base}/v1/c/{slug} . The path is identical in both URL modes ( path and subdomain ) — only the host the SDK targets changes. The SDK derives {base} from the canvas location, so you never hard-code it. In a canvas, the SDK is on the page as the global canvasdrop : // served at {base}/sdk/v1.js, exposed as window.canvasdrop const me = await canvasdrop. me (); // → { id, email, name, avatarUrl, kind } await canvasdrop. kv . set ( "greeting" , "hi" ); // shared KV There is no cd alias — the single global is canvasdrop . Auth: every request is credentialed with the session cookie , sent automatically by the browser. Identity is resolved server-side from that session — the canvas never asserts who the viewer is. Unauthenticated requests are stopped by the auth gateway before any route runs — a 401 in proxy / dev mode, or a 302 redirect to /auth/login in oidc mode. (This differs from the Bearer-key Deploy API .) Capabilities: each primitive is gated by its capability ( identity , kv , files , ai , realtime ). When a capability is off for the canvas or the instance, the route returns 403 CAPABILITY_DISABLED . Pipeline errors Before any handler runs, every /v1/c/{slug}/* request passes through resolve + authorize + isolation + capability checks. These can return before your handler: Code HTTP When NOT_FOUND 404 Missing slug param, or the resolver denies (canvas not found / not authorized). PASSWORD_REQUIRED 403 Password-gated shared canvas, gate cookie not satisfied. STATIC_ONLY 403 A public_link canvas accessed by a non-owner or anonymous viewer — the runtime API is fully closed. CAPABILITY_DISABLED 403 The route's capability is off. Body: { code, capability } . Preflight OPTIONS /v1/c/{slug}/* is answered before the auth gateway. In subdomain mode the runtime API emits credentialed CORS for the canvas's exact subdomain origin; in path mode the canvas is same-origin and no cross-origin CORS header is sent. The path itself is identical in both modes. A request that reaches a handler unauthenticated is stopped by the auth gateway before the pipeline runs — a 401 in proxy / dev mode, or a 302 redirect to /auth/login in oidc mode. All code values are stable — see Error codes . Identity Capability: identity . Returns 403 CAPABILITY_DISABLED when identity is off. GET {base}/v1/c/{slug}/me → 200 { id , email, name, avatarUrl, kind } avatarUrl may be null . kind is "member" (an org user) or "guest" (an email-invited viewer) so canvas code can branch on who's viewing; an anonymous visitor never reaches the runtime API (a public_link canvas is static-only and returns STATIC_ONLY ). This runtime me() deliberately omits isAdmin ; the dashboard SPA's /api/me is a separate endpoint that includes it. Key–value Capability: kv . Two scopes, identical method set: Shared at /kv — readable and writable by every viewer of the canvas. Per-viewer at /kv/user — scope is forced to the caller's server-resolved user.id , never client-supplied. GET {base}/v1/c/{slug}/kv?prefix=&cursor=& limit = list → { entries, nextCursor } GET {base}/v1/c/{slug}/kv/{key} read → { value } (404 if absent) PUT {base}/v1/c/{slug}/kv/{key} write (JSON body = the value) DELETE {base}/v1/c/{slug}/kv/{key} delete (idempotent, no 404) POST {base}/v1/c/{slug}/kv/{key}/increment atomic add → { value } The same five routes exist under /kv/user/... for the per-viewer namespace. increment body is { by?: number } (default 1 ) and requires a numeric value. Limits: value ≤ 64 KiB, key ≤ 512 B, ≤ 10 000 shared keys / ≤ 1 000 user keys (admin-tunable). Errors: KEY_TOO_LARGE (413), VALUE_TOO_LARGE (413), INVALID_BODY (400), KEY_LIMIT (409), NOT_NUMERIC (409, increment on a non-number). Files Capability: files . POST {base}/v1/c/{slug}/files upload (multipart, field "file" ) → 201 { id , name, size, url } GET {base}/v1/c/{slug}/files list → 200 { files: [{ id , name, size, mime, createdAt }] } GET {base}/v1/c/{slug}/files/{ id }/content download → 200 raw bytes DELETE {base}/v1/c/{slug}/files/{ id } delete → 200 { ok: true } (404 if absent) Upload returns url pointing at the content route ( /v1/c/{slug}/files/{id}/content ). Content is served with X-Content-Type-Options: nosniff and a sanitized filename; SVGs are forced to attachment to neutralize inline scripts. Errors: INVALID_BODY (400, missing/invalid file), FILE_TOO_LARGE (413, over the per-file size limit), and a 409 when the canvas storage quota is exceeded. AI Capability: ai — effective only when the canvas's ai capability is on and an effective provider key is configured. The provider key is server-side only and never appears in any response. POST {base}/v1/c/{slug}/ai/chat chat completion (SSE stream) Request body: { "model" : "" , "messages" : [ { "role" : "user" , "content" : "Hello" } ] , "system" : "optional system prompt" , "maxTokens" : 1024 } messages needs ≥ 1 entry with roles user or assistant (the system prompt rides the system field, not a message role). maxTokens defaults to 1024, hard max 8192. Success is an SSE stream ( text/event-stream ): zero or more { type: "delta", text } events, then a terminal { type: "done", usage: { inputTokens, outputTokens }, cost } . Errors are split by when they occur: Pre-stream (status set before the body): INVALID_BODY (400), MODEL_NOT_ALLOWED (403, model not in the allowlist or allowlisted-but-unpriced), GUEST_AI_DISABLED (403, a guest called AI on a canvas that hasn't opted guests in), GUEST_AI_CAP (429, guest spend cap reached; body { code, scope: "guest" } ), QUOTA_EXCEEDED (429, spend/rate cap; body { code, scope } ), CAPABILITY_DISABLED (403, no effective provider key after the gate). In-stream (HTTP already 200): an SSE { type: "error", code: "AI_UPSTREAM_ERROR", message } event. Usage and quota are recorded even if the client aborts mid-stream. Realtime Capability: realtime . Available only when the instance has a WebSocket adaptor wired. WS {base}/v1/c/{slug}/realtime channel pub/sub + presence Auth, authorization, password-gate, and Origin are all enforced before the upgrade — a failure refuses the 101 (no socket). The capability check is the one post-upgrade gate: if realtime is off, the server accepts then sends { type: "error", code: "CAPABILITY_DISABLED", capability: "realtime" } and closes with code 4403 . A socket that hits the connection limit is closed with 4429 . The frame protocol — publish , subscribe , unsubscribe , presence , and the message / presence / join / leave frames the server sends back — is managed by the realtime hub. Use the SDK's realtime.channel(name) API rather than driving the socket by hand; it handles framing, reconnection, and presence for you. See the SDK reference . Adjacent endpoints These are part of the client surface but not under /v1/c/{slug} : GET { base}/api/me dashboard SPA identity → { id, email, name, avatarUrl, isAdmin, canPublishPublic, authMode, urlMode, baseUrl } GET { base}/sdk/v1.js the served browser SDK bundle ( 503 if not built) /api/me sits behind the session gateway but is not capability-gated, and unlike the runtime me() it adds isAdmin , canPublishPublic , and the instance config authMode ( proxy | oidc | dev ), urlMode ( path | subdomain ), and baseUrl — config, not user data. /sdk/v1.js is served behind the auth gateway as application/javascript; charset=utf-8 with cache-control: public, max-age=3600 . Errors All endpoints return stable code values — see Error codes . ## Error codes Error codes When a primitive call fails, branch on the error's code — never on message text. Every failure from the runtime API carries a stable, machine-readable code and an HTTP status , and the browser SDK throws typed errors extending CanvasdropError (each with a readonly .code and .status ). The global is window.canvasdrop (loaded from /sdk/v1.js ); the error classes are also named exports of @canvas-drop/sdk . There is no cd alias. try { await canvasdrop. kv . set ( "prefs" , { theme : "dark" }); } catch (err) { if (err. code === "QUOTA_EXCEEDED" ) showQuotaBanner (); else throw err; } kv.get already returns null instead of throwing on a missing key, so most reads don't need a try/catch ; the example shows the general pattern. The codes This table is the SDK's exported ERROR_CODES , verbatim. Each entry is { status, summary } . Code Status Meaning NOT_AUTHENTICATED 401 The viewer is not signed in. PASSWORD_REQUIRED 403 The canvas is password-protected. CAPABILITY_DISABLED 403 Backend or the specific feature is off for this canvas. CROSS_CANVAS_FORBIDDEN 403 A request targeted another canvas's resources. MODEL_NOT_ALLOWED 403 The requested AI model is not in the allow-list. DISABLED 403 The canvas has been disabled by an administrator. STATIC_ONLY 403 The canvas is a public link ( public_link ) — every backend primitive is refused for non-owners. GUEST_AI_DISABLED 403 The canvas owner has not enabled AI for invited guests. GUEST_AI_CAP 429 The canvas reached its guest-AI spend cap. NOT_FOUND 404 The key, file, or canvas does not exist. INVALID_BODY 400 The request body failed validation. KEY_TOO_LARGE 413 The KV key exceeds the size limit. VALUE_TOO_LARGE 413 The KV value exceeds the size limit. FILE_TOO_LARGE 413 An uploaded file exceeds the per-file size limit. KEY_LIMIT 409 The canvas hit its key-count limit. NOT_NUMERIC 409 increment was called on a non-numeric value. QUOTA_EXCEEDED 429 A spend or rate quota was exceeded. CONNECTION_LIMIT 429 Too many concurrent realtime connections. AI_STREAM_TRUNCATED 502 An AI stream ended before completion. AI_UPSTREAM_ERROR 502 The AI provider returned an error. REQUEST_FAILED 0 A request failed without a more specific code. REQUEST_FAILED carries status 0 — it's the fallback when a request fails without a more specific code. Typed SDK errors The SDK exports four CanvasdropError subclasses. Any code without a dedicated subclass is thrown as the base CanvasdropError with its .code set from the table above. Class .code .status NotAuthenticatedError NOT_AUTHENTICATED 401 CapabilityDisabledError CAPABILITY_DISABLED 403 NotFoundError NOT_FOUND 404 QuotaExceededError QUOTA_EXCEEDED (default) 409 (default) CanvasdropError (base) any code any status QuotaExceededError is the one quota-shaped class, so it's reused for related limits: the SDK constructs it with the actual wire code and status for 409 and 413 responses (e.g. KEY_LIMIT , VALUE_TOO_LARGE , KEY_TOO_LARGE ), for the guest-AI cap ( code: "GUEST_AI_CAP" , status: 429 ), and realtime constructs it with code: "CONNECTION_LIMIT" , status: 429 . Always read err.code / err.status — don't assume a QuotaExceededError is literally QUOTA_EXCEEDED . Classes such as CrossCanvasForbiddenError , ModelNotAllowedError , and PasswordRequiredError do not exist; those codes arrive on the base CanvasdropError . Branching on err.code is the only reliable check. AI stream errors ai.chat / ai.stream consume a server-sent stream. Failures surface two ways: Before the stream starts (an HTTP error) — thrown as a typed error per the tables above, e.g. MODEL_NOT_ALLOWED (403), QUOTA_EXCEEDED (429), or CAPABILITY_DISABLED (403). Mid-stream — an error frame maps to CAPABILITY_DISABLED → CapabilityDisabledError , QUOTA_EXCEEDED → QuotaExceededError , otherwise a base CanvasdropError with the frame's code and status 502 (typically AI_UPSTREAM_ERROR ). If the stream ends without a terminal done or error frame, the SDK throws AI_STREAM_TRUNCATED (502). Realtime close codes A terminal WebSocket close maps to a typed error (no reconnect): Close code Error 4403 CapabilityDisabledError ( realtime off) 4401 NotAuthenticatedError 4429 QuotaExceededError ( CONNECTION_LIMIT , status 429)