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 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
and GET /attention, 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 and
GET /tickets/KEY, 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}},
"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}},
"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},
"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,
"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,
"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 or the default; 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.
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. 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. 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. 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. 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¶
{"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, "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, "pr_url": null, "host": "writer-1"}
], "since": 1788393600000, "until": null, "limit": 200,
"truncated": false, "total": 2,
"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, or the loop's base as /runs/N answers it; and
pr_url and host, as /shipped spells them.
The body echoes since, until (null when omitted) and the limit applied,
and carries total, the number of matching runs before the limit, and
now, the daemon's clock the open rows were measured against. limit defaults to
200 and is capped at 500; truncated is true when more runs matched than
limit, and there is no paging past it. A missing or non-integer since,
a non-integer until, or a non-positive or non-integer limit, is 400
with an error naming the parameter. 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}]},
{"round": 2, "started_ms": 1788451061675, "ended_ms": 1788451181675,
"verdict": "pass", "reviewer_model": "reviewer-model", "findings": [],
"instructions": [], "operator_notes": []}
],
"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}
]}
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 recorded before the store carried the cap answers
the loop's base of 2. 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.
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 posted its answer on the pull request,
answered_ms is when, url the posted comment's html_url (null if
GitHub answered none) and answer the answer text as posted, redacted;
until then 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."},
{"at": 1788451181675, "run": 50, "ticket": "KO-217", "kind": "intervention",
"source": "operator", "text": "answered: keep the flag name",
"cleared": "question", "waited_ms": 818325},
{"at": 1788450941675, "run": 52, "ticket": "KO-219", "kind": "round",
"source": "loop", "text": "Round 1: changes_requested · reviewer reviewer-model · verify passed"}
]}
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. 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}
]}
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.
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. 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 /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.
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),
and POST /actions/restart-daemon
(Restart daemon). 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.
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
limit, run the run id and its view, ticket the key, and the
writes their own ticket, run or body and the required note and
author, steer also its hint and now booleans. 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 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.
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/... and /config; 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); 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 |
| 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; /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 |
| 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 |
| 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 and PUT /tickets/ID; 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, 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 |