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
401inproxy/devmode, or a302redirect to/auth/logininoidcmode. (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 returns403 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 as not-found (canvas absent, deleted). |
ARCHIVED / NOT_INVITED / OWNER_ONLY / SHARE_EXPIRED |
404 | Other resolver denials, each returned as its own uppercased code. |
DISABLED |
403 | The canvas is disabled. |
PASSWORD_REQUIRED |
403 | Password-gated shared canvas, non-owner, 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. Body: { code, message }. |
CROSS_CANVAS_FORBIDDEN |
403 | Cross-canvas request: subdomain mode with an Origin that doesn't match this canvas's origin (a request with no Origin is treated as a non-browser caller and passes), or path mode with a Referer not on this canvas. |
CROSS_SITE_FORBIDDEN |
403 | path mode with Sec-Fetch-Site not same-origin/none. |
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-resolveduser.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": "<provider model id>",
"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.
Limits: 30 connections per canvas, 100 messages per minute per user, 16 KiB max
frame. Close codes after the socket is open: 4401 (the session lost access on
revalidation — canvas gone, access revoked, became static-only, password gate, or
user deactivated), 4403 (realtime capability disabled), 4429 (connection limit
reached).
The frame protocol — client frames publish, subscribe, unsubscribe,
presence, and the subscribed / message / presence / join / leave frames
the server sends back — is managed by the realtime hub. The server resolves sender
identity (from) itself; the client cannot spoof it. In-band error frames carry a
code: RATE_LIMITED, MESSAGE_TOO_LARGE, INVALID_FRAME, UNKNOWN_FRAME. 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 with cache-control: public, max-age=3600 (503 plain text
when no built bundle is available).
Errors
All endpoints return stable code values — see Error codes.