Skip to content

REST API ​

Coordination's HTTP surface, on 5526. It is the twin of the MCP tools — same commands and the same rules. One naming difference: where an MCP tool takes agent (who is asserting), a route takes by, in the body or as ?by=, because the person behind a button and an agent both write through it.

For the run receiver's routes on 5527 — ingestion, runs, exports, the Event Model wire — see Ports and wire contracts.

What is gated, and what is not ​

Stoat is free on its own machine: a request from a loopback address with no forwarding header is never refused. A Stoat that other machines reach is Stoat Team, which needs a CritterStack licence, and the table is what a caller on another machine meets once the check is enforced.

Licence-gatedEverything under /api/plans, /api/agents, /api/repositories, /api/memories and /api/runners, reads included. With no active licence a remote caller gets 402 with the same envelope the MCP tools return.
Not gatedWhat Bobcat (MIT) calls: the run monitor (POST /api/ingest, /api/runs, /api/ping, /api/event-model, and the Event Model tools' warnings under /api/event-model-warnings) and the runner wire (POST /api/runners/events, GET /api/runners/{id}/commands). And GET /api/license and GET /api/about, which say whether this caller is local and what the server's licence is.

The 0.6.0 preview does not check the licence for other machines either. See Installation.

Licence and version ​

Both are open, on every port, and answer for the caller that asks.

Route
GET /api/license{ licensed, message, local, enforced, tier, organization, expiresAt, isExpired, keyPresent, keyValid }. licensed is whether this caller may use the coordination surface: always true from the server's own machine (local), and from another machine only with an active licence once enforced. The rest is what the server's key says: tier is Free with no key and Development in the Development environment.
GET /api/about{ version, license, docs, releaseNotes, licensePage }: the server's version, the same licence object, and where the docs, the release notes and a licence are. The web UI's About Stoat dialog reads it.

The agent roster ​

Stoat reads Claude Code's own files under ~/.claude to see the sessions on this machine, and joins them to claims.

Route
GET /api/agentsThe whole board: every session, every working tree, and the claims nothing could be linked to.
GET /api/agents/{sessionId}One session, or 404.
GET /api/agents/{sessionId}/runsThe test runs tied to the session, newest first, each with its link: session (the run says this session started it), claim (tagged for a node it holds) or inferred (from its checkout — a guess, and labelled as one). 404 for a session the roster does not know.
GET /api/agents/{sessionId}/journalWhat the session did, oldest first: its hook-captured starts, ends, commits and pull requests, joined with its claims, the memories it recorded in Session scope, and its runs. Each line has a source — hook, claim, memory or run — so the agent's own words read apart from what was observed. 404 when nothing is recorded.
POST /api/agents/hooksSession capture: a Claude Code hook's own payload, posted verbatim (curl --data-binary @-). Records SessionStart, SessionEnd, and a Bash PostToolUse whose output shows a commit or a new pull request; everything else records nothing. Always 202 — a hook never waits on Stoat. See the hook setup.
POST /api/agents/session-contextWhat a session opening hears: the SessionStart hook (on startup, clear and compact) posts its payload and prints the answer, Claude Code's hookSpecificOutput.additionalContext: the repository's ready nodes with their skills, the claims on it, and its memories, at most five of each. An empty 200 for a repository Stoat does not know, which is the ordinary answer.
POST /api/agents/{sessionId}/declareDeclare yourself: { agent, host, cwd?, repository?, branch?, intent?, activity?, leaseMinutes? }. 409 naming the holder when another agent has a live declaration for the id.
POST /api/agents/{sessionId}/activity{ agent, activity?, leaseMinutes? }: say what you are doing and renew. 400 without a live declaration.
POST /api/agents/{sessionId}/depart{ agent, reason? }: leave as departed rather than lapsed.

The reads are licence-gated like every route under /api/agents. Stoat observes sessions, it does not own them, so there is no "kill this agent" route. The session id is the key, so /api/agents/{sessionId} is a stable link to one agent.

A session's lastActivityAt is its last state transition (a tool call is not news to the store); activeAt is when it last did anything, its latest tool call as the 5-second session sweep read it, never older than lastActivityAt. It is held in memory only, so after a restart it equals lastActivityAt until the first sweep. The board lists sessions by it, newest first.

The declarations are license-gated like the rest of the work loop. They are the REST twins of declare_agent / report_activity / depart_agent, and the way onto the roster for an agent this box cannot observe, because one on another machine can always reach HTTP. Each answers { recorded, declaration }, where recorded: false means the command was accepted but was not news.

What it answers that a lease cannot ​

The lease answers "will this eventually let go". The roster answers "is anyone working it right now", by joining each claim to the session holding it: a claim on its own cannot be stalled, and a session on its own cannot be orphaning anything.

Claim health ​

heldThe session holding it is running.
stalledThe wait-trap. The session is alive but idle — it said it was working and then stopped between turns. The lease still stands, so the node looks taken and nothing is happening to it.
orphanedThe session is gone. The claim outlives it until the lease expires — this is what makes that expiry explainable instead of mysterious, and it carries the stop cause as the reason.
unknownA session was correlated, but we cannot tell what it is doing.
unlinkedNo session correlated. Stoat knows the claim and nothing about who is working it, and says so rather than implying health it has not got.

Stoat marks, it never releases. A stalled claim is still a claim: the lease is the mechanism, and quietly freeing a node because a session looked idle would be Stoat deciding something it merely observed. Marking is the whole intervention.

Claim health also rides on each node's status view, so the DAG shows it without a second request.

Tree ownership ​

Sessions grouped by the git root above their cwd. A worktree is its own root, so two agents in two worktrees of one repository are correctly not sharing.

  • contested — more than one live session in a tree.
  • needsCleanup — a .claude/worktrees tree whose sessions are all gone and which is still on disk: a worktree an agent died inside.

Correlation says how strong it is ​

claim and report take an optional session (agents pass $CLAUDE_CODE_SESSION_ID) — that is the strong link. Without it, a claim is matched to the single live session whose repository matches the node's, and reported as inferred.

Ambiguity refuses to guess: two live sessions on one repository leaves the claim unlinked — and that ambiguity is itself a contested tree the roster surfaces.

The work loop ​

The REST twin of next_ready_nodes / claim_node / report_node / release_node, for an agent that can reach HTTP but has no MCP client. Without these, such an agent can author and observe plans yet works unclaimed, and the board's claims column lies by omission.

GET /api/plans/ready ​

Ready work across every plan, or one with ?plan=, sorted by leverage. Each entry carries claimedBy when someone holds it. A ?plan= naming no valid plan answers 404.

POST /api/plans/{slug}/nodes/{id}/claim ​

json
{ "agent": "my-agent", "leaseMinutes": 30, "session": "$CLAUDE_CODE_SESSION_ID" }
200{ claimed: true, expiresAt, node, readyWarning } — readyWarning is set when the node is not ready, rather than refusing: its dependencies may not be done, and that is worth saying but not worth blocking.
409{ claimed: false, heldBy, heldUntil } — another agent holds a live lease.
400The node is already done — there is nothing to claim. Or agent is missing.
404No such plan or node.

leaseMinutes defaults to 30 (at most 240). A cloud agent adds "where": "cloud" and its session's page as "link"; the card then says the holder runs in the cloud and links it. where is cloud or nothing, and link an http(s) URL, else 400.

POST /api/plans/{slug}/nodes/{id}/report ​

json
{ "agent": "my-agent", "note": "reproduced; the fix is in the parser" }

Attaches or replaces the claim's note and renews the lease. Only the holder can — anyone else gets 409 telling them to claim first. link (a cloud agent's session page) may ride a report too.

On an issue node it also takes pr (the number of the PR you opened, in the node's repository: linked however its body is worded, and never closing the node) and the scope report: leftOut (what the issue asks for that you did not do; [] clears it), scopeDone and scopeTotal. Anything left out makes the node partial until a person accepts it.

POST /api/plans/{slug}/nodes/{id}/release ​

json
{ "agent": "my-agent" }

Status stays whatever observation says. Finishing is proven by the issue closing or the suite passing, never by this call. The answer carries gateRunning when a stoat gate exec of the node is still going: its result is not in until it prints its exit code.

POST /api/plans/{slug}/nodes/{id}/cost ​

json
{ "agent": "stoat-runner", "costUsd": 22.46, "session": "0d7c6a3e-…", "turns": 229, "model": "claude-sonnet-5-5" }

What one agent attempt on the node cost, as its launcher reported it. run-plan posts it as each attempt exits, from Claude Code's total_cost_usd; the claim may be released by then, and it still lands, but a node no agent ever claimed is refused (400). It is kept on the node's claim stream across claims: the node's status carries spentUsd, the plan's spentUsd and costedAttempts. Asserted, and a floor: an attempt that was killed reports nothing. No MCP twin: an agent does not report its own spend.

POST /api/plans/{slug}/nodes/{id}/fix-attempt ​

json
{ "cause": "base-moved", "attempt": 1, "max": 5, "head": "aaa1111", "pullRequest": "JasperFx/marten#5852", "baseTip": "5851aaa", "by": "stoat-runner" }

A resume run-plan spent on the node's fix budget: own (red CI, an owed gate, a conflict on a head the agent pushed) against --max-fix-attempts, or base-moved (a conflict another PR's merge made) against --max-rebases, with the base tip it was spent on. Kept on the node's claim stream across claims; the node's status carries fixBudget (fix and rebase, each used, max and the pr it was spent on, and the spent keys), which the card's PR line and a restarted runner read. A node no agent ever claimed is refused (400). No MCP twin.

DELETE /api/plans/{slug}/nodes/{id}/fix-attempt?key=…&by=… ​

Gives one resume back by its key (a usage limit or a person's question stopped it), or with no key clears the node's fix budgets (a restart of the node). Answers { "changed": true|false }.

POST /api/plans/{slug}/nodes/{id}/steps ​

json
{ "agent": "my-agent", "steps": [
  { "id": "repro", "title": "Reproduce it" },
  { "id": "gate", "title": "Run CommitGate", "gate": "CommitGate" },
  { "id": "specs", "title": "Specs green", "runTag": "my-plan/my-node" } ] }

Declares, or replaces, the holder's checklist on the node. A step whose id survives keeps the state it reached. runTag names the run that observably proves the step.

POST /api/plans/{slug}/nodes/{id}/steps/{stepId} ​

json
{ "agent": "my-agent", "state": "failed", "note": "two suites red", "exitCode": 1 }

state is pending / running / done / failed / skipped. Both step endpoints are holder only, and anyone else gets 409, as for a report. Steps appear on the claim in GET /api/agents, each with the agent's state and, separately, any observed run evidence. They never change the node's status.

POST /api/plans/{slug}/nodes/{id}/questions ​

json
{ "agent": "my-agent", "text": "Keep the old wire name?", "options": ["keep", "rename"],
  "recommended": "keep", "needs": "person", "session": "$CLAUDE_CODE_SESSION_ID" }

The holder asks a person something, the twin of ask_person. It does not park: it answers 200 with the question, and the agent reads the answer off the node later (GET /api/plans/{slug}/nodes/{id} or /conversation). needs is agent (the plan's live lead may answer) or person; left out, the lead while one is alive, else a person. One open question at a time; 409 when the asker does not hold the node. A question never changes the node's status, and dies with the claim.

POST /api/plans/{slug}/nodes/{id}/questions/{questionId}/answer ​

json
{ "answer": "keep", "by": "jeremy" }

A person's answer, whichever way the question is routed. This is the card's ? answer; the conversation endpoint below is the chat's. No MCP tool answers a question marked for a person: the lead's answer_question takes only one marked agent.

Resident plan runners ​

stoat run-plan … --watch registers as the plan's runner. Its routes, all under the licensed /api/plans:

Route
POST /api/plans/runners/{runnerId}/registerThe runner: { runnerId, plan, projectDir, repository?, host, pid, paused }. 404 for no active plan.
POST /api/plans/runners/{runnerId}/statusThe runner: { state, running[], callouts[], detail?, workers[]? }. Each worker is an agent process it holds (stoat#271): { node, session, worktree, attempt, maxAttempts, kind, startedAt, model?, fixAttempt?, maxFixAttempts?, log? }, kind one of launch / fix-ci / rebase / fix-gate / test-resolution / answer / continue / restart / resolve / base-fix; fixAttempt only on a follow-up kind. A runner that predates it sends none. 404 tells it to register again.
POST /api/plans/runners/{runnerId}/acksThe runner: { commandId, accepted, reason? }.
GET /api/plans/runners/{runnerId}/commandsThe runner's command stream (server-sent events, command events, Last-Event-ID to resume).
GET /api/plans/runnersEvery runner, with connected (its stream is open), state, running nodes, workers, callouts and recent commands. A gone or disconnected runner has no running nodes, no callouts and workers: []; workers: null is a connected runner that predates the field.
POST /api/plans/runners/{runnerId}/commandsA person: { kind, node?, by, text? }, kind start / pause / resume / restart-node / dismiss-question / answer-question (with a node of the plan; answer-question with the answer as text). 202 with the command id.
GET /api/plans/{slug}/runner-start?repository=owner/nameWhat Start… would run for that repository on this machine: the exact command, the settings it came from, and every reason it would be refused. checkout, launcher, parallel and agentArgs in the query override the stored settings.
POST /api/plans/{slug}/runner-startStart it: { repository, checkout?, launcher?, parallel?, agentArgs?, by }. 202 with what ran and its log; 400 with every refusal; 403 for any request that is not Stoat's own page on this machine (another machine, a proxy, a non-loopback Host, no X-Stoat-Person header, or a foreign Origin). Off with Stoat:RunnerStarts=false.
GET /api/plans/{slug}/runner-start/log?repository=owner/nameThe last runner the page started for that repository: its log's tail, and whether it has registered or exited. 404 when none.
PUT /api/repositories/{owner}/{name}/runner-launch{ checkout, launcher?, parallel?, agentArgs?, by }: how the page starts that repository's runner on this machine, without starting one. Same 403 rule.

The runner also records what only it knows, with no MCP twin: an attempt's cost and fix-attempt (above), the words an agent's attempt ended on (POST …/nodes/{id}/agent-question, and …/agent-question/delivered once it handed over the answer), the issue it created for an approved unbound node (POST …/nodes/{id}/realize, { issue, by }), a PR its worker opened that does not close the issue (POST …/nodes/{id}/linked-pr, { number, repo?, url?, by }), and the pin-bump PR a consume node rides (POST …/nodes/{id}/bump-pr, { repo, number, url, by }).

A node's conversation ​

Route
GET /api/plans/{slug}/nodes/{id}/conversationWhat is waiting on an answer (a question parked on the claim, with options, recommended and timedOutAt, or an exited agent's question a runner holds), the agent's context (holder, last report, session, link, its log tail while a resumed agent works), every question and answer so far as entries, and the runner's answer-question commands for the node.
POST /api/plans/{slug}/nodes/{id}/conversationA person's reply, { text, by }: to the agent parked on the claim (200, sent: claim), else to the plan runner holding an exited agent's question, which resumes its session with it (202, sent: runner, kept on the plan's Activity), or to a runner's fix callout (waiting.callout: fix), where it resumes the agent with the reply as its brief and one more attempt, recorded as the person's (stoat#263). 409 when nothing waits.

Test-run gate evidence ​

A gate whose suite reports to no Bobcat monitor is proven by stoat gate exec, which posts these:

Route
POST /api/plans/{slug}/nodes/{id}/gate-runsA command started: { runId, step?, command, commit?, dirty, host?, pid?, processStartedAt? }. The caller mints runId, so a retried POST is the same run. The pid and start time are the wrapper's own: when that process is gone from this machine, an unfinished run reads as abandoned at once.
POST /api/plans/{slug}/nodes/{id}/gate-runs/{runId}/finishIt exited: { exitCode, durationSeconds?, outputTail? }. The tail is the last 1 KB of both output streams, shown on the card and the Activity as description, never as evidence. 400 for a run the gate never saw.
POST /api/plans/{slug}/nodes/{id}/gate-resultAn agent's asserted result, { passed, by, note? }, the twin of report_gate_result. The answer says whether it counts.
POST / DELETE /api/plans/{slug}/nodes/{id}/allow-asserted?by=A person allows, or stops allowing, asserted results to decide this gate. Relayed over MCP only by the plan's live lead (allow_asserted_results).
POST /api/plans/{slug}/nodes/{id}/accept-stale?by=A person accepts a gate's stale pass (a dirty tree, or behind its release branch) as it stands, so what depends on it goes on; refused for a gate with no stale pass. A newer run replaces it. Relayed over MCP only by the plan's live lead (accept_stale_gate_pass).
DELETE /api/plans/{slug}/nodes/{id}/accept-stale?by=Take that acceptance back: the gate reads stale again.
POST /api/plans/{slug}/nodes/{id}/reverify?by=A person asks for a done gate to be verified again: every pass from before now stops counting, so the gate is ready again. Relayed over MCP only by the plan's live lead (reverify_gate).

Each is 400 for a node that is not a test-run gate and 404 for no such plan or node. Every start, finish and assertion is an append to the gate's one evidence stream, so steps run side by side (stoat gate run --parallel) race on it; an append that loses the race is retried a few times after a short random pause, and only a conflict still standing after every attempt is answered 409. A gate's status says what decided it in evidence: run (a Bobcat run under its tag) outranks exec (stoat gate exec), which outranks asserted. A weaker level is heard only when no stronger one has spoken, so a green exec run never outvotes a red Bobcat run. That order is a gate with no steps. A gate with declared steps is decided by its steps alone: a run under the bare {plan}/{node} tag is named in detail as unassigned and never counts, and each step reads the newer of its Bobcat run and its gate exec --step run.

A person's controls ​

Each of these records a person's decision on the plan's own stream, with who and when. No worker can use one: an agent able to approve, expedite or auto-merge its own work is no checkpoint. Each has an MCP twin that only the plan's live lead may call, relaying a person's word with relayFor, recorded as that person's with the lead as relayedBy (see Relaying a person's decision). Each takes { "by": "..." } (or ?by= on a DELETE), and none is ever part of an export, apart from heavy and exclusive, which are fields of the node. See A person's controls for what each one does to a run.

Route
POST / DELETE /api/plans/{slug}/nodes/{id}/approveThe go-ahead for the agent loop to claim this issue node. 400 for any other kind. Shown as approvedForAgents: { by, at }; changing or rebinding the node clears it.
POST /api/plans/{slug}/approveApprove all: { "nodes": [...], "by": "..." }, one go-ahead per node with the same who and when. A node that went done, lost its binding or was approved since is skipped and named. 400 for an empty list, an unknown node, or one that takes no go-ahead.
DELETE /api/plans/{slug}/approve?node=a&node=b&by=Withdraw all: those nodes' go-aheads back.
POST / DELETE /api/plans/{slug}/nodes/{id}/expediteRun this node next. It changes the runner's order only; the go-ahead, dependencies and resources still apply. Kept through a node change, cleared by a rebind, and ignored once the node is done.
POST / DELETE /api/plans/{slug}/nodes/{id}/cloudKeep the node for a cloud agent: the runner never takes a local slot for it. A node whose plan declares agent: cloud refuses the DELETE (edit the node). The status carries cloud: { by, at, declared } and cloudMarkable.
POST /api/plans/{slug}/nodes/{id}/merge{ "merge": "manual-review" | "merge-on-green", "by": "..." }: the card's merge pill. Unlike a node change, it keeps the go-ahead. The only way to turn merge-on-green on besides a person's YAML import.
POST /api/plans/{slug}/notes · POST /api/plans/{slug}/nodes/{id}/notesA person's note on the plan or a node: { text, links?, by }. Asserted, shown on the card's ✍ and in the Activity, never exported, and never changes status. An agent's notes ride its claim instead.
POST /api/plans/{slug}/nodes/{id}/machine-use{ heavy?, exclusive?, by }: mark the node heavy (one at a time on the machine) or exclusive (the machine to itself). Unlike a node change, it keeps the go-ahead.
POST / DELETE /api/plans/{slug}/nodes/{id}/accept-partialAccept a partial node's work as enough: it counts as done, with what was left out still said. DELETE sends it back.
POST / DELETE /api/plans/{slug}/nodes/{id}/doneMark a person's node done, or undo it. 400 for any other node: every other node is done by evidence.
PUT /api/plans/{slug}/agent-cap{ cap, by }: how many agents may hold claims on the plan at once, across every runner and every agent; a null cap clears it.
PUT /api/plans/{slug}/realize-issues{ on, by }: let the plan's runners create the issue for an approved unbound node.
PUT /api/plans/limits{ repository?, limit?, release?, reason?, by }: a repository's session limit, or the machine's with no repository. 0 is the hold, a null limit clears it, and release: true restores the limit from before a hold. stoat limit sends the same.
POST /api/plans/{slug}/nodes/{id}/base-fix{ by }: add the fix node for the default branch this node's merge turned red, as a person, or answer the one that exists. A runner sends it with fromRunner: true, and takes back only its own untouched proposal with DELETE …/base-fix?by=&why=.
DELETE /api/plans/{slug}/nodes/{id}/agent-question?question=&by=Dismiss the question an agent's attempt ended on, kept on the plan: the node may run again. People only.
DELETE /api/plans/{slug}/lead?by=&reason=Clear the plan's lead agent, alive or not. The lead takes it with lead_plan. People only.

Approving a gate's asserted results and re-verifying a gate are a person's too; they are with the gate evidence above.

A review node ​

Route
POST /api/plans/{slug}/nodes/{id}/reviewThe reviewer (the claim holder): { agent, findings: [{ title, brief, severity?, steps?, cause?, repository?, fixKind?, evidence? }], summary?, session? } (a test-run gate's holder too, stoat#270), the twin of submit_review. Submitting again replaces only the undecided findings.
POST /api/plans/{slug}/nodes/{id}/findings/{findingId}/acceptA person accepts a finding: { node?, by }. Its fix node (a test-run gate by default, titled and briefed by the finding; node edits it first) is added, and the review is linked to depend on it.
POST /api/plans/{slug}/nodes/{id}/findings/{findingId}/rejectA person rejects it: { reason?, by }.
POST /api/plans/{slug}/nodes/{id}/review/sign-off?by=A person signs off a review that found nothing. An empty review is never done until then.

Only submitting has an MCP twin. A review that could decide its own findings would decide its own completion.

Plans ​

Reads:

Route
GET /api/plansActive plans.
GET /api/plans/{slug}One plan's full DAG.
GET /api/plans/{slug}/statusPer-node derived status plus the ready set. runner is the plan's runner (state: none, running, paused with paused.by / paused.at, …; parallel, slotsInUse), and each ready node the runner would launch carries launch (reason: paused, waiting-for-slot, no-runner, next, …; detail).
GET /api/plans/{slug}/historyEvery event on the plan's stream, with who and when. 404 for a stream with no events — the plan never existed here.
GET /api/plans/{slug}/timeline?limit=The plan page's Activity, newest first (default 100, at most 2000): the plan's own stream joined with each node's claim, gate-run and GitHub streams, one line per change naming its node, who (with the session for a claim), and whether it was asserted or observed. Only news: a renewal is a line when its note changed, a GitHub sweep only for what moved.
GET /api/plans/{slug}/exportThe plan as a schema-1 YAML document, as text. Importing it back (PUT) appends nothing.
GET /api/plans/archivedArchived plans, newest archive first: slug, title, colour, node count, when and by whom.
GET /api/plans/{slug}/nodes/{id}One node, zoomed in (below).
GET /api/plans/{slug}/nodes/{id}/githubThe GitHub text the node is about, on demand (below).
GET /api/plans/skillsEvery skill the active plans' nodes use: what stoat setup checks this machine for.
GET /api/plans/feedsThe local folder feeds this server watches, by name: what stoat setup registers with NuGet.

Authoring — the same commands the MCP tools send, with by (in the body, or ?by=) naming who:

Route
POST /api/plansCreate: { slug, title?, anchor?, repos?, repoBranches?, color?, branch?, by }. repoBranches is the branch each repository's consume pins are read from, alias or org/name → branch.
PATCH /api/plans/{slug}The header: { title?, anchor?, repos?, repoBranches?, color?, branch?, by }; omitted fields keep their value, repoBranches: {} clears them.
PUT /api/plans/{slug}?by=&checkout=Import a YAML document (raw body): creates the plan or diffs it in.
DELETE /api/plans/{slug}?by=Archive.
POST /api/plans/{slug}/nodes · PUT /api/plans/{slug}/nodes/{id}Add, or replace wholesale: { node: { … }, by, checkout? }.
DELETE /api/plans/{slug}/nodes/{id}?by=Remove; refused while anything depends on it.
POST / DELETE /api/plans/{slug}/nodes/{id}/depends-onLink or unlink: { dependsOn, by }.
POST /api/plans/{slug}/nodes/{id}/bind{ repo?, issue?, pr?, by }.
PUT /api/plans/{slug}/resourcesThe plan's shared resources, replaced whole: { resources: { "postgres": 1 }, by }.
POST /api/plans/rescanRe-import the plans directory (below).

A merge-on-green in a person's import is honoured; the MCP tools refuse it. With checkout (or ?checkout= on the import) naming a local clone, each dotnet test gate step is checked against it (the project exists, is a test project, targets the -f framework), and what is wrong comes back as warnings beside the result. Without one, this plan's runner's checkout on this machine is used, and with neither nothing is checked. A warning never refuses the save.

TIP

The depends-on endpoints take the dependency in the body or query, not the path — a cross-plan reference is plan/node, and a slash in a path segment does not survive routing.

POST /api/plans/rescan re-imports the Stoat:PlansPath directory, which startup also does. The store is the master copy: a file creates a plan the store has never had, and is otherwise ignored — whatever it says, and whether the stored plan is active or archived. So a stale file cannot undo an edit made over the API or MCP, and an archived plan stays archived. The itemized report names each file's outcome: imported, stored, archived, purged or failed. To change a stored plan from a document, PUT /api/plans/{slug} (or import_plan) — importing it there is the deliberate act, and diffs it in, removals included.

One node ​

GET /api/plans/{slug}/nodes/{id} is everything Stoat knows about one node in one read: what the plan page's node modal shows, and what the get_node MCP tool returns.

Field
nodeThe card's own view, exactly the node's entry in /status: status, PRs with CI and review, claim health. The zoom never disagrees with the card.
readinessWhy it is or is not ready, in words: ready, waiting on `fix` , ready, awaiting a go-ahead, ready, held back until postgres has room, running now. Presentation over the derived status; nothing reads it back.
url, brief, repositoryWhere its binding lives, the author's brief (Markdown), and the repository it is attributed to.
dependsOn, unblocksIts neighbours across plans, each with plan, id, title, kind, status and ready: what it waits on, and what depends on it directly.
claimThe node's latest claim, live or not: agent, claimedAt, expiresAt, lastActivityAt, live, released, session, the holder's note and its steps.
questionsEvery question asked on that claim, answered and open.
notesEvery note a person left on the node, oldest first.
gateRunsA test-run gate's stoat gate exec runs, newest first, with exit code and output tail.
runnerThe plan's resident runner, when one registered: whether it is connected, whether an agent of it is running this node now, its callouts about the node, and resume (cd <worktree> && claude --resume <session>) when a callout carries one. A runner that is not connected reports no callouts.
timelineThis node's lines of the plan's Activity, newest first (at most 100).
recallThe repository's notes, and the memories filed under the plan and the repository, newest first: what recall would give an agent starting it.

404 for no such plan or node, or a plan with document errors.

GET /api/plans/{slug}/nodes/{id}/github fetches the GitHub text the node is about, through the GitHub sweep's own token: an issue node's issue (title, body, labels, milestone, its ten most recent comments and the total) and its closing PRs, a pr node's PR. Each PR carries its description, state, review decision, mergeability, diff stat (additions, deletions, changedFiles), recent comments and every check on its head, failing ones first. A failing check carries its summary and first annotations, and a failing GitHub Actions job the last 40 lines of its log. Answers are cached for a minute (?refresh=true asks again) and never stored in the plan. When there is nothing to read, unavailable says why: no GitHub token, a node not bound yet, a kind with no GitHub thing behind it, or GitHub's own refusal. Bodies are GitHub's Markdown as written; the page renders them without ever injecting HTML.

POST /api/plans/{slug}/purge ​

Hard archive: delete an archived plan for good, with its claims (and their steps) and its publish baselines. Body { "confirm": "<the slug again>", "by": "..." }. The plan's YAML is written to Stoat:PurgedPath (default ~/.stoat/purged/{slug}-{timestamp}.yaml) before anything is deleted, and importing that file is the only way back. A plans-directory file for a purged plan is not brought back (purged in the rescan report); an explicit PUT or create_plan of the slug still works.

People only: there is no MCP tool. 400 when confirm is not the slug, 404 for no such plan, 409 when the plan is active (archive it first) or an active plan depends on one of its nodes. Run evidence and memories filed under the plan are not touched.

Repositories ​

GET /api/repositories and GET /api/repositories/{owner}/{name} — read. Registering, declaring and note-taking are MCP-only.

GET /api/repositories/icons — every repository with an icon of its own, slug → the URL to load it from. A repository with none is left out, so the page asks for nothing it cannot show.

GET /api/repositories/{owner}/{name}/icon — the icon itself, cached a day by the browser, 404 when the repository has none. An SVG is served sandboxed, with no scripts allowed, so an icon opened on its own never runs in Stoat's origin.

GET /api/repositories/lanes — one lane per repository: its agents, every plan's items attributed to it (being worked, ready, waiting; done counted), and its five most recent runs.

Two repository facts are a person's, each with by (and reason) as a query string or a JSON body, relayed over MCP only by a plan's live lead:

Route
POST / DELETE /api/repositories/{owner}/{name}/ci-offTurn the repository's CI off on purpose, with a reason, or back on. See CI off.
POST / DELETE /api/repositories/{owner}/{name}/gates/{gate}/stand-inLet a declared commit or push gate stand in for CI for merge-on-green, or take that back. See Local gates.

Runners ​

A Bobcat suite in resident mode asks Stoat for work. The runner is always the client; see Running specs from the UI.

Route
POST /api/runners/eventsThe wire's one inbound endpoint: a structured-mode CloudEvent (application/cloudevents+json) of type bobcat.runner.registered, bobcat.runner.command.acknowledged or stoat.runner.status. Any other type is 400 and is never even deserialized. Not licence-gated, like ingestion.
GET /api/runners/{runnerId}/commandsThe runner's command stream, text/event-stream, held open. Each event is a CloudEvent (stoat.runner.command.run or stoat.runner.command.restart) in data, with its type as the SSE event and its id as the SSE id. Send Last-Event-ID on reconnect. keepalive events arrive about every 15s. 404 for a runner that has not registered.
POST /api/runners/{runnerId}/commandsA person asks for a run: exactly one of { specs: [...] }, { feature } or { slice: { plan, node } }, plus mode (cold by default) — or { restart: true }. Licence-gated. 400 for a spec the runner did not register, or a mode it does not offer.
GET /api/runners/for?spec=…&spec=… · ?feature=The runners that can run it: every named spec registered, or at least one in the feature. Listening runners first. Empty is an answer, not a 404. What the run buttons' picker asks.
GET /api/runners · GET /api/runners/{runnerId}The runners: repository and its canonical slug, branch, lane, modes, connected, status, and recent commands, each with the runId it produced once known.

Memories ​

What agents have learned, the REST twins of the memory MCP tools. Licence-gated, reads included, like every coordination route.

Route
GET /api/memoriesBrowse, newest change first. ?scope=repository/JasperFx/stoat (a scope path), kind=, repeated tag= (AND-ed), archived=true, page=, pageSize=.
GET /api/memories/search?q=The same recall recall runs, best first, each hit naming the legs that found it. Takes the same filters, and limit=.
GET /api/memories/facetsThe scopes, kinds and tags in use, with counts.
GET /api/memories/{id}One memory in full: body, links both ways, and its whole history. 404 for none.
POST /api/memories/{id}/revise{ by, title?, body?, tags?, kind?, confidence?, reason? } — only what is given changes. 400 for an immutable memory.
POST /api/memories/{id}/refile{ by, scope, scopeKey? }
POST /api/memories/{id}/links{ by, targetId, relationship }
POST /api/memories/{id}/archive{ by, reason? } — out of recall and listings; the history stays.
POST /api/memories/{id}/restore{ by }

There is no delete: a memory is an event stream, and archive is the verb that can be undone.

The run monitor's ingest receipt ​

POST /api/ingest reads its batch one record at a time. A record whose type this console has no contract for (a newer Bobcat's additive kind) is skipped and counted, never fatal: the records beside it are still recorded, and the 202 carries a receipt, { accepted, skipped, unknownKinds, malformed }. A body with no events array is still the sender's error (400). GET /api/ingest/skipped is the count since the console started, by kind, for the question "why are this publisher's events not showing up". It is a person's read, not a call Bobcat makes, so unlike POST /api/ingest it is licence-gated.

GET /api/runs/{id} also carries progress.passed / failed / retried / workersBusy (the verdict split of a supervised run's live count; null from a Bobcat that does not send it) and the summary's droppedEvents (events the publisher dropped under load; null when none).

See also ​

Licensed under the Business Source License 1.1.