HTTP endpoints¶
--serve PORT|HOST:PORT PROJECT, the project daemon, answers the JSON
paths below and serves the console's built files at /. --serve with no
project, the host daemon, answers the same paths for each registered
project under /projects/NAME and its own /status and /attention at
the root (The host daemon); every section below
describes a project's route as either daemon answers it. Every response carries
Cache-Control: no-store and Access-Control-Allow-Origin: *; the JSON
ones Content-Type: application/json; every GET route opens the store
read-only and closes it. The open origin is for the console page, which
one daemon serves and which fetches the others from the browser: without
the header the browser refuses a cross-origin answer. The daemon reads by
default and writes only through two opt-ins, documented in
The daemon's actions: the POST /actions/... routes
[serve] actions = true opens, with a host daemon's board writes and
its root's PUT /prefs, and the PUT /config route
[serve] config_edit = true opens. On loopback the bind address is the
whole boundary for reads, and beyond it every JSON route but /peers is
behind a bearer token (Authentication). Unknown paths
are 404 and any method but GET is 405, both with a JSON error, except
those writing routes. A project with no store answers 503.
console/contract/ holds the machine-readable shapes of GET /status,
GET /attention and GET /peers, of the host root's GET /status,
GET /attention and GET /prefs, of the run routes GET /runs/N,
GET /runs/N/merge, GET /runs/N/asks, GET /runs/N/files,
GET /runs/N/turns and GET /runs/N/turns/T/transcript, and of the list routes
GET /shipped, GET /history, GET /holds, GET /ledger, GET /board,
GET /tickets/KEY and GET /tickets?q=TEXT, of the scout reads GET /scouts, GET /scouts/ID
and GET /scouts/routes, and of POST /tickets/check: one
JSON Schema per route, generated from holophyte/serve/contract.py.
GET /status¶
{
"project": "/path/to/repo",
"schema_version": 35,
"admission": "enabled",
"hold_note": null,
"workers": 1,
"active_routes": {"implementer": {"command": "claude"},
"reviewer": {"command": "claude", "fallback": "claude"},
"adjudicator": {"command": "codex"},
"writer": {"command": null},
"trimmer": {"command": null},
"implementer_profiles.sonnet": {"command": "claude"}},
"route_probes": {"implementer": {"primary": {"ok": false,
"at_ms": 1788450354491, "duration_ms": 1800,
"why": "exit 1", "age_ms": 180000}, "fallback": null},
"reviewer": {"primary": null, "fallback": null},
"adjudicator": {"primary": null, "fallback": null},
"writer": {"primary": null, "fallback": null},
"trimmer": {"primary": null, "fallback": null},
"implementer_profiles.sonnet": {"primary": null,
"fallback": null}},
"route_labels": {"implementer": "claude opus", "implementer_fallback": null,
"reviewer": "codex gpt-5.6-sol",
"reviewer_fallback": null, "adjudicator": "codex gpt-5.6-sol",
"adjudicator_fallback": null, "writer": "claude opus",
"trimmer": "claude opus", "trimmer_fallback": null,
"implementer_profiles": {"sonnet": "claude sonnet"}},
"workers_on_previous_build": 0,
"host": "writer-1",
"now": 1788450534491,
"toil": {"24h": {"interventions": 3, "merged": 2, "per_merge": 1.5,
"by_action": {"requeue": 2, "babysit": 1},
"by_actor": {"maintainer": 1, "operator": 2, "supervisor": 1,
"unknown": 0}},
"7d": {"interventions": 5, "merged": 3, "per_merge": 1.6666666666666667,
"by_action": {"requeue": 2, "approve": 1, "babysit": 1,
"steer": 1},
"by_actor": {"maintainer": 1, "operator": 4, "supervisor": 1,
"unknown": 0}}},
"daemon": {"started_ms": 1788446934491, "pid": 2801590},
"supervisor": {"state": "live", "pid": 2801613, "heartbeat_age_ms": 8258, "host": "writer-1"},
"thresholds": {"heartbeat_stale_ms": 300000, "strikes": 2, "run_cap": 3.0},
"actions": false,
"config_edit": false,
"colour": "auto",
"runs": [
{"id": 52, "ticket": "KO-219", "ticket_url": "https://linear.app/example/issue/KO-219",
"title": "The sweep frees a silent lease", "phase": "working",
"started_ms": 1788450461675, "heartbeat_age_ms": 71989, "elapsed_ms": 72816,
"working_ms": 61200, "work_started_ms": 1788450511675,
"agent_ms": 61200, "verify_ms": 0, "run_count": 1,
"verify_started_ms": null,
"time_box_ms": 1500000, "round": 0, "max_rounds": 2, "strikes": 0,
"host": "writer-1",
"stop_requested": null, "stop_action": null, "step": null,
"pr_url": null, "pr": null, "woke_ms": null}
],
"queued": [
{"ticket": "KO-220", "ticket_url": null, "title": "Name the held project",
"reason": "approved for merge; the next claim resumes the candidate at the merge gate",
"pr_url": "https://github.com/example/repo/pull/41", "waiting_ms": 95000}
],
"witnesses": {"story": "KO-200", "keys": ["W3", "W4", "W5", "W6"],
"started_ms": 1788450294491, "elapsed_ms": 240000}
}
runs lists every live run in a sweepable phase. Ages are computed by the
daemon against its own now, so a client compares one number to
thresholds.heartbeat_stale_ms and never has to agree with the writer
host about the time. A row reports its ticket's chain of runs: every
run of the ticket up to the live one since the ticket's last merged run,
so a run sent back to the babysitter, parked on CI, paused or failed and
requeued counts toward the live one. run_count is the number of runs in
the chain, 1 for a first attempt. started_ms is the chain's first run's
start as epoch milliseconds; round is the review rounds the live run
has recorded so far, so a run in reviewing is in round round + 1;
max_rounds is the review rounds the run may take: the cap its claim
recorded, else the newest an earlier run of its ticket recorded since
the ticket's last merged run, else the project's [loop] review_rounds; strikes is
the sweep's tally for the run, 0 when it is not under suspicion.
ticket_url is the ticket's page on the board (tickets.url), null when
the store has none. elapsed_ms is wall time since started_ms.
working_ms is the work the chain has recorded plus, while the live run's
span of work is open, the time since that span began, null when no run of
the chain had its work measured; agent_ms and verify_ms are summed over
the chain the same way; work_started_ms is when the open span began, as epoch
milliseconds, and null while no span is open,
so a client interpolates work between polls only from work_started_ms
and never from elapsed_ms. agent_ms is the part of working_ms the
time box is read against and verify_ms the rest, the time spent in the
ticket's verify commands (the supervisor's trip still judges the live
run's own agent time, not the chain's); agent_ms is null when working_ms is, and
verify_ms is null for a run recorded before the split, whose work all
counts as agent_ms; verify_started_ms is set only while the open span is a
verify, else null. time_box_ms is the box the run is counted
against, the estimate scaled by [agents] budget_scale, null for a
ticket with no estimate; thresholds.run_cap is the hard ceiling in
multiples of that box, so a time-box bar can draw it. stop_requested is
the note of a pause or abort the operator has asked of the run and the
loop has not yet acted on, and stop_action that request's action
(pause, abort or abort_close); both
are null when none is pending. step names the turn a merge_gate run
is in when that turn fixes failing checks, fixing checks (round N of
M), and is null otherwise.
pr_url is the pull request the run opened or carries (runs.prUrl),
null when it has none; pr is that pull request's number, checks,
review, threads, open_threads, title, state, additions,
deletions, changed_files, preview_url, screenshots and videos
exactly as on a
pr_open item of GET /attention, read from the run's prSeen*
columns, and null when pr_url is. woke_ms is when the factory woke
the pull request to fix it: the run's startedAt (epoch milliseconds)
when an earlier run on its ticket carried the same pull request and
parked in awaiting_merge_approval, and null otherwise.
supervisor.state is live, stale or none. daemon describes the
serving process: its pid and when it started. project is the
repository the daemon serves, as a path. schema_version is the
store's schema version (its PRAGMA user_version). admission is the
project's admission state (store/enums.py ProjectAdmission): enabled,
held or disabled; while it is disabled, runs is empty.
workers is the most runs the loop keeps at once, the project's
[loop] workers.
queued lists the project's tickets waiting for the next claim: ready,
with no active run and an ended last run, oldest wait first, whatever the
admission state. A ready ticket that never ran is not in it.
A ticket in the Backlog column is not in it, nor one in Canceled. Each
entry carries the ticket's ticket, ticket_url and title, the last run's
outcome reason as reason and its pull request as pr_url (null without
one), and waiting_ms, the daemon's now minus the run's end.
witnesses is the story witness set the loop's witness step is running
now, null when none runs: the story's ticket as story, its witness keys
in story order as keys, the set's start as started_ms (epoch
milliseconds) and elapsed_ms, the daemon's now minus that start. It is
read from witness-running.json beside the store, which the witness step
writes while it holds the witness lock, and is null when that file is
missing, does not parse or names a process that has exited. A hand-run
--witness-pass and an approval's baseline run write no record.
hold_note is the note of the latest admission change, null when there
is none.
actions is whether
[serve] actions = true opened the POST /actions/... routes of The daemon's actions; the
console draws its action buttons disabled while it is false.
config_edit is whether [serve] config_edit = true opened GET /config
and PUT /config; the console's settings sheet is read-only, naming the
key, while it is false. colour is the project's [console] colour
(auto, violet, teal, green or blue), auto when unset, read
from the file on each request so a saved colour shows on the next poll;
an older daemon leaves it out. route_labels names what each seat is configured
to run, labelled as a recorded turn is: the command's first word plus its
-m/--model value. A seat left unset shows the default the loop
dispatches, an unset writer or trimmer the implementer's label, and an unset
fallback label null. implementer_fallback, reviewer_fallback,
adjudicator_fallback and trimmer_fallback name the configured fallback
using the same label format. implementer_profiles maps each configured
[agents.implementer_profiles.NAME] profile's name, such as sonnet, to
its label, the label its turns record; it is {} for a project with no
profile, and an older daemon leaves it out. route_probes holds each seat’s latest primary
and fallback probe, each null when absent or the probe file is unreadable.
An entry carries ok, the end time at_ms in epoch milliseconds,
duration_ms, and age_ms (now minus at_ms). why is null on success,
otherwise the failure reason, redacted and limited to 200 characters;
commands and output tails are never recorded. Probes run at loop start
and before a fallback is used, not on a timer. The loop keeps the newest
entry per seat and route in route-probes.json under the project’s
holo_dir, using a lock and atomic replacement.
active_routes holds one entry per seat
(implementer, reviewer, adjudicator, writer, trimmer) naming what the seat
runs now: command is the executable alone, never its arguments, null for
a seat left unset in [agents]; while a running loop has switched the seat
to its fallback, command is the fallback's and the entry also carries
fallback, naming it. A configured trimmer that a running loop turned
off, its probe and trimmer_fallback both failed, shows command null and
down true, unlike an unset one's bare null. Each configured implementer
profile has its own entry, keyed implementer_profiles.NAME (as
implementer_profiles.sonnet), shaped like a seat's: command is the
profile's executable, and while a running loop has switched that profile's
own implementer to implementer_fallback, command and fallback name the
fallback's. A profile's switch leaves implementer as it is, and the
default implementer's switch leaves a profile's entry as it is. The claim's
probe of a ticket's profile is kept under the same key, so route_probes
reports it like a seat's. workers_on_previous_build counts the workers a
restarted loop inherited from the build it replaced and still owns, 0
when there are none. toil is the operator's hand work per merge over
the last 24h and the last 7d before now, the same windows as
--report's toil lines: interventions counts the interventions with
source human in the window, project-level ones such as a hold
included; merged counts the runs that ended merged in it; per_merge is
their ratio, null when merged is 0; by_action counts the interventions
by action, most frequent first, ties by name. by_actor counts every
intervention in the window, the supervisor's included and migrate rows
left out, by the interventions.actor that acted: maintainer (a daemon
action under any token but the machine token), operator (the command
line, MCP, or a daemon action under the machine token, unless its body's
author is maintainer, as holo --author maintainer sends it) and supervisor
(the supervisor and the loop); unknown counts the rows written before
the store recorded an actor.
Every host passes through [report] host_label.
GET /runs?limit=N¶
{"rows": [
{"run": 312, "ticket": "KO-241", "actual_min": 8.4, "estimate_min": 10.0,
"ratio": 0.84,
"rounds": 1, "outcome": "merged", "host": "writer-1", "ended_ms": 1788478953000,
"merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f"}
], "limit": null}
The --report table as JSON, oldest first, the same rows in the same
order the terminal prints. run is the run's id, as /runs/N takes it,
which the table does not print. ended_ms is the run's end as epoch
milliseconds, which the table does not print; the drawer ages the last
merge from it. merge_sha is the full merge commit a merged run landed
on main as, null for any other outcome or a run merged before the store
recorded it. ?limit=N keeps the first N rows and echoes limit; a
non-positive or non-integer limit is 400.
GET /shipped?limit=N&before=RUN_ID&outcome=merged¶
{"rows": [
{"id": 312, "ticket": "KO-241", "title": "Run detail: files touched",
"rounds": 1, "findings": 2, "started_ms": 1788478203000,
"ended_ms": 1788478953000, "actual_min": 8.4, "working_ms": 504000,
"agent_ms": 432000, "verify_ms": 72000, "wall_min": 12.5,
"run_count": 2, "turn_count": 3, "estimate_min": 10.0,
"merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"commit_url": "https://github.com/example/repo/commit/5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"pr_url": "https://github.com/example/repo/pull/2170",
"host": "writer-1", "outcome": "merged", "outcome_reason": null}
], "next_before": 298, "limit": 50}
Finished runs, newest end first, ordered by ended_ms descending then
id descending. A row is the run that closed its ticket's chain of runs:
a run that merged, or the newest run of its ticket. A run that later runs
of its ticket continue (sent back to the babysitter, parked on CI, paused,
or failed and requeued) has no row of its own; its figures count in the
row of the run that closed the chain. The chain runs from the run after
the ticket's last earlier merged run through the closing run.
id, ended_ms, outcome, outcome_reason, merge_sha, pr_url and
host are the closing run's. started_ms is the chain's first run's
start, and wall_min the minutes from it to ended_ms, CI waits and
parks included. working_ms, agent_ms, verify_ms and actual_min
(working_ms in minutes) are summed over the chain's runs, each null only
when no run of the chain had its work measured; rounds is summed over
the chain's runs. run_count is the number of runs in
the chain and turn_count the number of agent turns /runs/N/turns
lists, summed over them. outcome=merged (the default) returns only merged runs;
outcome=all returns every run that closed its chain, whatever its
outcome. Each row includes outcome and
outcome_reason (the stored reason cut at 400 characters, null when absent).
Any other outcome is 400 naming the parameter and its value. The console's
Shipped view scrolls back over it grouped by day; the Board's "shipped
today" is its first page. findings is the count of findings over the
chain's review rounds. limit defaults to 50 and is capped at 200; the
body echoes the limit applied. before=RUN_ID answers the rows that
ended before that run's end (ties broken by id), so a client pages by
passing next_before back; next_before is the last row's id while
more rows remain and null on the last page. A non-positive or
non-integer limit, or a non-integer before, is 400 with error
naming the parameter; a before no run has is 200 with no rows.
/runs is untouched: it stays the terminal's table, oldest first.
commit_url is the merge commit's page on the project's origin:
https://HOST/OWNER/REPO/commit/SHA when the origin URL is
https://HOST/OWNER/REPO(.git) or git@HOST:OWNER/REPO(.git) and the
sha is an ancestor of origin/main in the project's checkout. It is null
when the row has no merge_sha, the project has no origin, the remote
is of another shape (including one carrying a ? query, # fragment
or credentials, which would otherwise ride into the link), or the sha has not reached origin/main (a local
merge never pushed, one rewritten on the way up, a fresh clone with no
origin/main yet), so a link is only ever to a page that exists. The
remote is read once per request and the ancestry checked once per row;
a git failure is null, never an error.
pr_url is the pull request the run merged through under [merge] mode
= "pr", as the store recorded it when the run parked (runs.prUrl);
null for a run that opened none, including every run merged locally.
Nothing is read from GitHub: the URL is the one the factory itself
opened, so its last path segment is the PR number.
GET /holds?limit=N¶
{"admission": "held", "note": "schema bump", "rows": [
{"id": 3, "at": 1791568920000, "action": "hold",
"note": "schema bump", "actor": "maintainer"},
{"id": 2, "at": 1791568800000, "action": "release_hold",
"note": "Back to normal.", "actor": "operator"},
{"id": 1, "at": 1791553200000, "action": "hold",
"note": "Waiting for the migration.", "actor": null}
], "limit": 20, "truncated": false}
The project's current admission (enabled, held or disabled) and
note, as /status answers admission and hold_note, with its admission
changes in rows. Each row carries its id, at in epoch milliseconds,
action (hold, release_hold or disable), note as recorded (including
any console prefix, or null), and actor (maintainer, operator or
supervisor, or null for older records). Rows belong only to this project
and are ordered by at descending, then id descending.
limit defaults to 20 and is capped at 200. The body echoes the applied
limit; truncated is true when more rows exist than it returns. There is
no paging past limit. A non-positive or non-integer limit is 400 with
an error naming limit. A store with no row for the project answers
admission: "enabled", note: null and empty rows; a project with no
store answers 503. On a host daemon this route is
GET /projects/NAME/holds?limit=N.
Releasing a hold requires a reason, just like holding: holo release requires
note, and POST /actions/release-hold rejects a blank note. The Release
UI and its required-reason form are covered by HOLO-403.
GET /history?since=MS&until=MS&limit=N&offset=N&sort=KEY&order=asc|desc&q=TEXT&state=GROUP,…&failure=KIND,…&implementer=LABEL,…¶
{"rows": [
{"id": 231, "ticket": "KO-260", "ticket_url": "https://linear.app/example/issue/KO-260",
"title": "Run page: chain strip", "attempt": 2, "phase": "reviewing",
"outcome": null, "outcome_reason": null, "failure_kind": null,
"park_kind": null, "started_ms": 1788478203000, "ended_ms": null,
"elapsed_ms": 540000, "working_ms": 480000, "estimate_ms": 1800000,
"heartbeat_age_ms": 12000, "rounds": 1,
"max_rounds": 2, "findings": 2, "merge_sha": null, "commit_url": null,
"additions": 120, "deletions": 14, "resumed_as": null,
"implementer": "claude opus", "pr_url": null, "host": "writer-1"},
{"id": 225, "ticket": "KO-255", "ticket_url": null,
"title": "Ledger: fold resolutions", "attempt": 1, "phase": "failed",
"outcome": "failed", "outcome_reason": "verify failed: ruff check .",
"failure_kind": "verify", "park_kind": null,
"started_ms": 1788470000000, "ended_ms": 1788471200000,
"elapsed_ms": 1200000, "working_ms": null, "estimate_ms": null,
"heartbeat_age_ms": null, "rounds": 0,
"max_rounds": 2, "findings": 0, "merge_sha": null, "commit_url": null,
"additions": null, "deletions": null, "resumed_as": null,
"implementer": null, "pr_url": null, "host": "writer-1"}
], "since": 1788393600000, "until": null, "limit": 200, "offset": 0,
"sort": "started", "order": "desc",
"truncated": false, "total": 2,
"counts": {"state": {"active": 1, "merged": 0, "failed": 1, "abandoned": 0},
"failure": {"verify": 1, "review_route": 0, "fix_no_progress": 0,
"no_commits": 0, "budget": 0, "merge_lock": 0,
"infra": 0, "swept": 0, "unclassified": 0},
"implementer": {"claude opus": 1}},
"implementers": ["claude opus"],
"now": 1788478743000}
Without until, returns one row per run for the console's Runs page:
every run that has not ended, whenever it started, plus every run that
started at or after
since, ordered by started_ms descending then id descending. A run
that a later run of its ticket continues keeps its own row; nothing is
folded into a chain as /shipped folds it. A run that ended before
since is absent, a paused one included.
With until, only ended runs whose start is in [since, until) are
returned, in the same order. Open runs, including parked runs, are absent.
since is required; it and optional until are epoch milliseconds.
Each row is the run's id, ticket, ticket_url, title, attempt and
phase; outcome
(null while open); outcome_reason (the stored reason cut at 400
characters, null when absent); failure_kind (the failed run's kind,
null unless it failed); park_kind (what a parked run waits on, as the
store recorded it); started_ms and ended_ms (null while open);
elapsed_ms, from the start to ended_ms, or to now while open;
working_ms, measured working time through ended_ms, or through now
while open (null when never measured); estimate_ms, the ticket estimate
snapshotted when the run was claimed (null when it had none);
heartbeat_age_ms, now minus the run's last heartbeat while open, else
null; rounds, the run's own review rounds (those not in error);
max_rounds, its cap as /runs/N answers it; findings, the findings
the run's own review rounds recorded, counted as /shipped counts them;
merge_sha and commit_url, as /shipped serves them (commit_url
null unless the sha is on origin/main); additions and deletions,
the pull request size last recorded for the run (null when none was);
resumed_as, for a run whose outcome is paused, the id of the next run
of the same ticket after it, whether or not that run is in the answer (null
for a paused run with no later run, and for every run not paused); and
pr_url and host, as /shipped spells them; and implementer, the
label in the payload of the run's newest implementer_profile event (null
when it has none, as every run before those events began reads). An older
daemon omits findings, merge_sha, commit_url, additions, deletions,
resumed_as and implementer.
Optional parameters page, sort and narrow the list; without any of them the route answers as above. A row is served only when it matches every one given:
offset, a non-negative integer (default 0), skips the firstoffsetmatching rows after sorting;limitthen cuts what follows.sortis one ofstarted(the default),ended,duration,roundsandstate, andorderisascordesc(the default).startedorders bystarted_ms;endedbyended_ms, an open run later than every ended run;durationbyelapsed_ms, an open run's measured tonow;roundsbyroundsas the row serves it;stateby the state groups in the order active, merged, failed, abandoned, then a paused run a later run resumed. Ties under every key fall back tostarted_msdescending, theniddescending, so a sort is stable across pages.qmatches a run whose ticket key or title contains the text, ignoring ASCII case;%and_match only themselves, and a blankqis absent.stateis a comma list ofactive(not ended, or paused with no later run of its ticket),merged,abandoned(ended with that outcome) andfailed(ended with any other outcome butpaused,rejectedincluded). A paused run that a later run resumed is in no group, so anystateleaves it out.failureis a comma list of failure kinds (verify,review_route,fix_no_progress,no_commits,budget,merge_lock,infra,swept,unclassified), matching a run'sfailure_kind.implementeris a comma list of labels, matching a row'simplementer; labels hold no comma, so the list splits on commas after URL decoding.
The body echoes since, until (null when omitted), the limit applied,
and offset, sort and order as served, and carries total, the number
of matching runs before offset and limit, and now, the daemon's clock
the open rows were measured against. limit defaults to 200 and is capped
at 5000; truncated is true when matching runs remain beyond offset +
limit. counts holds the per-value counts beside each filter choice:
state (each group), failure (every failure kind) and implementer
(every label in implementers), each map counting the rows that match
every parameter given except its own, before offset and limit, with 0
for a value nothing matches. implementers is the sorted distinct labels
of every implementer_profile event in the store.
A missing or non-integer since, a non-integer until, a non-positive or
non-integer limit, a negative or non-integer offset, or a sort,
order, state or failure value not listed above, is 400 with an
error naming the parameter and any value it got. An until at or before
since is 400 with "until must be after since".
GET /runs/N¶
{"run": {"id": 52, "ticket": "KO-219", "ticket_url": "https://linear.app/example/issue/KO-219",
"title": "The sweep frees a silent lease",
"phase": "done", "attempt": 1, "started_ms": 1788450461675,
"ended_ms": 1788451661675, "outcome": "merged",
"outcome_reason": null, "failure_kind": null, "park_kind": null,
"elapsed_ms": 1200000, "working_ms": 912000, "agent_ms": 840000,
"verify_ms": 72000, "time_box_ms": 1500000,
"branch": "task/ko-219-the-sweep-frees-a-silent-lease", "host": "writer-1",
"heartbeat_age_ms": null,
"merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"commit_url": "https://github.com/example/repo/commit/5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"pr_url": "https://github.com/example/repo/pull/2170",
"work_started_ms": null, "verify_started_ms": null,
"approved_at": 1788451561675, "approved_by": "operator",
"max_rounds": 2},
"rounds": [
{"round": 1, "started_ms": 1788450761675, "ended_ms": 1788450941675,
"verdict": "changes_requested", "reviewer_model": "reviewer-model",
"findings": [{"path": "holophyte/serve/server.py", "line": 12, "severity": "p1",
"criterion": "AC1", "message": "the route is unmatched"},
{"path": "holophyte/serve/server.py", "line": 40, "severity": "p1",
"criterion": null, "message": "Validate input", "kind": "thread",
"author": "review-bot", "author_kind": "bot",
"summary": "Validate input", "verdict": "ADDRESS",
"raw": "Please validate input",
"url": "https://github.com/example/repo/pull/2170#discussion_r1"}],
"instructions": [{"kind": "instruction", "path": "holophyte/serve/server.py", "line": null,
"author": "operator", "severity": "nit",
"message": "Keep validation", "request": "Keep validation",
"url": "https://github.com/example/repo/pull/2170#discussion_r2",
"outcome": "changed", "reply": "Validation retained"}],
"operator_notes": [{"note": "Keep validation", "author": "operator",
"kind": "operator_note", "event_id": 7, "consumed": true,
"run_id": 52, "round": 1}],
"criteria": [{"id": "C1", "n": 1, "status": "not_met",
"note": "the expired lease stays claimed"},
{"id": "C2", "n": 2, "status": "met",
"note": "tests/test_lease.py::test_requeue_is_ledgered"}]},
{"round": 2, "started_ms": 1788451061675, "ended_ms": 1788451181675,
"verdict": "pass", "reviewer_model": "reviewer-model", "findings": [],
"instructions": [], "operator_notes": [],
"criteria": [{"id": "C1", "n": 1, "status": "met",
"note": "tests/test_lease.py::test_expired_lease_requeues"},
{"id": "C2", "n": 2, "status": "met",
"note": "tests/test_lease.py::test_requeue_is_ledgered"}]}
],
"findings": [{"tone": "advisory", "message": "https://github.com/example/repo/pull/2170#discussion_r3: Consider a cache"}],
"events": [
{"at": 1788450461675, "kind": "phase_change", "summary": "claimed"},
{"at": 1788450941675, "kind": "review", "summary": "round 1 asked for changes"}
],
"chain": {"started_ms": 1788449861675, "elapsed_ms": 1800000, "working_ms": 1212000,
"agent_ms": 1140000, "verify_ms": 72000,
"runs": [
{"id": 50, "attempt": 1, "outcome": "abandoned", "phase": "done",
"started_ms": 1788449861675, "ended_ms": 1788450161675, "elapsed_ms": 300000,
"working_ms": 300000, "agent_ms": 300000, "verify_ms": 0, "max_rounds": 2,
"turn_count": 3,
"rounds": [{"round": 1, "verdict": "pass", "reviewer_model": "reviewer-model"}]},
{"id": 52, "attempt": 2, "outcome": "merged", "phase": "done",
"started_ms": 1788450461675, "ended_ms": 1788451661675, "elapsed_ms": 1200000,
"working_ms": 912000, "agent_ms": 840000, "verify_ms": 72000, "max_rounds": 2,
"turn_count": 4,
"rounds": [{"round": 1, "verdict": "changes_requested", "reviewer_model": "reviewer-model"},
{"round": 2, "verdict": "pass", "reviewer_model": "reviewer-model"}]}
]},
"steer_notes": [
{"id": 4, "kind": "hint", "note": "Reuse the sweep's lease query",
"author": "maintainer", "at": 1788450561675, "run": 52, "consumed_by": 52,
"consumed_ms": 1788450571675, "interrupt": false, "withdrawn": false}
],
"criteria": [
{"n": 1, "id": "C1", "text": "Given an expired lease, then the run is requeued",
"status": "met", "note": "tests/test_lease.py::test_expired_lease_requeues",
"run": 52, "round": 2},
{"n": 2, "id": "C2", "text": "Given a requeue, then the ledger names it",
"status": "met", "note": "tests/test_lease.py::test_requeue_is_ledgered",
"run": 52, "round": 2}
],
"precheck": {"sha": "9f3c2a1b0e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
"criteria": [{"id": "C1", "n": 1, "witnessed": true, "note": ""},
{"id": "C2", "n": 2, "witnessed": false,
"note": "names no test file"}]}}
One run in full, by id: what the console shows when a run is expanded.
run is the row joined to its ticket. ended_ms is null while the run
is live; heartbeat_age_ms is the daemon's now minus the run's last
heartbeat while it is live and null once it has ended. max_rounds is
the review-round cap the loop gave this run (two rounds plus one per 800
changed lines, at most four), so a client can say "round 2 of 3" without
recomputing it; a run with no recorded cap (a babysit run resuming a pull
request) answers the newest cap an earlier run of its ticket recorded
since the ticket's last merged run, else the project's [loop] review_rounds. rounds lists the run's review rounds oldest
first, each with its findings decoded into objects (path, line,
severity, criterion, message) rather than the stored JSON string.
A finding decoded from a pull-request review thread also carries kind
(thread), author (the thread's last commenter), author_kind (bot
for a login [merge] bot_authors or bot_logins names, else user or
unknown as GitHub reported it), summary (the finding's one-line gist,
which message repeats), verdict (the adjudication: ADDRESS,
FOLLOW_UP or DECLINE), raw (the comment's text, redacted and cut at
20,000 characters) and url (the thread's page). A round's instructions are the requests a human
reviewer addressed to the factory on the pull request, split out of
findings: each an object with kind (instruction), path, line
(null when the thread has none), author, request (the ask), url and,
where the round recorded them, severity, message, outcome (changed
or asked) and reply, the factory's answer on the thread. A round's
operator_notes are the private operator notes (--babysit KO-n --note
TEXT on a parked pull request) that round consumed: each with the
note, its author, kind (operator_note), event_id (the run event
that recorded it),
consumed, and the run_id and round that consumed it. Top-level
findings are the advisory bot threads the factory noted on the pull
request without acting on: each with tone (advisory) and message,
the thread's URL and first line; empty when there are none.
elapsed_ms, working_ms, agent_ms, verify_ms, work_started_ms,
verify_started_ms, time_box_ms and ticket_url are as /status
carries them, except that a run's clocks stop at ended_ms once it has
ended. approved_at (epoch milliseconds) and approved_by (the
operator's login) record the approval (--approve) that released a
parked candidate to merge; both are null for a run no one approved, and a
--requeue clears them.
events is the narrative level of the run's event stream, oldest
first; detail events and their payloads are not served. An N that is
not an integer is 400; an integer with no run behind it is 404 carrying
run. Leading zeros are ignored, so /runs/007 is run 7. An integer no
run can have (negative, or wider than SQLite's 64-bit INTEGER, however
long) is 404 with run echoing the path segment as typed. host passes
through [report] host_label. commit_url and pr_url are as
/shipped carries them: the merge commit's page on origin and the
pull request the run opened, each null when there is none.
outcome_reason is the run's stored outcome reason cut at 400
characters, as /shipped cuts it, null when there is none;
failure_kind is the class of a failed run's failure (verify,
budget, infra and the like) and park_kind what a parked run waits
on (pull_request, ci, question and the like), each null when the
run recorded none.
chain is the ticket's chain of runs up to run N, as /shipped defines
it: every run of the ticket after its last merged run before N, through
N. run, rounds, events and findings describe run N alone.
chain's started_ms is the first run's start and elapsed_ms runs from
it to run N's end (or to now while N is live); working_ms, agent_ms
and verify_ms are summed over the chain's runs, each null only when no
run of the chain had its work measured. chain.runs lists the runs
oldest first, run N last, each with its own id, attempt, outcome
(null while live), phase, started_ms, ended_ms, its clocks as run
carries them, max_rounds, turn_count (the turns /runs/ID/turns
lists) and rounds, each round's round, verdict and
reviewer_model. A run with no earlier run in its chain has a chain of
itself alone, whose clocks equal run's.
steer_notes are the maintainer's steer notes (holo steer) on run N's
ticket recorded at or after chain.started_ms, oldest first, [] when
there are none: each with its id, kind (amendment or hint),
note, author and at (epoch milliseconds); run, the run the note
was routed to, null when it waited for the next run; consumed_by and
consumed_ms, the run whose agent read it and when, both null until
read; interrupt, true when it was sent with --now; and withdrawn,
true when a later withdrawal took the amendment back.
criteria is the newest review's judgement of the ticket's acceptance
criteria over the chain: run N's latest round when it has judged them,
else the latest round of the newest earlier run of the chain that did, so
a requeued run starts from what its predecessor's last review said. Each
criterion, in order, has its stable id (for example C4), its number n,
its text, its status (met,
not_met or unwitnessed) and the reviewer's note, which for a met
criterion names its witness; run and round name the review that
judged it. Older events without an id serve C plus n as their id.
[] until a round of the chain has judged them.
Each of run N's rounds carries its own criteria, in criterion order,
each with id, n, status and note: for a review round, the newest
judgement run N recorded for that round; for the final check (the
terminal adjudication, recorded as the round numbered one past
max_rounds), each criterion one of its criteria findings names takes
that finding's status and note, and every other criterion of run N's
newest judgement reads met with note ""; [] for a final check whose
verdict is error and for a round with no judgement (a babysit review,
or a round recorded before criteria were kept).
precheck is run N's first witness pre-check, null when it recorded
none: its sha, the candidate it checked, and criteria, each with
id, n, witnessed (true when the criterion names a test file and the
check found no gaps) and note, its gaps joined by ;, else
names no test file when it names none, else "".
GET /runs/N/files¶
{"run": 52,
"base": "038e4e513e5a4e8367b69a36d73ecc8fd0e12366",
"head": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"files": [
{"path": "docs/reference/http.md", "status": "M", "added": 31, "deleted": 2},
{"path": "holophyte/files.py", "status": "A", "added": 168, "deleted": 0},
{"path": "holophyte/serve/server.py", "status": "M", "added": 62, "deleted": 14}
],
"total_added": 261, "total_deleted": 16, "truncated": false}
The files a run touched, read from git: what the console's "files
touched" panel shows under a run. The store holds only the run's branch,
recorded the moment the loop cuts its worktree, and, once it landed, its
merge commit; the daemon resolves those to a commit range and runs git
diff --numstat and git diff --name-status over it, each under a
timeout, so the answer is what git says today, not a snapshot. For a
merged run (a recorded merge_sha) the range is the merge commit's
first parent to the merge commit in the project's checkout: exactly what
the --no-ff landing added to main, whether or not the branch still
exists. For a live run, one whose branch still has its worktree beside
the project, the diff is taken inside that worktree from the merge base
of its HEAD with the newer of main and origin/main to the working
tree: commits and uncommitted edits together, untracked files listed as
added, so the panel fills in as the implementer works, and a worktree
with nothing changed yet answers an empty files with 200. For a run
whose branch survives without a worktree it is the merge base of the
branch with the newer of main and origin/main to the branch head in
the checkout: what the branch has that main does not, unaffected by what
main gained since, and not crediting the run with main's commits merged
into the branch from origin/main before the local main caught up.
With no origin/main in the repository the base is the merge base with
main alone. The daemon fetches nothing; it reads only the refs already
there. base and head are the full shas the range resolved to; for a
live run head is the worktree's HEAD, and the edits beyond it are in
the counts.
files is sorted by path. status is A (added), M (modified), D
(deleted) or R (renamed, listed under the new path); a binary file
counts as 0 added and 0 deleted. At most 200 files are listed;
truncated is true when the diff named more, and total_added and
total_deleted still sum the whole diff, so a truncated list still says
how big the run was.
N parses as on /runs/N: a non-integer is 400, an integer with no run
is 404 carrying run. 409 with an error when no range can be found:
the run recorded neither a branch nor a merge sha, or the branch it
recorded has neither a worktree nor a ref (a preserved branch deleted by
hand; the error names the branch).
504 when git does not answer within its cap. The endpoint serves no file
contents or diff hunks and writes nothing to the repository.
GET /runs/N/turns¶
{"turns": [
{"id": 4410, "role": "implement", "route": "primary",
"label": "claude opus", "seconds": 372.5,
"session_id": "0198c2a4-5e1f-7b3a-9d6e-2f4c8a1b3d5e",
"ended_ms": 1760000000000, "exit_status": 0, "timed_out": false,
"input_tokens": 52100, "output_tokens": 9300, "cost_usd": 0.92},
{"id": 4433, "role": "review", "route": "fallback",
"label": "codex gpt-5.6-sol", "seconds": 95.25, "session_id": null,
"ended_ms": 1760000095250, "exit_status": 1, "timed_out": true,
"input_tokens": null, "output_tokens": null, "cost_usd": null}
]}
The agent turns of run N, oldest first: one entry per implementer,
reviewer, adjudicator, writer or trimmer turn the loop recorded when the
turn ended, so a turn still running is not listed yet. id is the turn's
event id, which /runs/N/turns/T/transcript takes as T. role is
implement, review, adjudicate, write or trim. label names
what ran, as route_labels labels a seat on /status: the command's
first word plus its -m/--model value, null for a turn recorded
without one. route is primary, or fallback when the seat ran its
configured *_fallback. seconds is how long the turn ran, null when
its record carries no duration. session_id is the agent session the
turn ran in, null when none was recorded beside it. ended_ms is the event's
recorded timestamp in epoch milliseconds, when the turn ended. exit_status
is the recorded integer exit status, null when none was recorded.
timed_out says whether the turn timed out, false when absent from the record.
input_tokens and output_tokens are the recorded non-negative integer token
counts; input tokens include cache reads. cost_usd is the recorded cost in
US dollars. Each usage field is null when usage is absent or its value is
not a valid number. These facts come from the run record even when no
transcript is kept. A run with no turns answers an empty list.
N parses as on /runs/N: a non-integer is 400, an integer with no run
is 404 carrying run. The route writes nothing.
GET /runs/N/turns/T/transcript¶
{"entries": [
{"speaker": "command", "text": "python3 -m unittest", "at_ms": 1760000000000,
"tool": "Bash", "call": "tool-1", "summary": "python3 -m unittest",
"failed": false, "cut": false},
{"speaker": "tool", "text": "OK", "at_ms": 1760000001000,
"tool": null, "call": "tool-1", "summary": null,
"failed": false, "cut": false}
]}
The transcript entries written while turn T of run N ran, in session order.
The daemon reads Claude Code sessions, Codex rollouts and Devin exports only
under the roots configured in [serve] transcripts, never following a link
out of a root. Timestamped entries use the turn's recorded duration and end
with two seconds of tolerance on either side. Entries without timestamps are
retained; when the turn has no duration or end timestamp, the whole session
is returned.
Every entry carries all eight keys:
speakerisuser,assistant,command,toolorerror.textis the message, command or tool output, always a string.at_msis the entry's timestamp in epoch milliseconds, null when the session provides no valid timestamp.toolis the tool name for a command, null when absent or for other entries.callis the tool call identifier linking a command and its result, null when unavailable or for entries unrelated to a tool call.summaryis a command's short description, such as its first line or file path, null when unavailable or for other entries.failedis true for an error entry or a tool result marked as failed; otherwise false.cutis true when the redactedtextexceeded 20,000 characters and was cut to its first 20,000 characters; otherwise false.
Every text and non-null summary is redacted with the project's known
secrets. Redaction happens before cutting text, including when a secret
straddles the cut. The full session file stays on the host; this route does
not serve the remainder of a cut entry.
404 {"error": "transcript unavailable"} when no root is configured, T is
not one of run N's turns, the turn has no session, or no session file is
found. With roots configured, N parses as on /runs/N: a non-integer is
400, an integer with no run is 404 carrying run. The route writes nothing.
GET /runs/N/merge¶
{"run": 52, "ticket": "KO-219",
"pr_url": "https://github.com/example/repo/pull/31",
"head_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"ready": false, "reason": "checks_pending",
"detail": "the required checks are pending: vitest",
"facts": [
{"name": "parked", "ok": true, "detail": "run 52 is KO-219's newest run, parked awaiting_merge_approval on https://github.com/example/repo/pull/31"},
{"name": "human_approval", "ok": true, "detail": "[merge] approve is \"human\""},
{"name": "ready_for_review", "ok": true, "detail": "the pull request is ready for review"},
{"name": "review_approved", "ok": true, "detail": "GitHub's review decision is APPROVED"},
{"name": "checks_passed", "ok": false, "detail": "the required checks are pending: vitest"},
{"name": "mergeable", "ok": true, "detail": "GitHub's mergeable is MERGEABLE"},
{"name": "threads_resolved", "ok": true, "detail": "no review thread is open"},
{"name": "head_unchanged", "ok": true, "detail": "origin's task/ko-219 and the pull request are at 5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f"}
]}
Whether a run parked for a human's merge may be merged now: what the
console reads to draw a Merge button or say why not. The same answer
POST /actions/merge (The daemon's actions)
computes again at call time before it releases anything; nothing is
cached. ready is true when every fact holds. facts is always these
eight, in this order, each its name, whether it holds (ok) and a
detail in words:
name |
Holds when |
|---|---|
parked |
the run is its ticket's newest run, in phase awaiting_merge_approval, the ticket blocked_on_operator with no live run, and the run recorded a pull request and a branch |
human_approval |
[merge] approve is "human" |
ready_for_review |
the pull request is not a draft (isDraft: false); a draft fails with reason draft |
review_approved |
GitHub's reviewDecision is APPROVED; a bypassable review (below) stays ok: false |
checks_passed |
the required checks fold to success, as the babysitter folds them; a check run that started before the pull request's latest ready-for-review event is not counted, so a required check whose only run is from the draft reads missing |
mergeable |
GitHub's mergeable is MERGEABLE |
threads_resolved |
no review thread is open, as the babysitter counts them |
head_unchanged |
the branch head on origin, read with git ls-remote from the project's checkout, and the pull request's headRefOid are the same commit, and it is the run's candidateSha or its approvedSha |
When parked or human_approval fails the daemon asks GitHub nothing:
the six GitHub facts are ok: false with a detail saying they were
not read. reason is null when ready, else the first failing fact's
reason (a REVIEW_REQUIRED review only when no other fact fails), one of not_parked, not_human_approval,
draft, review_not_approved, review_bypassable, checks_pending,
checks_failing, conflicting, mergeable_unknown (GitHub has not
computed mergeable yet), threads_unresolved, head_moved and
github_unreadable (a GitHub read or the ls-remote failed); detail
is that fact's detail. A repository whose rules require no review has a
null reviewDecision: review_not_approved. head_sha is origin's
branch head, null when it was not read. ticket and pr_url are the
run's.
review_bypassable is a REVIEW_REQUIRED review the host's GitHub user
may bypass. On that decision, and only once every other fact holds, the
daemon reads main's rules
(GET repos/OWNER/REPO/rules/branches/main) and, for each pull_request
rule that asks for a review (one or more approving reviews, a code
owner's review, approval of the most recent push, or a non-empty
required_reviewers), its ruleset (GET repos/OWNER/REPO/rulesets/ID).
When every such ruleset answers current_user_can_bypass as always,
pull_requests_only or exempt, the review_approved fact's reason is
review_bypassable and its detail names each ruleset, the reviews it
asks for and the bypass value:
GitHub's review decision is REVIEW_REQUIRED; the host's GitHub user may bypass it: ruleset human-review asks 1 approving review, current_user_can_bypass pull_requests_only
While any other fact fails the bypass is not read and that fact is the
answer's reason, so checks, conflicts, open threads and a moved head
read as themselves before a REVIEW_REQUIRED review, bypassable or not.
The bypass fails closed: an unreadable rules or ruleset answer, a rule or
value that cannot be made out, a ruleset answering never or anything
else, or no
ruleset asking for a review (classic branch protection) keeps
review_not_approved, its detail ending no bypass: and what stopped
it. A CHANGES_REQUESTED review is never bypassable.
N parses as on /runs/N: a non-integer is 400, an integer with no run
is 404 carrying run. The route writes nothing; a GitHub read that fails
is github_unreadable with 200.
GET /runs/N/asks¶
{"run": 53, "ticket": "KO-219",
"pr_url": "https://github.com/example/repo/pull/31",
"asks": [
{"id": 812, "question": "Why is the guest keyed by name?",
"author": "maintainer", "asked_ms": 1788451661675,
"answered_ms": 1788451781675,
"url": "https://github.com/example/repo/pull/31#issuecomment-901",
"answer": "Names are unique per party: src/app.py:30."},
{"id": 840, "question": "Can a guest be renamed?", "author": "reviewer",
"asked_ms": 1788452021675, "answered_ms": null, "url": null,
"answer": null}
]}
The questions asked about run N's pull request from the console
(POST /actions/ask), oldest first. ticket
and pr_url are run N's. asks holds every console ask recorded on any
run of N's ticket with N's pull request URL, so the run the loop re-parks
after answering lists the asks made on the run before it. Each ask is its
event id, its question and author, and asked_ms, when it was
recorded. Once the babysitter has answered it, answered_ms is when,
answer the answer text, redacted, and url null: the answer is recorded
in the console and nothing is posted to GitHub. An ask answered before
that keeps its posted comment's html_url in url. Until answered the
three are null. A run with no pull request answers an empty
list. N parses as on /runs/N: a non-integer is 400, an integer with no
run is 404 carrying run. The route writes nothing and asks GitHub
nothing.
GET /runs/N/ledger¶
{"run_id": 52, "ticket": "KO-219",
"entries": [
{"at": 1788450941675, "kind": "round",
"text": "Round 1: changes_requested · reviewer reviewer-model · verify passed",
"source": "loop"},
{"at": 1788451181675, "kind": "round",
"text": "Round 2: pass · reviewer reviewer-model", "source": "loop"},
{"at": 1788451661675, "kind": "merge",
"text": "MERGED to main as 5acc138.", "source": "loop"}
]}
One run's ledger: the narrative the store holds for it (design note 9),
which is what a frontend reads and what the Linear comments and the
FINDINGS.md window are projections of. entries is oldest first, each
its at in epoch milliseconds, its kind (round, merge, failure,
adjudication, intervention or note), its text and its source
(loop for an entry the factory wrote, operator for one recorded out of
band). An intervention entry carries two more fields, cleared and
waited_ms: what the operator's step cleared and how long that had
waited, computed here because the store knows both and a page that has
lost the item cannot (design note 13's "how long it waited and who
cleared it"). One closed rule, against the entry's own run: two marks are
read, the at of the run's newest redirect intervention strictly
before the entry (the ask) and the run's endedAt when set and strictly
before the entry (the failure); the newer mark wins, and a redirect in
the same millisecond as the failure is not newer, so cleared is
"question" or "failed" and waited_ms is the entry's at minus that
mark. A mark after the entry never counts, and a redirect entry never
pairs with itself since its own row is not strictly before it. With no
mark both are null. Entries of other kinds do not carry the fields.
{"at": 1788452000000, "kind": "intervention",
"text": "human resume: answered: keep the flag name", "source": "operator",
"cleared": "question", "waited_ms": 818325}
A run with no entries answers an empty list. N parses as on
/runs/N: a non-integer is 400, an integer with no run is 404 carrying
run. Nothing here is rendered; the endpoint serves the rows.
GET /ledger?since=MS¶
{"since": 1788450000000, "limit": 200,
"entries": [
{"at": 1788451661675, "run": 52, "ticket": "KO-219", "kind": "merge",
"source": "loop", "text": "MERGED to main as 5acc138.",
"pr_url": "https://github.com/OWNER/REPO/pull/12"},
{"at": 1788451421675, "run": 51, "ticket": "KO-218", "kind": "failure",
"source": "factory", "text": "no heartbeat for 20 minutes",
"failure_kind": "swept", "pr_url": null},
{"at": 1788451181675, "run": 50, "ticket": "KO-217", "kind": "intervention",
"source": "operator", "text": "answered: keep the flag name",
"cleared": "question", "waited_ms": 818325, "pr_url": null},
{"at": 1788451061675, "run": 50, "ticket": "KO-217", "kind": "park",
"source": "factory", "text": "which flag name should ship?",
"pr_url": null},
{"at": 1788450941675, "run": 52, "ticket": "KO-219", "kind": "round",
"source": "loop", "text": "Round 1: changes_requested · reviewer reviewer-model · verify passed",
"pr_url": "https://github.com/OWNER/REPO/pull/12"}
]}
The ledger across runs, newest first, from since on: the console's
"resolved today" fold is one window over the store's ledger table, and the
thread under a blocked ticket's question is the same window narrowed to
that ticket. since is required and is epoch milliseconds; entries at or
after it come back, ordered by at then id, newest first. kind narrows
to one of the kinds /runs/N/ledger names; ticket narrows to one
identifier (KO-n). limit defaults to 200 and is capped at 1000; there
is no paging past it, a day of ledger fits in one page. Each entry is its
at, run, ticket, kind, source and text, as /runs/N/ledger
spells them; an intervention entry carries cleared and waited_ms
too, by the rule /runs/N/ledger states, so the fold reads each
resolution's wait off the wire. Every entry carries pr_url: its run's
pull request URL, or null when the run has none or the entry has no run.
An unfiltered request (no kind, no ticket) also carries two kinds of
row the ledger table does not hold, each with source factory, its run's
run, ticket and pr_url, and the same since and limit rules: a
park row for each run phase change into blocked_on_operator, or into
awaiting_merge_approval because a thread needs a human's answer, whose
text is the change's reason; and a run-ending failure row for each
run that ended failed with no failure ledger row of its own, at its
end, whose text is the run's outcome reason and whose failure_kind
is the run's failure kind. A park on pending checks or a quiet wait has
no row, and park is not a kind the filter accepts. For a thread, /attention's blocked item is the question
and /ledger?ticket=KO-n&since=ASKED is the rest: the intervention
rows carry the operator's answer text. A missing or non-integer since,
a bad limit or an unknown kind is 400 with an error naming the
parameter.
The separate active_outages array contains one route_down row per project
with an uncleared launch backoff. These ongoing outages are independent of
since and limit, so an outage begun before midnight remains visible even
when entries fills its historical limit. Each outage carries project, at
(the outage's start in epoch milliseconds), reason, text, kind, source,
and null run and ticket. The array is empty for ticket-filtered requests
or kinds other than intervention; it is included for unfiltered requests.
Launch-loop interventions at or after an ongoing outage's start are suppressed
from history.
GET /findings?since=MS¶
{"since": 1788450000000, "limit": 100,
"runs": [
{"run": 52, "ticket": "KO-219", "title": "Serve the findings window",
"phase": "done", "outcome": "merged", "ended_ms": 1788451661675,
"pr_url": "https://github.com/OWNER/REPO/pull/12",
"rounds": [
{"round": 1, "started_ms": 1788450641675, "ended_ms": 1788450941675,
"verdict": "changes_requested", "reviewer_model": "reviewer-model",
"findings": [{"severity": "p1", "message": "the limit is not echoed",
"path": "holophyte/serve/serve_findings.py", "line": 40}],
"instructions": [], "operator_notes": []}],
"ledger": [
{"at": 1788450941675, "kind": "round",
"text": "Round 1: changes_requested · reviewer reviewer-model\nImplementer response:\nDECLINE: covered elsewhere",
"source": "loop"}],
"advisories": [{"tone": "advisory", "message": "https://github.com/OWNER/REPO/pull/12#discussion_r1: nit"}]}
],
"follow_ups": [
{"id": 7, "run": 52, "ticket": "KO-219", "kind": "feature",
"text": "page the window", "path": "holophyte/serve/serve_findings.py",
"line": 30, "created_ms": 1788451661675, "settled_ms": 1788451661675,
"state": "filed", "filed_as": "KO-230", "duplicate_of": null,
"error": null}
],
"ticket_defects": [
{"ticket": "KO-221", "run": 54, "kind": "contradiction", "criteria": ["C4"],
"reason": "implementer: criterion C4 cannot be met as written: the export has no header field",
"at": 1788452000000, "revision": 2, "revised_ms": 1788452600000,
"requeue_note": "C4 now names the export's columns"}
]}
Review findings across runs, from since on, in one read: what the
console's Findings page polls. since is required and is epoch
milliseconds. runs holds every run with a review round whose ended_ms
is at or after since, ordered by its newest round's end, newest first,
at most limit of them (default 100, capped at 500). Each carries the
run's run, ticket, title, phase, outcome, ended_ms and
pr_url; its rounds, every round of the run oldest first, exactly as
/runs/N answers them; its ledger, the run's round and adjudication
entries oldest first as /runs/N/ledger spells them, whose text keeps the
"Implementer response:" lines with the DECLINE and FOLLOW_UP adjudications;
and its advisories, the bot threads /runs/N answers as findings.
follow_ups holds the FOLLOW_UP rows a merged run's fix commits left,
every row created at or after since plus every row still unsettled,
newest first. Each is its id, run, ticket, kind (feature or
guardrail), text, path, line, created_ms, settled_ms, state,
filed_as, duplicate_of and error. state is pending until the
row settles, then filed, duplicate, ledger, superseded or
unfiled, read from the event that settled it; a row settled before
those events named their row reads ledger for a guardrail and
superseded for a feature. The route serves rows, not fates: which
group a finding falls in is the console's to work out.
ticket_defects holds the runs that stopped because their ticket was
wrong, newest stop first, at most limit of them. A run is one when it
recorded a contradiction event, or a supervisor sweep that failed it for
criterion_stuck or review_stuck (a sweep that took no action, or one
for stale_heartbeat or time_box, does not count), at or after since,
and its ticket then gained a revision above the one the run was claimed
at, written at or after the stop and, when the ticket has a later run, at
or below the revision that run was claimed at. A run claimed at no
revision is never one. Each entry is the ticket, the run, the stop's
kind (contradiction, criterion_stuck or review_stuck), its
criteria ids (the declared ones for a contradiction, the criteria
restated as unmet for criterion_stuck, none for review_stuck), the
stop event's text as reason and its time as at, the lowest qualifying
revision and its time as revised_ms, and requeue_note, the note of
the run's first requeue, or null. A run's newest stop gives its one entry.
A missing or non-integer since, or a bad limit, is 400 with an
error naming the parameter; a project with no store is 503.
GET /report?since=SPAN¶
{"project": "/home/op/repo",
"window": {"since": "7d", "from_ms": 1788000000000, "now_ms": 1788604800000},
"shipped": {"merged": 3, "abandoned": 1, "failed": 2,
"median_min": 16.0, "median_estimate_min": 30.0},
"failures": {"verify": 1, "review": 1},
"gaps": {"open": 2, "layers": {"none": 2, "test": 1},
"found_by": {"review": 3}},
"hands_on": {"interventions": 4, "by_action": {"requeue": 3, "pause": 1},
"send_backs": 1},
"runs": [
{"run": 312, "ticket": "KO-241", "actual_min": 8.4, "agent_min": 6.1,
"verify_min": 2.3, "estimate_min": 10.0, "ratio": 0.84, "rounds": 1,
"outcome": "merged", "host": "writer-1", "ended_ms": 1788478953000,
"merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f", "wall_min": 9.0}
],
"notes": [
{"run": 310, "ticket": "KO-240", "round": 2, "event_id": 881,
"author": "maintainer", "note": "name the window in the header",
"consumed_ms": 1788470000000}
],
"steers": [
{"id": 4, "ticket": "KO-242", "kind": "hint", "author": "maintainer",
"note": "the port is in config.toml", "steered_ms": 1788480000000,
"run": null, "event_id": null, "consumed_by": 315,
"consumed_ms": 1788481000000}
]}
holo report --json, the weekly report, as the daemon reads it from the
store, so holo report runs over transport = "http". since is the
window: Nh, Nd or all, 7d when absent; any other form is 400
naming the forms. project is the repository the daemon serves, as a
path. window echoes since and gives the window's start, from_ms
(null for all), and the read's moment, now_ms, both epoch
milliseconds. shipped counts the runs that ended in the window by
outcome, merged, abandoned and failed, with median_min, the
median actual minutes of the merged runs, and median_estimate_min, the
median of their estimates; both null with no merged run. failures
counts the window's failed runs by failure kind, most first. gaps is the
store's whole gap ledger, not the window's: open, the gaps with no
layer yet, layers, the count per layer, and found_by, the count per
finder. hands_on counts the window's interventions: interventions,
the total less send-backs, by_action, the count per action, and
send_backs, the operator_note rows. runs are the window's ended
runs, each as a /runs row without ticket_url: agent_min and
verify_min are its agent's and its verify's minutes, and wall_min its
wall-clock minutes.
notes are the send-back notes a run consumed in the window, newest
first: the run and ticket, the round that consumed it, the
event_id of the operator_note event, its author and note, and
consumed_ms. steers are the holo steer notes recorded in the window
or still pending, oldest first: the steer note's id, its ticket, kind
(amendment or hint), author and note, steered_ms, the run it
was recorded on and, for a parked run's send-back, its event_id, both
null otherwise; consumed_by is the run that first carried it and
consumed_ms when, both null while it is pending. holo report prints its
notes and steers only with --notes; the body always holds them.
GET /attention¶
What needs the operator, computed where the store is:
{"level": "attention", "now": 1788450534491,
"project": "/path/to/repo", "items": [
{"kind": "blocked", "ticket": "KO-n", "title": "…", "question": "…", "run": 50, "asked_ms": 1788449000000,
"pr_url": null, "park_kind": "question", "pr": null, "level": "attention"},
{"kind": "pr_open", "ticket": "KO-n", "title": "…", "run": 53,
"pr_url": "https://github.com/example/repo/pull/2170", "park_kind": "pull_request", "reason": "…", "asked_ms": 1788449000000,
"pr": {"number": 2170, "checks": "success", "review": "approved", "threads": 2, "title": "…", "state": "ready"},
"round": 2, "waits_on": [{"ticket": "KO-m", "pr_url": "https://github.com/example/repo/pull/2168"}],
"level": "attention"},
{"kind": "stale_run", "run": 52, "ticket": "KO-n", "title": "…", "phase": "working", "heartbeat_age_ms": 400000,
"pr_url": null, "level": "attention"},
{"kind": "failed", "run": 51, "ticket": "KO-n", "title": "…", "reason": "…", "ended_ms": 1788450000000, "attempt": 2,
"pr_url": null, "triage": {"choice": "infra", "confidence": 0.92, "backend": "claude",
"model": "sonnet", "requeued": true}, "level": "attention"},
{"kind": "stuck_wait", "ticket": "KO-n", "ticket_url": null, "title": "…", "dependency": "KO-m",
"dependency_state": "needs_spec", "reason": "critic: stale — already shipped by KO-l",
"behind": 4, "level": "attention"},
{"kind": "supervisor", "state": "stale", "heartbeat_age_ms": 1200000, "level": "attention"}
]}
A blocked item's run is the run parked for the ticket and asked_ms
when the question was asked: the newest redirect intervention on that
run, else the run's last heartbeat (both null only for a ticket parked
with no run behind it). Its park_kind is the recorded ticket.parkKind
from the reader (stored in runs.parkKind), null when unrecorded. Its pr
has the same number, checks, review, threads, open_threads and
title facts described for pr_open below, and is null when pr_url
is absent. The question text is unchanged. A pr_open item is a blocked_on_operator
ticket whose parked run has a pr_url and whose recorded park kind
(runs.parkKind, one of store/enums.py ParkKind) is pull_request,
as a park under [merge] mode = "pr" records it, ci, as a babysit
waiting only on pending checks or the quiet period records it, or
route, as a failed adjudicator turn records it until the host
retries it; the
question's wording plays no part. The item names that kind as
park_kind. The run waits on a review, its checks
or a merge, not on an answer,
so the item carries pr_url and reason (the question with its first
line removed, or the whole question when it has one line) in place of
question; its run and asked_ms are as on blocked. Its pr is
the pull request as the loop's reconcile last read it: number from the
URL, checks (success, pending, failure, null for a PR with no
checks), review (GitHub's review decision lower-cased: approved,
changes_requested, review_required, null when none is required) and
threads, the review-thread count, open_threads, the count of those
unresolved (the string "100+" when the read that counted it saw more
threads than the first page of 100 it counts over,
runs.prSeenOpenThreadsFloor), and title, the pull
request's title (runs.prSeenChecks, prSeenReview, prSeenThreads,
prSeenOpenThreads, prSeenTitle), and state (runs.prSeenState):
draft, ready (open and ready for review), closed (closed without
merging), or merged. State is null when the read could not determine it.
The same read gives the diff size, additions, deletions and
changed_files (runs.prSeenAdditions, prSeenDeletions,
prSeenChangedFiles); preview_url (runs.prSeenPreviewUrl), the URL of
the newest GitHub deployment on the head commit whose latest status is
SUCCESS, null when there is none; screenshots
(runs.prSeenScreenshots), up to six {"caption": …, "url": …} objects,
one per markdown image in the body's Evidence section in body order, []
when there are none; and videos (runs.prSeenVideos), up to four
{"caption": …, "url": …} objects, one per markdown link in the Evidence
section that is not an image and whose URL path, before any ?, ends
.webm or .mp4, in body order, [] when there are none. These six are
null (screenshots and videos []) for a run never polled since they
were recorded.
All six facts are null for a run never polled, and open_threads for one
polled before it was recorded. A pr_open item's round is the review
rounds its run recorded whose verdict is not error, the count a
GET /status run serves as round. Its waits_on lists, in the
ticket's dependsOn order, each dependency whose store status is not
merged, as {"ticket": "KO-m", "pr_url": …}, pr_url being that
ticket's newest run's runs.prUrl (null when it has none or never ran);
it is [] when the ticket has no dependency or every one merged. Every
item that names a ticket carries the ticket's title, which a pr_open
row shows when pr.title is null. A failed item's attempt is the run's 1-based
attempt number. Its triage is the run's cause question under
[questions.failures] (see docs/config.md): choice (infra, code or
spec), confidence, the backend and model asked, and requeued, true
when the factory requeued the ticket on that answer; choice and
confidence are null when the question failed, and triage is null for a
run never asked. Every item that names a run carries its pr_url: the
pull request the run opened under [merge] mode = "pr" (runs.prUrl),
null when it opened none, so a console can link the parked question to
the PR it waits on. A stuck_wait item is a blocked_on_deps ticket
(ticket, ticket_url, title) whose wait only a person can end: its
first dependsOn entry, of its own project, that is stuck, as
dependency. dependency_state is needs_spec, abandoned, parked
(blocked_on_operator, except a ticket served as pr_open) or failed
(in_flight with no active run and a last run that failed). reason is
one line of the dependency's newest critic, stale or validation note
for needs_spec, its cancel note for abandoned, its question when
parked and its last run's outcome reason when failed: the first *
bullet without the marker, else the first non-empty line, null when there
is none. behind counts the project's blocked_on_deps tickets waiting
on the ticket directly or through other waits, each once; a ticket that
waits only through another wait gets no item of its own. A stuck_wait
item is also a ready child, with no active run, of a closed or
abandoned story, which the claim refuses on every pass: the story is its
dependency and the story's state its dependency_state, and reason is
one line of the story's newest verdict note when closed (its close note)
or its cancel note when abandoned. It carries no age. project is the project path, as on
/status.
level is none, working, attention or critical; with no items it
is working if any run is live. Items come in this order: blocked and
pr_open tickets in ticket order, stale_run, failed within the last 24
hours while it is its ticket's latest attempt and the ticket has neither
merged nor started a new one (a requeue to ready keeps it), or at any
age while it strands its ticket (its last run, the ticket in_flight with
no active run, which only the operator will move on; a requeue ends this
unbounded stay, not the 24 hours), oldest end first, stuck_wait in
waiting-ticket order, supervisor when not live. A daemon older than this endpoint answers 404, and the
drawer then computes the stale-run and supervisor rows itself from
/status; any other failure of /attention is shown, never hidden.
GET /board¶
The open tickets by state, as the store mirrors them: the console's Board view, Linear's columns without a call to Linear.
{"now": 1788450534491, "editable": false, "columns": [
{"state": "needs_spec", "tickets": []},
{"state": "blocked_on_deps", "tickets": [
{"ticket": "KO-n", "title": "…", "time_box_ms": 1500000, "run": null,
"question": null, "waits_on": ["KO-m"], "mirrored_ms": 1788449000000,
"column": "ready", "priority": 2, "labels": ["ui"], "revision": 3}]},
{"state": "ready", "tickets": []},
{"state": "blocked_on_operator", "tickets": []},
{"state": "in_flight", "tickets": [
{"ticket": "KO-m", "title": "…", "time_box_ms": 1500000, "run": 52,
"question": null, "waits_on": [], "mirrored_ms": 1788449000000,
"column": null, "priority": null, "labels": [], "revision": 1}]}
], "stories": [
{"ticket": "KO-k", "title": "…", "children": ["KO-m", "KO-n", "KO-p"],
"merged": 1, "total": 3}
]}
columns is one entry per open state in the path-to-merge order
needs_spec, blocked_on_deps, ready, blocked_on_operator,
in_flight, every column present even when empty; merged and
abandoned tickets are absent. Tickets within a column are ordered by
identifier. run is the ticket's active run, null when none is working
it; question is what a blocked_on_operator ticket asks, null
otherwise; waits_on is the identifiers of the open tickets its Linear
dependencies name, empty when none (a dependency the store has never
mirrored is shown by its Linear issue id, since the store cannot name
what it has not seen); mirrored_ms is when the store last mirrored the
ticket. column (backlog, ready, canceled, null when the board
named none), priority (null when none), labels and revision are the
ticket's board-owned fields as the store holds them; revision is the one
a revision-checked edit sends as If-Match, 0 when none is recorded.
stories is one entry per story whose parent ticket is neither merged
nor abandoned, in the parent's identifier order and empty when there are
none: the parent's ticket and title, its child tickets' identifiers
in identifier order as children, merged ones included, merged how many
of them merged and total how many there are.
A native project ([board] kind = "native") answers a backlog column
first, before the five above: its tickets idle in needs_spec,
blocked_on_deps or ready whose column is backlog. A ticket in
in_flight or blocked_on_operator stays under its status whatever its
column, and a Linear project has no backlog column. editable is true
only when a host daemon with [serve] actions on answers for a native
project; a project daemon, or a Linear project, answers false.
The board is the store's mirror and nothing more, and the endpoint
never calls the provider. The loop mirrors every ready issue the provider
lists at each claim, a valid body to ready and one failing the template
to needs_spec, so the queue is on the board before the loop starts on
it; a ticket the loop could not claim (Backlog, closed, blocked in Linear)
is not.
GET /tickets/KO-n¶
One mirrored ticket by its Linear identifier, body included: what the console shows when the operator opens a ticket without leaving for Linear.
{"ticket": "KO-n", "title": "…", "status": "in_flight",
"body": "# The store mirrors a ticket's body…\n\n## Summary\n…",
"acceptance_criteria": ["Given …, when …, then …"],
"criteria": [{"id": "C1", "text": "Given …, when …, then …"}],
"verification_commands": ["ruff check ."],
"time_box_ms": 1800000, "run": 52, "mirrored_ms": 1788449000000,
"runs": [{"run": 52, "attempt": 2, "phase": "reviewing", "outcome": null,
"rounds": 2, "pr_url": "https://github.com/…/pull/9",
"started_ms": 1788449000000, "ended_ms": null},
{"run": 47, "attempt": 1, "phase": "failed", "outcome": "failed",
"rounds": 1, "pr_url": null, "started_ms": 1788362000000,
"ended_ms": 1788365000000}],
"steers": [{"id": 4, "kind": "hint", "note": "…", "author": "maintainer",
"at": 1788449100000, "run": 52, "read_by": 52,
"read_ms": 1788449200000, "withdrawn": false},
{"id": 3, "kind": "amendment", "note": "…",
"author": "maintainer", "at": 1788400000000, "run": null,
"read_by": null, "read_ms": null, "withdrawn": false}]}
ticket is the identifier as the store mirrors it; status is the
store's status, any of the seven including merged and abandoned;
body is the issue text the loop last read at claim time, served as the
text Linear held then and not rendered, so it is the exact contract the
run worked from, which Linear's current text may no longer be (empty for
a ticket mirrored before the store kept bodies); acceptance_criteria
and verification_commands are the lists the mirror parsed from it.
criteria lists current criteria in acceptance_criteria order, each with
a stable id (for example C4) and its text; retired criteria are omitted.
Tickets without criterion records use positional ids C1, C2, and so on.
The console joins run judgements to current criteria by id.
The claimed snapshot keeps its acceptance_criteria list;
run is the ticket's active run, null when none is working it;
mirrored_ms is when the store last mirrored the ticket. runs is every
run of the ticket, newest attempt first, each with its phase, outcome
(null while it runs), count of review rounds, pull request and clocks.
steers is every steer note on the ticket, newest first: kind is
amendment or hint, run the run it was sent to (null when none was in
flight), read_by and read_ms the run that read it and when (null while
unread), and withdrawn whether a later holo steer --withdraw withdrew
it, which only an amendment can be. An identifier
the store has never mirrored is 404 with an empty object, like an absent
run. The endpoint reads the store's mirror and nothing more; it never
calls the provider.
GET /tickets?q=TEXT¶
The identifiers of the tickets whose text holds a search: how the console's
Tickets page finds a ticket by a word only in its body, which GET /board
does not serve.
{"q": "quokka", "tickets": ["KO-2", "KO-3"]}
q is the search as asked, trimmed; tickets lists, in identifier order,
every ticket in the project's store whose identifier, title, one of its
labels or current body contains q, ignoring case. Merged and abandoned
tickets are included. A missing or blank q is 400
{"error": "q is required"}. The read takes the read token, like
GET /board, and a host daemon serves it as
GET /projects/NAME/tickets?q=TEXT. It reads the store's mirror and
never calls the provider.
GET /stories/KO-n¶
One story by its parent ticket's identifier: what the console's Story board draws as a dependency graph, with the story's witnesses and decisions under it.
{"ticket": "KO-n", "title": "…", "state": "approved", "generation": 2,
"children": [{"ticket": "KO-m", "title": "…", "status": "in_flight",
"column": "in_progress", "time_box_ms": 1800000, "run": 52,
"merged_ms": null, "role": "advances",
"witness_keys": ["W1"], "depends_on": ["KO-l"]}],
"witnesses": [{"key": "W1", "criterion": "…", "verdict": "red",
"main_sha": "b1c2…", "at": 1788449000000}],
"decisions": [{"id": 3, "kind": "regressed", "ticket": "KO-m",
"question": "…", "options": ["replan", "abandon"],
"default_option": "replan", "answer": "replan",
"answered_by": "maintainer", "answered_ms": 1788449100000,
"at": 1788449050000}]}
state and generation are the story's; children come in the order
the plan lists them, merged ones included. A child's column is its board
column, run its run with no end (null when none is working it),
merged_ms the end of its newest merged run (null before one), role and
witness_keys its place in the plan, and depends_on the identifiers of
the story's own children its dependencies name, in their order; a
dependency outside the story is left out. witnesses come by key, each
read at the newest main sha the store holds a result for: verdict,
main_sha and at are that result's, all null for a witness never run
there. decisions come oldest first, open and answered: ticket is the
child the decision is about, null for the whole story, and answer,
answered_by and answered_ms are null while it is open. An identifier
the store has never mirrored, or one that is not a story's parent (a
child's included), is 404 with an empty object.
GET /scouts¶
A project's scouts, newest first by id: what the console's Scouts list
draws. GET /scouts?limit=N reads at most N.
{"scouts": [{"id": 3, "title": "Why does the retry spin?",
"ask": "Why does the retry spin? It never backs off.",
"kind": "bug", "phase": "swarming", "reason": null, "lead": "opus",
"helper": "luna", "swarm": 4, "tasks_done": 2, "tasks_total": 3,
"findings": 0, "actions": 0, "filed": 0, "to_file": 0,
"created_ms": 1791568800000, "started_ms": 1791568810000,
"ended_ms": null, "elapsed_ms": 95000,
"heartbeat_age_ms": 4000,
"subject": {"kind": "run", "ref": "52"}}],
"limit": 20, "truncated": false}
Each row carries the scout's id, its title, its ask, its kind (bug,
research, recon or guardrails), its phase and the reason it ended (null until
it does), the lead and helper route names it runs on and its swarm,
helpers at once. tasks_done counts its helper tasks that have ended and
tasks_total all of them, over every pass. findings counts the
findings over every stored report pass, 0 before the first report.
actions counts the scout's stored action items, filed those with a
ticket key, and to_file those with no ticket key whose kind is not
revise; a report's own actions with no stored items count 0.
created_ms, started_ms and
ended_ms are epoch milliseconds, each null until it happens;
elapsed_ms runs from the start to the end, or to now while the scout
runs, and is null while it is queued. heartbeat_age_ms is the age of a
running scout's last heartbeat, null while it is queued or once it ended.
subject is what the scout is about, given at its launch: kind is
run, defect, ticket or pr, and ref the run id as text, the
ticket key or the pull request URL; it is null for a scout launched
without one.
title is the scout's name: the title its lead wrote in its pass 1
report, else one cut from the ask, its first sentence (the text before
the first ., ? or ! followed by whitespace, or before the first
line break; a closing ? or ! kept, a closing . dropped) with its
whitespace collapsed, and one over 70 characters cut back to its last
whole word within 69 and ended with …. Nothing stored is rewritten.
limit defaults to 20 and is capped at 200. The body echoes the applied
limit; truncated is true when more scouts exist than it returns. There
is no paging past limit and no filter. A non-positive or non-integer
limit is 400 with an error naming limit. A store with no scouts
answers an empty scouts; a project with no store answers 503. On a host
daemon this route is GET /projects/NAME/scouts. holo scouts [--limit N]
[--json] prints it, and the holo mcp scouts tool answers it.
GET /scouts/ID¶
One scout whole: what the console's Scout page draws.
{"scout": {"id": 3, "title": "Why does the retry spin?",
"ask": "Why does the retry spin?", "kind": "bug",
"phase": "done", "...": "the GET /scouts row's keys",
"base_sha": "a1b2…", "host": "writer",
"budget": {"minutes": 30, "helper_turns": 12, "waves": 2},
"totals": null},
"tasks": [{"id": 7, "pass": 1, "wave": 1, "title": "Find where the loop sleeps",
"question": "Where does the loop sleep?",
"paths": ["app/retry.py"], "mode": "read", "route": "luna",
"phase": "done", "started_ms": 1791568820000,
"ended_ms": 1791568900000, "duration_ms": 80000, "tokens": 5100,
"cost_usd": 0.02, "excerpt": "…", "reason": null}],
"reports": [{"pass": 1, "at": 1791569000000, "report": {
"headline": "The retry loop never backs off.",
"summary": "…",
"bullets": ["…", "…", "…"],
"findings": [{"title": "…", "status": "confirmed", "evidence": [
{"file": "app/retry.py", "line": 12, "verified": true, "why": null},
{"command": "python3 -m unittest tests.test_retry", "output": "…"},
{"url": "https://example.test/issues/7"}]}],
"actions": [{"title": "…", "why": "…", "kind": "fix", "body": "…",
"layer": null, "covers": null}],
"reproduction": {"level": "test", "hypotheses": [
{"hypothesis": "…", "separates": "…", "verdict": "supported"}]},
"cut": [],
"prior": [{"scout": 4, "finding": 1, "verdict": "holds", "why": "…"}]}}],
"actions": [{"position": 1, "pass": 1, "title": "…", "why": "…",
"kind": "fix", "body": "…", "layer": null, "covers": null,
"revises": null, "ticket": null, "filed_ms": null}],
"events": [{"id": 1, "at": 1791568800000, "kind": "scout_queued",
"summary": "bug scout queued by maintainer (holo scout)"}],
"thread": [{"id": 1, "author": "maintainer", "mode": "ask", "text": "…",
"blocks": [], "answers": null, "at": 1791569100000,
"consumed_by": 6, "consumed_ms": 1791569110000},
{"id": 2, "author": "scout", "mode": "answer", "text": "…",
"blocks": [{"type": "finding", "pass": 1, "index": 1}],
"answers": 1, "at": 1791569200000, "consumed_by": null,
"consumed_ms": null}]}
scout holds the GET /scouts row's keys, id, title, ask, kind, phase,
reason, lead, helper, swarm, tasks_done, tasks_total,
findings, actions, filed, to_file, created_ms, started_ms, ended_ms, elapsed_ms,
heartbeat_age_ms and subject (its kind and ref, or null), plus the base_sha it planned
at and the host it ran on (each null while queued), its budget of
minutes, helper_turns and waves, and its totals as recorded, or
null.
tasks are its helper tasks ordered by pass, wave and id. Each
carries the lead's title, question and paths, its mode (read or run),
the helper route, its phase (queued, running, done, failed or
stopped), started_ms, ended_ms and duration_ms, and the tokens,
cost_usd and answer excerpt it recorded, each null until it does.
A task stored with no title serves its question's first clause: the
text before the first :, ;, ,, ( or ., or through the
first ? with its ? kept; one of more than 8 words keeps its first 8
and ends with …, and one still over 60 characters is cut as the scout's
title is, within 59. reason is null unless the task's phase is
failed; then it is one line read from its excerpt: a line holding
could not launch: TEXT reads could not start: TEXT (with
implementer credential variable NAME is not set read as
implementer credential not set), else the excerpt's last non-empty
line, or no output for an empty one, cut as the scout's title is at
100 characters.
reports holds one entry per stored pass, its pass, the at it was
stored and the report. A report serves only the fields the report check
defines, and a key a lead added beside them is left out: its headline,
the answer in one sentence, its summary, and its bullets, 3 to 5
short lines, headline and bullets each null for a report stored
without them (the report's own title serves as the scout's title,
not here);
its findings, each a title, a status of confirmed, partial or
unconfirmed, and its evidence; its actions; its reproduction, a
level and hypotheses, each with its hypothesis, what it separates
and its verdict, or null; cut, the helper questions cut at the
time budget, or null; and prior, or null when the report has none: the
lead's judgement of each finding of the earlier scouts its plan goal
offered, those whose reports or sub-tasks named the same paths, each its
scout, the finding numbered from 1, a verdict of holds, fixed or
unchecked, and why. An evidence item is one of a file item, file,
line, verified (whether the file and line resolve in the base commit)
and why it did not (null when it did); a command item, command and
output; a url; or a text item, text. A report stored before the
report check serves inside these fields: a finding's free-text evidence
serves as one text item holding the string as stored, and a report
action's kind outside those actions lists serves investigate. No
stored report is rewritten; each finding's stored status serves by this
table:
| stored | served |
|---|---|
shown |
confirmed |
likely |
partial |
unconfirmed |
unconfirmed |
confirmed |
confirmed |
partial |
partial |
unverified |
unconfirmed |
| any other word | unconfirmed |
actions are the scout's action items by position, which counts across
passes: each its pass, title, why, kind (fix, test,
investigate, decide, revise or guardrail), body, layer,
covers and revises, the
ticket key it was filed as and filed_ms, both null until it is filed. A
revise item, offered only to a scout whose subject is a defect, is
the defect ticket's whole body rewritten, and its revises is
{"ticket": KEY, "revision": N}: the defect run's ticket and that
ticket's revision when the item was stored. A scout whose subject is a
parked ticket, one in needs_spec when its plan turn started, is offered
a revise item too: that ticket's body rewritten so the park's reason no
longer holds, its revises that ticket and its revision when the item
was stored. Every other item's revises is null. holo scout show
prints a revise item as
A2 revise NAT-3 r2: TITLE. A guardrail item, offered only to a
guardrails scout, drafts a guard: its layer is the first layer of the
correction ladder (impossible, static, witness, guidance or
review) that would have caught every row it covers, and covers lists
the findings digest's row ids it answers. Both are null on every other
item, and a report's actions carry them the same way. Once a
guardrail item is filed, the rows it covers leave the digest of later
guardrails scouts of the project. holo scout show prints a guardrail
item as A1 guardrail static: TITLE (covers 3). events holds this scout's
events in the order written, each its id, at, kind and summary,
as GET /runs/N serves a run's.
thread holds the scout's messages oldest first: the asks
POST /actions/scout-message
records and the lead's answers. Each carries its id, author, mode
(ask, steer or answer), text, blocks, answers (the id of the message it
answers, or null), at, and consumed_by and consumed_ms, the id of
the event that took it and when, both null while it waits. A block is a
finding of the report, {"type": "finding", "pass", "index"}, or an
action item, {"type": "action", "position"}.
An id that is not a positive integer is 400 with an error. An id the
store does not hold is 404 with error no such scout; a project with no
store answers 503. On a host daemon this route is
GET /projects/NAME/scouts/ID. holo scout show ID [--json] prints it,
and the holo mcp scout_show tool answers it.
GET /scouts/routes¶
The launch form's pickers: the configured scout routes a launch can name,
and the [scout] defaults.
{"leads": [{"name": "opus", "harness": "claude", "model": "opus",
"default": true,
"probe": {"ok": true, "at_ms": 1791568800000,
"duration_ms": 9000, "why": null, "age_ms": 60000}}],
"helpers": [{"name": "luna", "harness": "codex", "model": "gpt-6-luna",
"default": true, "probe": null}],
"defaults": {"lead": "opus", "helper": "luna", "swarm": 4, "minutes": 30,
"helper_turns": 12, "waves": 2}}
leads and helpers list each [scout.leads.NAME] and
[scout.helpers.NAME] route by its name, with its harness and model,
and default true for the [scout] lead or helper. probe is the
route's last recorded probe, the primary entry of seat
scout-lead:NAME or scout-helper:NAME in route-probes.json, shaped as
/status's route_probes: ok, at_ms, duration_ms, why and
age_ms. A route whose last probe failed is left out; a route never
probed is listed with probe null, so a fresh host still offers its
defaults. defaults holds the config's lead, helper, swarm,
minutes ([scout] budget_min), helper_turns and waves, whatever
their probes say. The route reads config and the probe snapshot only, so
a project with no store still answers it. On a host daemon this route is
GET /projects/NAME/scouts/routes.
POST /actions/steer¶
Opened by [serve] actions = true (The daemon's actions),
behind its token. It does what holo steer KEY NOTE does: the store's one
steer, routed by the ticket's state, so a run parked on its pull request
goes back to the babysitter with the note, a live run's next implementer
turn or babysitter fix round reads it, and a ticket with no run in flight
carries it into its next run. The console's Steer box posts it from a live
run's detail card and from a parked pull request row; holo steer posts
it under transport = "http".
{"ticket": "KO-n", "note": "remove the subheader", "hint": false,
"now": false, "author": "maintainer"}
ticket (required) is the Linear identifier; note is the steer;
hint (default false) makes it one turn's hint rather than a contract
amendment; now (default false) is --now, stopping a live run's
implementer turn to resume its session with the note; author defaults
to maintainer.
{"action": "steer", "ok": true, "ticket": "KO-n", "recorded": 42,
"detail": "KO-n steered: run 52 sent back to the babysitter as a maintainer instruction (operator_note event 7, steer note 3)"}
detail is the line holo steer prints; recorded is the interventions
row the steer wrote, steer or, for a parked run, operator_note. A
blank note, a ticket the store never mirrored or holds twice, and every
refusal the store makes for the ticket's state are 200 with ok: false
and the reason in detail, and nothing is written. A missing ticket,
or a hint or now that is not a boolean, is 400; a project with no
store is 503.
POST /actions/mark-ready¶
Opened by [serve] actions = true (The daemon's actions),
behind its token. It marks a run's draft pull request ready for review with
the factory's own GitHub access, sending the markPullRequestReadyForReview
mutation the babysitter sends, and records a pr_ready event on the run.
The console's opened pull request row posts it for a draft held for review.
In one transaction it then wakes the run the way the supervisor's ready wake
does, without reading the pull request again: the run's seen state becomes
ready, a ci_wake event reads URL was marked ready for review, and a
babysit intervention (source human) sends the run back to the
babysitter, so GET /status lists the ticket under queued until the
loop's next claim. A run holding a parked approval (an approval_parked
event) gets only the seen state: it stays parked, and the supervisor's
ready wake, which carries the approval, picks it up.
{"run": 52, "author": "maintainer"}
run (required) is the run parked on its pull request; author defaults
to maintainer and is named in the pr_ready event.
{"action": "mark-ready", "ok": true, "run": 52, "recorded": 311,
"detail": "https://github.com/org/repo/pull/7 marked ready for review; run 52 sent back to the babysitter"}
recorded is the babysit intervention's id, null when the run was not woken
(a parked approval). When the store refuses the wake because the ticket
moved while GitHub was asked, nothing of the wake is written and the answer
stays ok: true, the pull request being ready, with the refusal in
detail; the supervisor's next read records the truth. A run that is not
the ticket's latest attempt parked on a pull request whose last seen state
is draft and held for review (a pr_draft_held event, the hold the
factory wakes from) is refused, nothing written and nothing sent:
{"ok": false, "detail": "run N is not a draft held for review"}. A GitHub
read or mutation that fails is also ok: false, naming the pull request,
with no event and no wake. A run that is not a positive integer is 400; a
project with no store is 503.
GET /peers¶
Where the other daemons are, so the page can fan out from whichever daemon it was loaded from:
{"self": "127.0.0.1:7710", "peers": ["writer-2:7710", "writer-3:7710"]}
self is the address this daemon bound, HOST:PORT as --serve
announced it, not the machine's name. peers is the project's [console]
daemons list (see docs/config.md) in its configured order, empty when
the table is absent: an empty list, never an error. The daemon answers
from config and never contacts a peer; it needs no store, so a project
with none still answers 200 here. A host daemon answers the same shape at its root from
host.toml's [console] daemons.
The host daemon¶
factory.py --serve with no project serves every project in the host
registry, HOLOPHYTE_HOME/host.toml (Operating).
Each route of this page answers under the project's prefix,
/projects/NAME/..., with the body a project daemon answers at its root:
/projects/holophyte/status, /projects/holophyte/runs/52,
/projects/holophyte/config, /projects/holophyte/actions/requeue.
NAME is the project's [serve] name, percent-decoded (a client encodes a
name that holds a space). It resolves through the registry alone, re-read
when host.toml changes: a name outside it is 404, naming the registry,
before any file of any project is opened, and a project project remove
dropped stops answering at the next request.
One project's failure is that project's answer. Before every project route
the daemon checks the store's schema stamp, read-only. A store stamped
newer than the daemon's build still answers when its readableFrom floor
is at or below the daemon's schema version (an additive bump); one this
build cannot read, a floor above it or none, is 503 naming the versions
(and makes the daemon look at the checkout's HEAD at once, so a daemon
whose checkout moved leaves for the new code). A locked or corrupt store is 503 with its
error, in a read or in an action's write; /projects/NAME/status for a
store with no row for the project's path is 503 naming factory.py project
add (the other routes still answer, and a hold may write the row); any
other failure is 500. Every read waits at most one second for a store's
lock (HOST_READ_WAIT_S in holophyte/serve/serve_host.py, through
store.read.lock_wait()), so a locked store answers 503 inside the
drawer's two-second limit. The root lists each such project with its
error and the others whole.
The bound has a cost a project daemon's thirty-second wait does not: a
store whose write lock is held past that second, even briefly, answers 503
database is locked for that poll, and the root carries it as the
project's error and a project_error item on /attention. The tray and
the drawer can show a needs-you row for that one poll; it clears at the
next. A row that stays across polls is a store that stays locked.
The root answers GET /status and GET /attention for the host, below,
GET /peers from host.toml's [console] daemons, / and the console's
files, POST /actions/run-sweep (Run sweep),
POST /actions/restart-daemon
(Restart daemon), and GET /prefs
and PUT /prefs (Host GET /prefs and PUT /prefs).
Any other JSON path at the root
is 404, naming the /projects/NAME prefix. Tokens are in
Authentication.
Host GET /status¶
{
"now": 1788450534491,
"daemon": {"started_ms": 1788446934491, "pid": 2801590},
"build": {"daemon": "abc1234…", "sweep": "abc1234…", "head": "abc1234…"},
"console": {"served": "def5678…", "tree": "0a1b2c3…", "stale": true,
"reason": "`bun install --frozen-lockfile` did not start: …",
"failed_ms": 1788446935100, "tried": "0a1b2c3…"},
"sweep": {"started": 1788450500000, "ended": 1788450512000,
"revision": "abc1234…", "pid": 2801700, "exit": 0,
"projects": {"holophyte": "ok", "lotuspod": "skipped: disabled"},
"error": null, "state": "fresh"},
"actions": true,
"projects": [
{"name": "holophyte", "path": "/path/to/holophyte",
"store": "/home/op/.holophyte/holophyte-HASH/store.db", "error": null,
"host": "writer-1", "schema_version": 41, "admission": "enabled",
"hold_note": null, "project_row": 1,
"supervisor": {"state": "live", "pid": 0, "heartbeat_age_ms": 21000,
"host": "writer-1"},
"runs": [{"id": 52, "ticket": "KO-219", "phase": "working",
"heartbeat_age_ms": 71989}],
"workers_on_previous_build": 0}
]
}
now is the daemon's clock, epoch milliseconds, as on a project's
/status; daemon is its started_ms and pid. build names three
builds side by side: daemon, the checkout's revision when this daemon
started (null when it runs from no git checkout); sweep, the revision the
last host sweep ran; head, the checkout's HEAD now. The console flags
any that differ. actions is whether host.toml's [serve] actions is
on.
console is the console build this daemon serves. served is the
console tree its build is stamped with and tree the checkout's
console tree now, each null when unknown. stale is true when tree
is known and differs from served. While it is, reason, failed_ms
(epoch milliseconds) and tried (the tree the build started from) are
the last failed startup build's, from HOLOPHYTE_HOME/console-build.json;
otherwise, or with no such record, each is null. A successful startup
build removes the record. The console shows a banner while the daemon
that served it reports stale.
sweep is HOLOPHYTE_HOME/sweep.json as the last run wrote it: started
and ended (epoch milliseconds), revision, pid, exit (0, or 1 when a
project errored or a signal stopped the run) and projects, each name's
outcome (ok, skipped: WHY, error: WHY), each null when the file lacks
it; error is why the file could not be read, else null. state is
none with no file, unreadable, running while a run started within
the sweep unit's 120 s has not ended, killed once it is past that with no
ended, fresh when the last run ended within two [supervisor]
sweep_sec intervals, and stale after that.
projects is every registry entry in registry order. name is its
[serve] name (null for an entry whose config cannot be read), path its
repository and store its store file, null when there is none. error is
the project's failure as text (a config that cannot be read, a store that
cannot be opened, a schema newer than the daemon's build can read), null
when it answered; the fields below are then null. host is the project's
[report] host_label, schema_version its store's stamp, admission and
hold_note as on a project's /status, and project_row the id of the
store's row for this path, null when it has none (a store deleted or
recreated since project add). supervisor is the store's watcher beat
(state live, stale or none, pid, heartbeat_age_ms, host),
stale after two sweep intervals; pid 0 is the host sweep's one beat per
store. runs is every live run in a sweepable phase with its id,
ticket, phase and heartbeat_age_ms, empty for a disabled project.
workers_on_previous_build is as on a project's /status.
Host GET /attention¶
Every project's /attention items in one list, each item carrying its
project name, after the host's own:
{"level": "attention", "now": 1788450534491, "items": [
{"kind": "sweep_stale", "project": null, "state": "killed",
"started": 1788450300000, "ended": null, "level": "attention"},
{"kind": "project_error", "project": "lotuspod", "path": "/path/to/lotuspod",
"error": "OperationalError: database is locked", "level": "attention"},
{"kind": "stale_run", "run": 52, "ticket": "KO-219", "ticket_url": null,
"title": "Add a thing", "phase": "working", "heartbeat_age_ms": 1800000,
"pr_url": null, "level": "attention", "project": "holophyte"}
]}
sweep_stale comes first when the sweep's state is anything but fresh
or running, with that state and the run's started and ended; its
project is null. Then, per project in registry order: project_error
for a project the root /status shows with an error; no_store for one
with no store and no_project_row for a store with no row for its path,
each with a detail naming factory.py project add; and the project's own
items as its /attention answers them (kind, run, ticket,
ticket_url, title, phase, heartbeat_age_ms, pr_url and the rest,
above),
each with project added. A project's supervisor item judges the host
sweep's beat against two sweep intervals. level is attention when
there is any item, else working when any project has a live run, else
none; now is the daemon's clock.
Host GET /prefs and PUT /prefs¶
The console's preferences that follow the maintainer across devices, one
document at a host daemon's root. A daemon has one maintainer, the holder
of the machine token, so the document is that maintainer's. A project
daemon has no /prefs, and /projects/NAME/prefs is no project route:
both are 404 as any unknown path.
{"day_log": {"preset": "manual",
"items": {"merged": true, "review-round_2": false},
"projects": {"lotuspod": false}},
"updated_ms": 1788450534491}
day_log is the console's Today's log filter, or null when none is
stored: preset is one of highlights, everything and manual;
items maps the console's item ids (letters, digits, - and _, up to
64 characters) and projects maps project names (up to 128 characters)
to booleans, each at most 64 entries. The daemon checks that shape and
leaves its meaning to the console. updated_ms is when the document was
last written, in epoch milliseconds, or null when it never was. With no
file both are null. A file that cannot be read or parsed answers 200 with
both null and an error naming the file and the reason.
PUT /prefs takes a JSON object holding some of the document's keys,
today only day_log. Each key given replaces that key's value and null
removes it; a key not given keeps its stored value, so {} changes
nothing but updated_ms. Two writers race key by key: the last write
wins, and there is no If-Match. It answers 200 with the whole stored
document as GET /prefs reads it.
PUT /prefs
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json
{"day_log": {"preset": "highlights", "items": {}, "projects": {}}}
Refusals are 400 with an error naming the key or field, and nothing is
written: a body over 16 KiB (checked before it is parsed), a body that is
not a JSON object, a key other than day_log, and a day_log of another
shape (a missing or unknown field, another preset, an item id or
project name out of bounds, a value that is not a boolean, more than 64
entries).
GET /prefs is a read route: open on loopback and behind the machine
token beyond it, as the root's GET /status is. PUT /prefs answers
only to the machine token on every bind, loopback included, and never to
a project's own token_file, else 401 with {}; with host [serve]
actions off it is 404. Nothing is written in either case.
The document is the file console-prefs.json in HOLOPHYTE_HOME, outside
every store and project config. A write goes to a temporary file in the
same directory, flushed and synced, and replaces the file in one rename,
with mode 0600; a PUT over an unreadable file replaces it.
PUT /tickets/ID¶
A host daemon's edit of one ticket on a native board ([board] kind =
"native"), at /projects/NAME/tickets/ID: the console's Board writing a
ticket's body at the revision it read. A project daemon has no such
route, and a Linear project's board stays read-only on the console.
PUT /projects/holophyte/tickets/NAT-1
Authorization: Bearer MACHINE_TOKEN
If-Match: 2
Content-Type: application/json
{"body": "# …\n\n## Summary\n…", "priority": 2, "labels": ["ui"]}
{"ticket": "NAT-1", "revision": 3}
The gate, in order: the host's machine token on every bind, loopback
included, and never a project's own token_file, else 401 with {}; host
[serve] actions on and the project native, else 404 with nothing
written; If-Match a non-negative integer, the revision GET /board
served, else 428; a JSON object body, else 400. body is the ticket's
full text; priority (0 to 4) and labels (a list of strings) are
optional, and each left out keeps the ticket's own.
The edit is the store's edit_ticket(), judged as --file-ticket judges
a body and recorded as the ticket's next revision authored console,
never an author the body names. It answers 200 with the new revision;
409 with error and current, the ticket's revision now, when it has
moved past If-Match (another edit landed; read it again); and 422 with
problems, every blocking problem, when the body fails the template for
a ticket outside the backlog column or names a ticket the project does
not hold, with nothing written. A store failure is the project's 503 or
500 as a read's is.
POST /tickets¶
A host daemon's filing of a new ticket on a native board, at
/projects/NAME/tickets: the console's New ticket. It passes the gate of
PUT /tickets/ID but for If-Match, which it does not
read: a new ticket has no revision.
POST /projects/holophyte/tickets
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json
{"body": "# …\n\n## Summary\n…", "priority": 2, "column": "ready"}
{"ticket": "NAT-1", "revision": 1}
body is the ticket's full text; priority (0 to 4) is optional, and
column, ready by default or backlog, the column it lands in; any
other is 400. The filing is the store's file_ticket() under the board's
[board] prefix, authored console: 201 with the new ticket and its first
revision, 1; 422 with problems and nothing written when a body filed
to ready fails the template or Depends on: names a ticket the project
does not hold. A body filed to backlog with problems is saved as a
draft.
POST /tickets/check¶
A host daemon checks a native ticket body without filing or editing it, at
/projects/NAME/tickets/check. It passes the gate of
POST /tickets: the machine token on every bind, host
[serve] actions on and a native board, with no If-Match.
POST /projects/holophyte/tickets/check
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json
{"body": "# …\n\n## Summary\n…", "ticket": "NAT-1", "column": "ready"}
{"problems": [], "advisories": [], "blocks": false}
body is the full ticket text; a missing or blank body is 400. column
is optional, ready by default or backlog; any other value is 400.
GET on this route answers 405 with Allow: POST.
When optional ticket names an existing ticket, its own column determines
whether the problems block an edit. A project with no store answers 503.
The 200 answer lists problems and advisories in checker order, with the
advisory: prefix removed from advisories. It applies the template and
repository checks used by filing, without the freshness advisories.
Unknown Depends on entries are problems, including the ticket itself
when checking an edit. blocks is true for unknown dependencies or any
problem that blocks filing in the selected column; ordinary template
problems do not block backlog, but private-data problems block every
column. The store is opened read-only: no ticket, revision or sequence
is written.
POST /tickets/ID/move¶
A host daemon's move of a native ticket between Ready and Backlog, at
/projects/NAME/tickets/ID/move, behind the whole gate of
PUT /tickets/ID, If-Match included.
POST /projects/holophyte/tickets/NAT-1/move
Authorization: Bearer MACHINE_TOKEN
If-Match: 1
Content-Type: application/json
{"column": "backlog", "note": "after the release"}
{"ticket": "NAT-1", "revision": 2}
column is ready or backlog, else 400; note, optional, is the
move's note. The move is the store's move_ticket(), authored console:
200 with the new revision; 409 with error and current when the
ticket has moved past If-Match; 422 with problems and nothing written
for a canceled or closed ticket, one already in column, or a draft
moved to ready. A canceled ticket does not move.
POST /tickets/ID/cancel¶
A host daemon's cancel of a native ticket, at
/projects/NAME/tickets/ID/cancel, behind the whole gate of
PUT /tickets/ID, If-Match included.
POST /projects/holophyte/tickets/NAT-3/cancel
Authorization: Bearer MACHINE_TOKEN
If-Match: 1
Content-Type: application/json
{"note": "wrong scope"}
{"ticket": "NAT-3", "revision": 2, "run": 5}
note, the reason, is required: missing or blank is 400 with nothing
written. The cancel is the store's cancel_ticket(), authored console:
200 with the new revision and run, the live run the cancel asked to
abort (its stopRequested names an abort intervention), null when the
ticket had none; 409 with current and 422 with problems as a move's.
The abort, or the close-out of a run parked awaiting merge approval, is
an intervention with actor maintainer: the console sends every native
board write.
POST /mcp¶
The factory's MCP tools over HTTP, for an agent with no shell on the
seat: the Model Context Protocol's
Streamable HTTP transport, served by holo mcp --http [HOST:PORT], not by
either daemon. It is a process of its own on the writer host, the MCP
Python SDK's app under uvicorn, on 127.0.0.1:7711 by default or the
address given (the host's private-network address, beside the daemon's
7710); deploy/holophyte-mcp.service runs it. The daemon never imports the
SDK, and a host without it keeps its daemon.
POST /mcp
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json
Accept: application/json, text/event-stream
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "requeue",
"description": "put a failed run's ticket back in the queue (holo requeue KEY --json)",
"inputSchema": {"type": "object", "properties": {"project": {"type": "string",
"minLength": 1, "description": "..."}, "ticket": {}, "note": {}, "author": {}},
"required": ["ticket", "note", "author"], "additionalProperties": false},
"annotations": {"readOnlyHint": false, "destructiveHint": false}}]}}
Its tools are the ones holo mcp serves on stdio (holo mcp),
with the same schemas, annotations and results, and it adds none. Each
reply is a JSON-RPC 2.0 object, jsonrpc and the request's id beside
result or error. A tool list's result holds tools, each with its
name, description, inputSchema and annotations. An inputSchema is
a JSON Schema object: type, properties (each a type, minLength or
minimum, an enum and its default, and a description), required
and additionalProperties, false. Every tool takes an optional project,
a [serve] name or a repository path; report also takes since,
runs and scouts limit, run the run id and its view, ticket
the key, scout_show the scout id, and the writes their own ticket, run or body and the required note and
author, steer also its hint and now booleans, and scout its
required kind (bug, research, recon or guardrails) and its optional lead, helper, swarm, minutes and
subject (KIND:REF, what the scout is about), the note its ask, and scout_file its required scout id and item
position, the note and author the filed ticket's first board note, and
scout_stop its required project and scout id, its optional task,
one helper task's id to stop alone, and the note its reason, required
without task and checked when the tool is called,
scout_ask its required project and scout id, the note its ask, and
scout_steer its required project and scout id, the note its steer,
each of the two an optional answers, the id of the scout's card it
answers. annotations holds readOnlyHint, true for a read, and
destructiveHint, false for every tool served today, so a client that
asks a person before a destructive write asks here as on stdio. A tool
call's result holds content, a list of one object whose type is
text and whose text is the answer; structuredContent, the command's
JSON object, when it printed one; and isError, true for a refusal. An
unknown tool is the reply's error, with code -32602 and a message
naming the tools.
Each call runs its holo command on the host (HOLO_TRANSPORT=local) as
a subprocess, as on stdio, and a write records its author as AUTHOR via
MCP. The six write tools are listed and callable only when
host.toml's [serve] actions is true; otherwise only the reads are
listed and a write is an unknown tool.
The transport is stateless and answers in JSON: a request gets one JSON
reply, a notification 202 with no body, and no Mcp-Session-Id is
issued; an unsupported MCP-Protocol-Version is 400. No event stream is
offered. Every request, on every bind, loopback included, needs
host.toml's [serve] machine_token_file as its bearer, compared in
constant time; a host registry without that key is a startup error naming
it, and nothing listens. A request carrying an Origin header is a
browser's and is refused, the DNS-rebinding defence the transport asks
of a server; an MCP client sends none. These answers come before anything
runs:
| Status | When |
|---|---|
| 401 | no Authorization: Bearer header, another scheme or another value; body {} |
| 403 | the right bearer and an Origin header; body carries error |
| 405 | the right bearer and any method but POST, GET included; Allow: POST, body carries error |
Like the daemon, it follows the code: every CODE_CHECK_SEC (15 s) it
compares the factory checkout's HEAD with the one it started from and
exits 0 on a move, and its unit starts the new code. It stops accepting at
once and waits at most DRAIN_SEC (20 s) for requests and tool calls in
flight; a tool call still running then is cut off, its holo process
killed so its SQLite transaction rolls back, and its reply is lost.
Static files¶
GET / answers console/dist/index.html, the new console, and
GET /PATH answers console/dist/PATH for a regular file under that
directory: the repository's own console/dist/, where the renderer's build writes the
console, found from the package rather than the project's checkout. The
JSON routes above, and any added later, take precedence over a file of
the same name. The content type follows the extension:
| Extension | Content-Type |
|---|---|
.html |
text/html; charset=utf-8 |
.js |
text/javascript |
.css |
text/css |
.svg |
image/svg+xml |
.woff2 |
font/woff2 |
.png |
image/png |
.json, .map |
application/json |
.webmanifest |
application/manifest+json |
| anything else | application/octet-stream |
Every file answer is Cache-Control: no-store; there is no compression,
no other caching header and no range support. A path that resolves outside
the directory (.., an encoded .., an absolute path, a symlink pointing
out) or names no regular file is the same 404 JSON as an unknown route.
GET /next.html and GET /classic.html, the console's old addresses,
answer 301 Moved Permanently with Location: / (/?QUERY when the
request carries a query), with or without a token and whether or not the
console is built; the browser keeps the fragment, so /next.html#/runs
lands on /#/runs.
When console/dist/ does not exist, / is 404 JSON whose detail says
the console is not built, and the JSON routes answer as before: a daemon
on a host without the renderer's toolchain still serves its JSON.
Authentication¶
A daemon bound to anything but loopback runs with [serve] token_file
(see docs/config.md) and demands its contents on every JSON route:
Authorization: Bearer TOKEN
With [serve] machine_token_file also set, the contents of that file are
accepted as TOKEN too, on every route that demands the project's token:
one token for every daemon on the machine, beside the project's own.
Each value is compared whole, in constant time; a missing header, another
scheme or any other value is 401 with the body {} and no store access,
and nothing about the attempt is logged. GET /, the console's files
under it and GET /peers are served without the header, so the page can
load and learn where its peers are before it has a token to present. A
daemon bound to loopback never asks on the read routes: --serve 7710
answers them open, token file or not. The opt-in routes are the
exception: [serve] actions or [serve] config_edit needs
[serve] token_file on every bind, loopback included (the daemon
refuses to start without it), and POST /actions/..., GET /config and
PUT /config answer only to the bearer.
A host daemon takes its one token from host.toml: [serve]
machine_token_file is the bearer at the root and under every
/projects/NAME prefix. It is demanded on every read route beyond
loopback, and on every bind for POST /actions/..., /config and the
root's PUT /prefs; a bind
beyond loopback, [serve] actions or any project's config_edit without
it is a startup error naming the key. A project's own [serve] token_file
is accepted beside it under that project's prefix only, for one release;
it is 401 at the root and under any other prefix. A project's
machine_token_file is not read in host mode. The bind judged is the one
the daemon serves on, the handed-over socket's when the service manager
started it.
A page served by one daemon polls the others from the browser, and a
cross-origin GET carrying Authorization is not a simple request, nor
is the console's POST /actions/... or PUT with the bearer, a JSON
body and, for a ticket edit, If-Match: the
browser first sends a CORS preflight, OPTIONS on the path with
Access-Control-Request-Headers: authorization (authorization,
content-type for an action, and if-match beside them for a ticket
edit). Every daemon answers it on any path with
204, no body, Access-Control-Allow-Origin: *,
Access-Control-Allow-Methods: GET, POST, PUT, Access-Control-Allow-Headers:
authorization, accept, content-type, if-match and Access-Control-Max-Age: 600,
token or not: a preflight never carries credentials, so the answer
discloses nothing and touches no store, and the request it clears is
still refused without the bearer. Every other method but GET stays 405, POST
included on every path but the /actions/ routes and PUT on every path
but /config, both in The daemon's actions, and a host
daemon's /tickets/ID (PUT /tickets/ID) and its
root's /prefs (Host GET /prefs and PUT /prefs),
behind the machine token alone; a host
daemon also takes POST on /tickets (POST /tickets),
/tickets/check (POST /tickets/check),
/tickets/ID/move (POST /tickets/ID/move) and
/tickets/ID/cancel (POST /tickets/ID/cancel),
each behind the machine token alone.
Errors¶
| Status | When |
|---|---|
| 204 | OPTIONS on any path: the CORS preflight, empty, with the Access-Control-* headers above |
| 401 | a non-loopback daemon, any route but /, its files and /peers, without the exact Authorization: Bearer value; body {}; on a host daemon also a project's own token presented at the root or under another project's prefix, and PUT /prefs on any bind without the machine token |
| 400 | /runs or /holds with a bad limit; /shipped with a bad limit, before or outcome; /history with a missing or non-integer since, a non-integer until, an until at or before since, or a bad limit, offset, sort, order, state or failure; /ledger with a missing or non-integer since, a bad limit or an unknown kind; /report with a since not Nh, Nd or all; /runs/N, /runs/N/files, /runs/N/ledger or /runs/N/merge with a non-integer N; on a host daemon, PUT /prefs with a body over 16 KiB, not a JSON object, a key other than day_log or a day_log of another shape, error naming the key or field |
| 404 | /runs/N, /runs/N/files, /runs/N/ledger or /runs/N/merge with no such run, body carries run; /tickets/KO-n with no mirrored ticket, body {}; any other path with no console file behind it; body carries path, and detail when the console is not built. On a host daemon also /projects/NAME/... for a name outside the registry, and a project route at the root, both before any store is opened; restart-daemon on a project daemon or under /projects/NAME/actions/, or with [serve] actions off; PUT /prefs with [serve] actions off, and /prefs on a project daemon or under /projects/NAME |
| 405 | methods other than GET and OPTIONS, except these POST and PUT routes: POST /actions/* (at the host root, run-sweep and restart-daemon), PUT /config, and, on a host daemon, POST /tickets, POST /tickets/check, POST /tickets/ID/move, POST /tickets/ID/cancel, PUT /tickets/ID and the root's PUT /prefs; Allow: GET, except GET on a host daemon’s /tickets/check, which returns Allow: POST |
| 409 | /runs/N/files for a run with no branch and no merge sha, or whose branch or merge commit is no longer in the repository; error names it |
| 503 | any request while the daemon drains for restart (daemon restarting); the project has no store yet; body carries error, detail and project, the repository the daemon serves, as a path. On a host daemon, under one project's prefix: its store stamped newer than the build can read, locked or corrupt (error, project its name), or /status with no project row for its path (project_row null, detail naming project add); and any route when host.toml itself cannot be read |
| 500 | on a host daemon, PUT /prefs when console-prefs.json cannot be written, error naming the file; one project's route failing any other way; body carries error (type and message, redacted) and project, and the traceback goes to the daemon's log |
| 504 | /runs/N/files when git does not answer within its cap |