Skip to content

The control-plane service ​

The control-plane API service is the HTTP layer over the-loop's core that the CLI and the MCP endpoint consume (issue-161, decision-058). The service is the CLI's only execution path for core capabilities: sessions, check, graph, events, scenarios, instructions and critic all talk to it (auto-starting a local one when service.autoStart allows) instead of executing core logic in-process. Its lifecycle is the-loop start|stop|status|restart, like every other the-loop service (issue-228) — there is no service command any more.

Install and run it locally ​

Everything needed to host the service ships with the package — there are no extras to remember:

sh
uv tool install the-loopy-one     # or: pip install the-loopy-one
the-loop start

That is the whole setup. start boots the service (with whatever else the config enables), waits for /api/v1/health to answer, and prints the URL it is listening on:

console
$ the-loop start
service     started         [enabled]  started at http://127.0.0.1:4114; /mcp exposed

$ the-loop status
service     running (pid 24846) [enabled] — http://127.0.0.1:4114, healthy

$ curl -s http://127.0.0.1:4114/api/v1/health
{"status":"ok","version":"13.12.0","configPath":"…/cli-config.yaml",
 "stateRoot":"…/.the-loop","ingresses":[…]}

status is ok only while every ingress your config enables is actually running; it is degraded — at HTTP 200, deliberately — when one is not. See keeping it running.

You do not have to start it by hand. Any routed command starts one for you the first time it needs it, so a fresh install works immediately:

console
$ the-loop check issue-161
issue-161: ok (at pr-review)

Set service.autoStart: false if you would rather manage the process yourself (a systemd unit, a container); commands then fail naming the-loop start instead of booting one.

The service is also the default host for the other services: with service.hostIngresses at its default, an enabled poller and webhook receiver run as background threads inside this one process (issue-231) — one pid, one logfile — while keeping their own pidfile locks so status, stop and the daemons API answer unchanged. Set it false for one process per service.

Already run a Python service?

You do not have to run this as a process of its own. The same /api/v1 surface is mountable into a FastAPI app you already deploy — behind your auth and middleware, in one process — with the Python SDK. It is the same router, so nothing here drifts from what you get there.

To change the port or bind address, set service.host / service.port in your CLI config:

yaml
service:
  host: 127.0.0.1
  port: 4114

Authentication ​

The service carries no in-app authentication. It is meant to run behind a gateway that terminates auth, and locally it binds loopback only by default, so the network boundary — not a token — is what protects it. Do not expose it on a network without an auth-terminating gateway in front (service.exposed is the explicit opt-in that lets it bind beyond loopback at all).

The web dashboard, and CORS ​

The dashboard is a static page on GitHub Pages pointed at whichever machine runs the service, so its calls are cross-origin and the browser needs the service's permission to read the answers. That permission is service.cors.allowOrigins, and it ships allowing the published dashboard's origin — so the page works against a local service with nothing in between.

It is a read permission, not a network one: the loopback bind and the exposure guard are unchanged, and no CORS setting can loosen them. Read what the default admits before keeping it, and set allowOrigins: [] if you do not use the hosted page.

Lifecycle ​

the-loop start boots it detached and waits for /api/v1/health; stop signals it (SIGTERM) and waits for the lock to be released; status reports running (pid …) — http://…, healthy|unresponsive; restart [--with-upgrade] bounces it, and POST /api/v1/restart does the same over the API.

  • The pidfile is the lock (<state.root>/local/service.pid, flock — the issue-159 lifecycle discipline): a second start reports already-running and starts nothing; stop is idempotent.
  • Nothing restarts it if it dies. start starts each enabled service once and returns; the package ships no supervisor. See keeping it running for the systemd unit and the health check to watch.
  • Binding beyond loopback refuses to boot unless service.exposed is explicitly true — the API can spawn harness sessions with the operator's credentials, so "accidentally on the network" is made impossible. Set it only when a gateway fronts the service.

The API surface ​

The contract is authored in docs/api-specs/openapi/the-loop.v1.yaml — a parity test fails the build when the served schema drifts from it. Interactive docs are served at /api/docs. Work items, the process graph (show/check/advance/complete/force), sessions and their register/close/control verbs, the event log, daemon lifecycle, needs-attention, repo-scoped queries (scenarios / instructions / critics, and running one critic round) and the CLI config itself (GET/POST /api/v1/config, GET /api/v1/config/schema — issue-222) are all exposed; sessions reset deliberately is not (a destructive verb stays a local decision).

The config routes read and write the file this process already resolved — no request names a path — and a write is spliced into the file rather than re-serialized, so your comments survive. Nothing is written unless the merged document passes the schema, the migration gate and the CORS boot rule. A saved change is live on the next request, and the values read once at boot (service.host, service.port, service.exposed, service.cors.*) come back in the response as restartRequired. Which ingresses the service hosts follows the file too: it checks the config every five seconds, stops a hosted ingress the edit no longer enables (channels.slack.read.mode: off closes the Socket Mode connection) and starts one the edit newly enables, with no restart. See configuring the CLI.

MCP: connecting an agent ​

The same app serves an MCP endpoint at /mcp, built on the official MCP Python SDK. Transport is streamable HTTP only — there is no stdio server, so every client below is configured with a URL rather than a command to spawn.

Start the service first; the endpoint is live as soon as it is:

sh
the-loop start                  # -> http://127.0.0.1:4114/mcp

(/mcp is mounted while service.mcp.enabled is true — the default; set it false for a REST-only service.)

Claude Code ​

sh
claude mcp add --transport http the-loop http://127.0.0.1:4114/mcp

Add --scope project to write it to the repository's .mcp.json so everyone working the repo gets it, or --scope user for every project on your machine. Check it connected with /mcp inside a session.

Claude Desktop ​

Edit claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\):

json
{
  "mcpServers": {
    "the-loop": {
      "type": "http",
      "url": "http://127.0.0.1:4114/mcp"
    }
  }
}

Restart Claude Desktop afterwards.

Cursor ​

Add it to .cursor/mcp.json in the project (or ~/.cursor/mcp.json globally):

json
{
  "mcpServers": {
    "the-loop": {
      "url": "http://127.0.0.1:4114/mcp"
    }
  }
}

Anything else ​

Any MCP client that speaks streamable HTTP works — point it at http://<host>:<port>/mcp. Two things to know:

  • No auth header is needed and none is accepted: the service has no in-app auth (see above). If you put it behind a gateway, configure the credential in the client the way that gateway expects.
  • The SDK's DNS-rebinding protection is left on and pinned to the host the service is configured to bind. Reach it on that host (127.0.0.1 by default) — a Host header naming something else is rejected with a 421, on purpose.

What the tools do ​

The tools mirror the API's read and manage surface over the same core facade, so an agent sees exactly what the CLI does:

ToolWhat it does
list_work_items, get_work_itemThe portable records: control and poll state
check_work_itemEvaluate a work item's process-graph gates (the the-loop check report)
graph_show, graph_advance, graph_completeRead the graph; take an edge; file a completion claim
list_sessions, register_session, link_pull_request, close_session, control_sessionThe session registry — including recording a pull request as delivering a work item — and its start/pause/resume/stop verbs
query_eventsThe structured event log
daemon_status, control_daemonThe poller and gh-webhook daemons
list_attentionWhat needs a human: paused sessions, armed items with no session, recent errors
repo_scenarios, repo_instructions, repo_critics, repo_critic_runRepo-scoped queries, and one critic-review round

Two operations are deliberately not tools: sessions reset is destructive and stays a local decision, and graph force requires a human-attributed reason an agent must not forge.

Observability ​

Every API operation lands in the event log as an api.request record (source service, level debug since issue-283 — a polling dashboard's own reads must not drown the events an operator reads the log for). the-loop events --source service is the query.

Released under the MIT License.