Skip to content

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.

bash
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:

bash
export TOKEN=kbt_...
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/boards

last_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:

bash
# 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

bash
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.

bash
# 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/:idchecklistItems); the board tree carries only done/total counts.

bash
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):

bash
curl "${H[@]}" http://localhost:3000/api/cards/by-key/TRK-42
# → the same { "card": { … } } detail envelope as GET /api/cards/:id

Card-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).

bash
# 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/delegation

Assigning 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).

bash
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:

bash
# 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 nametrk-42-fix-login links TRK-42.
  • PR/MR titleTRK-42: fix login links TRK-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:

VerbWhen
vcs.branch_createda branch whose name carries a key is pushed
vcs.pr_drafteddraft PR/MR opened (or converted to draft)
vcs.pr_openedready (non-draft) PR/MR opened / reopened
vcs.review_requesteda review is requested / submitted (non-approval)
vcs.pr_approveda review approves the PR/MR
vcs.pr_mergedPR/MR merged — carries filters.targetBranch
vcs.pr_closedPR/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:

bash
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:

json
{ "error": { "code": "forbidden", "message": "Not a member of this board" } }

Validation errors add details (flattened Zod issues). Codes and statuses:

CodeHTTPMeaning
validation400request failed schema validation
unauthorized401missing/invalid session or token
invalid_credentials401bad email/password on login
invalid_token401unknown/revoked/expired Bearer token
forbidden403authenticated but not allowed (scope/role/leash)
not_found404entity missing or not visible to the principal
conflict409concurrent-edit / state conflict
duplicate_email409registration email already in use
rate_limited429per-principal bucket exceeded (carries Retry-After)
internal500unexpected 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.

Released under the AGPL-3.0 license.