Skip to content

HTTP endpoints

--serve PORT|HOST:PORT PROJECT, the project daemon, answers the JSON paths below and serves the console's built files at /. --serve with no project, the host daemon, answers the same paths for each registered project under /projects/NAME and its own /status and /attention at the root (The host daemon); every section below describes a project's route as either daemon answers it. Every response carries Cache-Control: no-store and Access-Control-Allow-Origin: *; the JSON ones Content-Type: application/json; every GET route opens the store read-only and closes it. The open origin is for the console page, which one daemon serves and which fetches the others from the browser: without the header the browser refuses a cross-origin answer. The daemon reads by default and writes only through two opt-ins, documented in The daemon's actions: the POST /actions/... routes [serve] actions = true opens, with a host daemon's board writes and its root's PUT /prefs, and the PUT /config route [serve] config_edit = true opens. On loopback the bind address is the whole boundary for reads, and beyond it every JSON route but /peers is behind a bearer token (Authentication). Unknown paths are 404 and any method but GET is 405, both with a JSON error, except those writing routes. A project with no store answers 503.

console/contract/ holds the machine-readable shapes of GET /status, GET /attention and GET /peers, of the host root's GET /status, GET /attention and GET /prefs, of the run routes GET /runs/N, GET /runs/N/merge, GET /runs/N/asks, GET /runs/N/files, GET /runs/N/turns and GET /runs/N/turns/T/transcript, and of the list routes GET /shipped, GET /history, GET /holds, GET /ledger, GET /board, GET /tickets/KEY and GET /tickets?q=TEXT, of the scout reads GET /scouts, GET /scouts/ID and GET /scouts/routes, and of POST /tickets/check: one JSON Schema per route, generated from holophyte/serve/contract.py.

GET /status

{
  "project": "/path/to/repo",
  "schema_version": 35,
  "admission": "enabled",
  "hold_note": null,
  "workers": 1,
  "active_routes": {"implementer": {"command": "claude"},
                    "reviewer": {"command": "claude", "fallback": "claude"},
                    "adjudicator": {"command": "codex"},
                    "writer": {"command": null},
                    "trimmer": {"command": null},
                    "implementer_profiles.sonnet": {"command": "claude"}},
  "route_probes": {"implementer": {"primary": {"ok": false,
                     "at_ms": 1788450354491, "duration_ms": 1800,
                     "why": "exit 1", "age_ms": 180000}, "fallback": null},
                   "reviewer": {"primary": null, "fallback": null},
                   "adjudicator": {"primary": null, "fallback": null},
                   "writer": {"primary": null, "fallback": null},
                   "trimmer": {"primary": null, "fallback": null},
                   "implementer_profiles.sonnet": {"primary": null,
                     "fallback": null}},
  "route_labels": {"implementer": "claude opus", "implementer_fallback": null,
                   "reviewer": "codex gpt-5.6-sol",
                   "reviewer_fallback": null, "adjudicator": "codex gpt-5.6-sol",
                   "adjudicator_fallback": null, "writer": "claude opus",
                   "trimmer": "claude opus", "trimmer_fallback": null,
                   "implementer_profiles": {"sonnet": "claude sonnet"}},
  "workers_on_previous_build": 0,
  "host": "writer-1",
  "now": 1788450534491,
  "toil": {"24h": {"interventions": 3, "merged": 2, "per_merge": 1.5,
                   "by_action": {"requeue": 2, "babysit": 1},
                   "by_actor": {"maintainer": 1, "operator": 2, "supervisor": 1,
                                "unknown": 0}},
           "7d": {"interventions": 5, "merged": 3, "per_merge": 1.6666666666666667,
                  "by_action": {"requeue": 2, "approve": 1, "babysit": 1,
                                "steer": 1},
                  "by_actor": {"maintainer": 1, "operator": 4, "supervisor": 1,
                               "unknown": 0}}},
  "daemon": {"started_ms": 1788446934491, "pid": 2801590},
  "supervisor": {"state": "live", "pid": 2801613, "heartbeat_age_ms": 8258, "host": "writer-1"},
  "thresholds": {"heartbeat_stale_ms": 300000, "strikes": 2, "run_cap": 3.0},
  "actions": false,
  "config_edit": false,
  "colour": "auto",
  "runs": [
    {"id": 52, "ticket": "KO-219", "ticket_url": "https://linear.app/example/issue/KO-219",
     "title": "The sweep frees a silent lease", "phase": "working",
     "started_ms": 1788450461675, "heartbeat_age_ms": 71989, "elapsed_ms": 72816,
     "working_ms": 61200, "work_started_ms": 1788450511675,
     "agent_ms": 61200, "verify_ms": 0, "run_count": 1,
     "verify_started_ms": null,
     "time_box_ms": 1500000, "round": 0, "max_rounds": 2, "strikes": 0,
     "host": "writer-1",
     "stop_requested": null, "stop_action": null, "step": null,
     "pr_url": null, "pr": null, "woke_ms": null}
  ],
  "queued": [
    {"ticket": "KO-220", "ticket_url": null, "title": "Name the held project",
     "reason": "approved for merge; the next claim resumes the candidate at the merge gate",
     "pr_url": "https://github.com/example/repo/pull/41", "waiting_ms": 95000}
  ],
  "witnesses": {"story": "KO-200", "keys": ["W3", "W4", "W5", "W6"],
                "started_ms": 1788450294491, "elapsed_ms": 240000}
}

runs lists every live run in a sweepable phase. Ages are computed by the daemon against its own now, so a client compares one number to thresholds.heartbeat_stale_ms and never has to agree with the writer host about the time. A row reports its ticket's chain of runs: every run of the ticket up to the live one since the ticket's last merged run, so a run sent back to the babysitter, parked on CI, paused or failed and requeued counts toward the live one. run_count is the number of runs in the chain, 1 for a first attempt. started_ms is the chain's first run's start as epoch milliseconds; round is the review rounds the live run has recorded so far, so a run in reviewing is in round round + 1; max_rounds is the review rounds the run may take: the cap its claim recorded, else the newest an earlier run of its ticket recorded since the ticket's last merged run, else the project's [loop] review_rounds; strikes is the sweep's tally for the run, 0 when it is not under suspicion. ticket_url is the ticket's page on the board (tickets.url), null when the store has none. elapsed_ms is wall time since started_ms. working_ms is the work the chain has recorded plus, while the live run's span of work is open, the time since that span began, null when no run of the chain had its work measured; agent_ms and verify_ms are summed over the chain the same way; work_started_ms is when the open span began, as epoch milliseconds, and null while no span is open, so a client interpolates work between polls only from work_started_ms and never from elapsed_ms. agent_ms is the part of working_ms the time box is read against and verify_ms the rest, the time spent in the ticket's verify commands (the supervisor's trip still judges the live run's own agent time, not the chain's); agent_ms is null when working_ms is, and verify_ms is null for a run recorded before the split, whose work all counts as agent_ms; verify_started_ms is set only while the open span is a verify, else null. time_box_ms is the box the run is counted against, the estimate scaled by [agents] budget_scale, null for a ticket with no estimate; thresholds.run_cap is the hard ceiling in multiples of that box, so a time-box bar can draw it. stop_requested is the note of a pause or abort the operator has asked of the run and the loop has not yet acted on, and stop_action that request's action (pause, abort or abort_close); both are null when none is pending. step names the turn a merge_gate run is in when that turn fixes failing checks, fixing checks (round N of M), and is null otherwise. pr_url is the pull request the run opened or carries (runs.prUrl), null when it has none; pr is that pull request's number, checks, review, threads, open_threads, title, state, additions, deletions, changed_files, preview_url, screenshots and videos exactly as on a pr_open item of GET /attention, read from the run's prSeen* columns, and null when pr_url is. woke_ms is when the factory woke the pull request to fix it: the run's startedAt (epoch milliseconds) when an earlier run on its ticket carried the same pull request and parked in awaiting_merge_approval, and null otherwise. supervisor.state is live, stale or none. daemon describes the serving process: its pid and when it started. project is the repository the daemon serves, as a path. schema_version is the store's schema version (its PRAGMA user_version). admission is the project's admission state (store/enums.py ProjectAdmission): enabled, held or disabled; while it is disabled, runs is empty. workers is the most runs the loop keeps at once, the project's [loop] workers. queued lists the project's tickets waiting for the next claim: ready, with no active run and an ended last run, oldest wait first, whatever the admission state. A ready ticket that never ran is not in it. A ticket in the Backlog column is not in it, nor one in Canceled. Each entry carries the ticket's ticket, ticket_url and title, the last run's outcome reason as reason and its pull request as pr_url (null without one), and waiting_ms, the daemon's now minus the run's end. witnesses is the story witness set the loop's witness step is running now, null when none runs: the story's ticket as story, its witness keys in story order as keys, the set's start as started_ms (epoch milliseconds) and elapsed_ms, the daemon's now minus that start. It is read from witness-running.json beside the store, which the witness step writes while it holds the witness lock, and is null when that file is missing, does not parse or names a process that has exited. A hand-run --witness-pass and an approval's baseline run write no record. hold_note is the note of the latest admission change, null when there is none. actions is whether [serve] actions = true opened the POST /actions/... routes of The daemon's actions; the console draws its action buttons disabled while it is false. config_edit is whether [serve] config_edit = true opened GET /config and PUT /config; the console's settings sheet is read-only, naming the key, while it is false. colour is the project's [console] colour (auto, violet, teal, green or blue), auto when unset, read from the file on each request so a saved colour shows on the next poll; an older daemon leaves it out. route_labels names what each seat is configured to run, labelled as a recorded turn is: the command's first word plus its -m/--model value. A seat left unset shows the default the loop dispatches, an unset writer or trimmer the implementer's label, and an unset fallback label null. implementer_fallback, reviewer_fallback, adjudicator_fallback and trimmer_fallback name the configured fallback using the same label format. implementer_profiles maps each configured [agents.implementer_profiles.NAME] profile's name, such as sonnet, to its label, the label its turns record; it is {} for a project with no profile, and an older daemon leaves it out. route_probes holds each seat’s latest primary and fallback probe, each null when absent or the probe file is unreadable. An entry carries ok, the end time at_ms in epoch milliseconds, duration_ms, and age_ms (now minus at_ms). why is null on success, otherwise the failure reason, redacted and limited to 200 characters; commands and output tails are never recorded. Probes run at loop start and before a fallback is used, not on a timer. The loop keeps the newest entry per seat and route in route-probes.json under the project’s holo_dir, using a lock and atomic replacement. active_routes holds one entry per seat (implementer, reviewer, adjudicator, writer, trimmer) naming what the seat runs now: command is the executable alone, never its arguments, null for a seat left unset in [agents]; while a running loop has switched the seat to its fallback, command is the fallback's and the entry also carries fallback, naming it. A configured trimmer that a running loop turned off, its probe and trimmer_fallback both failed, shows command null and down true, unlike an unset one's bare null. Each configured implementer profile has its own entry, keyed implementer_profiles.NAME (as implementer_profiles.sonnet), shaped like a seat's: command is the profile's executable, and while a running loop has switched that profile's own implementer to implementer_fallback, command and fallback name the fallback's. A profile's switch leaves implementer as it is, and the default implementer's switch leaves a profile's entry as it is. The claim's probe of a ticket's profile is kept under the same key, so route_probes reports it like a seat's. workers_on_previous_build counts the workers a restarted loop inherited from the build it replaced and still owns, 0 when there are none. toil is the operator's hand work per merge over the last 24h and the last 7d before now, the same windows as --report's toil lines: interventions counts the interventions with source human in the window, project-level ones such as a hold included; merged counts the runs that ended merged in it; per_merge is their ratio, null when merged is 0; by_action counts the interventions by action, most frequent first, ties by name. by_actor counts every intervention in the window, the supervisor's included and migrate rows left out, by the interventions.actor that acted: maintainer (a daemon action under any token but the machine token), operator (the command line, MCP, or a daemon action under the machine token, unless its body's author is maintainer, as holo --author maintainer sends it) and supervisor (the supervisor and the loop); unknown counts the rows written before the store recorded an actor. Every host passes through [report] host_label.

GET /runs?limit=N

{"rows": [
  {"run": 312, "ticket": "KO-241", "actual_min": 8.4, "estimate_min": 10.0,
   "ratio": 0.84,
   "rounds": 1, "outcome": "merged", "host": "writer-1", "ended_ms": 1788478953000,
   "merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f"}
], "limit": null}

The --report table as JSON, oldest first, the same rows in the same order the terminal prints. run is the run's id, as /runs/N takes it, which the table does not print. ended_ms is the run's end as epoch milliseconds, which the table does not print; the drawer ages the last merge from it. merge_sha is the full merge commit a merged run landed on main as, null for any other outcome or a run merged before the store recorded it. ?limit=N keeps the first N rows and echoes limit; a non-positive or non-integer limit is 400.

GET /shipped?limit=N&before=RUN_ID&outcome=merged

{"rows": [
  {"id": 312, "ticket": "KO-241", "title": "Run detail: files touched",
   "rounds": 1, "findings": 2, "started_ms": 1788478203000,
   "ended_ms": 1788478953000, "actual_min": 8.4, "working_ms": 504000,
   "agent_ms": 432000, "verify_ms": 72000, "wall_min": 12.5,
   "run_count": 2, "turn_count": 3, "estimate_min": 10.0,
   "merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
   "commit_url": "https://github.com/example/repo/commit/5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
   "pr_url": "https://github.com/example/repo/pull/2170",
   "host": "writer-1", "outcome": "merged", "outcome_reason": null}
], "next_before": 298, "limit": 50}

Finished runs, newest end first, ordered by ended_ms descending then id descending. A row is the run that closed its ticket's chain of runs: a run that merged, or the newest run of its ticket. A run that later runs of its ticket continue (sent back to the babysitter, parked on CI, paused, or failed and requeued) has no row of its own; its figures count in the row of the run that closed the chain. The chain runs from the run after the ticket's last earlier merged run through the closing run. id, ended_ms, outcome, outcome_reason, merge_sha, pr_url and host are the closing run's. started_ms is the chain's first run's start, and wall_min the minutes from it to ended_ms, CI waits and parks included. working_ms, agent_ms, verify_ms and actual_min (working_ms in minutes) are summed over the chain's runs, each null only when no run of the chain had its work measured; rounds is summed over the chain's runs. run_count is the number of runs in the chain and turn_count the number of agent turns /runs/N/turns lists, summed over them. outcome=merged (the default) returns only merged runs; outcome=all returns every run that closed its chain, whatever its outcome. Each row includes outcome and outcome_reason (the stored reason cut at 400 characters, null when absent). Any other outcome is 400 naming the parameter and its value. The console's Shipped view scrolls back over it grouped by day; the Board's "shipped today" is its first page. findings is the count of findings over the chain's review rounds. limit defaults to 50 and is capped at 200; the body echoes the limit applied. before=RUN_ID answers the rows that ended before that run's end (ties broken by id), so a client pages by passing next_before back; next_before is the last row's id while more rows remain and null on the last page. A non-positive or non-integer limit, or a non-integer before, is 400 with error naming the parameter; a before no run has is 200 with no rows. /runs is untouched: it stays the terminal's table, oldest first.

commit_url is the merge commit's page on the project's origin: https://HOST/OWNER/REPO/commit/SHA when the origin URL is https://HOST/OWNER/REPO(.git) or git@HOST:OWNER/REPO(.git) and the sha is an ancestor of origin/main in the project's checkout. It is null when the row has no merge_sha, the project has no origin, the remote is of another shape (including one carrying a ? query, # fragment or credentials, which would otherwise ride into the link), or the sha has not reached origin/main (a local merge never pushed, one rewritten on the way up, a fresh clone with no origin/main yet), so a link is only ever to a page that exists. The remote is read once per request and the ancestry checked once per row; a git failure is null, never an error.

pr_url is the pull request the run merged through under [merge] mode = "pr", as the store recorded it when the run parked (runs.prUrl); null for a run that opened none, including every run merged locally. Nothing is read from GitHub: the URL is the one the factory itself opened, so its last path segment is the PR number.

GET /holds?limit=N

{"admission": "held", "note": "schema bump", "rows": [
  {"id": 3, "at": 1791568920000, "action": "hold",
   "note": "schema bump", "actor": "maintainer"},
  {"id": 2, "at": 1791568800000, "action": "release_hold",
   "note": "Back to normal.", "actor": "operator"},
  {"id": 1, "at": 1791553200000, "action": "hold",
   "note": "Waiting for the migration.", "actor": null}
], "limit": 20, "truncated": false}

The project's current admission (enabled, held or disabled) and note, as /status answers admission and hold_note, with its admission changes in rows. Each row carries its id, at in epoch milliseconds, action (hold, release_hold or disable), note as recorded (including any console prefix, or null), and actor (maintainer, operator or supervisor, or null for older records). Rows belong only to this project and are ordered by at descending, then id descending.

limit defaults to 20 and is capped at 200. The body echoes the applied limit; truncated is true when more rows exist than it returns. There is no paging past limit. A non-positive or non-integer limit is 400 with an error naming limit. A store with no row for the project answers admission: "enabled", note: null and empty rows; a project with no store answers 503. On a host daemon this route is GET /projects/NAME/holds?limit=N.

Releasing a hold requires a reason, just like holding: holo release requires note, and POST /actions/release-hold rejects a blank note. The Release UI and its required-reason form are covered by HOLO-403.

GET /history?since=MS&until=MS&limit=N&offset=N&sort=KEY&order=asc|desc&q=TEXT&state=GROUP,…&failure=KIND,…&implementer=LABEL,…

{"rows": [
  {"id": 231, "ticket": "KO-260", "ticket_url": "https://linear.app/example/issue/KO-260",
   "title": "Run page: chain strip", "attempt": 2, "phase": "reviewing",
   "outcome": null, "outcome_reason": null, "failure_kind": null,
   "park_kind": null, "started_ms": 1788478203000, "ended_ms": null,
   "elapsed_ms": 540000, "working_ms": 480000, "estimate_ms": 1800000,
   "heartbeat_age_ms": 12000, "rounds": 1,
   "max_rounds": 2, "findings": 2, "merge_sha": null, "commit_url": null,
   "additions": 120, "deletions": 14, "resumed_as": null,
   "implementer": "claude opus", "pr_url": null, "host": "writer-1"},
  {"id": 225, "ticket": "KO-255", "ticket_url": null,
   "title": "Ledger: fold resolutions", "attempt": 1, "phase": "failed",
   "outcome": "failed", "outcome_reason": "verify failed: ruff check .",
   "failure_kind": "verify", "park_kind": null,
   "started_ms": 1788470000000, "ended_ms": 1788471200000,
   "elapsed_ms": 1200000, "working_ms": null, "estimate_ms": null,
   "heartbeat_age_ms": null, "rounds": 0,
   "max_rounds": 2, "findings": 0, "merge_sha": null, "commit_url": null,
   "additions": null, "deletions": null, "resumed_as": null,
   "implementer": null, "pr_url": null, "host": "writer-1"}
], "since": 1788393600000, "until": null, "limit": 200, "offset": 0,
 "sort": "started", "order": "desc",
 "truncated": false, "total": 2,
 "counts": {"state": {"active": 1, "merged": 0, "failed": 1, "abandoned": 0},
            "failure": {"verify": 1, "review_route": 0, "fix_no_progress": 0,
                        "no_commits": 0, "budget": 0, "merge_lock": 0,
                        "infra": 0, "swept": 0, "unclassified": 0},
            "implementer": {"claude opus": 1}},
 "implementers": ["claude opus"],
 "now": 1788478743000}

Without until, returns one row per run for the console's Runs page: every run that has not ended, whenever it started, plus every run that started at or after since, ordered by started_ms descending then id descending. A run that a later run of its ticket continues keeps its own row; nothing is folded into a chain as /shipped folds it. A run that ended before since is absent, a paused one included.

With until, only ended runs whose start is in [since, until) are returned, in the same order. Open runs, including parked runs, are absent.

since is required; it and optional until are epoch milliseconds. Each row is the run's id, ticket, ticket_url, title, attempt and phase; outcome (null while open); outcome_reason (the stored reason cut at 400 characters, null when absent); failure_kind (the failed run's kind, null unless it failed); park_kind (what a parked run waits on, as the store recorded it); started_ms and ended_ms (null while open); elapsed_ms, from the start to ended_ms, or to now while open; working_ms, measured working time through ended_ms, or through now while open (null when never measured); estimate_ms, the ticket estimate snapshotted when the run was claimed (null when it had none); heartbeat_age_ms, now minus the run's last heartbeat while open, else null; rounds, the run's own review rounds (those not in error); max_rounds, its cap as /runs/N answers it; findings, the findings the run's own review rounds recorded, counted as /shipped counts them; merge_sha and commit_url, as /shipped serves them (commit_url null unless the sha is on origin/main); additions and deletions, the pull request size last recorded for the run (null when none was); resumed_as, for a run whose outcome is paused, the id of the next run of the same ticket after it, whether or not that run is in the answer (null for a paused run with no later run, and for every run not paused); and pr_url and host, as /shipped spells them; and implementer, the label in the payload of the run's newest implementer_profile event (null when it has none, as every run before those events began reads). An older daemon omits findings, merge_sha, commit_url, additions, deletions, resumed_as and implementer.

Optional parameters page, sort and narrow the list; without any of them the route answers as above. A row is served only when it matches every one given:

  • offset, a non-negative integer (default 0), skips the first offset matching rows after sorting; limit then cuts what follows.
  • sort is one of started (the default), ended, duration, rounds and state, and order is asc or desc (the default). started orders by started_ms; ended by ended_ms, an open run later than every ended run; duration by elapsed_ms, an open run's measured to now; rounds by rounds as the row serves it; state by the state groups in the order active, merged, failed, abandoned, then a paused run a later run resumed. Ties under every key fall back to started_ms descending, then id descending, so a sort is stable across pages.
  • q matches a run whose ticket key or title contains the text, ignoring ASCII case; % and _ match only themselves, and a blank q is absent.
  • state is a comma list of active (not ended, or paused with no later run of its ticket), merged, abandoned (ended with that outcome) and failed (ended with any other outcome but paused, rejected included). A paused run that a later run resumed is in no group, so any state leaves it out.
  • failure is a comma list of failure kinds (verify, review_route, fix_no_progress, no_commits, budget, merge_lock, infra, swept, unclassified), matching a run's failure_kind.
  • implementer is a comma list of labels, matching a row's implementer; labels hold no comma, so the list splits on commas after URL decoding.

The body echoes since, until (null when omitted), the limit applied, and offset, sort and order as served, and carries total, the number of matching runs before offset and limit, and now, the daemon's clock the open rows were measured against. limit defaults to 200 and is capped at 5000; truncated is true when matching runs remain beyond offset + limit. counts holds the per-value counts beside each filter choice: state (each group), failure (every failure kind) and implementer (every label in implementers), each map counting the rows that match every parameter given except its own, before offset and limit, with 0 for a value nothing matches. implementers is the sorted distinct labels of every implementer_profile event in the store.

A missing or non-integer since, a non-integer until, a non-positive or non-integer limit, a negative or non-integer offset, or a sort, order, state or failure value not listed above, is 400 with an error naming the parameter and any value it got. An until at or before since is 400 with "until must be after since".

GET /runs/N

{"run": {"id": 52, "ticket": "KO-219", "ticket_url": "https://linear.app/example/issue/KO-219",
         "title": "The sweep frees a silent lease",
         "phase": "done", "attempt": 1, "started_ms": 1788450461675,
         "ended_ms": 1788451661675, "outcome": "merged",
         "outcome_reason": null, "failure_kind": null, "park_kind": null,
         "elapsed_ms": 1200000, "working_ms": 912000, "agent_ms": 840000,
         "verify_ms": 72000, "time_box_ms": 1500000,
         "branch": "task/ko-219-the-sweep-frees-a-silent-lease", "host": "writer-1",
         "heartbeat_age_ms": null,
         "merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
         "commit_url": "https://github.com/example/repo/commit/5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
         "pr_url": "https://github.com/example/repo/pull/2170",
         "work_started_ms": null, "verify_started_ms": null,
         "approved_at": 1788451561675, "approved_by": "operator",
         "max_rounds": 2},
 "rounds": [
  {"round": 1, "started_ms": 1788450761675, "ended_ms": 1788450941675,
   "verdict": "changes_requested", "reviewer_model": "reviewer-model",
   "findings": [{"path": "holophyte/serve/server.py", "line": 12, "severity": "p1",
                 "criterion": "AC1", "message": "the route is unmatched"},
                {"path": "holophyte/serve/server.py", "line": 40, "severity": "p1",
                 "criterion": null, "message": "Validate input", "kind": "thread",
                 "author": "review-bot", "author_kind": "bot",
                 "summary": "Validate input", "verdict": "ADDRESS",
                 "raw": "Please validate input",
                 "url": "https://github.com/example/repo/pull/2170#discussion_r1"}],
   "instructions": [{"kind": "instruction", "path": "holophyte/serve/server.py", "line": null,
                     "author": "operator", "severity": "nit",
                     "message": "Keep validation", "request": "Keep validation",
                     "url": "https://github.com/example/repo/pull/2170#discussion_r2",
                     "outcome": "changed", "reply": "Validation retained"}],
   "operator_notes": [{"note": "Keep validation", "author": "operator",
                       "kind": "operator_note", "event_id": 7, "consumed": true,
                       "run_id": 52, "round": 1}],
   "criteria": [{"id": "C1", "n": 1, "status": "not_met",
                 "note": "the expired lease stays claimed"},
                {"id": "C2", "n": 2, "status": "met",
                 "note": "tests/test_lease.py::test_requeue_is_ledgered"}]},
  {"round": 2, "started_ms": 1788451061675, "ended_ms": 1788451181675,
   "verdict": "pass", "reviewer_model": "reviewer-model", "findings": [],
   "instructions": [], "operator_notes": [],
   "criteria": [{"id": "C1", "n": 1, "status": "met",
                 "note": "tests/test_lease.py::test_expired_lease_requeues"},
                {"id": "C2", "n": 2, "status": "met",
                 "note": "tests/test_lease.py::test_requeue_is_ledgered"}]}
 ],
 "findings": [{"tone": "advisory", "message": "https://github.com/example/repo/pull/2170#discussion_r3: Consider a cache"}],
 "events": [
  {"at": 1788450461675, "kind": "phase_change", "summary": "claimed"},
  {"at": 1788450941675, "kind": "review", "summary": "round 1 asked for changes"}
 ],
 "chain": {"started_ms": 1788449861675, "elapsed_ms": 1800000, "working_ms": 1212000,
           "agent_ms": 1140000, "verify_ms": 72000,
           "runs": [
  {"id": 50, "attempt": 1, "outcome": "abandoned", "phase": "done",
   "started_ms": 1788449861675, "ended_ms": 1788450161675, "elapsed_ms": 300000,
   "working_ms": 300000, "agent_ms": 300000, "verify_ms": 0, "max_rounds": 2,
   "turn_count": 3,
   "rounds": [{"round": 1, "verdict": "pass", "reviewer_model": "reviewer-model"}]},
  {"id": 52, "attempt": 2, "outcome": "merged", "phase": "done",
   "started_ms": 1788450461675, "ended_ms": 1788451661675, "elapsed_ms": 1200000,
   "working_ms": 912000, "agent_ms": 840000, "verify_ms": 72000, "max_rounds": 2,
   "turn_count": 4,
   "rounds": [{"round": 1, "verdict": "changes_requested", "reviewer_model": "reviewer-model"},
              {"round": 2, "verdict": "pass", "reviewer_model": "reviewer-model"}]}
 ]},
 "steer_notes": [
  {"id": 4, "kind": "hint", "note": "Reuse the sweep's lease query",
   "author": "maintainer", "at": 1788450561675, "run": 52, "consumed_by": 52,
   "consumed_ms": 1788450571675, "interrupt": false, "withdrawn": false}
 ],
 "criteria": [
  {"n": 1, "id": "C1", "text": "Given an expired lease, then the run is requeued",
   "status": "met", "note": "tests/test_lease.py::test_expired_lease_requeues",
   "run": 52, "round": 2},
  {"n": 2, "id": "C2", "text": "Given a requeue, then the ledger names it",
   "status": "met", "note": "tests/test_lease.py::test_requeue_is_ledgered",
   "run": 52, "round": 2}
 ],
 "precheck": {"sha": "9f3c2a1b0e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b",
              "criteria": [{"id": "C1", "n": 1, "witnessed": true, "note": ""},
                           {"id": "C2", "n": 2, "witnessed": false,
                            "note": "names no test file"}]}}

One run in full, by id: what the console shows when a run is expanded. run is the row joined to its ticket. ended_ms is null while the run is live; heartbeat_age_ms is the daemon's now minus the run's last heartbeat while it is live and null once it has ended. max_rounds is the review-round cap the loop gave this run (two rounds plus one per 800 changed lines, at most four), so a client can say "round 2 of 3" without recomputing it; a run with no recorded cap (a babysit run resuming a pull request) answers the newest cap an earlier run of its ticket recorded since the ticket's last merged run, else the project's [loop] review_rounds. rounds lists the run's review rounds oldest first, each with its findings decoded into objects (path, line, severity, criterion, message) rather than the stored JSON string. A finding decoded from a pull-request review thread also carries kind (thread), author (the thread's last commenter), author_kind (bot for a login [merge] bot_authors or bot_logins names, else user or unknown as GitHub reported it), summary (the finding's one-line gist, which message repeats), verdict (the adjudication: ADDRESS, FOLLOW_UP or DECLINE), raw (the comment's text, redacted and cut at 20,000 characters) and url (the thread's page). A round's instructions are the requests a human reviewer addressed to the factory on the pull request, split out of findings: each an object with kind (instruction), path, line (null when the thread has none), author, request (the ask), url and, where the round recorded them, severity, message, outcome (changed or asked) and reply, the factory's answer on the thread. A round's operator_notes are the private operator notes (--babysit KO-n --note TEXT on a parked pull request) that round consumed: each with the note, its author, kind (operator_note), event_id (the run event that recorded it), consumed, and the run_id and round that consumed it. Top-level findings are the advisory bot threads the factory noted on the pull request without acting on: each with tone (advisory) and message, the thread's URL and first line; empty when there are none. elapsed_ms, working_ms, agent_ms, verify_ms, work_started_ms, verify_started_ms, time_box_ms and ticket_url are as /status carries them, except that a run's clocks stop at ended_ms once it has ended. approved_at (epoch milliseconds) and approved_by (the operator's login) record the approval (--approve) that released a parked candidate to merge; both are null for a run no one approved, and a --requeue clears them. events is the narrative level of the run's event stream, oldest first; detail events and their payloads are not served. An N that is not an integer is 400; an integer with no run behind it is 404 carrying run. Leading zeros are ignored, so /runs/007 is run 7. An integer no run can have (negative, or wider than SQLite's 64-bit INTEGER, however long) is 404 with run echoing the path segment as typed. host passes through [report] host_label. commit_url and pr_url are as /shipped carries them: the merge commit's page on origin and the pull request the run opened, each null when there is none. outcome_reason is the run's stored outcome reason cut at 400 characters, as /shipped cuts it, null when there is none; failure_kind is the class of a failed run's failure (verify, budget, infra and the like) and park_kind what a parked run waits on (pull_request, ci, question and the like), each null when the run recorded none.

chain is the ticket's chain of runs up to run N, as /shipped defines it: every run of the ticket after its last merged run before N, through N. run, rounds, events and findings describe run N alone. chain's started_ms is the first run's start and elapsed_ms runs from it to run N's end (or to now while N is live); working_ms, agent_ms and verify_ms are summed over the chain's runs, each null only when no run of the chain had its work measured. chain.runs lists the runs oldest first, run N last, each with its own id, attempt, outcome (null while live), phase, started_ms, ended_ms, its clocks as run carries them, max_rounds, turn_count (the turns /runs/ID/turns lists) and rounds, each round's round, verdict and reviewer_model. A run with no earlier run in its chain has a chain of itself alone, whose clocks equal run's.

steer_notes are the maintainer's steer notes (holo steer) on run N's ticket recorded at or after chain.started_ms, oldest first, [] when there are none: each with its id, kind (amendment or hint), note, author and at (epoch milliseconds); run, the run the note was routed to, null when it waited for the next run; consumed_by and consumed_ms, the run whose agent read it and when, both null until read; interrupt, true when it was sent with --now; and withdrawn, true when a later withdrawal took the amendment back.

criteria is the newest review's judgement of the ticket's acceptance criteria over the chain: run N's latest round when it has judged them, else the latest round of the newest earlier run of the chain that did, so a requeued run starts from what its predecessor's last review said. Each criterion, in order, has its stable id (for example C4), its number n, its text, its status (met, not_met or unwitnessed) and the reviewer's note, which for a met criterion names its witness; run and round name the review that judged it. Older events without an id serve C plus n as their id. [] until a round of the chain has judged them.

Each of run N's rounds carries its own criteria, in criterion order, each with id, n, status and note: for a review round, the newest judgement run N recorded for that round; for the final check (the terminal adjudication, recorded as the round numbered one past max_rounds), each criterion one of its criteria findings names takes that finding's status and note, and every other criterion of run N's newest judgement reads met with note ""; [] for a final check whose verdict is error and for a round with no judgement (a babysit review, or a round recorded before criteria were kept).

precheck is run N's first witness pre-check, null when it recorded none: its sha, the candidate it checked, and criteria, each with id, n, witnessed (true when the criterion names a test file and the check found no gaps) and note, its gaps joined by ;, else names no test file when it names none, else "".

GET /runs/N/files

{"run": 52,
 "base": "038e4e513e5a4e8367b69a36d73ecc8fd0e12366",
 "head": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
 "files": [
  {"path": "docs/reference/http.md", "status": "M", "added": 31, "deleted": 2},
  {"path": "holophyte/files.py", "status": "A", "added": 168, "deleted": 0},
  {"path": "holophyte/serve/server.py", "status": "M", "added": 62, "deleted": 14}
 ],
 "total_added": 261, "total_deleted": 16, "truncated": false}

The files a run touched, read from git: what the console's "files touched" panel shows under a run. The store holds only the run's branch, recorded the moment the loop cuts its worktree, and, once it landed, its merge commit; the daemon resolves those to a commit range and runs git diff --numstat and git diff --name-status over it, each under a timeout, so the answer is what git says today, not a snapshot. For a merged run (a recorded merge_sha) the range is the merge commit's first parent to the merge commit in the project's checkout: exactly what the --no-ff landing added to main, whether or not the branch still exists. For a live run, one whose branch still has its worktree beside the project, the diff is taken inside that worktree from the merge base of its HEAD with the newer of main and origin/main to the working tree: commits and uncommitted edits together, untracked files listed as added, so the panel fills in as the implementer works, and a worktree with nothing changed yet answers an empty files with 200. For a run whose branch survives without a worktree it is the merge base of the branch with the newer of main and origin/main to the branch head in the checkout: what the branch has that main does not, unaffected by what main gained since, and not crediting the run with main's commits merged into the branch from origin/main before the local main caught up. With no origin/main in the repository the base is the merge base with main alone. The daemon fetches nothing; it reads only the refs already there. base and head are the full shas the range resolved to; for a live run head is the worktree's HEAD, and the edits beyond it are in the counts.

files is sorted by path. status is A (added), M (modified), D (deleted) or R (renamed, listed under the new path); a binary file counts as 0 added and 0 deleted. At most 200 files are listed; truncated is true when the diff named more, and total_added and total_deleted still sum the whole diff, so a truncated list still says how big the run was.

N parses as on /runs/N: a non-integer is 400, an integer with no run is 404 carrying run. 409 with an error when no range can be found: the run recorded neither a branch nor a merge sha, or the branch it recorded has neither a worktree nor a ref (a preserved branch deleted by hand; the error names the branch). 504 when git does not answer within its cap. The endpoint serves no file contents or diff hunks and writes nothing to the repository.

GET /runs/N/turns

{"turns": [
  {"id": 4410, "role": "implement", "route": "primary",
   "label": "claude opus", "seconds": 372.5,
   "session_id": "0198c2a4-5e1f-7b3a-9d6e-2f4c8a1b3d5e",
   "ended_ms": 1760000000000, "exit_status": 0, "timed_out": false,
   "input_tokens": 52100, "output_tokens": 9300, "cost_usd": 0.92},
  {"id": 4433, "role": "review", "route": "fallback",
   "label": "codex gpt-5.6-sol", "seconds": 95.25, "session_id": null,
   "ended_ms": 1760000095250, "exit_status": 1, "timed_out": true,
   "input_tokens": null, "output_tokens": null, "cost_usd": null}
 ]}

The agent turns of run N, oldest first: one entry per implementer, reviewer, adjudicator, writer or trimmer turn the loop recorded when the turn ended, so a turn still running is not listed yet. id is the turn's event id, which /runs/N/turns/T/transcript takes as T. role is implement, review, adjudicate, write or trim. label names what ran, as route_labels labels a seat on /status: the command's first word plus its -m/--model value, null for a turn recorded without one. route is primary, or fallback when the seat ran its configured *_fallback. seconds is how long the turn ran, null when its record carries no duration. session_id is the agent session the turn ran in, null when none was recorded beside it. ended_ms is the event's recorded timestamp in epoch milliseconds, when the turn ended. exit_status is the recorded integer exit status, null when none was recorded. timed_out says whether the turn timed out, false when absent from the record. input_tokens and output_tokens are the recorded non-negative integer token counts; input tokens include cache reads. cost_usd is the recorded cost in US dollars. Each usage field is null when usage is absent or its value is not a valid number. These facts come from the run record even when no transcript is kept. A run with no turns answers an empty list.

N parses as on /runs/N: a non-integer is 400, an integer with no run is 404 carrying run. The route writes nothing.

GET /runs/N/turns/T/transcript

{"entries": [
  {"speaker": "command", "text": "python3 -m unittest", "at_ms": 1760000000000,
   "tool": "Bash", "call": "tool-1", "summary": "python3 -m unittest",
   "failed": false, "cut": false},
  {"speaker": "tool", "text": "OK", "at_ms": 1760000001000,
   "tool": null, "call": "tool-1", "summary": null,
   "failed": false, "cut": false}
 ]}

The transcript entries written while turn T of run N ran, in session order. The daemon reads Claude Code sessions, Codex rollouts and Devin exports only under the roots configured in [serve] transcripts, never following a link out of a root. Timestamped entries use the turn's recorded duration and end with two seconds of tolerance on either side. Entries without timestamps are retained; when the turn has no duration or end timestamp, the whole session is returned.

Every entry carries all eight keys:

  • speaker is user, assistant, command, tool or error.
  • text is the message, command or tool output, always a string.
  • at_ms is the entry's timestamp in epoch milliseconds, null when the session provides no valid timestamp.
  • tool is the tool name for a command, null when absent or for other entries.
  • call is the tool call identifier linking a command and its result, null when unavailable or for entries unrelated to a tool call.
  • summary is a command's short description, such as its first line or file path, null when unavailable or for other entries.
  • failed is true for an error entry or a tool result marked as failed; otherwise false.
  • cut is true when the redacted text exceeded 20,000 characters and was cut to its first 20,000 characters; otherwise false.

Every text and non-null summary is redacted with the project's known secrets. Redaction happens before cutting text, including when a secret straddles the cut. The full session file stays on the host; this route does not serve the remainder of a cut entry.

404 {"error": "transcript unavailable"} when no root is configured, T is not one of run N's turns, the turn has no session, or no session file is found. With roots configured, N parses as on /runs/N: a non-integer is 400, an integer with no run is 404 carrying run. The route writes nothing.

GET /runs/N/merge

{"run": 52, "ticket": "KO-219",
 "pr_url": "https://github.com/example/repo/pull/31",
 "head_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
 "ready": false, "reason": "checks_pending",
 "detail": "the required checks are pending: vitest",
 "facts": [
  {"name": "parked", "ok": true, "detail": "run 52 is KO-219's newest run, parked awaiting_merge_approval on https://github.com/example/repo/pull/31"},
  {"name": "human_approval", "ok": true, "detail": "[merge] approve is \"human\""},
  {"name": "ready_for_review", "ok": true, "detail": "the pull request is ready for review"},
  {"name": "review_approved", "ok": true, "detail": "GitHub's review decision is APPROVED"},
  {"name": "checks_passed", "ok": false, "detail": "the required checks are pending: vitest"},
  {"name": "mergeable", "ok": true, "detail": "GitHub's mergeable is MERGEABLE"},
  {"name": "threads_resolved", "ok": true, "detail": "no review thread is open"},
  {"name": "head_unchanged", "ok": true, "detail": "origin's task/ko-219 and the pull request are at 5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f"}
 ]}

Whether a run parked for a human's merge may be merged now: what the console reads to draw a Merge button or say why not. The same answer POST /actions/merge (The daemon's actions) computes again at call time before it releases anything; nothing is cached. ready is true when every fact holds. facts is always these eight, in this order, each its name, whether it holds (ok) and a detail in words:

name Holds when
parked the run is its ticket's newest run, in phase awaiting_merge_approval, the ticket blocked_on_operator with no live run, and the run recorded a pull request and a branch
human_approval [merge] approve is "human"
ready_for_review the pull request is not a draft (isDraft: false); a draft fails with reason draft
review_approved GitHub's reviewDecision is APPROVED; a bypassable review (below) stays ok: false
checks_passed the required checks fold to success, as the babysitter folds them; a check run that started before the pull request's latest ready-for-review event is not counted, so a required check whose only run is from the draft reads missing
mergeable GitHub's mergeable is MERGEABLE
threads_resolved no review thread is open, as the babysitter counts them
head_unchanged the branch head on origin, read with git ls-remote from the project's checkout, and the pull request's headRefOid are the same commit, and it is the run's candidateSha or its approvedSha

When parked or human_approval fails the daemon asks GitHub nothing: the six GitHub facts are ok: false with a detail saying they were not read. reason is null when ready, else the first failing fact's reason (a REVIEW_REQUIRED review only when no other fact fails), one of not_parked, not_human_approval, draft, review_not_approved, review_bypassable, checks_pending, checks_failing, conflicting, mergeable_unknown (GitHub has not computed mergeable yet), threads_unresolved, head_moved and github_unreadable (a GitHub read or the ls-remote failed); detail is that fact's detail. A repository whose rules require no review has a null reviewDecision: review_not_approved. head_sha is origin's branch head, null when it was not read. ticket and pr_url are the run's.

review_bypassable is a REVIEW_REQUIRED review the host's GitHub user may bypass. On that decision, and only once every other fact holds, the daemon reads main's rules (GET repos/OWNER/REPO/rules/branches/main) and, for each pull_request rule that asks for a review (one or more approving reviews, a code owner's review, approval of the most recent push, or a non-empty required_reviewers), its ruleset (GET repos/OWNER/REPO/rulesets/ID). When every such ruleset answers current_user_can_bypass as always, pull_requests_only or exempt, the review_approved fact's reason is review_bypassable and its detail names each ruleset, the reviews it asks for and the bypass value:

GitHub's review decision is REVIEW_REQUIRED; the host's GitHub user may bypass it: ruleset human-review asks 1 approving review, current_user_can_bypass pull_requests_only

While any other fact fails the bypass is not read and that fact is the answer's reason, so checks, conflicts, open threads and a moved head read as themselves before a REVIEW_REQUIRED review, bypassable or not. The bypass fails closed: an unreadable rules or ruleset answer, a rule or value that cannot be made out, a ruleset answering never or anything else, or no ruleset asking for a review (classic branch protection) keeps review_not_approved, its detail ending no bypass: and what stopped it. A CHANGES_REQUESTED review is never bypassable.

N parses as on /runs/N: a non-integer is 400, an integer with no run is 404 carrying run. The route writes nothing; a GitHub read that fails is github_unreadable with 200.

GET /runs/N/asks

{"run": 53, "ticket": "KO-219",
 "pr_url": "https://github.com/example/repo/pull/31",
 "asks": [
  {"id": 812, "question": "Why is the guest keyed by name?",
   "author": "maintainer", "asked_ms": 1788451661675,
   "answered_ms": 1788451781675,
   "url": "https://github.com/example/repo/pull/31#issuecomment-901",
   "answer": "Names are unique per party: src/app.py:30."},
  {"id": 840, "question": "Can a guest be renamed?", "author": "reviewer",
   "asked_ms": 1788452021675, "answered_ms": null, "url": null,
   "answer": null}
 ]}

The questions asked about run N's pull request from the console (POST /actions/ask), oldest first. ticket and pr_url are run N's. asks holds every console ask recorded on any run of N's ticket with N's pull request URL, so the run the loop re-parks after answering lists the asks made on the run before it. Each ask is its event id, its question and author, and asked_ms, when it was recorded. Once the babysitter has answered it, answered_ms is when, answer the answer text, redacted, and url null: the answer is recorded in the console and nothing is posted to GitHub. An ask answered before that keeps its posted comment's html_url in url. Until answered the three are null. A run with no pull request answers an empty list. N parses as on /runs/N: a non-integer is 400, an integer with no run is 404 carrying run. The route writes nothing and asks GitHub nothing.

GET /runs/N/ledger

{"run_id": 52, "ticket": "KO-219",
 "entries": [
  {"at": 1788450941675, "kind": "round",
   "text": "Round 1: changes_requested · reviewer reviewer-model · verify passed",
   "source": "loop"},
  {"at": 1788451181675, "kind": "round",
   "text": "Round 2: pass · reviewer reviewer-model", "source": "loop"},
  {"at": 1788451661675, "kind": "merge",
   "text": "MERGED to main as 5acc138.", "source": "loop"}
 ]}

One run's ledger: the narrative the store holds for it (design note 9), which is what a frontend reads and what the Linear comments and the FINDINGS.md window are projections of. entries is oldest first, each its at in epoch milliseconds, its kind (round, merge, failure, adjudication, intervention or note), its text and its source (loop for an entry the factory wrote, operator for one recorded out of band). An intervention entry carries two more fields, cleared and waited_ms: what the operator's step cleared and how long that had waited, computed here because the store knows both and a page that has lost the item cannot (design note 13's "how long it waited and who cleared it"). One closed rule, against the entry's own run: two marks are read, the at of the run's newest redirect intervention strictly before the entry (the ask) and the run's endedAt when set and strictly before the entry (the failure); the newer mark wins, and a redirect in the same millisecond as the failure is not newer, so cleared is "question" or "failed" and waited_ms is the entry's at minus that mark. A mark after the entry never counts, and a redirect entry never pairs with itself since its own row is not strictly before it. With no mark both are null. Entries of other kinds do not carry the fields.

{"at": 1788452000000, "kind": "intervention",
 "text": "human resume: answered: keep the flag name", "source": "operator",
 "cleared": "question", "waited_ms": 818325}

A run with no entries answers an empty list. N parses as on /runs/N: a non-integer is 400, an integer with no run is 404 carrying run. Nothing here is rendered; the endpoint serves the rows.

GET /ledger?since=MS

{"since": 1788450000000, "limit": 200,
 "entries": [
  {"at": 1788451661675, "run": 52, "ticket": "KO-219", "kind": "merge",
   "source": "loop", "text": "MERGED to main as 5acc138.",
   "pr_url": "https://github.com/OWNER/REPO/pull/12"},
  {"at": 1788451421675, "run": 51, "ticket": "KO-218", "kind": "failure",
   "source": "factory", "text": "no heartbeat for 20 minutes",
   "failure_kind": "swept", "pr_url": null},
  {"at": 1788451181675, "run": 50, "ticket": "KO-217", "kind": "intervention",
   "source": "operator", "text": "answered: keep the flag name",
   "cleared": "question", "waited_ms": 818325, "pr_url": null},
  {"at": 1788451061675, "run": 50, "ticket": "KO-217", "kind": "park",
   "source": "factory", "text": "which flag name should ship?",
   "pr_url": null},
  {"at": 1788450941675, "run": 52, "ticket": "KO-219", "kind": "round",
   "source": "loop", "text": "Round 1: changes_requested · reviewer reviewer-model · verify passed",
   "pr_url": "https://github.com/OWNER/REPO/pull/12"}
 ]}

The ledger across runs, newest first, from since on: the console's "resolved today" fold is one window over the store's ledger table, and the thread under a blocked ticket's question is the same window narrowed to that ticket. since is required and is epoch milliseconds; entries at or after it come back, ordered by at then id, newest first. kind narrows to one of the kinds /runs/N/ledger names; ticket narrows to one identifier (KO-n). limit defaults to 200 and is capped at 1000; there is no paging past it, a day of ledger fits in one page. Each entry is its at, run, ticket, kind, source and text, as /runs/N/ledger spells them; an intervention entry carries cleared and waited_ms too, by the rule /runs/N/ledger states, so the fold reads each resolution's wait off the wire. Every entry carries pr_url: its run's pull request URL, or null when the run has none or the entry has no run. An unfiltered request (no kind, no ticket) also carries two kinds of row the ledger table does not hold, each with source factory, its run's run, ticket and pr_url, and the same since and limit rules: a park row for each run phase change into blocked_on_operator, or into awaiting_merge_approval because a thread needs a human's answer, whose text is the change's reason; and a run-ending failure row for each run that ended failed with no failure ledger row of its own, at its end, whose text is the run's outcome reason and whose failure_kind is the run's failure kind. A park on pending checks or a quiet wait has no row, and park is not a kind the filter accepts. For a thread, /attention's blocked item is the question and /ledger?ticket=KO-n&since=ASKED is the rest: the intervention rows carry the operator's answer text. A missing or non-integer since, a bad limit or an unknown kind is 400 with an error naming the parameter.

The separate active_outages array contains one route_down row per project with an uncleared launch backoff. These ongoing outages are independent of since and limit, so an outage begun before midnight remains visible even when entries fills its historical limit. Each outage carries project, at (the outage's start in epoch milliseconds), reason, text, kind, source, and null run and ticket. The array is empty for ticket-filtered requests or kinds other than intervention; it is included for unfiltered requests. Launch-loop interventions at or after an ongoing outage's start are suppressed from history.

GET /findings?since=MS

{"since": 1788450000000, "limit": 100,
 "runs": [
  {"run": 52, "ticket": "KO-219", "title": "Serve the findings window",
   "phase": "done", "outcome": "merged", "ended_ms": 1788451661675,
   "pr_url": "https://github.com/OWNER/REPO/pull/12",
   "rounds": [
    {"round": 1, "started_ms": 1788450641675, "ended_ms": 1788450941675,
     "verdict": "changes_requested", "reviewer_model": "reviewer-model",
     "findings": [{"severity": "p1", "message": "the limit is not echoed",
                   "path": "holophyte/serve/serve_findings.py", "line": 40}],
     "instructions": [], "operator_notes": []}],
   "ledger": [
    {"at": 1788450941675, "kind": "round",
     "text": "Round 1: changes_requested · reviewer reviewer-model\nImplementer response:\nDECLINE: covered elsewhere",
     "source": "loop"}],
   "advisories": [{"tone": "advisory", "message": "https://github.com/OWNER/REPO/pull/12#discussion_r1: nit"}]}
 ],
 "follow_ups": [
  {"id": 7, "run": 52, "ticket": "KO-219", "kind": "feature",
   "text": "page the window", "path": "holophyte/serve/serve_findings.py",
   "line": 30, "created_ms": 1788451661675, "settled_ms": 1788451661675,
   "state": "filed", "filed_as": "KO-230", "duplicate_of": null,
   "error": null}
 ],
 "ticket_defects": [
  {"ticket": "KO-221", "run": 54, "kind": "contradiction", "criteria": ["C4"],
   "reason": "implementer: criterion C4 cannot be met as written: the export has no header field",
   "at": 1788452000000, "revision": 2, "revised_ms": 1788452600000,
   "requeue_note": "C4 now names the export's columns"}
 ]}

Review findings across runs, from since on, in one read: what the console's Findings page polls. since is required and is epoch milliseconds. runs holds every run with a review round whose ended_ms is at or after since, ordered by its newest round's end, newest first, at most limit of them (default 100, capped at 500). Each carries the run's run, ticket, title, phase, outcome, ended_ms and pr_url; its rounds, every round of the run oldest first, exactly as /runs/N answers them; its ledger, the run's round and adjudication entries oldest first as /runs/N/ledger spells them, whose text keeps the "Implementer response:" lines with the DECLINE and FOLLOW_UP adjudications; and its advisories, the bot threads /runs/N answers as findings.

follow_ups holds the FOLLOW_UP rows a merged run's fix commits left, every row created at or after since plus every row still unsettled, newest first. Each is its id, run, ticket, kind (feature or guardrail), text, path, line, created_ms, settled_ms, state, filed_as, duplicate_of and error. state is pending until the row settles, then filed, duplicate, ledger, superseded or unfiled, read from the event that settled it; a row settled before those events named their row reads ledger for a guardrail and superseded for a feature. The route serves rows, not fates: which group a finding falls in is the console's to work out.

ticket_defects holds the runs that stopped because their ticket was wrong, newest stop first, at most limit of them. A run is one when it recorded a contradiction event, or a supervisor sweep that failed it for criterion_stuck or review_stuck (a sweep that took no action, or one for stale_heartbeat or time_box, does not count), at or after since, and its ticket then gained a revision above the one the run was claimed at, written at or after the stop and, when the ticket has a later run, at or below the revision that run was claimed at. A run claimed at no revision is never one. Each entry is the ticket, the run, the stop's kind (contradiction, criterion_stuck or review_stuck), its criteria ids (the declared ones for a contradiction, the criteria restated as unmet for criterion_stuck, none for review_stuck), the stop event's text as reason and its time as at, the lowest qualifying revision and its time as revised_ms, and requeue_note, the note of the run's first requeue, or null. A run's newest stop gives its one entry.

A missing or non-integer since, or a bad limit, is 400 with an error naming the parameter; a project with no store is 503.

GET /report?since=SPAN

{"project": "/home/op/repo",
 "window": {"since": "7d", "from_ms": 1788000000000, "now_ms": 1788604800000},
 "shipped": {"merged": 3, "abandoned": 1, "failed": 2,
             "median_min": 16.0, "median_estimate_min": 30.0},
 "failures": {"verify": 1, "review": 1},
 "gaps": {"open": 2, "layers": {"none": 2, "test": 1},
          "found_by": {"review": 3}},
 "hands_on": {"interventions": 4, "by_action": {"requeue": 3, "pause": 1},
              "send_backs": 1},
 "runs": [
  {"run": 312, "ticket": "KO-241", "actual_min": 8.4, "agent_min": 6.1,
   "verify_min": 2.3, "estimate_min": 10.0, "ratio": 0.84, "rounds": 1,
   "outcome": "merged", "host": "writer-1", "ended_ms": 1788478953000,
   "merge_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f", "wall_min": 9.0}
 ],
 "notes": [
  {"run": 310, "ticket": "KO-240", "round": 2, "event_id": 881,
   "author": "maintainer", "note": "name the window in the header",
   "consumed_ms": 1788470000000}
 ],
 "steers": [
  {"id": 4, "ticket": "KO-242", "kind": "hint", "author": "maintainer",
   "note": "the port is in config.toml", "steered_ms": 1788480000000,
   "run": null, "event_id": null, "consumed_by": 315,
   "consumed_ms": 1788481000000}
 ]}

holo report --json, the weekly report, as the daemon reads it from the store, so holo report runs over transport = "http". since is the window: Nh, Nd or all, 7d when absent; any other form is 400 naming the forms. project is the repository the daemon serves, as a path. window echoes since and gives the window's start, from_ms (null for all), and the read's moment, now_ms, both epoch milliseconds. shipped counts the runs that ended in the window by outcome, merged, abandoned and failed, with median_min, the median actual minutes of the merged runs, and median_estimate_min, the median of their estimates; both null with no merged run. failures counts the window's failed runs by failure kind, most first. gaps is the store's whole gap ledger, not the window's: open, the gaps with no layer yet, layers, the count per layer, and found_by, the count per finder. hands_on counts the window's interventions: interventions, the total less send-backs, by_action, the count per action, and send_backs, the operator_note rows. runs are the window's ended runs, each as a /runs row without ticket_url: agent_min and verify_min are its agent's and its verify's minutes, and wall_min its wall-clock minutes. notes are the send-back notes a run consumed in the window, newest first: the run and ticket, the round that consumed it, the event_id of the operator_note event, its author and note, and consumed_ms. steers are the holo steer notes recorded in the window or still pending, oldest first: the steer note's id, its ticket, kind (amendment or hint), author and note, steered_ms, the run it was recorded on and, for a parked run's send-back, its event_id, both null otherwise; consumed_by is the run that first carried it and consumed_ms when, both null while it is pending. holo report prints its notes and steers only with --notes; the body always holds them.

GET /attention

What needs the operator, computed where the store is:

{"level": "attention", "now": 1788450534491,
 "project": "/path/to/repo", "items": [
  {"kind": "blocked", "ticket": "KO-n", "title": "…", "question": "…", "run": 50, "asked_ms": 1788449000000,
   "pr_url": null, "park_kind": "question", "pr": null, "level": "attention"},
  {"kind": "pr_open", "ticket": "KO-n", "title": "…", "run": 53,
   "pr_url": "https://github.com/example/repo/pull/2170", "park_kind": "pull_request", "reason": "…", "asked_ms": 1788449000000,
   "pr": {"number": 2170, "checks": "success", "review": "approved", "threads": 2, "title": "…", "state": "ready"},
   "round": 2, "waits_on": [{"ticket": "KO-m", "pr_url": "https://github.com/example/repo/pull/2168"}],
   "level": "attention"},
  {"kind": "stale_run", "run": 52, "ticket": "KO-n", "title": "…", "phase": "working", "heartbeat_age_ms": 400000,
   "pr_url": null, "level": "attention"},
  {"kind": "failed", "run": 51, "ticket": "KO-n", "title": "…", "reason": "…", "ended_ms": 1788450000000, "attempt": 2,
   "pr_url": null, "triage": {"choice": "infra", "confidence": 0.92, "backend": "claude",
   "model": "sonnet", "requeued": true}, "level": "attention"},
  {"kind": "stuck_wait", "ticket": "KO-n", "ticket_url": null, "title": "…", "dependency": "KO-m",
   "dependency_state": "needs_spec", "reason": "critic: stale — already shipped by KO-l",
   "behind": 4, "level": "attention"},
  {"kind": "supervisor", "state": "stale", "heartbeat_age_ms": 1200000, "level": "attention"}
]}

A blocked item's run is the run parked for the ticket and asked_ms when the question was asked: the newest redirect intervention on that run, else the run's last heartbeat (both null only for a ticket parked with no run behind it). Its park_kind is the recorded ticket.parkKind from the reader (stored in runs.parkKind), null when unrecorded. Its pr has the same number, checks, review, threads, open_threads and title facts described for pr_open below, and is null when pr_url is absent. The question text is unchanged. A pr_open item is a blocked_on_operator ticket whose parked run has a pr_url and whose recorded park kind (runs.parkKind, one of store/enums.py ParkKind) is pull_request, as a park under [merge] mode = "pr" records it, ci, as a babysit waiting only on pending checks or the quiet period records it, or route, as a failed adjudicator turn records it until the host retries it; the question's wording plays no part. The item names that kind as park_kind. The run waits on a review, its checks or a merge, not on an answer, so the item carries pr_url and reason (the question with its first line removed, or the whole question when it has one line) in place of question; its run and asked_ms are as on blocked. Its pr is the pull request as the loop's reconcile last read it: number from the URL, checks (success, pending, failure, null for a PR with no checks), review (GitHub's review decision lower-cased: approved, changes_requested, review_required, null when none is required) and threads, the review-thread count, open_threads, the count of those unresolved (the string "100+" when the read that counted it saw more threads than the first page of 100 it counts over, runs.prSeenOpenThreadsFloor), and title, the pull request's title (runs.prSeenChecks, prSeenReview, prSeenThreads, prSeenOpenThreads, prSeenTitle), and state (runs.prSeenState): draft, ready (open and ready for review), closed (closed without merging), or merged. State is null when the read could not determine it. The same read gives the diff size, additions, deletions and changed_files (runs.prSeenAdditions, prSeenDeletions, prSeenChangedFiles); preview_url (runs.prSeenPreviewUrl), the URL of the newest GitHub deployment on the head commit whose latest status is SUCCESS, null when there is none; screenshots (runs.prSeenScreenshots), up to six {"caption": …, "url": …} objects, one per markdown image in the body's Evidence section in body order, [] when there are none; and videos (runs.prSeenVideos), up to four {"caption": …, "url": …} objects, one per markdown link in the Evidence section that is not an image and whose URL path, before any ?, ends .webm or .mp4, in body order, [] when there are none. These six are null (screenshots and videos []) for a run never polled since they were recorded. All six facts are null for a run never polled, and open_threads for one polled before it was recorded. A pr_open item's round is the review rounds its run recorded whose verdict is not error, the count a GET /status run serves as round. Its waits_on lists, in the ticket's dependsOn order, each dependency whose store status is not merged, as {"ticket": "KO-m", "pr_url": …}, pr_url being that ticket's newest run's runs.prUrl (null when it has none or never ran); it is [] when the ticket has no dependency or every one merged. Every item that names a ticket carries the ticket's title, which a pr_open row shows when pr.title is null. A failed item's attempt is the run's 1-based attempt number. Its triage is the run's cause question under [questions.failures] (see docs/config.md): choice (infra, code or spec), confidence, the backend and model asked, and requeued, true when the factory requeued the ticket on that answer; choice and confidence are null when the question failed, and triage is null for a run never asked. Every item that names a run carries its pr_url: the pull request the run opened under [merge] mode = "pr" (runs.prUrl), null when it opened none, so a console can link the parked question to the PR it waits on. A stuck_wait item is a blocked_on_deps ticket (ticket, ticket_url, title) whose wait only a person can end: its first dependsOn entry, of its own project, that is stuck, as dependency. dependency_state is needs_spec, abandoned, parked (blocked_on_operator, except a ticket served as pr_open) or failed (in_flight with no active run and a last run that failed). reason is one line of the dependency's newest critic, stale or validation note for needs_spec, its cancel note for abandoned, its question when parked and its last run's outcome reason when failed: the first * bullet without the marker, else the first non-empty line, null when there is none. behind counts the project's blocked_on_deps tickets waiting on the ticket directly or through other waits, each once; a ticket that waits only through another wait gets no item of its own. A stuck_wait item is also a ready child, with no active run, of a closed or abandoned story, which the claim refuses on every pass: the story is its dependency and the story's state its dependency_state, and reason is one line of the story's newest verdict note when closed (its close note) or its cancel note when abandoned. It carries no age. project is the project path, as on /status.

level is none, working, attention or critical; with no items it is working if any run is live. Items come in this order: blocked and pr_open tickets in ticket order, stale_run, failed within the last 24 hours while it is its ticket's latest attempt and the ticket has neither merged nor started a new one (a requeue to ready keeps it), or at any age while it strands its ticket (its last run, the ticket in_flight with no active run, which only the operator will move on; a requeue ends this unbounded stay, not the 24 hours), oldest end first, stuck_wait in waiting-ticket order, supervisor when not live. A daemon older than this endpoint answers 404, and the drawer then computes the stale-run and supervisor rows itself from /status; any other failure of /attention is shown, never hidden.

GET /board

The open tickets by state, as the store mirrors them: the console's Board view, Linear's columns without a call to Linear.

{"now": 1788450534491, "editable": false, "columns": [
  {"state": "needs_spec", "tickets": []},
  {"state": "blocked_on_deps", "tickets": [
    {"ticket": "KO-n", "title": "…", "time_box_ms": 1500000, "run": null,
     "question": null, "waits_on": ["KO-m"], "mirrored_ms": 1788449000000,
     "column": "ready", "priority": 2, "labels": ["ui"], "revision": 3}]},
  {"state": "ready", "tickets": []},
  {"state": "blocked_on_operator", "tickets": []},
  {"state": "in_flight", "tickets": [
    {"ticket": "KO-m", "title": "…", "time_box_ms": 1500000, "run": 52,
     "question": null, "waits_on": [], "mirrored_ms": 1788449000000,
     "column": null, "priority": null, "labels": [], "revision": 1}]}
], "stories": [
  {"ticket": "KO-k", "title": "…", "children": ["KO-m", "KO-n", "KO-p"],
   "merged": 1, "total": 3}
]}

columns is one entry per open state in the path-to-merge order needs_spec, blocked_on_deps, ready, blocked_on_operator, in_flight, every column present even when empty; merged and abandoned tickets are absent. Tickets within a column are ordered by identifier. run is the ticket's active run, null when none is working it; question is what a blocked_on_operator ticket asks, null otherwise; waits_on is the identifiers of the open tickets its Linear dependencies name, empty when none (a dependency the store has never mirrored is shown by its Linear issue id, since the store cannot name what it has not seen); mirrored_ms is when the store last mirrored the ticket. column (backlog, ready, canceled, null when the board named none), priority (null when none), labels and revision are the ticket's board-owned fields as the store holds them; revision is the one a revision-checked edit sends as If-Match, 0 when none is recorded. stories is one entry per story whose parent ticket is neither merged nor abandoned, in the parent's identifier order and empty when there are none: the parent's ticket and title, its child tickets' identifiers in identifier order as children, merged ones included, merged how many of them merged and total how many there are.

A native project ([board] kind = "native") answers a backlog column first, before the five above: its tickets idle in needs_spec, blocked_on_deps or ready whose column is backlog. A ticket in in_flight or blocked_on_operator stays under its status whatever its column, and a Linear project has no backlog column. editable is true only when a host daemon with [serve] actions on answers for a native project; a project daemon, or a Linear project, answers false.

The board is the store's mirror and nothing more, and the endpoint never calls the provider. The loop mirrors every ready issue the provider lists at each claim, a valid body to ready and one failing the template to needs_spec, so the queue is on the board before the loop starts on it; a ticket the loop could not claim (Backlog, closed, blocked in Linear) is not.

GET /tickets/KO-n

One mirrored ticket by its Linear identifier, body included: what the console shows when the operator opens a ticket without leaving for Linear.

{"ticket": "KO-n", "title": "…", "status": "in_flight",
 "body": "# The store mirrors a ticket's body…\n\n## Summary\n…",
 "acceptance_criteria": ["Given …, when …, then …"],
 "criteria": [{"id": "C1", "text": "Given …, when …, then …"}],
 "verification_commands": ["ruff check ."],
 "time_box_ms": 1800000, "run": 52, "mirrored_ms": 1788449000000,
 "runs": [{"run": 52, "attempt": 2, "phase": "reviewing", "outcome": null,
           "rounds": 2, "pr_url": "https://github.com/…/pull/9",
           "started_ms": 1788449000000, "ended_ms": null},
          {"run": 47, "attempt": 1, "phase": "failed", "outcome": "failed",
           "rounds": 1, "pr_url": null, "started_ms": 1788362000000,
           "ended_ms": 1788365000000}],
 "steers": [{"id": 4, "kind": "hint", "note": "…", "author": "maintainer",
             "at": 1788449100000, "run": 52, "read_by": 52,
             "read_ms": 1788449200000, "withdrawn": false},
            {"id": 3, "kind": "amendment", "note": "…",
             "author": "maintainer", "at": 1788400000000, "run": null,
             "read_by": null, "read_ms": null, "withdrawn": false}]}

ticket is the identifier as the store mirrors it; status is the store's status, any of the seven including merged and abandoned; body is the issue text the loop last read at claim time, served as the text Linear held then and not rendered, so it is the exact contract the run worked from, which Linear's current text may no longer be (empty for a ticket mirrored before the store kept bodies); acceptance_criteria and verification_commands are the lists the mirror parsed from it. criteria lists current criteria in acceptance_criteria order, each with a stable id (for example C4) and its text; retired criteria are omitted. Tickets without criterion records use positional ids C1, C2, and so on. The console joins run judgements to current criteria by id. The claimed snapshot keeps its acceptance_criteria list; run is the ticket's active run, null when none is working it; mirrored_ms is when the store last mirrored the ticket. runs is every run of the ticket, newest attempt first, each with its phase, outcome (null while it runs), count of review rounds, pull request and clocks. steers is every steer note on the ticket, newest first: kind is amendment or hint, run the run it was sent to (null when none was in flight), read_by and read_ms the run that read it and when (null while unread), and withdrawn whether a later holo steer --withdraw withdrew it, which only an amendment can be. An identifier the store has never mirrored is 404 with an empty object, like an absent run. The endpoint reads the store's mirror and nothing more; it never calls the provider.

GET /tickets?q=TEXT

The identifiers of the tickets whose text holds a search: how the console's Tickets page finds a ticket by a word only in its body, which GET /board does not serve.

{"q": "quokka", "tickets": ["KO-2", "KO-3"]}

q is the search as asked, trimmed; tickets lists, in identifier order, every ticket in the project's store whose identifier, title, one of its labels or current body contains q, ignoring case. Merged and abandoned tickets are included. A missing or blank q is 400 {"error": "q is required"}. The read takes the read token, like GET /board, and a host daemon serves it as GET /projects/NAME/tickets?q=TEXT. It reads the store's mirror and never calls the provider.

GET /stories/KO-n

One story by its parent ticket's identifier: what the console's Story board draws as a dependency graph, with the story's witnesses and decisions under it.

{"ticket": "KO-n", "title": "…", "state": "approved", "generation": 2,
 "children": [{"ticket": "KO-m", "title": "…", "status": "in_flight",
               "column": "in_progress", "time_box_ms": 1800000, "run": 52,
               "merged_ms": null, "role": "advances",
               "witness_keys": ["W1"], "depends_on": ["KO-l"]}],
 "witnesses": [{"key": "W1", "criterion": "…", "verdict": "red",
                "main_sha": "b1c2…", "at": 1788449000000}],
 "decisions": [{"id": 3, "kind": "regressed", "ticket": "KO-m",
                "question": "…", "options": ["replan", "abandon"],
                "default_option": "replan", "answer": "replan",
                "answered_by": "maintainer", "answered_ms": 1788449100000,
                "at": 1788449050000}]}

state and generation are the story's; children come in the order the plan lists them, merged ones included. A child's column is its board column, run its run with no end (null when none is working it), merged_ms the end of its newest merged run (null before one), role and witness_keys its place in the plan, and depends_on the identifiers of the story's own children its dependencies name, in their order; a dependency outside the story is left out. witnesses come by key, each read at the newest main sha the store holds a result for: verdict, main_sha and at are that result's, all null for a witness never run there. decisions come oldest first, open and answered: ticket is the child the decision is about, null for the whole story, and answer, answered_by and answered_ms are null while it is open. An identifier the store has never mirrored, or one that is not a story's parent (a child's included), is 404 with an empty object.

GET /scouts

A project's scouts, newest first by id: what the console's Scouts list draws. GET /scouts?limit=N reads at most N.

{"scouts": [{"id": 3, "title": "Why does the retry spin?",
             "ask": "Why does the retry spin? It never backs off.",
             "kind": "bug", "phase": "swarming", "reason": null, "lead": "opus",
             "helper": "luna", "swarm": 4, "tasks_done": 2, "tasks_total": 3,
             "findings": 0, "actions": 0, "filed": 0, "to_file": 0,
             "created_ms": 1791568800000, "started_ms": 1791568810000,
             "ended_ms": null, "elapsed_ms": 95000,
             "heartbeat_age_ms": 4000,
             "subject": {"kind": "run", "ref": "52"}}],
 "limit": 20, "truncated": false}

Each row carries the scout's id, its title, its ask, its kind (bug, research, recon or guardrails), its phase and the reason it ended (null until it does), the lead and helper route names it runs on and its swarm, helpers at once. tasks_done counts its helper tasks that have ended and tasks_total all of them, over every pass. findings counts the findings over every stored report pass, 0 before the first report. actions counts the scout's stored action items, filed those with a ticket key, and to_file those with no ticket key whose kind is not revise; a report's own actions with no stored items count 0. created_ms, started_ms and ended_ms are epoch milliseconds, each null until it happens; elapsed_ms runs from the start to the end, or to now while the scout runs, and is null while it is queued. heartbeat_age_ms is the age of a running scout's last heartbeat, null while it is queued or once it ended. subject is what the scout is about, given at its launch: kind is run, defect, ticket or pr, and ref the run id as text, the ticket key or the pull request URL; it is null for a scout launched without one.

title is the scout's name: the title its lead wrote in its pass 1 report, else one cut from the ask, its first sentence (the text before the first ., ? or ! followed by whitespace, or before the first line break; a closing ? or ! kept, a closing . dropped) with its whitespace collapsed, and one over 70 characters cut back to its last whole word within 69 and ended with …. Nothing stored is rewritten.

limit defaults to 20 and is capped at 200. The body echoes the applied limit; truncated is true when more scouts exist than it returns. There is no paging past limit and no filter. A non-positive or non-integer limit is 400 with an error naming limit. A store with no scouts answers an empty scouts; a project with no store answers 503. On a host daemon this route is GET /projects/NAME/scouts. holo scouts [--limit N] [--json] prints it, and the holo mcp scouts tool answers it.

GET /scouts/ID

One scout whole: what the console's Scout page draws.

{"scout": {"id": 3, "title": "Why does the retry spin?",
           "ask": "Why does the retry spin?", "kind": "bug",
           "phase": "done", "...": "the GET /scouts row's keys",
           "base_sha": "a1b2…", "host": "writer",
           "budget": {"minutes": 30, "helper_turns": 12, "waves": 2},
           "totals": null},
 "tasks": [{"id": 7, "pass": 1, "wave": 1, "title": "Find where the loop sleeps",
            "question": "Where does the loop sleep?",
            "paths": ["app/retry.py"], "mode": "read", "route": "luna",
            "phase": "done", "started_ms": 1791568820000,
            "ended_ms": 1791568900000, "duration_ms": 80000, "tokens": 5100,
            "cost_usd": 0.02, "excerpt": "…", "reason": null}],
 "reports": [{"pass": 1, "at": 1791569000000, "report": {
   "headline": "The retry loop never backs off.",
   "summary": "…",
   "bullets": ["…", "…", "…"],
   "findings": [{"title": "…", "status": "confirmed", "evidence": [
     {"file": "app/retry.py", "line": 12, "verified": true, "why": null},
     {"command": "python3 -m unittest tests.test_retry", "output": "…"},
     {"url": "https://example.test/issues/7"}]}],
   "actions": [{"title": "…", "why": "…", "kind": "fix", "body": "…",
                "layer": null, "covers": null}],
   "reproduction": {"level": "test", "hypotheses": [
     {"hypothesis": "…", "separates": "…", "verdict": "supported"}]},
   "cut": [],
   "prior": [{"scout": 4, "finding": 1, "verdict": "holds", "why": "…"}]}}],
 "actions": [{"position": 1, "pass": 1, "title": "…", "why": "…",
              "kind": "fix", "body": "…", "layer": null, "covers": null,
              "revises": null, "ticket": null, "filed_ms": null}],
 "events": [{"id": 1, "at": 1791568800000, "kind": "scout_queued",
             "summary": "bug scout queued by maintainer (holo scout)"}],
 "thread": [{"id": 1, "author": "maintainer", "mode": "ask", "text": "…",
             "blocks": [], "answers": null, "at": 1791569100000,
             "consumed_by": 6, "consumed_ms": 1791569110000},
            {"id": 2, "author": "scout", "mode": "answer", "text": "…",
             "blocks": [{"type": "finding", "pass": 1, "index": 1}],
             "answers": 1, "at": 1791569200000, "consumed_by": null,
             "consumed_ms": null}]}

scout holds the GET /scouts row's keys, id, title, ask, kind, phase, reason, lead, helper, swarm, tasks_done, tasks_total, findings, actions, filed, to_file, created_ms, started_ms, ended_ms, elapsed_ms, heartbeat_age_ms and subject (its kind and ref, or null), plus the base_sha it planned at and the host it ran on (each null while queued), its budget of minutes, helper_turns and waves, and its totals as recorded, or null.

tasks are its helper tasks ordered by pass, wave and id. Each carries the lead's title, question and paths, its mode (read or run), the helper route, its phase (queued, running, done, failed or stopped), started_ms, ended_ms and duration_ms, and the tokens, cost_usd and answer excerpt it recorded, each null until it does. A task stored with no title serves its question's first clause: the text before the first :, ;, ,, ( or ., or through the first ? with its ? kept; one of more than 8 words keeps its first 8 and ends with …, and one still over 60 characters is cut as the scout's title is, within 59. reason is null unless the task's phase is failed; then it is one line read from its excerpt: a line holding could not launch: TEXT reads could not start: TEXT (with implementer credential variable NAME is not set read as implementer credential not set), else the excerpt's last non-empty line, or no output for an empty one, cut as the scout's title is at 100 characters.

reports holds one entry per stored pass, its pass, the at it was stored and the report. A report serves only the fields the report check defines, and a key a lead added beside them is left out: its headline, the answer in one sentence, its summary, and its bullets, 3 to 5 short lines, headline and bullets each null for a report stored without them (the report's own title serves as the scout's title, not here); its findings, each a title, a status of confirmed, partial or unconfirmed, and its evidence; its actions; its reproduction, a level and hypotheses, each with its hypothesis, what it separates and its verdict, or null; cut, the helper questions cut at the time budget, or null; and prior, or null when the report has none: the lead's judgement of each finding of the earlier scouts its plan goal offered, those whose reports or sub-tasks named the same paths, each its scout, the finding numbered from 1, a verdict of holds, fixed or unchecked, and why. An evidence item is one of a file item, file, line, verified (whether the file and line resolve in the base commit) and why it did not (null when it did); a command item, command and output; a url; or a text item, text. A report stored before the report check serves inside these fields: a finding's free-text evidence serves as one text item holding the string as stored, and a report action's kind outside those actions lists serves investigate. No stored report is rewritten; each finding's stored status serves by this table:

stored served
shown confirmed
likely partial
unconfirmed unconfirmed
confirmed confirmed
partial partial
unverified unconfirmed
any other word unconfirmed

actions are the scout's action items by position, which counts across passes: each its pass, title, why, kind (fix, test, investigate, decide, revise or guardrail), body, layer, covers and revises, the ticket key it was filed as and filed_ms, both null until it is filed. A revise item, offered only to a scout whose subject is a defect, is the defect ticket's whole body rewritten, and its revises is {"ticket": KEY, "revision": N}: the defect run's ticket and that ticket's revision when the item was stored. A scout whose subject is a parked ticket, one in needs_spec when its plan turn started, is offered a revise item too: that ticket's body rewritten so the park's reason no longer holds, its revises that ticket and its revision when the item was stored. Every other item's revises is null. holo scout show prints a revise item as A2 revise NAT-3 r2: TITLE. A guardrail item, offered only to a guardrails scout, drafts a guard: its layer is the first layer of the correction ladder (impossible, static, witness, guidance or review) that would have caught every row it covers, and covers lists the findings digest's row ids it answers. Both are null on every other item, and a report's actions carry them the same way. Once a guardrail item is filed, the rows it covers leave the digest of later guardrails scouts of the project. holo scout show prints a guardrail item as A1 guardrail static: TITLE (covers 3). events holds this scout's events in the order written, each its id, at, kind and summary, as GET /runs/N serves a run's.

thread holds the scout's messages oldest first: the asks POST /actions/scout-message records and the lead's answers. Each carries its id, author, mode (ask, steer or answer), text, blocks, answers (the id of the message it answers, or null), at, and consumed_by and consumed_ms, the id of the event that took it and when, both null while it waits. A block is a finding of the report, {"type": "finding", "pass", "index"}, or an action item, {"type": "action", "position"}.

An id that is not a positive integer is 400 with an error. An id the store does not hold is 404 with error no such scout; a project with no store answers 503. On a host daemon this route is GET /projects/NAME/scouts/ID. holo scout show ID [--json] prints it, and the holo mcp scout_show tool answers it.

GET /scouts/routes

The launch form's pickers: the configured scout routes a launch can name, and the [scout] defaults.

{"leads": [{"name": "opus", "harness": "claude", "model": "opus",
            "default": true,
            "probe": {"ok": true, "at_ms": 1791568800000,
                      "duration_ms": 9000, "why": null, "age_ms": 60000}}],
 "helpers": [{"name": "luna", "harness": "codex", "model": "gpt-6-luna",
              "default": true, "probe": null}],
 "defaults": {"lead": "opus", "helper": "luna", "swarm": 4, "minutes": 30,
              "helper_turns": 12, "waves": 2}}

leads and helpers list each [scout.leads.NAME] and [scout.helpers.NAME] route by its name, with its harness and model, and default true for the [scout] lead or helper. probe is the route's last recorded probe, the primary entry of seat scout-lead:NAME or scout-helper:NAME in route-probes.json, shaped as /status's route_probes: ok, at_ms, duration_ms, why and age_ms. A route whose last probe failed is left out; a route never probed is listed with probe null, so a fresh host still offers its defaults. defaults holds the config's lead, helper, swarm, minutes ([scout] budget_min), helper_turns and waves, whatever their probes say. The route reads config and the probe snapshot only, so a project with no store still answers it. On a host daemon this route is GET /projects/NAME/scouts/routes.

POST /actions/steer

Opened by [serve] actions = true (The daemon's actions), behind its token. It does what holo steer KEY NOTE does: the store's one steer, routed by the ticket's state, so a run parked on its pull request goes back to the babysitter with the note, a live run's next implementer turn or babysitter fix round reads it, and a ticket with no run in flight carries it into its next run. The console's Steer box posts it from a live run's detail card and from a parked pull request row; holo steer posts it under transport = "http".

{"ticket": "KO-n", "note": "remove the subheader", "hint": false,
 "now": false, "author": "maintainer"}

ticket (required) is the Linear identifier; note is the steer; hint (default false) makes it one turn's hint rather than a contract amendment; now (default false) is --now, stopping a live run's implementer turn to resume its session with the note; author defaults to maintainer.

{"action": "steer", "ok": true, "ticket": "KO-n", "recorded": 42,
 "detail": "KO-n steered: run 52 sent back to the babysitter as a maintainer instruction (operator_note event 7, steer note 3)"}

detail is the line holo steer prints; recorded is the interventions row the steer wrote, steer or, for a parked run, operator_note. A blank note, a ticket the store never mirrored or holds twice, and every refusal the store makes for the ticket's state are 200 with ok: false and the reason in detail, and nothing is written. A missing ticket, or a hint or now that is not a boolean, is 400; a project with no store is 503.

POST /actions/mark-ready

Opened by [serve] actions = true (The daemon's actions), behind its token. It marks a run's draft pull request ready for review with the factory's own GitHub access, sending the markPullRequestReadyForReview mutation the babysitter sends, and records a pr_ready event on the run. The console's opened pull request row posts it for a draft held for review. In one transaction it then wakes the run the way the supervisor's ready wake does, without reading the pull request again: the run's seen state becomes ready, a ci_wake event reads URL was marked ready for review, and a babysit intervention (source human) sends the run back to the babysitter, so GET /status lists the ticket under queued until the loop's next claim. A run holding a parked approval (an approval_parked event) gets only the seen state: it stays parked, and the supervisor's ready wake, which carries the approval, picks it up.

{"run": 52, "author": "maintainer"}

run (required) is the run parked on its pull request; author defaults to maintainer and is named in the pr_ready event.

{"action": "mark-ready", "ok": true, "run": 52, "recorded": 311,
 "detail": "https://github.com/org/repo/pull/7 marked ready for review; run 52 sent back to the babysitter"}

recorded is the babysit intervention's id, null when the run was not woken (a parked approval). When the store refuses the wake because the ticket moved while GitHub was asked, nothing of the wake is written and the answer stays ok: true, the pull request being ready, with the refusal in detail; the supervisor's next read records the truth. A run that is not the ticket's latest attempt parked on a pull request whose last seen state is draft and held for review (a pr_draft_held event, the hold the factory wakes from) is refused, nothing written and nothing sent: {"ok": false, "detail": "run N is not a draft held for review"}. A GitHub read or mutation that fails is also ok: false, naming the pull request, with no event and no wake. A run that is not a positive integer is 400; a project with no store is 503.

GET /peers

Where the other daemons are, so the page can fan out from whichever daemon it was loaded from:

{"self": "127.0.0.1:7710", "peers": ["writer-2:7710", "writer-3:7710"]}

self is the address this daemon bound, HOST:PORT as --serve announced it, not the machine's name. peers is the project's [console] daemons list (see docs/config.md) in its configured order, empty when the table is absent: an empty list, never an error. The daemon answers from config and never contacts a peer; it needs no store, so a project with none still answers 200 here. A host daemon answers the same shape at its root from host.toml's [console] daemons.

The host daemon

factory.py --serve with no project serves every project in the host registry, HOLOPHYTE_HOME/host.toml (Operating). Each route of this page answers under the project's prefix, /projects/NAME/..., with the body a project daemon answers at its root: /projects/holophyte/status, /projects/holophyte/runs/52, /projects/holophyte/config, /projects/holophyte/actions/requeue. NAME is the project's [serve] name, percent-decoded (a client encodes a name that holds a space). It resolves through the registry alone, re-read when host.toml changes: a name outside it is 404, naming the registry, before any file of any project is opened, and a project project remove dropped stops answering at the next request.

One project's failure is that project's answer. Before every project route the daemon checks the store's schema stamp, read-only. A store stamped newer than the daemon's build still answers when its readableFrom floor is at or below the daemon's schema version (an additive bump); one this build cannot read, a floor above it or none, is 503 naming the versions (and makes the daemon look at the checkout's HEAD at once, so a daemon whose checkout moved leaves for the new code). A locked or corrupt store is 503 with its error, in a read or in an action's write; /projects/NAME/status for a store with no row for the project's path is 503 naming factory.py project add (the other routes still answer, and a hold may write the row); any other failure is 500. Every read waits at most one second for a store's lock (HOST_READ_WAIT_S in holophyte/serve/serve_host.py, through store.read.lock_wait()), so a locked store answers 503 inside the drawer's two-second limit. The root lists each such project with its error and the others whole.

The bound has a cost a project daemon's thirty-second wait does not: a store whose write lock is held past that second, even briefly, answers 503 database is locked for that poll, and the root carries it as the project's error and a project_error item on /attention. The tray and the drawer can show a needs-you row for that one poll; it clears at the next. A row that stays across polls is a store that stays locked.

The root answers GET /status and GET /attention for the host, below, GET /peers from host.toml's [console] daemons, / and the console's files, POST /actions/run-sweep (Run sweep), POST /actions/restart-daemon (Restart daemon), and GET /prefs and PUT /prefs (Host GET /prefs and PUT /prefs). Any other JSON path at the root is 404, naming the /projects/NAME prefix. Tokens are in Authentication.

Host GET /status

{
  "now": 1788450534491,
  "daemon": {"started_ms": 1788446934491, "pid": 2801590},
  "build": {"daemon": "abc1234…", "sweep": "abc1234…", "head": "abc1234…"},
  "console": {"served": "def5678…", "tree": "0a1b2c3…", "stale": true,
              "reason": "`bun install --frozen-lockfile` did not start: …",
              "failed_ms": 1788446935100, "tried": "0a1b2c3…"},
  "sweep": {"started": 1788450500000, "ended": 1788450512000,
            "revision": "abc1234…", "pid": 2801700, "exit": 0,
            "projects": {"holophyte": "ok", "lotuspod": "skipped: disabled"},
            "error": null, "state": "fresh"},
  "actions": true,
  "projects": [
    {"name": "holophyte", "path": "/path/to/holophyte",
     "store": "/home/op/.holophyte/holophyte-HASH/store.db", "error": null,
     "host": "writer-1", "schema_version": 41, "admission": "enabled",
     "hold_note": null, "project_row": 1,
     "supervisor": {"state": "live", "pid": 0, "heartbeat_age_ms": 21000,
                    "host": "writer-1"},
     "runs": [{"id": 52, "ticket": "KO-219", "phase": "working",
               "heartbeat_age_ms": 71989}],
     "workers_on_previous_build": 0}
  ]
}

now is the daemon's clock, epoch milliseconds, as on a project's /status; daemon is its started_ms and pid. build names three builds side by side: daemon, the checkout's revision when this daemon started (null when it runs from no git checkout); sweep, the revision the last host sweep ran; head, the checkout's HEAD now. The console flags any that differ. actions is whether host.toml's [serve] actions is on.

console is the console build this daemon serves. served is the console tree its build is stamped with and tree the checkout's console tree now, each null when unknown. stale is true when tree is known and differs from served. While it is, reason, failed_ms (epoch milliseconds) and tried (the tree the build started from) are the last failed startup build's, from HOLOPHYTE_HOME/console-build.json; otherwise, or with no such record, each is null. A successful startup build removes the record. The console shows a banner while the daemon that served it reports stale.

sweep is HOLOPHYTE_HOME/sweep.json as the last run wrote it: started and ended (epoch milliseconds), revision, pid, exit (0, or 1 when a project errored or a signal stopped the run) and projects, each name's outcome (ok, skipped: WHY, error: WHY), each null when the file lacks it; error is why the file could not be read, else null. state is none with no file, unreadable, running while a run started within the sweep unit's 120 s has not ended, killed once it is past that with no ended, fresh when the last run ended within two [supervisor] sweep_sec intervals, and stale after that.

projects is every registry entry in registry order. name is its [serve] name (null for an entry whose config cannot be read), path its repository and store its store file, null when there is none. error is the project's failure as text (a config that cannot be read, a store that cannot be opened, a schema newer than the daemon's build can read), null when it answered; the fields below are then null. host is the project's [report] host_label, schema_version its store's stamp, admission and hold_note as on a project's /status, and project_row the id of the store's row for this path, null when it has none (a store deleted or recreated since project add). supervisor is the store's watcher beat (state live, stale or none, pid, heartbeat_age_ms, host), stale after two sweep intervals; pid 0 is the host sweep's one beat per store. runs is every live run in a sweepable phase with its id, ticket, phase and heartbeat_age_ms, empty for a disabled project. workers_on_previous_build is as on a project's /status.

Host GET /attention

Every project's /attention items in one list, each item carrying its project name, after the host's own:

{"level": "attention", "now": 1788450534491, "items": [
  {"kind": "sweep_stale", "project": null, "state": "killed",
   "started": 1788450300000, "ended": null, "level": "attention"},
  {"kind": "project_error", "project": "lotuspod", "path": "/path/to/lotuspod",
   "error": "OperationalError: database is locked", "level": "attention"},
  {"kind": "stale_run", "run": 52, "ticket": "KO-219", "ticket_url": null,
   "title": "Add a thing", "phase": "working", "heartbeat_age_ms": 1800000,
   "pr_url": null, "level": "attention", "project": "holophyte"}
]}

sweep_stale comes first when the sweep's state is anything but fresh or running, with that state and the run's started and ended; its project is null. Then, per project in registry order: project_error for a project the root /status shows with an error; no_store for one with no store and no_project_row for a store with no row for its path, each with a detail naming factory.py project add; and the project's own items as its /attention answers them (kind, run, ticket, ticket_url, title, phase, heartbeat_age_ms, pr_url and the rest, above), each with project added. A project's supervisor item judges the host sweep's beat against two sweep intervals. level is attention when there is any item, else working when any project has a live run, else none; now is the daemon's clock.

Host GET /prefs and PUT /prefs

The console's preferences that follow the maintainer across devices, one document at a host daemon's root. A daemon has one maintainer, the holder of the machine token, so the document is that maintainer's. A project daemon has no /prefs, and /projects/NAME/prefs is no project route: both are 404 as any unknown path.

{"day_log": {"preset": "manual",
             "items": {"merged": true, "review-round_2": false},
             "projects": {"lotuspod": false}},
 "updated_ms": 1788450534491}

day_log is the console's Today's log filter, or null when none is stored: preset is one of highlights, everything and manual; items maps the console's item ids (letters, digits, - and _, up to 64 characters) and projects maps project names (up to 128 characters) to booleans, each at most 64 entries. The daemon checks that shape and leaves its meaning to the console. updated_ms is when the document was last written, in epoch milliseconds, or null when it never was. With no file both are null. A file that cannot be read or parsed answers 200 with both null and an error naming the file and the reason.

PUT /prefs takes a JSON object holding some of the document's keys, today only day_log. Each key given replaces that key's value and null removes it; a key not given keeps its stored value, so {} changes nothing but updated_ms. Two writers race key by key: the last write wins, and there is no If-Match. It answers 200 with the whole stored document as GET /prefs reads it.

PUT /prefs
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json

{"day_log": {"preset": "highlights", "items": {}, "projects": {}}}

Refusals are 400 with an error naming the key or field, and nothing is written: a body over 16 KiB (checked before it is parsed), a body that is not a JSON object, a key other than day_log, and a day_log of another shape (a missing or unknown field, another preset, an item id or project name out of bounds, a value that is not a boolean, more than 64 entries).

GET /prefs is a read route: open on loopback and behind the machine token beyond it, as the root's GET /status is. PUT /prefs answers only to the machine token on every bind, loopback included, and never to a project's own token_file, else 401 with {}; with host [serve] actions off it is 404. Nothing is written in either case.

The document is the file console-prefs.json in HOLOPHYTE_HOME, outside every store and project config. A write goes to a temporary file in the same directory, flushed and synced, and replaces the file in one rename, with mode 0600; a PUT over an unreadable file replaces it.

PUT /tickets/ID

A host daemon's edit of one ticket on a native board ([board] kind = "native"), at /projects/NAME/tickets/ID: the console's Board writing a ticket's body at the revision it read. A project daemon has no such route, and a Linear project's board stays read-only on the console.

PUT /projects/holophyte/tickets/NAT-1
Authorization: Bearer MACHINE_TOKEN
If-Match: 2
Content-Type: application/json

{"body": "# …\n\n## Summary\n…", "priority": 2, "labels": ["ui"]}
{"ticket": "NAT-1", "revision": 3}

The gate, in order: the host's machine token on every bind, loopback included, and never a project's own token_file, else 401 with {}; host [serve] actions on and the project native, else 404 with nothing written; If-Match a non-negative integer, the revision GET /board served, else 428; a JSON object body, else 400. body is the ticket's full text; priority (0 to 4) and labels (a list of strings) are optional, and each left out keeps the ticket's own.

The edit is the store's edit_ticket(), judged as --file-ticket judges a body and recorded as the ticket's next revision authored console, never an author the body names. It answers 200 with the new revision; 409 with error and current, the ticket's revision now, when it has moved past If-Match (another edit landed; read it again); and 422 with problems, every blocking problem, when the body fails the template for a ticket outside the backlog column or names a ticket the project does not hold, with nothing written. A store failure is the project's 503 or 500 as a read's is.

POST /tickets

A host daemon's filing of a new ticket on a native board, at /projects/NAME/tickets: the console's New ticket. It passes the gate of PUT /tickets/ID but for If-Match, which it does not read: a new ticket has no revision.

POST /projects/holophyte/tickets
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json

{"body": "# …\n\n## Summary\n…", "priority": 2, "column": "ready"}
{"ticket": "NAT-1", "revision": 1}

body is the ticket's full text; priority (0 to 4) is optional, and column, ready by default or backlog, the column it lands in; any other is 400. The filing is the store's file_ticket() under the board's [board] prefix, authored console: 201 with the new ticket and its first revision, 1; 422 with problems and nothing written when a body filed to ready fails the template or Depends on: names a ticket the project does not hold. A body filed to backlog with problems is saved as a draft.

POST /tickets/check

A host daemon checks a native ticket body without filing or editing it, at /projects/NAME/tickets/check. It passes the gate of POST /tickets: the machine token on every bind, host [serve] actions on and a native board, with no If-Match.

POST /projects/holophyte/tickets/check
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json

{"body": "# …\n\n## Summary\n…", "ticket": "NAT-1", "column": "ready"}
{"problems": [], "advisories": [], "blocks": false}

body is the full ticket text; a missing or blank body is 400. column is optional, ready by default or backlog; any other value is 400. GET on this route answers 405 with Allow: POST. When optional ticket names an existing ticket, its own column determines whether the problems block an edit. A project with no store answers 503.

The 200 answer lists problems and advisories in checker order, with the advisory: prefix removed from advisories. It applies the template and repository checks used by filing, without the freshness advisories. Unknown Depends on entries are problems, including the ticket itself when checking an edit. blocks is true for unknown dependencies or any problem that blocks filing in the selected column; ordinary template problems do not block backlog, but private-data problems block every column. The store is opened read-only: no ticket, revision or sequence is written.

POST /tickets/ID/move

A host daemon's move of a native ticket between Ready and Backlog, at /projects/NAME/tickets/ID/move, behind the whole gate of PUT /tickets/ID, If-Match included.

POST /projects/holophyte/tickets/NAT-1/move
Authorization: Bearer MACHINE_TOKEN
If-Match: 1
Content-Type: application/json

{"column": "backlog", "note": "after the release"}
{"ticket": "NAT-1", "revision": 2}

column is ready or backlog, else 400; note, optional, is the move's note. The move is the store's move_ticket(), authored console: 200 with the new revision; 409 with error and current when the ticket has moved past If-Match; 422 with problems and nothing written for a canceled or closed ticket, one already in column, or a draft moved to ready. A canceled ticket does not move.

POST /tickets/ID/cancel

A host daemon's cancel of a native ticket, at /projects/NAME/tickets/ID/cancel, behind the whole gate of PUT /tickets/ID, If-Match included.

POST /projects/holophyte/tickets/NAT-3/cancel
Authorization: Bearer MACHINE_TOKEN
If-Match: 1
Content-Type: application/json

{"note": "wrong scope"}
{"ticket": "NAT-3", "revision": 2, "run": 5}

note, the reason, is required: missing or blank is 400 with nothing written. The cancel is the store's cancel_ticket(), authored console: 200 with the new revision and run, the live run the cancel asked to abort (its stopRequested names an abort intervention), null when the ticket had none; 409 with current and 422 with problems as a move's. The abort, or the close-out of a run parked awaiting merge approval, is an intervention with actor maintainer: the console sends every native board write.

POST /mcp

The factory's MCP tools over HTTP, for an agent with no shell on the seat: the Model Context Protocol's Streamable HTTP transport, served by holo mcp --http [HOST:PORT], not by either daemon. It is a process of its own on the writer host, the MCP Python SDK's app under uvicorn, on 127.0.0.1:7711 by default or the address given (the host's private-network address, beside the daemon's 7710); deploy/holophyte-mcp.service runs it. The daemon never imports the SDK, and a host without it keeps its daemon.

POST /mcp
Authorization: Bearer MACHINE_TOKEN
Content-Type: application/json
Accept: application/json, text/event-stream

{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [{"name": "requeue",
 "description": "put a failed run's ticket back in the queue (holo requeue KEY --json)",
 "inputSchema": {"type": "object", "properties": {"project": {"type": "string",
 "minLength": 1, "description": "..."}, "ticket": {}, "note": {}, "author": {}},
 "required": ["ticket", "note", "author"], "additionalProperties": false},
 "annotations": {"readOnlyHint": false, "destructiveHint": false}}]}}

Its tools are the ones holo mcp serves on stdio (holo mcp), with the same schemas, annotations and results, and it adds none. Each reply is a JSON-RPC 2.0 object, jsonrpc and the request's id beside result or error. A tool list's result holds tools, each with its name, description, inputSchema and annotations. An inputSchema is a JSON Schema object: type, properties (each a type, minLength or minimum, an enum and its default, and a description), required and additionalProperties, false. Every tool takes an optional project, a [serve] name or a repository path; report also takes since, runs and scouts limit, run the run id and its view, ticket the key, scout_show the scout id, and the writes their own ticket, run or body and the required note and author, steer also its hint and now booleans, and scout its required kind (bug, research, recon or guardrails) and its optional lead, helper, swarm, minutes and subject (KIND:REF, what the scout is about), the note its ask, and scout_file its required scout id and item position, the note and author the filed ticket's first board note, and scout_stop its required project and scout id, its optional task, one helper task's id to stop alone, and the note its reason, required without task and checked when the tool is called, scout_ask its required project and scout id, the note its ask, and scout_steer its required project and scout id, the note its steer, each of the two an optional answers, the id of the scout's card it answers. annotations holds readOnlyHint, true for a read, and destructiveHint, false for every tool served today, so a client that asks a person before a destructive write asks here as on stdio. A tool call's result holds content, a list of one object whose type is text and whose text is the answer; structuredContent, the command's JSON object, when it printed one; and isError, true for a refusal. An unknown tool is the reply's error, with code -32602 and a message naming the tools.

Each call runs its holo command on the host (HOLO_TRANSPORT=local) as a subprocess, as on stdio, and a write records its author as AUTHOR via MCP. The six write tools are listed and callable only when host.toml's [serve] actions is true; otherwise only the reads are listed and a write is an unknown tool.

The transport is stateless and answers in JSON: a request gets one JSON reply, a notification 202 with no body, and no Mcp-Session-Id is issued; an unsupported MCP-Protocol-Version is 400. No event stream is offered. Every request, on every bind, loopback included, needs host.toml's [serve] machine_token_file as its bearer, compared in constant time; a host registry without that key is a startup error naming it, and nothing listens. A request carrying an Origin header is a browser's and is refused, the DNS-rebinding defence the transport asks of a server; an MCP client sends none. These answers come before anything runs:

Status When
401 no Authorization: Bearer header, another scheme or another value; body {}
403 the right bearer and an Origin header; body carries error
405 the right bearer and any method but POST, GET included; Allow: POST, body carries error

Like the daemon, it follows the code: every CODE_CHECK_SEC (15 s) it compares the factory checkout's HEAD with the one it started from and exits 0 on a move, and its unit starts the new code. It stops accepting at once and waits at most DRAIN_SEC (20 s) for requests and tool calls in flight; a tool call still running then is cut off, its holo process killed so its SQLite transaction rolls back, and its reply is lost.

Static files

GET / answers console/dist/index.html, the new console, and GET /PATH answers console/dist/PATH for a regular file under that directory: the repository's own console/dist/, where the renderer's build writes the console, found from the package rather than the project's checkout. The JSON routes above, and any added later, take precedence over a file of the same name. The content type follows the extension:

Extension Content-Type
.html text/html; charset=utf-8
.js text/javascript
.css text/css
.svg image/svg+xml
.woff2 font/woff2
.png image/png
.json, .map application/json
.webmanifest application/manifest+json
anything else application/octet-stream

Every file answer is Cache-Control: no-store; there is no compression, no other caching header and no range support. A path that resolves outside the directory (.., an encoded .., an absolute path, a symlink pointing out) or names no regular file is the same 404 JSON as an unknown route. GET /next.html and GET /classic.html, the console's old addresses, answer 301 Moved Permanently with Location: / (/?QUERY when the request carries a query), with or without a token and whether or not the console is built; the browser keeps the fragment, so /next.html#/runs lands on /#/runs. When console/dist/ does not exist, / is 404 JSON whose detail says the console is not built, and the JSON routes answer as before: a daemon on a host without the renderer's toolchain still serves its JSON.

Authentication

A daemon bound to anything but loopback runs with [serve] token_file (see docs/config.md) and demands its contents on every JSON route:

Authorization: Bearer TOKEN

With [serve] machine_token_file also set, the contents of that file are accepted as TOKEN too, on every route that demands the project's token: one token for every daemon on the machine, beside the project's own. Each value is compared whole, in constant time; a missing header, another scheme or any other value is 401 with the body {} and no store access, and nothing about the attempt is logged. GET /, the console's files under it and GET /peers are served without the header, so the page can load and learn where its peers are before it has a token to present. A daemon bound to loopback never asks on the read routes: --serve 7710 answers them open, token file or not. The opt-in routes are the exception: [serve] actions or [serve] config_edit needs [serve] token_file on every bind, loopback included (the daemon refuses to start without it), and POST /actions/..., GET /config and PUT /config answer only to the bearer.

A host daemon takes its one token from host.toml: [serve] machine_token_file is the bearer at the root and under every /projects/NAME prefix. It is demanded on every read route beyond loopback, and on every bind for POST /actions/..., /config and the root's PUT /prefs; a bind beyond loopback, [serve] actions or any project's config_edit without it is a startup error naming the key. A project's own [serve] token_file is accepted beside it under that project's prefix only, for one release; it is 401 at the root and under any other prefix. A project's machine_token_file is not read in host mode. The bind judged is the one the daemon serves on, the handed-over socket's when the service manager started it.

A page served by one daemon polls the others from the browser, and a cross-origin GET carrying Authorization is not a simple request, nor is the console's POST /actions/... or PUT with the bearer, a JSON body and, for a ticket edit, If-Match: the browser first sends a CORS preflight, OPTIONS on the path with Access-Control-Request-Headers: authorization (authorization, content-type for an action, and if-match beside them for a ticket edit). Every daemon answers it on any path with 204, no body, Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, POST, PUT, Access-Control-Allow-Headers: authorization, accept, content-type, if-match and Access-Control-Max-Age: 600, token or not: a preflight never carries credentials, so the answer discloses nothing and touches no store, and the request it clears is still refused without the bearer. Every other method but GET stays 405, POST included on every path but the /actions/ routes and PUT on every path but /config, both in The daemon's actions, and a host daemon's /tickets/ID (PUT /tickets/ID) and its root's /prefs (Host GET /prefs and PUT /prefs), behind the machine token alone; a host daemon also takes POST on /tickets (POST /tickets), /tickets/check (POST /tickets/check), /tickets/ID/move (POST /tickets/ID/move) and /tickets/ID/cancel (POST /tickets/ID/cancel), each behind the machine token alone.

Errors

Status When
204 OPTIONS on any path: the CORS preflight, empty, with the Access-Control-* headers above
401 a non-loopback daemon, any route but /, its files and /peers, without the exact Authorization: Bearer value; body {}; on a host daemon also a project's own token presented at the root or under another project's prefix, and PUT /prefs on any bind without the machine token
400 /runs or /holds with a bad limit; /shipped with a bad limit, before or outcome; /history with a missing or non-integer since, a non-integer until, an until at or before since, or a bad limit, offset, sort, order, state or failure; /ledger with a missing or non-integer since, a bad limit or an unknown kind; /report with a since not Nh, Nd or all; /runs/N, /runs/N/files, /runs/N/ledger or /runs/N/merge with a non-integer N; on a host daemon, PUT /prefs with a body over 16 KiB, not a JSON object, a key other than day_log or a day_log of another shape, error naming the key or field
404 /runs/N, /runs/N/files, /runs/N/ledger or /runs/N/merge with no such run, body carries run; /tickets/KO-n with no mirrored ticket, body {}; any other path with no console file behind it; body carries path, and detail when the console is not built. On a host daemon also /projects/NAME/... for a name outside the registry, and a project route at the root, both before any store is opened; restart-daemon on a project daemon or under /projects/NAME/actions/, or with [serve] actions off; PUT /prefs with [serve] actions off, and /prefs on a project daemon or under /projects/NAME
405 methods other than GET and OPTIONS, except these POST and PUT routes: POST /actions/* (at the host root, run-sweep and restart-daemon), PUT /config, and, on a host daemon, POST /tickets, POST /tickets/check, POST /tickets/ID/move, POST /tickets/ID/cancel, PUT /tickets/ID and the root's PUT /prefs; Allow: GET, except GET on a host daemon’s /tickets/check, which returns Allow: POST
409 /runs/N/files for a run with no branch and no merge sha, or whose branch or merge commit is no longer in the repository; error names it
503 any request while the daemon drains for restart (daemon restarting); the project has no store yet; body carries error, detail and project, the repository the daemon serves, as a path. On a host daemon, under one project's prefix: its store stamped newer than the build can read, locked or corrupt (error, project its name), or /status with no project row for its path (project_row null, detail naming project add); and any route when host.toml itself cannot be read
500 on a host daemon, PUT /prefs when console-prefs.json cannot be written, error naming the file; one project's route failing any other way; body carries error (type and message, redacted) and project, and the traceback goes to the daemon's log
504 /runs/N/files when git does not answer within its cap