Decision 085: ship the SDK as a router + lifespan seam, not a second application
- Status: proposed
- Date: 2026-08-15
- Work item: issue-212
- Deciders: maintainer (via ticket); harness (proposal)
Context
the-loop's control plane is reachable one way: the-loop start, a whole process, its own port. The ticket asks for the deployment that is not that — a team with an existing Python service that wants the-loop inside it: their FastAPI app, their auth middleware, their transaction-id logging, one process.
The capability is already there and already correctly layered (decision-058: one transport-free core, transports on top). What is missing is a supported seam. Today an embedder can only call create_app() and get an application — its own title, its own /api/docs, its own lifespan, /mcp mounted at its root — or mount that application as a sub-app, where Starlette runs no lifespan so the MCP session manager never starts, or reach into the_loop.core directly, which is undocumented and unversioned.
Decision
the_loop.sdkis the public surface; everything else stays internal.TheLoop, its eight capability namespaces, its HTTP-seam methods and the environment contract change under semantic versioning.the_loop.core,the_loop.apiand the rest may change in any release.- The
/api/v1surface becomes oneAPIRouter(api/routes.py) with two consumers:create_appand the SDK. A parity test asserts router == served app == authored OpenAPI contract, so the embedded and standalone surfaces cannot drift — they are the same object. - Per-request behaviour rides on the router's route class, not on an application. The config refresh (issue-222), the
ValueError/LookupError/SpliceErrortranslation and theapi.requestaudit event move off middleware and app-level handlers, because a route class is the only extension point that travels withinclude_routerinto an application the-loop does not own.create_apploses its middleware and its three handlers and behaves identically. - The SDK touches a host application in at most two ways:
include_routerand — unlesslifespan=False— its lifespan. No middleware, no exception handlers, no CORS. CORS in particular is refused on principle:service.corsis an application-wide policy, and applying one the-loop config key to every route of somebody else's app is not a library's decision. mount()wraps the host lifespan by default. Not composing it is silent at import, silent at startup, and surfaces as a session-manager error on the first MCP call, from a client, in production. The host's lifespan still runs, inside the-loop's;lifespan=Falsehands composition back, and an MCP request arriving with the lifespan not running is answered503naming the omission.- The MCP app mounts at the prefix, and the prefix may not be empty. Mounting at the prefix (after the router) is what makes
<prefix>/mcpanswer with no trailing-slash redirect — the same arrangementcreate_appuses at the root, for the same reason. At the root inside a host app it would shadow every route declared after the mount, so that combination is refused rather than produced.mcp_allowed_hostslets an embedded deployment declare its real hostnames; the default derivation describes the standalone service's bind. - Authorization stays the deployment's (decision-059 unchanged). The SDK's obligation is to make attaching it a parameter:
dependencies=onrouter()/mount(), applied to every operation before any handler executes, documented at the top of the embedding page rather than in an appendix. No in-app auth layer is added — a second, weaker one would be worse than none. - Construction reads the CLI config strictly.
TheLoop(config_path=…)is the ticket's shape; a missing or unparseable file raises rather than degrading to{}. The CLI's leniency is right for a short-lived command and wrong for a long-lived service, where an emptyrouting.authorizedUsersfails closed invisibly and looks like a healthy start. - Lifecycle writes are not on the SDK.
status()reads;start/stop/restartmanage the-loop's own processes, and--with-upgradereaches the installer. The REST router still carriesPOST /api/v1/restartand the daemon controls — dropping them would break §2 — and the docs name both as operations whose meaning changes when embedded. - The environment contract is a table in code (
sdk/environment.py): per binary, the config key that renames it, the capability it serves, and the predicate deciding whether this configuration needs it.check_environment()resolves withshutil.whichand never executes what it finds; it is a report, never a gate. A parity test binds the table todocs/sdk/environment.mdin both directions. - No new runtime dependency, and the no-extras rule (PR #162) holds: one
pip install the-loopy-oneyields the SDK. - The vendor SDKs stay out of this work item. The Claude Agent SDK, Cursor's programmatic surface and PyGithub are analysed in
docs/reports/vendor-sdk-analysis.mdand raised as their own tickets. decision-016's reasoning still holds for what ships today, and swapping a harness adapter is a behaviour change that deserves its own spec chain rather than a rider on a packaging work item.
Consequences
- Additive to the package: nothing is removed, renamed or moved in the CLI config, the CLI surface, the on-disk state or
create_app's signature. No migration. - A second public contract now exists, and it is versioned. A rename in
coreis free; a rename onthe_loop.sdkis a breaking change. - The security boundary moves for embedded deployments. The exposure guard and the CORS allowlist are the standalone service's; an embedder who mounts on a public app with no dependency has published a session-spawning API. That residual risk is closed by documentation and by
dependencies=being a first-class argument, not by a guard the SDK could enforce on somebody else's application. create_appno longer has middleware or app-level exception handlers. Anything that depended on those objects (nothing does today) would need the route class instead.
Spec: docs/specs/issue-212/