Capability: control plane
The API layer over the-loop's core, and its clients: the service-routed CLI, the MCP endpoint (issue-161, decision-058) and the static web dashboard (issue-207).
What it is
the-loop's executable functionality is layered as core → API → clients: a transport-agnostic core facade (the_loop.core, one module per capability) is the single implementation; the API service (the_loop.api, FastAPI) exposes it at /api/v1 plus an MCP endpoint at /mcp; the CLI and an agent host are thin clients of that surface. Everything needed to host the service ships with the package — there are no install extras (owner decision, PR #162).
Current behaviour
- The core facade SHALL be importable and invocable with no CLI or HTTP context; every capability (work items, events, graphs, repo-scoped queries, sessions, daemons, attention) SHALL be implemented once there, delegating to the modules that already carry the behaviour.
- A daemon the core starts SHALL have its stdout/stderr appended to that daemon's logfile under
state.root, never sent to/dev/null(issue-191) — a control-plane start that runs fine and logs nowhere is the same defect a hand-backgrounded poller had.daemon_statusSHALL carry that logfile, plus the poller'sstartedAtandlastCycleAtfrom its heartbeat (empty forgh-webhook, which keeps none). - The API service SHALL expose the core at
/api/v1per the authored OpenAPI contract (docs/api-specs/openapi/the-loop.v1.yaml); a parity test SHALL fail the build when the served schema's paths/methods/operationIds drift from it. Interactive docs are served at/api/docs, generated, never hand-written. - The service SHALL carry no in-app authentication — a gateway terminates auth for any exposed deployment (owner decision, PR #162). Its own boundary SHALL be network scoping: it SHALL bind loopback by default and refuse a non-loopback bind unless
service.exposed: true. No credential SHALL be minted, stored, or required. - The service SHALL be able to push change to a browser rather than wait to be asked (
GET /api/v1/stream,text/event-stream, issue-239). The stream SHALL be a read surface over recordsGET /api/v1/eventsalready serves, opening no new source of truth: alogframe is one event-log record with its byte offset as the SSEid, atranscriptframe carries a watched session's ref and line count and no content, and adesyncframe says the client's cursor could not be honoured. The transport is SSE and not a WebSocket because a WebSocket handshake is exempt from CORS and would need a hand-writtenOrigincheck to recover the boundary every other route inherits (decision-087). - The stream SHALL never carry
api.requestormcp.call, and SHALL offer no way to opt in. Every route emitsapi.request, so a stream that carried it would deliver a frame for each of the control plane's own refreshes, each frame triggering another — a loop that never idles and worsens the more people watch. - A subscriber SHALL be able to resume losslessly:
Last-Event-IDis a byte offset, and the service SHALL replay the records after it or state that it cannot. Replay SHALL be bounded; a truncated file, a rotated one and an over-wide gap SHALL each resolve to onedesyncrather than to an unbounded read. - Simultaneous connections SHALL be bounded by
service.stream.maxSubscribers, refused at accept time with503before any task, queue or file handle exists, and the bound SHALL NOT be configurable away (a value below 1 clamps up). One shared tailer SHALL serve every subscriber, so N connections cost one read of the event log per tick, and a subscriber that stops reading SHALL be bounded by its own queue and desynced rather than buffered without limit. - The viewer SHALL choose how the dashboard refreshes — streaming, polling at an interval, or manual — stored per browser. Settings written before that choice existed SHALL be read for what they imply (
pollSeconds: 0→ manual, otherwise polling at that interval) rather than switched onto a transport the viewer's tunnel may not carry, and the storage key SHALL be unchanged so no viewer loses their base URL. - A stream that cannot be opened or that keeps dropping SHALL be visible: the dashboard SHALL show live / connecting / reconnecting / unavailable in words as well as colour, and SHALL fall back to polling with the reason rather than leaving an unchanging screen. A streamed change SHALL refresh only what it touches — a
graph.*frame re-checks that one loop, anything else refetches the lists — and an event type the bundle does not recognise SHALL refresh the lists rather than be ignored. - Which browser origins may read the service's responses SHALL be configuration (
service.cors, issue-211) and SHALL be a separate question from who may connect: no value undercorswidens the bind, and the exposure guard is unaffected. The allowlist SHALL ship containing exactly the origin the-loop publishes its own dashboard to, so the hosted page works against a local service with nothing in between (decision-077); an emptyallowOriginsSHALL install no middleware at all, restoring same-origin-only behaviour. Origins SHALL be compared exact-string — no prefix, suffix or regex matching — and"*"together withallowCredentials: trueSHALL refuse to start, before the bind and before the run lock. Chromium's private-network preflight SHALL be answered only for an origin the allowlist already admits. - CORS SHALL be a property of the standalone application, never of the router (issue-212): a preflight is answered by the middleware and reaches no route, so it runs no operation and emits no
api.requestevent, and an embedded mount applies the host application's cross-origin policy rather than the-loop's./mcpSHALL keep the SDK's DNS-rebinding protection with its own origin allowlist, so no CORS setting makes the MCP endpoint drivable from a page. - The router SHALL call a facade with one method per operation, never
the_loop.coredirectly (issue-374, decision-138 D3): the worker'sCoreFacade, or a manager'sManagerFacadeover it. So a manager (instance.role: manager) serves the identical surface — the contract parity test runs against both roles and asserts one MCP tool list — while a list read on it is its own rows plus every registered member's, each stampedinstance, and a keyed operation routes to the instance that manages the key. Every keyed operation carries an optionalinstance(a query parameter on aGET, a body field on aPOST) that a worker accepts only as its own name (404otherwise); two more mappings on the route class —Conflict→409(a key two instances manage) andMemberUnavailable→502(a member that did not answer) — are the manager's outcomes; and a list read that could not reach every member names them inThe-Loop-Instances-Unreachable. Theinstancesfamily —GET /api/v1/instances,POST /api/v1/instances/registerand/unregister— is served by every role: a worker lists itself and refuses the writers naminginstance.role. See instances. - The
/api/v1surface SHALL be oneAPIRouter(the_loop.api.routes) consumed by both the standalone app and the SDK, and the per-request behaviour — the CLI config refresh, theValueError/LookupError/SpliceErrortranslation and theapi.requestaudit event — SHALL ride on that router's route class rather than on application middleware or application-level handlers, so it travels wherever the router goes.healthSHALL stay audit-exempt, keyed on its operation id. - The service's lifecycle SHALL be the one surface every the-loop service shares —
the-loop start|stop|status|restartovercore.lifecycle(issue-228, PR #229 review: no granularservicecommand) — with the issue-159 discipline: the pidfile is the flock, a second start reportsalready-running, stop signals and waits. Hosting needs no extra:fastapi,uvicornand the officialmcpSDK are required dependencies, sopip install the-loopy-oneis always enough to run the service. - The service SHALL be the default host process for the ingresses (issue-231, decision-084 §8): with
service.hostIngressestrue (the default), its lifespan starts the enabled poller and webhook receiver as background threads — each acquiring its own pidfile flock under the service's pid, so single-instance,status/stopand the daemons API semantics are unchanged — and stops them, in reverse order, when the service shuts down. A lock already held by another process is skipped with a warning; a hosting failure never takes down the API. The hosted set follows the config (issue-395): a supervisor thread re-hashes the config file every five seconds, and WHEN an edit means an ingress the service hosts is no longer enabled (polling.enabled,webhooks.ghWebhook.enabled,channels.slack.enabledwithread.mode: socket) THEN the service SHALL stop it — loop ended through its stop event, lock released,ingress.hosted_stoppedwithreason: config— without a restart; WHEN an edit newly enables one THEN the service SHALL start it through the same starter, lock and refusals as at boot (ingress.hostedwithreason: config; a refusal isingress.hosted_failedas at boot), and oneconfig.reloadednames what was stopped and started. Membership only: an edit that leaves the enabled set as it is stops and starts nothing, and an unloadable edit changes nothing.service.hostIngressesitself stays boot-only. An enabled ingress that does not start SHALL recordingress.hosted_failedat levelerrorwith the ingress and the reason (issue-339) — a lock another process holds, an enabled poller with nopolling.sourcesor no top-levelrepositories(issue-348), a Slack token variable that is not set (its name, never its value). Before it, that failure was a logfile line and nothing in the event log, so a poller that never came up was visible only as the absence of apoller.started, which nothing was computing. An ingress nobody enabled SHALL record nothing. A hosted run loop that ends on its own — without a shutdown having been requested — SHALL release its pidfile lock and record the same event: the lock is liveness, and a hosted ingress holds it under the service's pid, so a thread that exited inside a living service would otherwise be reported as a running ingress bystatusand/api/v1/healthalike. GET /api/v1/healthSHALL report on what this process is hosting (issue-339):statusisokonly while every ingress the config enables holds its pidfile lock, anddegradedwhen one does not, with that row'sdetailnaming the config key that enables it and the pidfile nothing holds. Liveness SHALL be read from the same locksthe-loop statusreads, so the two surfaces cannot disagree. The response SHALL also carry theconfigPathandstateRootthis process resolved — the two facts that say which files it is using, and no escalation besideGET /api/v1/config, which serves the whole document across the same boundary. The status code SHALL stay 200 while degraded: it answers "did the service answer", which is whatclient.healthymeasures and whatensure_serviceloops on, and a non-2xx would have every unrelated CLI command spawn a second service because a poller stopped. The response SHALL carry no pid, token or environment value.- The service SHALL be the CLI's only execution path for core capabilities (owner decision, PR #162): a command auto-starts a local service when
service.autoStartallows and otherwise fails closed namingthe-loop start— never an in-process fallback. Every core-capability command routes:check,events,graph(show/status/advance/complete/force/run),sessions(register/list/close/start/pause/resume/stop),scenarios,instructionsandcritic(list/run). Some commands stay local by nature:sessions attachreplaces the caller's terminal with tmux,sessions resetis a recovery action that must work when nothing is running, the daemon entry point (python -m the_loop.daemon_entry <poller|gh-webhook>) runs a daemon in-process because cron and systemd units depend on it, and the bootstrap commands (start,stop,status,restart,install,upgrade,migrate-config,--version) precede — or manage — any service (issue-228, decision-084).THE_LOOP_SERVICE_LOCAL=1is a test seam, not an operator switch. - The CLI SHALL NOT re-implement any routed operation: commands render the
messagesandexitCodethe core facade returns, so an operator'ssessions pauseand an agent'scontrol_sessiontool call produce identical words. /mcpSHALL serve the MCP interface over HTTP transport only (no stdio), built on the official MCP Python SDK (mcp) rather than a hand-rolled protocol implementation (owner decision, PR #162). The SDK's DNS-rebinding protection stays on, pinned to the hosts the service answers on.sessions reset(destructive) andgraph force(requires a human-attributed reason) SHALL NOT be exposed as tools.- Every API operation SHALL land in the event log (
api.request; tool calls asmcp.call), queryable viathe-loop events --source service. - The service SHALL say which instance it is (issue-322):
GET /api/v1/instancereturns the instance's name, scope (mode, the declaredworkItemsnormalised to refs) and its managed set — one row per work item, sorted by ref, naming every source that puts it there (declared,session,control). Every instance serves the same surface; this document is what distinguishes them, and what a manager of several instances aggregates across (decision-110 D8). The MCP registry exposes it asget_instance; the SDK asloop.instance(). - The Slack channel's
/the-loopslash command (issue-334, decision-116) is one more thin client of the facade for the verbs that have no ticket:status,restart,upgrade(core.lifecycle.status_all/schedule_restart) andstanding list|start|stop|restart(core.standing), each behind its own grant inchannels.slack.publishand the samerouting.authorizedUsers; a work-item verb from the same command goes through the ledger, not the facade — see channels and the Slack integration guide. - A static web dashboard (
ui/, issue-207) SHALL be the third client of the same surface, adding no state and no server of its own. It SHALL be a pure build artifact — published to GitHub Pages at/the-loop/ui/, beside the docs site, from the one Pages artifact both are assembled into — with the API base URL chosen at runtime and persisted per browser, so one hosted copy serves any number of workstations. - The dashboard SHALL derive a work item's loop position from
graph/check, whoserepocomes from the session record'scwdand whoseworkItemcomes from the portable record'sgraph.workItem. Because that join spans two records, an item with no session on this machine SHALL still be listed, showing its frozen node list with no pointer rather than an error — the API's inability to answer "where is it?" is not the same as the item not existing. - A
repothat does not resolve SHALL be answered, not refused (issue-238). A session record outlives the checkout it names — nothing blankscwdwhen a worktree is removed — sograph/checkSHALL return200withrepoResolved: false, an emptynodeslist and nocurrentNode, and the dashboard SHALL drop that answer exactly as it drops a rejection, falling back to the frozen node list. The field SHALL be absent on every other response.4xxstays reserved for a malformed request, and the mutating graph verbs (complete,advance,force,skip) SHALL keep refusing a repository that is not there: only the polled verb treats a cleaned-up checkout as expected state. The path SHALL still reach no graph read — the boundary's report changed, not what it admits. - The dashboard's inbox SHALL be the union of
/attentionand the graph gates that endpoint deliberately excludes: gate waits are repo-scoped, socore.attentiondocuments them as reaching a client throughgraphs.checkper work item, and the client that already reads those reports folds them back in. - An agent's question SHALL travel through
the-loop ask(issue-208): the verb posts it on the work item with the loop-prevention marker stamped centrally (no agent is trusted to remember it), records the wait as asession.awaiting_inputevent — comment URL included, and emitted (as a warning) even when the post failed, since the agent is waiting either way — and executes in-process, because the escalation path must not depend on the service being up. Thework-iteminteraction directive names the verb; manualgh+ marker remains only as the stated fallback. - The harness's GitHub acts SHALL be work-item and pull-request scoped routes, never a generic GitHub proxy (issue-447):
POST /work-items/comments(postWorkItemComment),GET /work-items/ticket(getTicket),POST /work-items/tickets(createTicket),POST /work-items/pull-requests(createPullRequest),GET /pull-requests/status(getPullRequestStatus),GET /pull-requests/checks(listPullRequestChecks, issue-462),GET /pull-requests/threads(listPullRequestThreads),POST /pull-requests/merge(mergePullRequest),POST /work-items/tickets/close(closeTicket),POST /pull-requests/threads/resolve(resolveReviewThread),POST /pull-requests/ready(markPullRequestReady, issue-465), anddiscoveronPOST /sessions/link-pr— each with its MCP tool (post_comment,get_ticket,create_ticket,create_pull_request,pull_request_status,pull_request_checks,pull_request_threads,merge_pull_request,close_ticket,resolve_review_thread,mark_pull_request_ready). The lifecycle acts require a work item registered on the executing instance (decision-140 D8), so a manager routes them to the member that manages the work item. Each executes core with this process's config, so the token and the merge policy (routing.mergeOnApproval) are the daemon's; a manager serves them by instance. Thecomment,ticketandprverbs route here when a service answers/health, and otherwise run in-process with a stderr note, never auto-starting one (decision-140 D2) — the one other exception to "the service is the only execution path" besidesask. - An operator's answer SHALL travel through
POST /api/v1/sessions/reply(issue-208): the text is bracketed-pasted into the session's tmux pane under a provenance header,session.reply_sentis emitted, and a marked report comment lands on the ticket (best-effort,comment: falseto skip) so the thread stays the paper trail without the poller delivering the answer a second time. The ref SHALL resolve the way dispatch resolves it (issue-230): the ref's own record first, else the record holding it as a pull-request endpoint — so a PR's ref delivers into that inner loop's pane — with a closed PR endpoint falling back to the work item's own session, the same rulesession_forapplies to events. The route SHALL be fail-closed: no registered session or no live pane is 404 — a reply never spawns, respawns or resumes anything — a paused record or endpoint is 400, and the claimedactoris recorded for audit, never trusted as auth. GET /attentionSHALL report the wait as kindawaiting-input: open while the work item's newestsession.awaiting_inputis newer than its newestsession.reply_sent— the same rule the dashboard'sawaitingInputmodel applies, so the two surfaces cannot disagree. An answer given on the ticket instead emits noreply_sent, so the row stays lit — a known, documented gap (the poller cannot know which forwarded comment answered the question). A work item whose portable record carries anendedsection (issue-329) SHALL report neitherawaiting-inputnorarmed-without-session: nobody is waiting for the answer. The dashboard applies the same rule to the same record.- A session's transcript SHALL be served by
GET /api/v1/sessions/transcript(issue-209): the harness runs as a CLI in tmux, so the record of a session's turns and tool calls is the harness's own file — for Claude Code,<projects root>/<cwd munged per character>/<harnessSessionId>.jsonl, resolved from the registration alone ($CLAUDE_CONFIG_DIRor~/.claude; a munge miss degrades to a scan of the project directories). The response is a bounded tail by default (tail=200;0means the whole file), each line parsed as a JSON object with unparseable lines returned as{"malformed": …}, plus the resolved path,totalLinesandtruncated. Closed sessions and PR endpoints SHALL resolve — the file outlives the registration, and review is the use case. The same read SHALL be an MCP tool (session_transcript). - The transcript route SHALL be fail-closed to transcripts: it is the plane's first route returning file contents, and only a regular
<id>.jsonlwhose resolved path (symlinks followed) sits inside the resolved projects root is ever opened. A session id carrying a path separator or..SHALL be refused before any filesystem touch; an escape SHALL be indistinguishable from a missing file; Cursor SHALL be refused by name (undocumented store), never guessed at. No redaction is applied — the JSONL is raw harness output served to the plane's existing audience under the existing posture (decision-059), with every read audited asapi.request(decision-079). - The operator's CLI config SHALL be readable and writable through the plane (issue-222):
GET /api/v1/configserves the resolved file with its path and anexistsflag (a machine that has never been configured is a normal state, not an error; an unparseable file is a 400, never an empty config),GET /api/v1/config/schemaserves the packagedcli-config.schema.jsonwith every$refresolved, andPOST /api/v1/configapplies a sparse patch. The file this writes SHALL be the one the process already reads — resolved by the usual precedence (--config,$THE_LOOP_CLI_CONFIG,./.the-loop/,~/) — and no request field SHALL name a path. - A save SHALL be spliced into the file, never a re-serialization of it: comments, key order, blank lines and quoting SHALL survive, because about half of a the-loop config is the prose explaining it. Nothing SHALL be written until the merged document passes the schema, the migration gate (
assert_current) and the samecors_configcheck the service refuses to boot on, and until the edited text has been re-parsed and shown to hold the intended document; any failure SHALL leave the file byte-identical. The write itself SHALL be atomic (temp file in the same directory, thenos.replace), and a file created this way SHALL open with the schema modeline and be mode0600. POST /api/v1/restartSHALL schedule a whole-system restart (issue-228, decision-084): the service cannot stop itself synchronously and still answer, so the route spawns a detachedthe-loop restart— a fixed argv carrying only the config path this process already reads plus at most--with-upgradefrom the body's one boolean — with output at<state.root>/logs/restart.out, answers at once with the spawned pid, and landsrestart.scheduled/restart.completedin the event log. It SHALL NOT be an MCP tool: it tears down the MCP transport mid-call, and--with-upgradereaches the installer — an agent must not replace the code it is judged by. The MCP endpoint itself SHALL be disableable (service.mcp.enabled: falsemounts nothing;/mcpanswers 404) so a deployment can be REST-only.- A saved change SHALL take effect without a restart: the daemons already reload from the file's content hash, and the service SHALL do the same — its in-process config is refreshed once per request, so a hand-edit is picked up too, and a file that becomes unparseable keeps the last good config rather than reverting to defaults. The values read only at boot —
service.host,service.port,service.exposedand everything underservice.cors— SHALL be reported back asrestartRequired, and that list SHALL be empty when nothing in it changed. Which ingresses the service hosts is not boot-only either (issue-395): the hosted set is reconciled to the file on a supervisor thread, soread.mode: offstops the listener the service hosts andthe-loop statusnever has to printrunning … [disabled]— a row that is running against its config says so and names what ends it (the service's next config check for a hosted row,the-loop stopfor any other). - A config write SHALL be visible: every successful save emits
config.updatedwith the file and the changed key paths, and never the values, which name people, hosts and binaries. The route SHALL NOT be exposed as an MCP tool — a daemon config an agent can rewrite isgraph force's problem with a longer half-life — and its authority is otherwise the plane's existing one: a caller who can reach it can already start harness sessions, so the boundary remains the loopback bind, the exposure guard and the deploying gateway. - The dashboard's Settings tab SHALL render that config from the served schema — one section per top-level property, nested objects as nested groups, typed controls for scalars, enums and string lists, and an editable JSON field for any subtree with no typed control, so no key is unreachable from the screen. A schema
defaultSHALL be shown as a placeholder, never adopted as a value (that distinction is what keeps today's defaults out of the operator's file), and Save SHALL send only what changed. - Where the dashboard's design specified a surface this API cannot back, that surface SHALL be rendered disabled and named, never mocked and never silently dropped. None remains today: the inline reply shipped disabled and went live with issue-208, and the turns-and-tool-calls trace did the same with issue-209 — it renders the served transcript, keeping the event-log trail as the stated fallback when the route answers 404.
- The dashboard SHALL render a served transcript as a readable stream (issue-230), not a flat dump of lines: tool calls collapsed to the tool name plus a one-line summary of their input, each call carrying the
tool_resultthat answered it (paired bytool_use_id; errors flagged), thinking and harness bookkeeping collapsed and labelled, and no line rendered blank — a result whose call fell outside the served tail is its own visible row, and an unknown shape degrades to a labelled row rather than disappearing or throwing. All of it renders as text (React escaping), never as markup. - The trace SHALL default to what a human can read or act on, with one Verbose switch over the whole stream (issue-419). With the switch off — its default — the panel SHALL hide the tool groups, raw tool output, the harness meta rows that carry no text, and the event trail's bookkeeping families (
webhook,routing,dispatch,poll,bus,reaction,control,stream,api,mcp,config,service,server,poller,restart,ingress,workspace,cleanup). It SHALL keep, whatever the switch says: any event atlevel=error, any event family nobody classified, agent prose, thinking, human replies, malformed lines, meta rows that carry text, and the gate and question banners. With the switch on the stream SHALL render exactly what it rendered before the switch existed. The trail's row budget SHALL be spent after the filter, not before, and a view the filter emptied SHALL say how many rows are hidden and which switch reveals them rather than render an empty column. - The dashboard SHALL be one screen of three columns (issue-327, the owner's prototype — Control Plane UI 3.0 — superseding issue-298's Classical pair of surfaces): a sidebar of work items grouped by what they need from a human (Needs you, In flight, Shipped, Idle; each row
#n · title / repo · node · age, a search box filtering the loaded rows, standing sessions as their own group, Settings and the health word in the footer), each item with its pull requests nested beneath it (issue-300) — and a work item whose record says it ended (ended, issue-329, decision-113) SHALL sit under Shipped (merged, or an issue closed) or Idle (a pull request closed unmerged), never under Needs you: its question, its parked gate and its blocked nodes are retired at the join, its row wears a mutedmerged/closedchip, and the inbox holds nothing for it or its pull requests; a record without the field, or with a malformed one, reads as open; a main column showing the selected item's title and ref chip, its loop as a compacted node strip (first, last and two either side of the current node; Full graph expands it), one tab per session, the harness trace as a reading column with a Verbose switch, the parked gate and the agent's question as accent banners, and the composer at the foot — the most recently active item when nothing is selected; and a session panel naming the viewed session's harness, id, status, current node and transcript file, its tmux target and cwd, the ticket or pull request it delivers, and the work item's session verbs.#/standingand#/settingsSHALL swap the main column for those panes and keep the sidebar. Either side panel SHALL collapse from its own control and reopen from the header; below 1280 px the session panel and below 768 px the sidebar SHALL start closed. The visual system is the prototype's: IBM Plex Sans, JetBrains Mono for identifiers, Space Grotesk for the title, surfaces and hairlines instead of shadows, one primary for the human's actions, five state colours behind a 6 px dot — its oklch tokens declared once as a Tailwind theme (ui/src/styles/app.css, decision-112). The dashboard SHALL render in light and dark, followingprefers-color-schemeuntil the operator chooses from the header, persisting the choice beside the base URL and applying it before the first paint; an unknown stored value SHALL be ignored. It SHALL render no control the service cannot back (no New work item, no checks, no cleanup verb). Every earlier hash — the pre-283 Dashboard/Sessions/Attention routes and#/eventsalike — SHALL keep parsing and land on the Work surface (an#/events/<ref>permalink on that item). A selected session — the outer loop's or a PR endpoint's — is the hash route, its stream is the readable transcript (event-trail fallback unchanged), and the composer posts to/sessions/replywith the viewed ref, disabled with the reason when that session cannot receive (issue-230). - The Work sidebar SHALL be two levels deep, and no deeper (issue-300): a work item, then one row per pull request whose inner
pdlc-pr-loophas a session — a PR has no pull requests, so the tree mirrors the registry's own nesting. A PR row SHALL carry its session dot, its identity and its age, and neither a title nor an attention chip (the item's row carries the attention); it SHALL print the number alone (#216) when the PR is in its work item's repository and the qualified ref (loop-docs#47) when it is elsewhere (issue-183's multi-repo delivery). A loop with no outer/inner split —pdlc-adhoc-loop,pdlc-contribution-loop,pdlc-review-loop— SHALL render treeless. The hash is the single source of truth for the viewed trace: a nested PR row and the canvas's trace tabs are the same navigation, both ordinary links to#/item/<ref>, and a ref the shown item does not own falls back to that item's own session rather than requesting a transcript for another item's (the service's fail-closed resolution, issue-209, remains the enforcing boundary). - A pull request SHALL appear on the board once (issue-302). A labeled PR has two identities the service writes on purpose — a portable record keyed by its own ref (the poller's ledger) and a session endpoint nested under the work item it delivers — and the browser join reconciles them: a ref drawn as some item's nested PR row SHALL NOT also be a top-level work item. The claim SHALL be refused, leaving the PR top-level, when nothing would draw it (a treeless owner), when the ref has a session record of its own (worked standalone before it was linked —
record_owningresolves it the same way, and its nested endpoint is a stub), when a session claims itself, and when the claimant is itself claimed. A PR no session claims — one linked to no issue — stays top-level: that is the standalone-PR path. Removing a row SHALL move what it carried, not delete it: the nested row takes the PR's portable record (so its age falls back topoll.lastPolledAt, which the read surface joins from this machine's poll clocks since issue-382), and the PR's attention and open question surface on the owning item's card and chip, named for the pull request.GET /api/v1/work-itemsis unchanged — it still serves every portable record, and the client is where the two sources meet. attentionSHALL answer "is this being worked?" against the place the session actually is (issue-302): a live nested pull-request endpoint counts as a session for its ref, so a linked, labeled PR no longer reportsarmed-without-sessionfor as long as it is worked. Onlyactive/pausedcount, and a ref's own record still wins over an endpoint claiming it.- Attention SHALL stay deduplicated and tiered (issue-283) and SHALL surface without an inbox surface of its own (issue-298): one entry per (work item, kind), the newest first by tier (needs-input > human gate > waits > errors), rendered as the sidebar rows' chips and the canvas's cards and rail note. A parked gate SHALL be approvable from the item's canvas (
POST /graph/complete, the paper trail posted by the service) with the ticket link beside it, and an agent's question SHALL be answered by the chat bar (POST /sessions/reply) the question card points at. - The poller SHALL cache each ticket's title in the portable record's poll section, refreshed per cycle, so the board is human-readable against a live service and not only in the demo (issue-283 B1).
recent-errorattention items SHALL age out (RECENT_ERROR_MAX_AGE_HOURS) and apoll.*error SHALL clear once its item polls clean — the inbox converges to empty when nothing needs a human (issue-283 B5). Entries carry the raw timestamp so clients render age, not a bare ISO string.api.requestSHALL be emitted at debug level (issue-283 B7): the audit trail is intact and queryable (GET /api/v1/events,the-loop events), but a dashboard's own reads must not fill every default event window. The standalone Events screen retired with issue-298; the trail the dashboard still renders is the trace's event-log fallback per work item.- The three daemon chips SHALL be one health dot whose popover carries the stream state, each daemon's last cycle and a manual refresh (issue-283 B10); the stream state SHALL come from the one connection the board owns, so no two surfaces can disagree about it.
- A poll cycle SHALL NOT re-check dormant loops:
graph/checkis skipped for an item with no checkout (frozen rail covers it) and answered from the held report for a session that is neither active nor paused — nothing advances a graph nobody is driving (issue-283, feature #9). - The dashboard SHALL hold no credential and mint none. The network posture is unchanged and is stated in its Settings screen: the service binds loopback, so a service on another machine is reached through an SSH tunnel or a gateway that terminates auth — never by exposing the service. A service on the same machine needs neither, since this page's origin is in the shipped
service.cors.allowOrigins(issue-211).
Design
docs/specs/issue-161/design.md · docs/specs/issue-207/design.md · docs/api-specs/openapi/the-loop.v1.yaml · CLI: the service · config: service options · ui/README.md
History
| Work item | What changed | Links |
|---|---|---|
| issue-462 | GET /pull-requests/checks (listPullRequestChecks) and the MCP tool pull_request_checks list a pull request's checks with failed jobs' log tails, over core.github_ops.pull_request_checks; read-only, so a manager runs it on the member it targets, like pr status | spec, cli, issue |
| issue-465 | POST /pull-requests/ready (markPullRequestReady) and the MCP tool mark_pull_request_ready take a registered work item's draft pull request out of draft, over core.github_ops.mark_ready; a manager runs it on the member that manages the work item | spec, cli, issue |
| issue-447 | Seven routes and seven MCP tools for the harness's GitHub acts — comment, ticket read and create, pull request open, status, threads and merge — plus discover on sessions/link-pr, all over core.github_ops with the executing process's token and merge policy; harness_routed sends the CLI verbs to a running service and otherwise runs them in-process, loudly | spec, decision-140, issue |
| issue-374 | The manager (2026-09-30): the router calls a facade — CoreFacade on a worker, ManagerFacade on a manager — so one APIRouter serves both roles; the instance parameter on every keyed operation; 409 and 502 on the route class; the The-Loop-Instances-Unreachable header; the instances family; the stream fanned in from every member with a per-member cursor; the dashboard's Instances tab, instance pane, chip and filter | spec, decision-138, instances, issue |
| issue-419 | The work-item trace stopped burying the conversation in its own bookkeeping: the Tool calls switch became one Verbose switch over the whole stream, defaulting off. Quiet, the panel drops the tool groups, raw tool output, the empty system (system) meta rows and the trail's plumbing families, and drops an assistant turn whose only content was tool calls rather than leaving a header over an empty body; verbose renders everything exactly as before. Two pure predicates in model.ts — isReadable over a projected row, isBookkeeping over an event's family — carry the classification, and both fail open: level=error is never hidden and a family nobody classified renders. The trail's 40-row budget now runs after the filter, so a graph.parked is no longer buried behind forty poll.* rows, and a view the filter emptied names its hidden count and the switch. Presentation only: no route, payload, stored setting or event type changed | spec, issue |
| issue-395 | The hosted ingress set follows the config (2026-09-20, B5 of the e2e run): HostedIngresses in api/ingress.py composes the set as the-loop start did, then re-hashes the config file every five seconds on a supervisor thread and reconciles membership on change — an ingress no longer enabled is stopped (ingress.hosted_stopped reason=config, lock released, no restart), one newly enabled is started through the same starter, lock and refusals as at boot (ingress.hosted reason=config), and one config.reloaded names both. So channels.slack.read.mode: off now ends the hosted listener's Socket Mode connection instead of leaving it consuming events until a restart. the-loop status no longer prints running … [disabled]: a running row the config disables says so and names what ends it. Membership only — a running ingress's own config keeps reloading as before; service.hostIngresses stays boot-only. No config key, schema, state or event type change | spec, issue |
| issue-339 | The plane stopped reporting ok for a job it was not doing (2026-09-11): GET /api/v1/health carries the enabled ingresses with a reason for each one that is down, plus the configPath and stateRoot this process resolved — at HTTP 200 still, because the code is what the CLI's auto-start loop reads. An enabled ingress that fails to start records ingress.hosted_failed instead of only a logfile line. Both spawns of the service now carry THE_LOOP_CLI_CONFIG so the service and everything it hosts read the operator's config rather than re-resolving one from the working directory they inherited | spec, decision-119, supervision, issue |
| issue-368 | One portable record per work item (2026-09-15): a pull request delivering a tracked work item no longer gets a record of its own — its poll ledger is keyed under the owner's record — so GET /api/v1/work-items serves one row per work item, each naming the pull requests it links. The client-side reconciliation of the two identities (issue-302) stays: records written for a pull request before this change are read and never deleted, so a board that dropped the join would draw those twice. The nodes view the portable graph section used to carry is derived from the-loop check, which the client already calls | spec, decision-128, cli, issue |
| issue-329 | Closed work items leave Needs you (2026-09-09): the join reads the portable record's new ended section, nulls the item's question and parked gate, keeps it out of needs-you, groups it under Shipped (merged or issue closed) or Idle (PR closed unmerged) with a muted merged / closed chip, and contributes nothing to the inbox for it or its pull requests; GET /attention applies the same rule and reports neither awaiting-input nor armed-without-session for a stamped record. A record without the field, or with a malformed one, is open, as at 13.6.0. The demo's shipped item now carries the stamp | spec, decision-113, webhook-triggers, issue |
| issue-322 | GET /api/v1/instance (getInstance; the get_instance MCP tool; loop.instance() on the SDK): which instance of the-loop answered — its name, scope mode, declared work items and the managed set with the source of each entry. The seam a manager of several instances aggregates across: every instance serves the same surface, and this document says which one it is | spec, decision-110, instances, issue |
| issue-302 | A pull request stopped appearing twice on the board. A labeled, linked PR carries two identities the service writes on purpose — the poller's portable ledger under its own ref, and a session endpoint nested under the work item it delivers — and buildWorkItemViews unioned them, so PR #301's nesting made the same PR render as a live nested row and a dead top-level shell (grey dot, "no session", because its session is nested elsewhere). The join now reconciles the two: a ref another item's row draws as its pull request is not a work item, unless nothing would draw it (a treeless owner), it has a session record of its own, or the claim is a self- or two-level claim a hand-edited record could use to hide a row. What the removed row carried is folded onto the nested one — its portable record for the age fallback, and its attention and open question onto the owning item's card and chip — so the drop moves information rather than deleting it. Service side, list_attention now counts a live nested PR endpoint as a session for its ref, which is what made every linked, labeled PR report a permanent stall | spec, issue |
| issue-300 | The Work sidebar stopped flattening the board: each work item now carries its pull requests as nested rows (dot · number-or-qualified-ref · age), rendered from the sessionTree join the retired Sessions screen left behind rather than a second derivation, so treeless loops stay treeless. Selecting a PR row opens that PR's session on its work item's canvas, and the parent row keeps a lighter marker so the open item is still visible. The viewed trace moved from pane-local state to the hash — the trace tabs became links to the same route the sidebar rows use — which removes the second source of truth and, with it, the class of bug where a PR of the already-open item could be selected and change nothing | spec, issue |
| issue-327 | Control Plane UI 3.0: the dashboard's presentational layer rebuilt on the owner's prototype (https://the-loopy-one.lovable.app/), light and dark. One screen of three columns — a work-item sidebar grouped Needs you / In flight / Shipped / Idle with search and nested PR rows, a main column with the loop as a compacted node strip, session tabs, the harness trace as a reading column with a Tool calls switch and the composer, and a session panel (harness · tmux · ticket · controls); Standing and Settings as panes of the same column. Tokens are the prototype's oklch values in a Tailwind v4 @theme; the theme follows the browser until chosen, is persisted beside the base URL and applied before first paint. Presentation only: every connector, model join, route and control verb is unchanged, and nothing the service cannot back is rendered | spec, decision-112, issue |
| issue-298 | The dashboard's design overhaul, in two rounds. Round one: the Industry system replaced wholesale by Classical (Cormorant Garamond over Lora, hairline rules, color as stroke), vendored as ui/src/styles/classical.css from the owner's signed-off export (checked in under docs/specs/issue-298/design/); the header bar retired into the sidebar; the labelled node rail became a tick bar captioned current · n of m (node names in tooltips); the transcript an editorial "You" / "the-loop" thread. Round two, on the owner's decluttering direction: one flat Work-items sidebar (chips carry the attention), the canvas pared to header + trace + chat with the most recent item shown by default, the question answered by the chat bar itself, the inbox/overview and PR cards folded into chips, cards and trace tabs, and the standalone Events screen retired (the trail remains as the trace's fallback; #/events lands on Work). Presentation only: every API connector, model join and control verb is unchanged | design, issue |
| issue-283 | The dashboard calmed down to the claude.ai/code shape: six tabs became three (Work · Events · Settings) with a persistent sidebar (deduped, tiered inbox with in-place gate approval and question replies; items grouped needs-you/running/idle; standing sessions under a divider) and one main pane. Service side: the poller caches ticket titles in the portable record, recent-error attention ages out and clears on a clean poll, and api.request dropped to debug level. Plus the audit's contained fixes — frozen rail rendered instead of "no work-item state", relative timestamps with the date when not today, the duplicated event trail and the broken fallback sentence on the detail pane, round session dots with a dash for "no session", provenance notes and the route footer removed, Settings prose behind disclosures — and dormant loops answered from the held graph report instead of a fresh check per poll | audit |
| issue-277 | The plane gained a second session namespace: GET /api/v1/standing-sessions, …/one, POST …/control and POST …/say over one core implementation, with list/get/say on MCP and control deliberately off it (an agent that could stop a standing session could stop the one supervising it), plus loop.standing on the SDK and a Standing screen on the dashboard (list, create, delete, start/stop/restart, per-session message box). the-loop start/stop/status report the sessions in their own section beside the services | spec, standing-sessions, issue |
| issue-239 | The plane stops waiting to be asked: GET /api/v1/stream (SSE) holds a connection open and pushes event-log records, transcript-growth notifications and desync signals, fed by one shared tailer over events.jsonl with a bounded queue per subscriber and service.stream.maxSubscribers refusing the rest at 503. SSE over WebSocket on a security argument — the WebSocket handshake is exempt from CORS. The stream never carries api.request/mcp.call, which would feed it from the control plane's own refreshes. The dashboard gains streaming/polling/manual as a per-browser choice with a visible connection state, refreshes one loop for a graph.* frame instead of sweeping the board, and finally puts the chat bar in reach with a self-scrolling trace panel | spec, decision-087, issue |
| issue-238 | A cleaned-up checkout stopped being caller error: graph/check answers a non-resolving repo with 200 + repoResolved: false instead of 400, and fetchGraphs drops that answer where the old rejection was dropped, so the rail still renders from the frozen record and the browser console stops accumulating 4xx at a layer no catch can reach. The boundary itself is unchanged — repo_resolves is factored out of resolve_repo so the predicate exists once, and check returns before _runtime, so no graph read ever sees an unvetted path. Only the polled verb changed; the mutating ones still refuse | spec, issue |
| issue-230 | The transcript becomes readable and steerable: the dashboard's stream pairs each tool_result to its tool_use by id and collapses tool calls/thinking/bookkeeping behind disclosure (no line renders blank — the reported bug), a new Sessions screen lists every work item in a sidebar with its sessions as a two-level outer/inner tree (ad-hoc items treeless), and a chat bar under any stream posts to /sessions/reply with the viewed ref. Server side, the reply route resolves PR endpoints the way dispatch does, so an inner loop's chat lands in that PR's pane; every issue-208 refusal is kept | spec, issue |
| issue-212 | The plane gained a second consumer without gaining a second implementation: /api/v1 moved out of create_app's body into one APIRouter (api/routes.py), and the per-request behaviour that was middleware and app-level handlers — config refresh, error translation, the api.request audit — moved onto that router's route class, so it travels into an application the-loop does not own. create_app keeps its signature and behaviour; api/lifespan.py holds the MCP-session-manager and hosted-ingress composition both consumers need; api/mcp.build_app gained an optional allowed_hosts for deployments that do not bind where service.host says. The new capability is sdk | spec, decision-085, issue |
| issue-228 | The plane can bounce itself: POST /api/v1/restart schedules a detached, fixed-argv the-loop restart [--with-upgrade] (output at logs/restart.out, restart.scheduled/restart.completed in the event log) — deliberately not an MCP tool. The MCP endpoint became disableable (service.mcp.enabled: false mounts nothing; /mcp 404s), and the service's start/stop mechanics moved into core.lifecycle behind the-loop start|stop|status|restart (the granular service command folded away on owner review). Amended in the same PR (issue-231): with service.hostIngresses (default true) the service hosts the enabled ingresses as threads in its lifespan, each holding its own pidfile flock under the service's pid | spec, decision-084, issue, issue-231 |
| issue-161 | Capability minted: core facade extracted, API service + OpenAPI contract, loopback-default network posture (no in-app auth — the gateway owns it, decision-059), service lifecycle commands, every core-capability command routed through the service, HTTP-only MCP endpoint on the official SDK, no install extras. The UI was descoped on owner review | spec, decision-058, decision-059, issue |
| issue-211 | The dashboard can actually read the service: service.cors makes the allowed browser origins configuration, shipping the published page's own origin as the default. Exact-string origins only; "*" with credentials refuses to start; an empty list installs no middleware. The bind, the exposure guard and the MCP transport's origin check are unchanged | spec, decision-077, issue |
| issue-207 | The descoped UI lands: a static dashboard in ui/ over the same /api/v1, published to /the-loop/ui/ from the docs site's Pages artifact. Loop position joined from the session's cwd and the record's spec id; the inbox unions /attention with the repo-scoped graph gates it excludes; the two surfaces the API cannot back ship disabled and named | spec, issue |
| issue-208 | Agent questions become a verb and get an answer route: the-loop ask posts the question with the marker stamped centrally and emits session.awaiting_input; POST /api/v1/sessions/reply pastes the answer into the pane (fail-closed — never spawns, refuses paused), emits session.reply_sent, and records a marked report on the ticket. attention gains the awaiting-input kind; the dashboard's reply box goes live | spec, decision-078, issue |
| issue-222 | The CLI config becomes editable from the plane: GET/POST /api/v1/config and GET /api/v1/config/schema, over a comment-preserving splice writer (yamlpatch) and a packaged-schema validator (configschema) that adds no runtime dependency. Nothing is written until the merged document clears the schema, the migration gate and the CORS boot rule, and the splice has re-parsed to what it promised. The service gains the daemons' hot reload, so a save is live on the next request; boot-only keys come back as restartRequired. The dashboard's Settings tab renders the whole config from the schema | spec, decision-081, issue |
| issue-209 | The harness's own JSONL is served: GET /api/v1/sessions/transcript (+ the session_transcript MCP tool) resolves the file from the recorded cwd + session id and returns a bounded tail, fail-closed to *.jsonl inside the projects root — the plane's first file-contents route. The dashboard's turns-and-tool-calls trace goes live, with the event trail kept as the 404 fallback; its path caption switches to the harness's per-character munge | spec, decision-079, issue |