Skip to main content
REST surface over the durable board, the transactional source of truth for team/task coordination. These routes create and list tasks, atomically claim a task for a single assignee, transition status through the state machine (with the intrinsic verification gate), record the execution ledger, link dependency chains, cancel a dead downstream chain, and provision / inspect / pause / complete a task’s per-task git worktree (its system-of-record). The board is a thin HTTP layer over @clawboo/db’s board repository, the data boundary. Every POST/PATCH body is validated by a co-located zod schema; an invalid body returns 400 { error: 'invalid body', details: <zod flatten> }. The worktree-handoff route validates against @clawboo/worktrees’ handoff schema and returns 400 { error: 'invalid handoff', details: <zod flatten> } instead. All bodies are parsed by express.json({ limit: '2mb' }).
The atomic claim is the board’s concurrency primitive. POST /api/board/:taskId/claim returns 409 when another worker already won. Per the board contract, a 409 is data, not a transient error; do not retry it. The loser did not lose a race it can re-run; the task is simply taken. The same rule applies to the 409 illegal_transition on PATCH /api/board/:taskId.

Routes

The worktree routes share a :taskId prefix; the router registers every longer two-segment path (/workspace/handoff, /workspace/detail, /workspace/tree, /workspace/file, /workspace/file-diff, /workspace/status) before the bare /workspace, and the execution paths (/executions, /executions/:execId) are distinct two-segment forms, so there is no path collision with /:taskId.
The 7 task statuses are backlog, todo, in_progress, in_review, blocked, done, cancelled. done and cancelled are terminal. The legal forward transitions are fixed by the state machine; an illegal transition is rejected with 409 illegal_transition.

GET /api/board

Lists tasks for a team, or the subset that is ready to work. With ready=true the response is getReadyTasks (status todo, not dropped, with every dependency done); otherwise it is listTasks filtered by teamId / status / includeDropped. Dropped (soft-deleted) tasks are excluded unless includeDropped=true.
  • Query params:
  • Request body: none.

Responses

200 OK: the task list (tasks[] is DbTask[], newest-updatedAt first; the ready variant orders by priority then updatedAt):
attention is computed from the task’s status, its bound agent (the :agent: segment of sourceDelegationId), its execution ledger, and its verification verdict, with the pure taskAttention rule from @clawboo/board-core. A blocked task always has one (needs_review when its verdict cannot be promoted, else timed_out / failed from its last run, else blocked). A todo task has one only when nothing will fire it: no bound agent (unassigned), a last run the user stopped (stopped), or three failed runs in a row (failed / timed_out). The board renders every task with an attention in its Needs you column. 500 Internal Server Error: any DB failure:

Example


POST /api/board

Creates a task. status defaults to todo (immediately claimable); pass backlog for triage. A subtask is created by setting parentTaskId (the parent chain bounds delegation depth). This route is not capped; the per-parent child-count and depth ceilings are enforced at the Tasks MCP boundary, where an attached model creates rows unsupervised. On success the handler emits a task_created observability event (best-effort: an append failure is swallowed and never fails the request).
  • Path/query params: none.
  • Request body (validated by createTaskBody):
With assigneeAgentId (the board’s New task dialog), the task is created for one agent: teamId is required, the agent must be an active member of that team, and status may only be todo (the default) or backlog. The server writes a sourceDelegationId that binds the task to the agent and marks it as assigned by a person, then, for a todo task, hands it to the team’s orchestrator to run now (detached: the response does not wait for the run to start). The agent receives the title and description together. Its result is recorded on the task (the report comment) and is not delivered to the team lead as a task update: a person-assigned task reports to the person, on its card. If the agent is busy, the task waits in todo and runs when the agent is free.

Responses

400 Bad Request: body failed validation ({ "error": "invalid body", ... }), or, with assigneeAgentId: team_required, agent_not_in_team (unknown, archived, or on another team), or invalid_status:
200 OK: the created task (full DbTask with attention, shape as in GET /api/board):
500 Internal Server Error: DB failure:

Example


POST /api/board/:taskId/retry

Runs a stuck task again because a person asked: one that failed, timed out, was stopped, or was parked after failing three times in a row. A blocked or backlog task is moved back to todo first (which clears its assignee and verdict), a user comment Retry requested. is added to its trail, and the task is handed straight to its bound agent. The ready-pump’s automatic re-fire limit is waived for this run: it stops the machine from retrying forever, not the person from asking again. Detached, like the create above.
  • Path params: taskId.
  • Request body: none.

Responses

202 Accepted: { ok: true, task: DbTask } (the task as it now stands, in todo). 404 Not Found: unknown task. 409 Conflict: { ok: false, error } where error is not_retryable (the task is running, finished, or cancelled), no_team, or unassigned (no agent is bound to it; use assign).

POST /api/board/:taskId/assign

Gives a task that nobody is working on to one member of its team, and starts it. For a card no agent will ever pick up (created without one, or by an agent’s own create_task), and for handing a failed task to someone else. The task is re-bound to the agent as a person’s assignment (see POST /api/board), moved back to todo if it was blocked or backlog, noted on its trail (Assigned to <name>.), and dispatched.
  • Path params: taskId.
  • Request body: { agentId: string }.

Responses

200 OK: { ok: true, task: DbTask }. 400 Bad Request: agentId required, or agent_not_in_team. 404 Not Found: unknown task. 409 Conflict: no_team, or not_assignable (the task is being worked on, finished, cancelled, or dropped).

GET /api/board/:taskId

Returns one task plus its comments (oldest first) and its ancestor chain (the parent-task lineage, via recursive CTE).
  • Path params: taskId.
  • Request body: none.

Responses

200 OK:
404 Not Found: no such task:
500 Internal Server Error:

Example


POST /api/board/:taskId/claim

Atomically claims a todo task for a single assignee. The claim is a guarded UPDATE (status='todo' AND assignee IS NULL AND dropped=0); at most one caller wins, the task flips to in_progress, and the loser gets a 409. On a win the handler emits a task_claimed event and narrates the claim into the team chat room (best-effort, after the canonical write, never a write path back).
A 409 means another worker holds the task. Do not retry it. A dead in_progress task is recovered by orphan reconciliation (which releases it to todo), after which a normal claim re-acquires it; the liveness logic lives there, not in the claim.
  • Path params: taskId.
  • Request body (validated by claimBody):

Responses

400 Bad Request: body failed validation:
200 OK: the claim won; the task is now in_progress and assigned:
404 Not Found: the task does not exist (reason: 'not_found'):
409 Conflict: the task was not in a claimable state (already claimed, dropped, or not todo):
500 Internal Server Error:

Example


PATCH /api/board/:taskId

Transitions a task’s status and/or edits its metadata (priority / title / description). At least one field is required. The status change is enforced by the state machine inside a BEGIN IMMEDIATE transaction (illegal transition → 409). When the target is done, the intrinsic verification gate applies: a task carrying a non-promotable verdict (a failing deterministic gate, including red-gate debt) is rejected with 409 verification_required. The only bypass is humanOverride: true; when used with status: 'done' the override is recorded in the governance audit log so it is never silent.
A task with no stored verification verdict is unverified, not failing; it lands done normally. The gate blocks known-failing verdicts, not un-run verification; manually completing unverified work is an intentional human judgment call (the autonomous path always writes a verdict via the verification gate before this transition). Moving a task to todo releases it (clears the assignee + verdict) so the atomic claim can re-acquire it.
On a successful status change the handler emits a status_changed event and narrates the mutation into the team chat room (best-effort, after the canonical write).
  • Path params: taskId.
  • Request body (validated by updateTaskBody; at least one field required):

Responses

400 Bad Request: empty body or failed validation:
200 OK: applied; the updated task:
404 Not Found: the task does not exist (reason: 'not_found', or the field update found no row):
(or, when only fields were edited and the row vanished:)
409 Conflict: the status transition is illegal or the verification gate blocked →done:
500 Internal Server Error:

Example


POST /api/board/:taskId/comments

Adds a comment to a task (discussion or a system note). authorType defaults to agent. The handler emits a comment_added event and narrates a truncated form of the comment into the team chat room (best-effort, after the write). The handler does not 404 a missing task before inserting; the comment row is created against the supplied taskId.
  • Path params: taskId.
  • Request body (validated by commentBody):

Responses

400 Bad Request: body failed validation:
200 OK: the created comment:
500 Internal Server Error:

Example


POST /api/board/:taskId/executions

Opens an execution-process row for a task, recorded only after a successful claim. The exec ledger is what orphan reconciliation reads on restart: an executor that starts work MUST open one here and close it via the PATCH below, or a crash leaves the run orphaned (reconciliation then marks it failed and releases the task). On success the handler emits an execution_started event.
  • Path params: taskId.
  • Request body (validated by createExecutionBody):

Responses

400 Bad Request: body failed validation:
404 Not Found: the task does not exist:
200 OK: the created execution row (starts in status: 'running'):
500 Internal Server Error:

Example


PATCH /api/board/executions/:execId

Closes out an execution row with its outcome and an optional token/cost ledger. The handler emits an execution_completed event carrying the run’s taskId, teamId, agentId and runtime, recovered from the closed row, so the event correlates with the execution_started it pairs with. A terminal ledger row is immutable, so only a still-running execution can be closed: an execId that is unknown, or whose row some other path already closed, closes nothing and returns 404 without appending an event.
  • Path params: execId.
  • Request body (validated by completeExecutionBody):

Responses

400 Bad Request: body failed validation:
200 OK: the execution was closed:
404 Not Found: no still-running execution row matched execId, either because the id is unknown or because the row is already terminal (most often the stale sweep closed it as timed_out). Nothing was closed and no event was appended, and this 404 is data rather than a transient error to retry:
500 Internal Server Error:

Example


GET /api/board/:taskId/executions

Lists a task’s execution-process rows (the run ledger), every spawned run, oldest first.
  • Path params: taskId.
  • Request body: none.

Responses

200 OK: the ledger (executions[] is DbExecutionProcess[], shape as in POST .../executions):
500 Internal Server Error:

Example


POST /api/board/:taskId/deps

Links a dependency: taskId will not become ready until dependsOnTaskId is done. Plans become a dep chain; the orchestrator’s ready-pump fires the next step when its blocker completes. Both endpoints of the edge must exist (the handler guards against orphan dep rows). On success the handler emits a dep_linked event and narrates the dependency into the team chat room.
  • Path params: taskId (the dependent).
  • Request body (validated by linkDepBody):

Responses

400 Bad Request: body failed validation:
404 Not Found: either the task or the blocker does not exist:
200 OK: the edge was linked (duplicate edges are ignored):
409 Conflict: the edge would close a direct or transitive dependency cycle. A cycle can never resolve, so the plan is unworkable rather than the server being at fault:
500 Internal Server Error:

Example


POST /api/board/:taskId/cancel-dependents

Cancels the still-pending (todo/backlog) transitive dependents of a failed task. A blocked/failed blocker can never become done, so its downstream chain is dead; cancelling it surfaces the stall instead of leaving ghost todo cards that can never become ready. Dependents already in_progress/done/cancelled are left untouched. Each cancelled task emits a status_changed event with reason: 'blocker_failed'. The returned list lets the caller report the stalled plan chain to the leader.
  • Path params: taskId (the failed blocker).
  • Request body: none.

Responses

404 Not Found: the task does not exist:
200 OK: the cancelled dependents (id + title only; may be an empty array):
500 Internal Server Error:

Example


POST /api/board/:taskId/workspace

Provisions a git worktree + branch + system-of-record (SoR) scaffold for a file-mutating task, and records the worktree/branch refs on the task. The worktree lives outside the user’s repo (under the clawboo state dir, namespaced by a hash of the repo path) so it never pollutes the repo’s own git status. Isolation is decided by task kind: code (and unknown kinds) → worktree; research / review → none (refused). A repeated provision for a task that already has a live, registered checkout reuses it rather than inserting a duplicate workspace row.
A read-only / research task is refused with 422; it has no file mutations to isolate, so it should not pay the worktree cost.
  • Path params: taskId.
  • Request body (validated by provisionWorkspaceBody):

Responses

400 Bad Request: body failed validation:
404 Not Found: the task does not exist (reason: 'not_found'):
422 Unprocessable Entity: the task kind resolves to no worktree isolation (research/review):
200 OK: the worktree was provisioned (or an existing one reused):
500 Internal Server Error:

Example


GET /api/board/:taskId/workspace

The cold-resume read: the task’s workspace row plus the resume state reconstructed purely from the worktree’s system-of-record (AGENT_HANDOFF.json, falling back to task-progress.md + init.sh), no chat history, no board UI. This is what lets a fresh runtime (or a human) pick up a task from the worktree alone.
  • Path params: taskId.
  • Request body: none.

Responses

404 Not Found: no workspace row for the task (or it has no recorded worktree path):
200 OK: the workspace, the reconstructed resume state, and the parsed handoff (each may be null when the worktree is paused-away or no handoff was written):

Example


PATCH /api/board/:taskId/workspace

Pauses or completes a task’s worktree.
  • pause: commit any uncommitted work, drop the worktree, keep the branch (the workspace stays active and resumable).
  • complete: an empty diff cleans up the worktree + branch and drives the task to done (an empty diff has no deliverable, so the verification gate is intentionally bypassed). A non-empty diff lands the task in in_review, runs the verification gate, then gates →done: only a pass promotes to done; a fail with attempts left reverts to in_progress; a fail at the attempt budget, and completed_with_debt, both route to blocked with a system comment and an inbox notice to the delegator. A repeat complete on an already-blocked task cannot re-enter in_review, so it returns the task’s current status with verified: 'fail' and does not re-run the gate. SoR bookkeeping files are excluded from the diff (a session that only wrote its own progress/handoff is still “empty”).
  • Path params: taskId.
  • Request body (validated by workspaceActionBody):

Responses

400 Bad Request: body failed validation:
404 Not Found: no workspace row (or it has no worktree path / branch):
200 OK (pause): committed-or-not + the resulting HEAD sha:
200 OK (complete): the diff outcome, the resulting task status, and (when the gate ran on a dirty diff) the verification verdict:
500 Internal Server Error:

Example


POST /api/board/:taskId/workspace/handoff

Writes the clock-out AGENT_HANDOFF.json into the task’s worktree, structured data, not prose, so a different runtime (or a human) can pick up the task cleanly. timestamp is defaulted server-side to the current ISO-8601 time when omitted before validation.
  • Path params: taskId.
  • Request body (validated by agentHandoffSchema; timestamp defaulted server-side):

Responses

400 Bad Request: the handoff failed validation:
404 Not Found: no workspace row (or it has no worktree path):
200 OK: the handoff was written:
500 Internal Server Error:

Example


GET /api/board/:taskId/workspace/detail

The task-detail drawer’s Workspace tab: the SoR file contents (TASK.md, task-progress.md, DECISIONS.json, init.sh, VERIFICATION.md, AGENT_HANDOFF.json, only those present) plus the unified diff and diff-stat against the branch-point baseline, with the SoR bookkeeping files excluded.
  • Path params: taskId.
  • Request body: none.

Responses

404 Not Found: no workspace row (or it has no worktree path):
200 OK: the SoR contents + diff (diffStat / diff may be empty when the worktree is paused-away or git is unavailable):
500 Internal Server Error:

Example


Error envelope

Every error response on these routes is the standard envelope { error: string }, except:
  • Body-validation 400s, which add details (the zod flatten() output): { error: 'invalid body', details: {...} } (or { error: 'invalid handoff', details: {...} } on the handoff route).
  • The claim route’s failure paths and the PATCH /:taskId status-failure paths, which return { ok: false, error: <reason> } (reason ∈ not_found | conflict | illegal_transition | verification_required).
  • The provision route’s failure paths, which return { ok: false, error: 'task not found' } (404) or { ok: false, error: 'no_isolation', isolation } (422).
  • The workspace action / handoff 404s, which return { ok: false, error: 'workspace not found' } (the cold-resume GET and the detail GET use the bare { error: 'workspace not found' }).

See also

  • The board, the state machine, atomic claim, dep chains, and orphan reconciliation
  • Worktrees and handoff, the per-task system-of-record + cross-runtime AGENT_HANDOFF.json
  • Verification, builder≠judge, the deterministic gate + critic, completed_with_debt
  • Runtimes API, POST /api/runtimes/:id/run claims and drives one of these tasks end to end
  • Database schema, the tasks, task_deps, task_comments, workspaces, execution_processes tables
  • REST API overview

The workspace filesystem view

Four read-only routes behind the agent-detail Workspace tab. They serve the task’s worktree: the tree, one file, one file’s diff, and the git status that drives the change badges. Nothing here writes. Paths are resolved from the stored workspaces row, never from the request. Every relative path is confined to the worktree root in three layers: absolute paths and .. segments are refused; the resolved path must sit inside the root (checked with path.relative, so a sibling directory like <root>-evil cannot pass as <root>); and symlinks are refused outright, with the result re-checked after resolving any symlinked ancestor. A refused path answers 400 with a message naming the problem. All four share one miss shape. reason distinguishes three states that must not be conflated: unreadable is deliberately not a 404: a client that treated a git failure as “no workspace” would render a dirty worktree as clean.

GET /api/board/:taskId/workspace/tree

One directory level, for a lazily-expanded tree. .git, symlinks and special files are omitted; directories sort before files, then case-insensitively by name.
  • Query: dir (worktree-relative; omitted or empty lists the root).
  • 200 OK: { ok, dir, entries: [{ name, kind: "file" | "dir", size? }], truncated }. size is present on files only. At most 500 entries; truncated reports that the cap was hit.

GET /api/board/:taskId/workspace/file

One file’s contents, capped at 256 KB.
  • Query: path (worktree-relative).
  • 200 OK (text): { ok, path, binary: false, size, content, truncated }. A cut lands on a codepoint boundary, so a truncated file never ends in a broken character.
  • 200 OK (binary): { ok, path, binary: true, size }. A NUL byte in the first 8 KB classifies the file as binary; no bytes are returned.

GET /api/board/:taskId/workspace/file-diff

The unified diff for ONE file against the task’s baseline commit.
  • Query: path (worktree-relative). A directory is refused with 400 Not a file., since git pathspecs match by prefix and would otherwise return a whole subtree.
  • 200 OK: { ok, path, diff, truncated }. Capped at 1 MB, measured in bytes and cut on a codepoint boundary. The diff is empty for an untracked file; clients fall back to the file route.

GET /api/board/:taskId/workspace/status

git status --porcelain -z --untracked-files=all, parsed.
  • 200 OK: { ok, branch, entries: [{ path, x, y, origPath? }], truncated }. x is the index column and y the worktree column; origPath carries the rename or copy source. At most 2000 entries.
Last modified on September 7, 2026