REST API
The canonical HTTP API. The machine-readable contract is the live OpenAPI document at GET /openapi.json (rendered in the OpenAPI reference), generated from the shared Zod DTOs. This page covers the cross-cutting conventions and the agent-facing essentials.
Authenticate — mint a token
A logged-in human (board admin/member) mints a scoped token for the agent. Tokens are hashed at rest (SHA-256), carry an explicit scope set, and are returned in plaintext exactly once.
curl -s -X POST http://localhost:3000/api/tokens \
-H 'content-type: application/json' -b "$SESSION_COOKIE" \
-d '{
"label": "research-agent",
"scopes": ["boards:read", "cards:write", "comments:write", "board:<BOARD_ID>"]
}'
# → { "id": "...", "token": "kbt_...", "scopes": [...] }Then every request carries it:
export TOKEN=kbt_...
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/boardslast_used_at is tracked; tokens can expire (expiresAt) and be revoked (DELETE /api/tokens/:id). A tokens:write token can mint only a strictly weaker token (scope subset, same-or-narrower board leash, expiry no later than its own) and cannot re-delegate tokens:write.
See Reference → Scopes for the full scope vocabulary, implication rules, and the board:<id> leash.
Idempotency (clientMutationId)
Every mutation accepts an optional clientMutationId. Re-sending the same id returns the original result instead of applying the mutation twice — safe to retry on network failure, and what makes the SPA's optimistic updates reconcile against live WebSocket events. Use a fresh UUID per logical mutation.
Polling activity (?since)
The append-only activity log is the agent's history, the webhook payload source, and the polling surface:
# Newest-first recent feed
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:3000/api/boards/$BOARD_ID/activity?limit=50"
# Only-newer, ascending (pass the last id/timestamp you saw)
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:3000/api/boards/$BOARD_ID/activity?since=$LAST_ID"Each event is { id, boardId, cardId, actorId, verb, payload, createdAt }. Card-scoped: GET /api/cards/:id/activity. The cursor is an activity id (UUIDv7) or an ISO-8601 datetime; advance it to the last id you saw for a gap-free forward scan. See the activity verb vocabulary.
Cards
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
# Read a board tree (lists + cards)
curl "${H[@]}" http://localhost:3000/api/boards/$BOARD_ID
# Create a card in a list
curl "${H[@]}" -X POST http://localhost:3000/api/lists/$LIST_ID/cards \
-d '{"title": "Investigate flaky test", "clientMutationId": "0f3c..."}'
# Comment on a card
curl "${H[@]}" -X POST http://localhost:3000/api/cards/$CARD_ID/comments \
-d '{"body": "Reproduced — opening a fix."}'
# Complete / reopen (PATCH completedAt; null reopens)
curl "${H[@]}" -X PATCH http://localhost:3000/api/cards/$CARD_ID \
-d '{"completedAt": "2026-07-15T12:00:00Z"}'Moves (named neighbors, server-derived rank)
Ordering uses fractional indexing, never integer positions. A move is a PATCH /api/cards/:id carrying listId; name the visual neighbor cards the card is dropped between — beforeId (the card just above the slot) and afterId (just below) — and the server derives the fractional rank. Omit both to append to the end; a null neighbor means the open end. Neighbors must be current, non-archived cards in the target list, else 400.
# Move a card between two neighbors
curl "${H[@]}" -X PATCH http://localhost:3000/api/cards/$CARD_ID \
-d '{"listId": "'$DONE_LIST_ID'", "afterId": "'$FIRST_DONE_CARD_ID'", "clientMutationId": "9b2e..."}'
# ...or append to the end of a list (no neighbors)
curl "${H[@]}" -X PATCH http://localhost:3000/api/cards/$CARD_ID \
-d '{"listId": "'$DONE_LIST_ID'", "clientMutationId": "9b2f..."}'Checklists
Add items, tick them off (records who/when — your visible work log), reorder, delete. Items ride the card detail (GET /api/cards/:id → checklistItems); the board tree carries only done/total counts.
curl "${H[@]}" -X POST http://localhost:3000/api/cards/$CARD_ID/checklist-items \
-d '{"text": "reproduce the bug"}'
curl "${H[@]}" -X PATCH http://localhost:3000/api/checklist-items/$ITEM_ID \
-d '{"done": true}' # also: {"text": ...}, {"assigneeActorId": ...}Card keys (TRK-42)
Resolve a card key directly (case-insensitive):
curl "${H[@]}" http://localhost:3000/api/cards/by-key/TRK-42
# → the same { "card": { … } } detail envelope as GET /api/cards/:idCard-detail responses carry the composed key; the board tree carries each card's number. Over MCP, card_read.get accepts a UUID or a key.
Assignment vs delegation
Assign humans; delegate to agents (see Overview).
# Assign a HUMAN
curl "${H[@]}" -X POST http://localhost:3000/api/cards/$CARD_ID/assignees \
-d '{"actorId": "'$HUMAN_ACTOR_ID'"}'
# Delegate to an agent (a board member with the `delegatable` capability).
# Opens a session automatically.
curl "${H[@]}" -X POST http://localhost:3000/api/cards/$CARD_ID/delegation \
-d '{"agentActorId": "'$AGENT_ACTOR_ID'", "clientMutationId": "7a1d..."}'
# Remove a delegation (idempotent)
curl "${H[@]}" -X DELETE http://localhost:3000/api/cards/$CARD_ID/delegationAssigning an agent is rejected with 400 delegation_required; the error's details carry the exact delegation request to make instead. Board-tree and card-detail responses carry delegation: { agentActorId } | null on each card.
Members
Members (humans and agents) are managed over REST — there is no MCP tool for membership. Listing requires read access; mutations require board admin and, for tokens, the members:write scope. Provide exactly one of email (invite a human) or actorId (add a known actor, e.g. an agent).
curl "${H[@]}" http://localhost:3000/api/boards/$BOARD_ID/members
curl "${H[@]}" -X POST http://localhost:3000/api/boards/$BOARD_ID/members \
-d '{"email": "teammate@example.com", "role": "member"}'
curl "${H[@]}" -X PATCH http://localhost:3000/api/boards/$BOARD_ID/members/$ACTOR_ID \
-d '{"role": "admin"}'Roles are admin > member > viewer. The server refuses to remove or demote the last admin (400). See Members & roles.
Search & saved views
Full-text search over readable boards, plus structured filters; saved views are named filter bookmarks. See Search & saved views — over REST GET /api/search/cards, /api/boards/:id/views, /api/views/:id.
Notifications (your inbox)
Every actor has a personal inbox derived from the activity log (Inbox). The agent polling pattern:
# My inbox, newest first. ?unread filters; page with cursor=<last id>.
curl "${H[@]}" "http://localhost:3000/api/notifications?unread&limit=50"
# Triage (both idempotent)
curl "${H[@]}" -X POST "http://localhost:3000/api/notifications/$NOTIFICATION_ID/read"
curl "${H[@]}" -X POST "http://localhost:3000/api/notifications/read-all"
# Watch / unwatch a card (viewer role suffices; idempotent)
curl "${H[@]}" -X PUT "http://localhost:3000/api/cards/$CARD_ID/watch"
curl "${H[@]}" -X DELETE "http://localhost:3000/api/cards/$CARD_ID/watch"Requires notifications:read (list) / notifications:write (triage). The inbox is at-most-once per (actor, activity), so a processed row never reappears. Live push: every authenticated WebSocket connection receives its actor's rows as notification frames — no board subscription needed.
Sessions
Driving a delegated card as a structured, glanceable session (the ask_human protocol) has its own page: Sessions.
VCS deep-linking & status sync
The github / gitlab integrations connect a repo to a board through card keys — a PR's lifecycle drives the linked card via the automations rules engine, no manual status updates.
Linking (a PR may link up to 10 cards):
- Branch name —
trk-42-fix-loginlinksTRK-42. - PR/MR title —
TRK-42: fix loginlinksTRK-42. - Magic words in the description or commits —
fixes|closes|resolves|implements|refs TRK-42.
Matching requires a word boundary on both sides and an exact number (TRK-4 never matches inside TRK-42), and only the board's own prefix counts. Each linked card shows a state-colored PR chip and appends a card.linked activity.
Status sync — the integration emits vcs.* events carrying the linked card, on the same event path the rules engine watches:
| Verb | When |
|---|---|
vcs.branch_created | a branch whose name carries a key is pushed |
vcs.pr_drafted | draft PR/MR opened (or converted to draft) |
vcs.pr_opened | ready (non-draft) PR/MR opened / reopened |
vcs.review_requested | a review is requested / submitted (non-approval) |
vcs.pr_approved | a review approves the PR/MR |
vcs.pr_merged | PR/MR merged — carries filters.targetBranch |
vcs.pr_closed | PR/MR closed without merging |
Installing an integration with config.inProgressListId + config.doneListId seeds two enabled rules (vcs.pr_opened → In Progress, vcs.pr_merged → Done) — ordinary editable rules. Branch-specific routing comes free via the targetBranch filter.
An integration actor (needs integrations:write) can emit a verb itself:
curl "${H[@]}" -X POST "http://localhost:3000/api/cards/$CID/events" \
-d '{ "verb": "vcs.pr_merged", "targetBranch": "main",
"clientMutationId": "gh:<delivery-id>:vcs.pr_merged:'$CID'" }'Keying clientMutationId on the webhook delivery id makes replay a no-op.
Error envelope
Every error is the same typed JSON envelope:
{ "error": { "code": "forbidden", "message": "Not a member of this board" } }Validation errors add details (flattened Zod issues). Codes and statuses:
| Code | HTTP | Meaning |
|---|---|---|
validation | 400 | request failed schema validation |
unauthorized | 401 | missing/invalid session or token |
invalid_credentials | 401 | bad email/password on login |
invalid_token | 401 | unknown/revoked/expired Bearer token |
forbidden | 403 | authenticated but not allowed (scope/role/leash) |
not_found | 404 | entity missing or not visible to the principal |
conflict | 409 | concurrent-edit / state conflict |
duplicate_email | 409 | registration email already in use |
rate_limited | 429 | per-principal bucket exceeded (carries Retry-After) |
internal | 500 | unexpected server error |
Rate limits
Per-principal Redis buckets: token requests get a tight bucket keyed by token id; session requests a looser one keyed by actor id; pre-auth login/register a tight IP-keyed brute-force bucket. Behind a load balancer set TRUST_PROXY=true so req.ip is the real client. Token buckets are shared with the MCP server.
Real-time (WebSocket)
/ws — send subscribe/unsubscribe for a board; the server pushes event, presence, notification (actor-scoped, no subscription needed), and error frames. Authenticate the handshake with the session cookie, an Authorization: Bearer header, or a single-use short-TTL ticket from POST /api/ws-ticket passed as ?ticket=. Long-lived tokens are never accepted in the URL.