Files
kandev/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md
T
2026-09-23 20:51:55 +01:00

13 KiB

Canvas browser API

The host exposes protocol version 1 below the capability URL. Resolve every route from the application document with a relative URL such as ./_kandev/v1/context. Do not use a host URL, a hard-coded port, or a token from application state. The browser sends the capability token as part of the current URL.

Every request is checked against the current canvas release, scope, status, and grants. A request can fail after a release is archived, a grant is revoked, or the canvas is removed. Abort requests during iframe teardown and show a retry state for transient reads.

Context

GET ./_kandev/v1/context returns:

{
  "protocol_version": 1,
  "instance_id": "instance-id",
  "plugin_id": "example-canvas",
  "release_id": "release-id",
  "web_app_key": "main",
  "placement": "task-canvas",
  "scope_kind": "task",
  "data_scope_kind": "workspace",
  "workspace_id": "workspace-id",
  "task_id": "task-id",
  "session_id": "session-id",
  "repository_id": "repository-id",
  "capabilities": ["api_read:tasks", "api_write:messages"]
}

Scope identifiers are omitted when they do not apply. capabilities contains the effective, approved permission keys. It is not a replacement for handling permission errors from later requests. scope_kind describes canvas placement and lifecycle. data_scope_kind describes the Kandev data boundary. A task canvas can have scope_kind: "task" and data_scope_kind: "workspace".

Data routes

All data responses use JSON. Collection responses use this envelope:

{
  "items": [],
  "page_info": { "next_cursor": "", "has_more": false }
}

next_cursor is omitted when there is no next page. limit defaults to 50 and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface does not clamp an out-of-range limit into that window: a supplied value outside 1..200 is rejected outright with HTTP 400 invalid_request, and only an omitted limit falls back to the 50-row default. Task data scope returns only the bound task. Workspace data scope returns tasks from the instance's trusted workspace, including when the canvas is still placed in one task. A workspace_id filter must match that workspace; another workspace is denied. Follow page_info.next_cursor until it is omitted to load every page.

Method Route Permission Use
GET ./_kandev/v1/data/tasks api_read:tasks List tasks
GET ./_kandev/v1/data/tasks/{task_id} api_read:tasks Read one task
GET ./_kandev/v1/data/tasks/{task_id}/step-transitions api_read:tasks Read recorded task moves
PATCH ./_kandev/v1/data/tasks/{task_id} api_write:tasks Update a task
POST ./_kandev/v1/data/tasks/{task_id}/messages api_write:messages Send a task message
GET ./_kandev/v1/data/workflows api_read:workflows List workflows
GET ./_kandev/v1/data/workflows/{workflow_id}/steps api_read:workflows Read workflow steps
GET ./_kandev/v1/data/workflows/{workflow_id}/transition-groups api_read:tasks and api_read:workflows; workspace canvas only Read recorded workflow route counts

The task-list query accepts cursor, limit, include_archived, workflow_id, state, and parent_id. workflow_id and state can be repeated or comma-separated. include_archived is a boolean.

A task object contains these fields: id, workspace_id, workflow_id, title, description, state, priority, created_by, created_at, updated_at, started_at, completed_at, parent_id, identifier, is_ephemeral, repositories, metadata, archived_at, pull_requests, workflow_step_id, position, assignee_agent_profile_id, labels, autopilot, wip_admitted, queued_for_step_id, queued_at, project_id, external_id, blocked, blocked_reason, depends_on, blocks, depends_on_truncated, blocks_truncated, and start_when_unblocked. Repository entries contain id, repository_id, base_branch, position, and checkout_branch.

depends_on and blocks are read-only dependency-edge lists. Each entry contains id, title, state, and, on a depends_on entry only, status (a blocks entry never carries status, since a task cannot be "pending" or "resolved" against a task it blocks). Each list is capped at 512 entries; depends_on_truncated and blocks_truncated report whether more edges exist than were returned. title and state are blanked (empty string) on any edge end the caller's canvas scope does not directly admit: a workspace-scoped canvas admits an edge end sharing its workspace; a repository- or session-scoped canvas admits an edge end only when it is also returned as a directly readable task in the same response, never by workspace equality alone; a task-scoped canvas admits none, seeing only the edge's id and, for a depends_on entry, its status. blocked, blocked_reason, and start_when_unblocked summarize the same projection at the task level. When an internal read failure keeps the host from deriving a verdict for a task a route does return, it substitutes a withheld verdict rather than failing that route: blocked: true, blocked_reason: "unknown", empty depends_on/blocks, both truncation flags false, and start_when_unblocked: false. Treat that shape as "no answer," not as "task is actually blocked." A canvas lacking the read capability never sees this verdict: every task route, including the PATCH route, checks the read capability before returning any task and fails outright with 403 plugin_permission_denied when it is missing, so accessor denial and a withheld verdict are never the same response. This is also distinct from the fan-out limit described below, which fails the whole page with response_too_large rather than substituting a withheld verdict onto any task.

These seven fields exist on a task object only once the host's manifest min_kandev_version floor is met; declare that floor for the release your canvas expects them from, since an older host omits the fields entirely rather than sending empty defaults for them. A host running a dev or other non-release build always satisfies that floor check regardless of its actual age, so a canvas can still receive zero-value dependency fields on such a host even when the floor is declared correctly.

A workflow object contains id, workspace_id, name, description, sort_order, created_at, and updated_at. A workflow-step object contains id, workflow_id, name, position, stage_type, color, is_start_step, wip_limit, agent_profile_id, and on_enter_action_types.

The task transition route returns rows newest first, with id as a decimal string, nullable from_workflow_id, from_workflow_step_id, to_workflow_id, and to_workflow_step_id, trigger, and RFC3339 occurred_at. It omits actor and session identity. The opaque cursor continues by ledger ID; it is bound to the requested task. Empty history means no retained move was recorded, not that the task never moved.

The workflow transition-group route returns kind (within, entry, or exit), nullable from_step_id and to_step_id, and count. It includes archived tasks and removed step IDs, but not deleted tasks. Its opaque cursor orders groups by kind and step IDs. Both routes use the collection envelope and the 1..200 limit rule above. A task canvas cannot request workflow-wide groups; promotion to workspace scope is a user action, not a fallback.

Writes and workflow movement

PATCH ./_kandev/v1/data/tasks/{task_id} accepts one or more of these JSON fields: title, description, state, and workflow_step_id. A workflow step move is therefore a task patch. There is no separate workflow-step write route. The response is the updated task object.

For example, to continue work, send a message through the normal task message path:

POST ./_kandev/v1/data/tasks/task-id/messages
Content-Type: application/json

{"text":"continue"}

To move the task, use its known target step ID:

PATCH ./_kandev/v1/data/tasks/task-id
Content-Type: application/json

{"workflow_step_id":"step-in-progress"}

POST ./_kandev/v1/data/tasks/{task_id}/messages requires a non-empty text field and accepts an optional session_id. It returns HTTP 202 with session_id and status, where status is queued, sent, or started.

Instance state

GET ./_kandev/v1/state lists state entries. GET ./_kandev/v1/state/{key} reads one entry. PUT and DELETE use ./_kandev/v1/state/{key} and require If-Match.

Method Route Permission Use
GET ./_kandev/v1/state state List state
GET ./_kandev/v1/state/{key} state Read state
PUT ./_kandev/v1/state/{key} state Replace state
DELETE ./_kandev/v1/state/{key} state Delete state

If-Match is a non-negative integer revision, either bare (If-Match: 3) or quoted (If-Match: "3"). A wildcard, missing header, malformed value, or negative value returns HTTP 428 with plugin_state_precondition_required. The PUT body must be one valid JSON value. A revision mismatch returns HTTP 409 with:

{"error":"plugin_state_conflict","current_revision":4}

State entries contain key, value, revision, writer_kind, and updated_at. State is shared by all approved releases of the same canvas instance. Keep values small and do not store secrets.

Events and reconnect

GET ./_kandev/v1/events opens a bounded Server-Sent Events stream. Send the last received event ID in the Last-Event-ID request header when reconnecting. Normal events have an ID in generation:sequence form and contain the full event envelope in the SSE data field:

id: generation:12
event: task.updated
data: {"id":"generation:12","generation":"generation","sequence":12,"type":"task.updated","scope":{"instance_id":"instance-id"},"data":{}}

The envelope fields are id, generation, sequence, type, scope, and data. The scope contains instance_id and can contain workspace_id, task_id, session_id, and repository_id. The event data is specific to the event type.

The server sends : heartbeat comments at the heartbeat interval. Streams have bounded queues and a finite lifetime. A slow consumer can be closed. When the requested cursor is invalid, expired, from another process generation, or ahead of the current sequence, the server sends runtime.resync_required with an empty SSE ID. Its data contains reason, generation, and reset: true. On resync, clear the cursor, refetch the authoritative data, and reconnect without a cursor.

Event delivery does not replace HTTP reads. Refetch after an event that can change visible data, and stop using the iframe immediately when its host reports a lifecycle or authority change.

No event ever carries the dependency projection (blocked, blocked_reason, depends_on, blocks, the truncation flags, start_when_unblocked) in its data payload — those fields are refetch-on-signal only. Refetch a cached task's dependency fields when: a task.updated event names that task or either end of one of its edges; a task.dependencies_resolved or task.dependency_failed event names that task; or a task.state_changed event names any task ID present in that task's cached depends_on or blocks list, since a predecessor or dependent simply advancing state is not itself one of the first three signals. A single task read is not a transactional snapshot: with no surrounding lock, an edge can change while the read is being derived, so one response can show an edge asymmetrically (for example, a predecessor still listed as pending after it has already resolved). Treat what a response returns as the union of independently-read facts, and resolve staleness by refetching on the next matching signal rather than by trusting any single response as authoritative.

Actions and errors

POST ./_kandev/v1/actions/{key} accepts a JSON body only when the manifest declares the matching action:{key} permission. The current static canvas host has no action implementation and returns HTTP 501 with plugin_action_unavailable. An undeclared action returns HTTP 403 with plugin_permission_denied.

Other stable error codes are:

Status Error
400 invalid_request
401 runtime_token_stale
403 plugin_permission_denied
404 not_found
405 method_not_allowed
409 plugin_state_conflict
428 plugin_state_precondition_required
413 or 500 response_too_large when a host limit is exceeded
503 runtime_unavailable

On the task list route, response_too_large also covers dependency derivation refusing a page: deriving depends_on/blocks for every task on a page reads a bounded number of distinct task IDs across all of that page's edges (shared edge ends count once), and a page that would cross that bound fails the whole request with response_too_large rather than returning a partial or withheld-verdict page. Retry with a smaller limit. A single-task read or write is never subject to this bound, since it can only ever derive one task's edges.

All request bodies are bounded. Treat unknown error codes as retryable only when the operation is a read and the canvas is still mounted.