nextv1.4.0
Reference

HTTP API

Which of Kraft's HTTP routes are stable, where the full schema is, and who may call them.

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.

RouteUse
POST /api/triggersFile a work item from a webhook or script. See Inbound triggers.
GET /api/healthCheck 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):

FieldMeaning
display_statusThe board's status badge: one of archived, done, cancelled, paused, running, waiting, needs_you, escalated, failed.
stopnull 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:

PathWhat it serves
/openapi.jsonThe OpenAPI schema, as JSON.
/docsSwagger UI for that schema.
/redocReDoc 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

CallerWhat it needs
A process on the same machine, while Kraft is bound to loopbackNothing.
Anyone, once Kraft is bound off loopbackA 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 loopbackFor POST /api/triggers only, the trigger token in $KRAFT_HOME/run/trigger-token, sent the same way.
A remote caller while no password is setNothing 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.

Copyright © 2026