HTTP API
The Kraft server serves its board, and the JSON API behind it, on one port
(127.0.0.1:8765 by default). The kraft command and the MCP server are
clients of that API.
Stable routes
Only two routes are meant for other programs, and only these keep their shape between minor releases. See Versioning and stability.
| Route | Use |
|---|---|
POST /api/triggers | File a work item from a webhook or script. See Inbound triggers. |
GET /api/health | Check that the server is up. Answers status (ok or degraded), the reasons it is degraded, and which instance this is (run_dir, pid, bind, port). Needs no login. |
Every other route under /api/, and the /api/ws/events WebSocket, exists
for the board and can change in any release. For those, prefer the kraft
command with --json, which is stable.
POST /api/work-items, the board's create route, files the item paused unless
the body sets autostart: true, like kraft item create without
--autostart. A body with no chain_template gets the repo's
default_chain_template. Every repo path you send must be absolute: the
server would read a relative one against its own directory, not yours.
POST /api/repos, POST /api/work-items and POST /api/triggers refuse it
with a 422, and PATCH or DELETE /api/repos?path= answer 404.
Work item status
GET /work-items and GET /work-items/{id} both carry two read-only fields
on every item, computed on read rather than stored (see
Displayed status):
| Field | Meaning |
|---|---|
display_status | The board's status badge: one of archived, done, cancelled, paused, running, waiting, needs_you, escalated, failed. |
stop | null unless the item is needs_human, waiting or rate_limited. On the list, {kind, node, resume_at, reason}; on the detail, also task, attempt and facts. |
stop.kind is one of gate, question, cap, budget, failed,
conflict, mr_closed, config, infra, stuck, wait, rate_limit (see
Events → work_item_needs_human for
what each means). A rate_limit stop's facts also carries fallback and
fallback_allowed -- the stopped task's declared fallback harnesses, and
which of those its resolved policy still allows.
Cancelling
GET /work-items/{id}/cancel-preview shows what POST /work-items/{id}/cancel
would do, without doing it: 409 once the item has already ended, same as
/cancel itself.
{
"running": {"node": "mr_checks", "task": "mr_checks", "attempt": 1} | null,
"kept": {"branch": "kraft/...", "worktree": "/path", "findings": 0, "threads": 1},
"mr": {"ref": 54, "url": "https://...", "state": "open"} | null,
"spend": {"spent_usd": 1.2, "cap_usd": 5.0}
}
running is the item's running session, if any; kept is what cancel leaves
behind (cancel never removes the worktree or the branch); mr is the item's
merge request and its live state, or null before one was ever opened.
POST /work-items/{id}/cancel takes an optional close_mr: bool = false.
When set and the item's merge request is open, cancel closes it on the forge
once the cancel itself has landed and the response gains close_mr: {ok: true} (or {ok: false, error} when there was no open merge request or the
forge call failed -- a failed close never undoes the cancel). The key is
absent when close_mr was not asked for.
Retrying, duplicating, acting in bulk
POST /work-items/{id}/retry's response gains attempt: the attempt number
the task it relaunches will run as -- the count of that (item, node, task)'s
own sessions so far, plus one. A path naming one task counts only that
task's sessions; a path-less retry (the whole stopped node, or restart's
whole chain) takes the highest next-attempt over all of the node's own tasks,
since a walk may enter at any one of them.
POST /work-items/{id}/duplicate files a fresh, paused item from {id}'s own
title, description, repo, chain template, workspace selection and
attachments (re-snapshotted under the new item's own id). Any source status is
accepted, archived and cancelled included. No run state, override, policy
override, budget or bead link carries over -- this is a fresh intake, not a
clone of the row.
{"id": "...", "status": "paused", "duplicate_warning": "..."}
duplicate_warning is present only when another open item looks like a
duplicate, the same check POST /work-items makes. A source whose stored
attachment copy has gone missing answers 409 naming it, before anything is
filed.
POST /work-items/bulk applies one action to several items at once:
{"action": "pause", "ids": ["...", "..."], "reason": "..."}
action is one of pause, cancel, archive, restore -- the same door
each one's own single-item route already has, called once per id, each in its
own write, in ids order. reason is required, and checked before anything
is touched, when action is cancel (422 otherwise); the other three
ignore it. The response is one result per id, in order:
{"results": [{"id": "...", "ok": true, "status": "paused"}, {"id": "...", "ok": false, "error": "..."}]}
An id whose action raises (an item in the wrong status, say) becomes {id, ok: false, error} and the rest of the batch still runs; an id naming no work
item answers {id, ok: false, error: "not found"}. status is the item's
stored status after the action ran.
MR closed externally
A poller checks every waiting/needs_human item parked at a merge-request
node against the forge every policy.forge_poll_s seconds (default 300). When
the merge request was closed there without merging, it stops the item with
stop.kind: "mr_closed" and facts: {ref, url} (see Events →
work_item_needs_human).
POST /work-items/{id}/reopen-mr reopens that merge request on the forge and
retries the stopped node, the same as POST /work-items/{id}/retry with no
path -- it answers 409 unless the item is actually stopped with
stop.kind: "mr_closed", and 502 with the forge's own message when the
forge call fails (the item stays stopped). Its response is /retry's own
response shape. "Open a new MR" instead is /retry with the MR node's path;
cancelling the item instead is /cancel.
Daily total and dry run
GET /budget/today answers the instance's spend since local midnight against
policy.budget.daily_usd:
{"spent_usd": 3.4, "cap_usd": 20.0}
cap_usd is null when policy.budget.daily_usd is unset. GET /work-items/{id}'s budget_cap gains the same two numbers under daily
({spent_usd, cap_usd}); its three existing keys (cap_usd, source,
spent_usd) are unchanged and stay the item's own, all-time spend.
POST /work-items?dry_run=1 runs every check POST /work-items runs, up to
and including validating node_overrides, then answers 200 without filing
anything -- no row, no bead, no attachment copy, no spawn. A body that would
answer 422 or 503 on an ordinary create answers the same on a dry run.
{
"dry_run": true,
"nodes": [...],
"skipped": [{"node": "spec", "why": "covered_by", "kind": "spec"}, {"node": "...", "why": "skip"}],
"gates": ["spec_approval", "..."],
"caps": {
"budget_usd": 5.0,
"budget_source": "item",
"daily_usd": 20.0,
"nodes": {"implement": {"attempts": 3, "wall_clock_s": 7200}}
}
}
nodes is the chain that would be filed, in chain_definition's own node
shape. skipped lists every node the attachments or skip_nodes dropped:
covered_by names the attachment kind that made it redundant, skip is an
operator's own choice. gates is the ids of the gate nodes left in the chain.
caps.budget_usd/budget_source mirror the create route's own cap
resolution ("item" for the body's own budget_usd, else "policy");
caps.nodes is each fix-loop node's attempts/wall_clock_s, resolved the
same way the walk resolves them at run time -- the node's own policy,
node_overrides entry and any item-wide override each layered on in the same
order, so a node's own override and an item-wide value each show where they
apply.
Events paging and the run summary
GET /work-items/{id}/events normally returns every event after after_seq
(0 by default). before_seq pages backward instead: it returns the last
limit events with seq < before_seq, oldest first, in the same row shape --
100 when before_seq is given with no limit. limit alone bounds
after_seq's own forward window to the first limit events after it (1-500;
outside that range, or passing both cursors at once, answers 422). Every
event row carries a node_id (see Events).
GET /work-items/{id} adds summary, a run's progress at a glance:
{"nodes_done": 3, "nodes_total": 8, "gates_passed": 1, "step": {"index": 2, "count": 2} | null}
nodes_done/nodes_total count against the frozen chain; gates_passed is
how many of the completed nodes were gates. step is the current node's step
(1-based) out of its own steps, read from its latest non-escalation session's
task, when the node declares more than one -- null otherwise.
GET /api/work-items/{id}/diff and GET /api/work-items/{id}/compare take an
optional ignore_whitespace=true, like git diff -w. A file whose only change
is whitespace then drops out of files, the per-file counts and diff together,
so the three never disagree; touched_by and untracked do not follow it. Both
answers carry ignore_whitespace, the value they used.
GET /api/work-items/{id} carries fix_target while a gate is pending: the
node a request-changes review restarts at, the nodes then that run before it
returns to the gate, and the round (n of max) a rejection now would start,
counted against the cap the gate's reject loop already snapshotted. It is null
with no gate pending. GET /api/work-items/{id}/fix-target?node=&gate= answers
for a target the reviewer picked; with no gate pending it gives what a gateless
request_changes would target, with reason one of requested (you named the
node), threads on <path or node>, current node, or gate. It answers 400
for a node that is not before the gate, 409 for a gate that is not pending or an
ended item, and does git work per thread, so call it on demand.
GET /api/work-items/{id}/compare marks each file viewed. PUT and
DELETE /api/work-items/{id}/viewed?file=<path>&to=<target> set and clear the
mark and answer {file, to, viewed}; to is a compare target (default latest)
and file a repository-relative path. A mark belongs to the file's content at
to, not to the from side, so a file that has not changed since you ticked it
stays ticked in any other comparison and a new attempt that edits it clears the
tick. DELETE clears the mark on that content wherever it was made. The routes
answer 404 for a target the item does not have and 400 for a path outside the
repository.
The schema
FastAPI generates a schema of every route:
| Path | What it serves |
|---|---|
/openapi.json | The OpenAPI schema, as JSON. |
/docs | Swagger UI for that schema. |
/redoc | ReDoc for that schema. |
curl -s http://127.0.0.1:8765/openapi.json | jq '.paths | keys'
These three paths need no login. Open /docs or /redoc in a browser to read
the API there. Both pages load their scripts from a public CDN, so on a machine
without internet access, load /openapi.json into your own OpenAPI viewer.
Who may call
| Caller | What it needs |
|---|---|
| A process on the same machine, while Kraft is bound to loopback | Nothing. |
| Anyone, once Kraft is bound off loopback | A browser session from the login page, or the MCP bearer token in $KRAFT_HOME/run/mcp-token, sent as Authorization: Bearer <token>. |
| A trigger sender, once Kraft is bound off loopback | For POST /api/triggers only, the trigger token in $KRAFT_HOME/run/trigger-token, sent the same way. |
| A remote caller while no password is set | Nothing works. Kraft answers 403. |
A request without valid credentials gets 401. A browser request whose Host
is not an allowed name, or a cross-site write, gets 403. The MCP bearer token is
a full-access credential. See Security for the whole
model, and Remote access to set a password.