nextv2.0.0
HTTP API

Drafts

The routes the Templates and Settings editors use to keep, change, publish and discard drafts, the policy and intake-check routes beside them, and the `result` they answer.

These are the routes for the drafts behind the Templates and Settings editors, the policy and intake-check routes beside them, one entry per route, and the result every draft route answers. The editors are their callers; a script rarely needs them. Like the other board routes they can change in any release, as Filing, reading and changing work items says. The ops a draft takes are on their own page.

RouteWhat it does
GET /api/draftsLists every open draft.
GET /api/drafts/{area}/{key}Reads a draft, or the published files when there is none.
GET /api/drafts/{area}/{key}/fragment?path=Reads one component of a draft as YAML.
GET /api/drafts/policy/{key}/preview?chain=Shows a published chain's caps under the policy draft.
PUT /api/drafts/{area}/{key}/files/{file}Stores one file's text as typed.
POST /api/drafts/{area}/{key}/opsApplies a list of ops, all or nothing.
POST /api/drafts/{area}/{key}/ops?preview=1Answers what a list of ops would do, and saves nothing.
POST /api/drafts/{area}/{key}/undoUndoes the last request.
POST /api/drafts/{area}/{key}/publishWrites the draft's files and applies them.
POST /api/drafts/{area}/{key}/rebaseKeeps the draft's text over files that changed on disk.
DELETE /api/drafts/{area}/{key}Discards a draft for good.
GET /api/policyReads policy.yaml from disk.
PUT /api/policySaves policy.yaml without a draft, and applies it.
GET /api/intake/checks?limit=Lists what recent intake polls found.

Areas and keys

The Templates editor keeps unpublished edits on the server, as drafts. There is one draft per area and key. File names are relative to the config directory.

AreaKeyFilesA publish applies it by
chainsA chain id: a lowercase letter, then lowercase letters, digits, _ or -chains/<key>.yamlReloading the library and chains.
librarylibrarylibrary.yamlReloading the library and chains.
harnessesharnessesharnesses.yaml, policy.yamlReloading the policy, only when policy.yaml is in the draft.
reposreposrepos.yamlNothing: repos.yaml is read when needed.
policypolicypolicy.yamlReloading the policy, as PUT /api/policy does.
intakeintakeintake.yamlRereading intake.yaml and replacing the poller.

An op can join more files to a chain or library draft. The move_to_library op adds library.yaml to a chain's draft, and renaming a library component adds each chain file it rewrites to the library's. Each joined file records its own base, and a publish writes them all. A PUT also takes any file an op joined, such as the new file of a chain the rename op moved, which keeps its old key.

A settings draft is one of the last four areas. Its problems are every problem its files have, found by the checks the matching PUT route runs, so a draft with none is one that route accepts.

The harnesses and policy drafts may both hold policy.yaml: the first publish wins, and the second answers 409, which rebase resolves. A publish whose apply step fails has already written its files: it answers 500, keeps the draft, and tries again on the next publish.

Every route below that takes {area} and {key} refuses an unknown area with 404, a key the area does not accept with 400, and (for PUT) a file that is not one of the key's files with 422.

Reading drafts

GET /api/drafts

Lists every open draft, newest write first.

[
  {
    "area": "chains",
    "key": "default",
    "files": ["chains/default.yaml"],
    "changes": 2,
    "problems": 0,
    "updated_at": "2026-10-07T09:12:44.123456+00:00"
  }
]

files are the file names. changes and problems are counts as of the draft's last write.

GET /api/drafts/{area}/{key}

Reads the draft, or, when there is none, the published files and their result.

{
  "area": "chains",
  "key": "default",
  "draft": true,
  "files": {"chains/default.yaml": "id: default\n…"},
  "base": {"chains/default.yaml": "3b1f…"},
  "published": {"chains/default.yaml": "id: default\n…"},
  "updated_at": "2026-10-07T09:12:44.123456+00:00",
  "result": {"problems": [], "changes": [], "…": "…"}
}
FieldMeaning
drafttrue when a draft is open. With none it is false, and files are the published ones.
files{file: text}: the published files with the draft's text laid over them. A file the draft deletes is null.
base{file: digest}: the SHA-256 of each file's text when it joined the draft, null for a file that did not exist. With no draft, the digests of the published files.
publishedThe text of each file as it is on disk, null for one that does not exist yet: the left side of a diff.
updated_atThe draft's last write, or null with no draft.
resultWhat the draft resolves to. See The result object.
addonOn a chains key: {id, version} of the Kraft add-on the chain comes from, else null.
addon_libraryOn the library key: the loaded add-ons' components, as {section: {"release:implementer": definition}}. null on every other area. They are never in files.

An add-on's chain is read under its qualified id: GET /api/drafts/chains/release:ship. It answers 200 as published, with draft false and addon set: it never has a draft. Its file is shown with the add-on's own references written out as release:<name>. Every other route on that key (PUT, ops, undo, publish, rebase, DELETE) answers 409, with <id> comes from add-on <addon id>; extend it or copy it to your library.

Refused with 404 for a chains or library key that has neither a file nor a draft. A settings area answers for a file that is not there yet, because an op creates it.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

GET /api/drafts/{area}/{key}/fragment?path=

Reads the YAML of the component at path (a task, a node, a policy block), from the draft when there is one, else from the published file. The text is what the set_fragment op takes back.

{"path": "implementation.main.implement", "text": "id: implement\n…"}

On a repos draft path is a repo's path, and the answer is read-only, since there is no set_fragment for it. The other settings areas have no fragments.

Refused with 400 when path is missing, and with 404 when nothing is at path or the area has no fragments.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

GET /api/drafts/policy/{key}/preview?chain=

Shows what a published chain's caps are under the policy draft. The key is policy. It is a read: the draft does not change.

{
  "chain": "default",
  "scopes": [
    {
      "path": "default",
      "kind": "chain",
      "level": "work_item",
      "caps": {
        "time_cap_minutes": {"value": null, "source": "default", "maximum": null, "exceeds": false}
      }
    }
  ]
}

Each scope is the chain itself, then every node, step and task. A gate waits for a person and has none. kind is chain, node, step or task, and level is the policy level it reads its caps at. Every scope lists all four caps (time_cap_minutes, total_time_cap_minutes, token_budget, budget_usd), each with these fields:

FieldMeaning
valueWhat the scope runs under, or null when no layer sets one.
sourcechain, library:<section>.<name>, policy or default.
maximumThe {value, level} that bounds it at its level, or null for no bound.
exceedsWhether value is past maximum.

Refused with 400 for a key that is not policy, 404 for a chain that is not in the library, 422 for a missing chain, a draft whose policy.yaml does not load or a chain that does not resolve, and 503 when the template library failed to load.

Changing a draft

PUT /api/drafts/{area}/{key}/files/{file}

Stores one file's text as typed, comments kept. The first write makes the draft. A write whose files parse to the published ones drops the draft. {file} is the file's name with its folder, such as chains/default.yaml.

Body: {"text": "…"}. Answers as GET /api/drafts/{area}/{key} does. A text that does not parse is stored, and the answer's result carries a yaml_error.

Consecutive PUTs to one file less than 4 seconds apart are one undo step.

Refused with 422 for a file that is not one of the key's files, unless an op joined it to the draft.

An unknown {area} answers 404, a {key} the area does not accept 400, and a file that is not one of the key's files, unless an op joined it, 422, as under Areas and keys.

POST /api/drafts/{area}/{key}/ops

Applies the ops in the body in order, all or nothing, as one undo step. A chains or library draft is written back whole, so its comments are dropped, and warnings says so until a PUT of that file. The settings areas write over each file's own text, so their comments stay.

Body: {"ops": [{"op": "add_step", …}]}. Answers as GET /api/drafts/{area}/{key} does, plus ops, one {op, result?} per op in the order sent, result being that op's own answer.

{"…": "the fields of the GET answer",
 "ops": [{"op": "add_step", "result": {"path": "spec.step_2"}}, {"op": "set_field"}]}

Refused with 409 ("fix the YAML first") when the draft has a yaml_error. An op whose path, container or node addresses an add-on's component answers 409 {detail, op}, naming the add-on.

An op that cannot apply answers 422 {detail, op}, and nothing is saved. op is the op's index in the list, or null when the refusal comes from writing the files back, as when a settings file's layout cannot take the change. A refusal can add keys of its own: a set_fragment that does not parse adds line and col.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

POST /api/drafts/{area}/{key}/ops?preview=1

Answers as POST /api/drafts/{area}/{key}/ops does, without saving: the draft, its undo steps and the draft list stay as they were. It takes the same body and is refused in the same cases.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

POST /api/drafts/{area}/{key}/undo

Undoes the last request and answers as GET /api/drafts/{area}/{key} does. Undoing back to the published state drops the draft. A draft keeps its last 80 requests. No body.

Refused with 409 when there is nothing to undo.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

Ending a draft

POST /api/drafts/{area}/{key}/publish

Writes each file of the draft (a file the draft deletes is removed), applies them as the areas table says, and drops the draft. No body.

{"published": ["chains/default.yaml"], "changes": [], "result": {"…": "…"}}

published is the names of the files written. changes is what was published, as the draft's result.changes listed it. result is the state after, with no draft left.

A publish checks each file against the one on disk when the file joined the draft. If any changed since, for example through a save on the Settings screens, it writes nothing. A file that already holds the draft's text is not counted as changed, so a retry after a 500 answers 200, not 409. The refusal:

{"detail": "published since this draft began: chains/default.yaml",
 "files": {"chains/default.yaml": {"published": "…", "draft": "…", "diff": "--- published/chains/default.yaml\n+++ draft/chains/default.yaml\n…"}}}
StatusWhen
404There is no draft.
409A file changed on disk since it joined the draft. The body is the example above.
422The draft has a yaml_error or any problem. The body is {detail, problems}.
500The files were written but applying them failed. The draft stays.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

POST /api/drafts/{area}/{key}/rebase

Takes the published files as the draft's new base and keeps its text, so the next publish no longer answers 409. It is the way out of a conflict when the draft should win. No body. It pushes no undo step. Answers as GET /api/drafts/{area}/{key} does.

Refused with 404 when there is no draft.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

DELETE /api/drafts/{area}/{key}

Discards the draft for good, with no undo. Answers 204 with no body.

Refused with 404 when there is no draft.

An unknown {area} answers 404 and a {key} the area does not accept 400, as under Areas and keys.

Settings routes

These routes read and save policy.yaml, and read the intake poll log, with no draft. A policy draft is checked the way PUT /api/policy checks a save.

GET /api/policy

Reads policy.yaml from disk. It also answers active_count (the items running now), and its max_concurrent is the value the loaded policy runs under. A body sent back to PUT /api/policy may keep active_count; it is ignored.

PUT /api/policy

Saves policy.yaml and makes the running server use it. The body is merged over the file on disk, top-level key by key: a key the body leaves out is kept, and a key it sends as null is removed. A save never drops what it did not edit. An unparseable file has nothing to keep, so the save replaces it.

FieldTypeDefaultMeaning
loopsobjectrequiredThe loops: entries, by fix loop.
defaultobjectrequiredThe top-level default: block, beside loops: attempts and wall_clock_s.
max_concurrentinteger3, when the file has none eitherHow many items run at once. At least 1.
findings, budget, archiveobjectthe file'sThe blocks of the same names.
triggerslistthe file'sThe schedules policy.yaml still holds, which still fire: see When a schedule fires. Schedules otherwise live in intake.yaml.
rate_limit_retries, auto_escalate_stuck, auto_escalate_stuck_cap, auto_escalate_delay_sinteger or booleanthe file'sThe keys of the same names.

Any other top-level key of policy.yaml is accepted and checked by the loader, such as defaults, maxima and forge_cli_timeout_s. policy.yaml lists every key.

The answer is the saved file's content. The new policy applies at once: a loop cap applies to loops that start after the save, and a counter already running keeps the cap it started with.

StatusWhen
422A required field is missing, a field has the wrong type or is out of range (max_concurrent: 0), or the file the merge would write does not load: the answer's detail is the loader's message. Nothing is written.

GET /api/intake/checks?limit=

Answers what each recent poll found, newest first. limit defaults to 20 and is at most 100; the newest 500 polls are kept. A check is not a work item event.

[{"id": 12, "at": "2026-10-07T09:12:44.123456+00:00", "ready": 1, "started": [],
  "skipped": [{"bead": "Kraft-9d8b2", "reason": "epic"}]}]
  • ready is how many issues in beads were ready.
  • started is the work item ids the poll filed.
  • skipped is the issues it left alone, each {bead, reason}.
  • reason is one of above_priority_ceiling, already_item, epic, max_concurrent, daily_budget, storage or invalid_config. storage is while worktrees are over the storage limit.
  • With invalid_config, bead is null when policy.yaml or repos.yaml did not load and the poll could not run, and the bead's id when its item could not be filed.

Refused with 422 for a limit outside 1 to 100.

The result object

result is the same on every route that answers a draft:

FieldHolds
modelPer file, the mapping it parses to. For a file that does not parse, the newest earlier state that did, else the published file.
resolvedFor a chain, the chain with extends expanded, as GET /api/templates/chains/{id}/resolved shows it, or null when it does not resolve. null for the library. A settings area has its own shape, with its ops.
problems[{path, field, message, file, line, col}]: everything a save would refuse, with every schema error listed. See result.problems.
sourcesFor a chain that resolves, {path: {field: {value, source}}} for each node, step, task and fix loop: its own fields, and each policy.* cap of its scope. See result.sources.
changes[{path, kind, summary, fields?}] against the published files, kind being add, change, remove or rename. See result.changes.
impactWhat a publish reaches. See result.impact.
warnings[{file, message}] for a file an edit rewrote whole, whose published text has comments a publish would drop.
policy_values{auto_escalate_delay_s, auto_review_attempts}, the instance's values from policy.yaml.
choicesThe closed sets the editors offer, by field: ref (a builtin task's actions), target (a forge task's targets), inputs (what an agent task can ask for) and grants. See result.choices.
yaml_errorOnly when a file does not parse: {file, line, col, message}.

result.problems

  • path is the component's canonical path (implementation.main.implement), and field the key below it.
  • On the library, problems lists everything PUT /api/templates/library would refuse, checked against every chain and repo.
  • Each library problem has chain or repo: the one it breaks, with both null when the problem is in library.yaml itself, where path is the component, such as tasks.implementer.
  • Each library problem also has component: the library component it comes from, or null.
  • A repo's problem is at path steering in repos.yaml.
  • A settings draft's problems carry keys of their own, listed with each area's resolved answer on Draft ops.

result.sources

source is:

  • chain (the file sets it at that path);
  • library:<section>.<name> (the nearest library component it inherits it from, such as library:tasks.implementer or library:nodes.verification);
  • policy (a cap no layer sets, at the value the scope runs under); or
  • default.

A subprocess, builtin or forge task leaves out steering and the agent-only caps unless it sets them. With an on_failure recovery it lists those caps with recovery: true, since the recovery runs under them.

result.changes

  • A reordered component is a change whose summary says moved.
  • A chain-level key that differs (description, policy) is one change row at the chain's own path, "", its summary naming the keys.
  • A chain the rename op moved to a new id is one rename row at "", with from and to the old and new ids.
  • On the policy and intake areas each row is one dotted key of a file, {path, kind, summary, file}. summary reads "was → now" (not set, or no bound for a maximum), and a list of mappings (schedules:) is keyed by index (schedules.0.cron).
  • On those two areas, a file that does not parse, or that the draft deletes, has one file-level row instead, {path, kind, summary, fields, file}, path being the file name.
  • On the harnesses area there is one row per changed file (path is the file name), then access.<harness> rows (summary reads available -> never), and harnesses.<id> and profiles.<name> rows.
  • On the repos area each row is one repo entry: path is the repo's path, and fields the keys that differ. The file-level row for repos.yaml stays only when no entry changed, such as a file that does not parse.
  • On the library, path is a component path (tasks.implementer, nodes.verification.review.code_review), and each change adds reaches, the ids of the chains that use the component, as published or as drafted.

result.impact

  • For a chain: {running, repos}, the items still running it and the repos whose default it is.
  • For the library: {chains, repos}, the chains any change reaches and the repos whose steering: names a changed profile.
  • For harnesses: {tasks, chains}, the count of tasks whose harness changed state and the ids of the chains they are in.
  • For repos: {running: {<repo path>: n}}, the items not ended, per repo path.
  • For policy and intake: null.

result.choices

Each of ref, target, inputs and grants is a list of {value, summary}, and a target also has waits, true for one that runs as an external wait.

Copyright © 2026