Skip to content

ADR-0076: Studio daemon route registry and local control plane

  • Status: Accepted
  • Kind: Retrospective
  • Area: studio
  • Date: 2026-07-09
  • Relations: supersedes v0-0001, v0-0002, v0-0006, v0-0008, v0-0018

Context

Studio is a FastAPI daemon in lionagi/studio/, not a passive database viewer. The running application exposes reads, launches, cancellation, definition and schedule mutation, approvals, show import, and maintenance. It also owns scheduler startup, stale-state reconciliation, optional transcript mirroring, launch cleanup, and a deferred WAL checkpoint. The earlier CLI-primary, browser-mostly-read-only boundary therefore no longer describes the service.

This ADR answers five concrete problems in the shipped daemon.

P1 — Endpoint decoration does not by itself make an endpoint public. Service modules call studio_route(), but registration happens as a Python import side effect. Without one explicit composition root, a valid decorated handler can remain unreachable, duplicate registration can depend on import order, and clients cannot tell which route set is authoritative (lionagi/studio/registry.py; lionagi/studio/app.py).

P2 — One process has three delivery shapes. Studio must run as an API-only daemon, serve a built Vite distribution, or sit behind the Vite development server. A catch-all SPA route would intercept FastAPI's trailing-slash redirects, while returning the SPA for unknown /api/* paths would turn API errors into HTML (lionagi/studio/app.py; lionagi/studio/cli.py).

P3 — Readiness depends on some lifecycle work but not all maintenance. Stateful routes must not observe phantom sessions left from a prior process, yet a WAL checkpoint or optional transcript mirror must not indefinitely block liveness. Shutdown must settle owned tasks and launched subprocesses before the scheduler stops (lionagi/studio/app.py).

P4 — Loopback-first is not the same as an absent trust boundary. A browser can reach a loopback daemon through DNS rebinding or cross-origin requests. When a bearer token is configured, schema and API paths expose protected information; the static SPA must still load without attaching a token to document navigation. State-changing requests with a body must not accept form-compatible content types that avoid a CORS preflight (lionagi/studio/app.py; lionagi/studio/config.py).

P5 — “SSE” names a framing mechanism, not one event protocol. Session output, persisted session signals, show-directory changes, and Leo turns all emit unnamed data: frames, but their cursors, terminal tests, and error paths differ. Treating them as one replayable event log would promise semantics the code does not implement (lionagi/studio/services/_sse.py; sessions.py; shows.py; leo.py).

Concern Decision
Public HTTP composition D1: Make the declarative registry plus fixed module manifest the sole API composition root.
API and SPA delivery D2: Mount registered routes below /api, keep /health direct, and make SPA hosting optional.
Process lifecycle D3: Reconcile before readiness, defer non-critical maintenance, and stop owned resources in order.
Local network boundary D4: Apply host validation, explicit CORS, JSON-body enforcement, and optional bearer auth centrally.
Live transport D5: Keep endpoint-specific fetch-compatible SSE contracts and do not imply a central event bus.

Out of scope:

  • Scheduler firing, lease, retry, and worker semantics are owned by the scheduling-control-plane area; this ADR records only daemon lifecycle wiring.
  • StateDB schema and file/database consistency are owned by ADR-0077.
  • The web client's information architecture is owned by ADR-0079 and ADR-0080.
  • A durable operator-command protocol is the aspirational decision in ADR-0083.
  • Network-wide identity, authorization roles, and remote exposure are not introduced here; the shipped boundary is a local daemon with one optional bearer secret.

Decision

D1 — Declarative route registry and explicit composition root

lionagi/studio/registry.py is the public HTTP composition root. Its shipped Python contract is:

Handler = TypeVar("Handler", bound=Callable[..., Any])
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]

@dataclass(frozen=True, slots=True)
class StudioRoute:
    order: int
    area: str
    path: str
    method: HttpMethod
    handler: Callable[..., Any]
    response_model: Any | None
    dependencies: tuple[Any, ...]
    status_code: int | None
    tags: tuple[str, ...]
    name: str | None
    summary: str | None
    description: str | None
    response_class: type[Response] | None
    responses: Mapping[int | str, Mapping[str, Any]] | None
    include_in_schema: bool

def studio_route(
    path: str,
    *,
    method: HttpMethod,
    area: str,
    response_model: Any | None = None,
    dependencies: Sequence[Any] = (),
    status_code: int | None = None,
    tags: Sequence[str] | None = None,
    name: str | None = None,
    summary: str | None = None,
    description: str | None = None,
    response_class: type[Response] | None = None,
    responses: Mapping[int | str, Mapping[str, Any]] | None = None,
    include_in_schema: bool = True,
) -> Callable[[Handler], Handler]: ...

def load_studio_route_modules() -> None: ...
def iter_studio_routes(*, area: str | None = None) -> tuple[StudioRoute, ...]: ...

The fixed manifest contains 22 service modules:

casts, runs, engine_runs, definitions, agents, playbooks, shows, skills,
plugins, teams, invocations, launches, projects, engine_defs, workflow_defs,
sessions, run_tags, leo, approvals, admin, schedules, stats

All names are relative to lionagi.studio.services. app._mount_studio_routes() imports that manifest, iterates registration order, and calls application.add_api_route(f"/api{route.path}", ...). Importing the current app produces 96 decorated /api routes. That number demonstrates the surface size; it is not a compatibility constant.

Exact semantics:

  • Registration appends in decorator execution order; iter_studio_routes() sorts by the captured integer order and returns an immutable tuple.
  • When tags is None, the route receives (area,); an explicit empty sequence means no tag.
  • The deduplication key is (path, method, fn.__module__, fn.__qualname__). Repeating that same registration raises ValueError; two different handlers can still claim the same method/path and must be caught by route-contract verification.
  • Import caching makes repeated manifest imports harmless for already-loaded modules. _reset_registry() exists for tests, not runtime hot reload.
  • A decorated handler in an unlisted module is not mounted. This is deliberate: public reachability requires both declaration and inclusion at the composition root.
  • The manifest order controls registration order. Literal routes that might otherwise be captured by parameter routes must be registered first within their service.

Why this way: one registry makes metadata enumerable without introducing a parallel APIRouter hierarchy per small service. The fixed manifest also makes public inclusion an explicit reviewable act. The tradeoff is manual composition, so a missing manifest entry is a realistic failure mode rather than an impossible state.

D2 — /api boundary, direct health, and optional SPA mount

The application factory is the stable construction seam:

def create_app() -> FastAPI: ...

app = create_app()

create_app() installs the lifespan, exception translation, central middleware, registered API routes, direct GET /health, optional assets, and final middleware order. LionError becomes {"detail": exc.message} with the error's status code. Health returns exactly:

{"status": "ok"}

SPA discovery and fallback use:

def _resolve_frontend_dist() -> Path | None: ...
def _mount_spa(application: FastAPI, dist: Path) -> None: ...

Exact semantics:

  • If LIONAGI_STUDIO_FRONTEND_DIST is unset, the daemon is API-only.
  • If it is set but <dist>/index.html does not exist, the daemon remains API-only.
  • If <dist>/assets exists, it is mounted at /assets with StaticFiles.
  • An unmatched /api or /api/* request remains JSON 404; it never receives the SPA.
  • An unmatched non-API GET or HEAD receives index.html with no-store, no-cache, and must-revalidate; any other method remains JSON 404.
  • The fallback is a 404 exception handler, not /{full_path:path}. This preserves FastAPI's trailing-slash redirect behavior for routes such as /api/runs/.
  • python -m lionagi.studio starts Uvicorn with HOST and STUDIO_PORT.

The shipped defaults in lionagi/studio/config.py are port 8765 and host 127.0.0.1. The port is inherited from the existing Studio surface; no separate rationale is recorded beyond compatibility. Loopback is the safety-oriented default.

D3 — Ordered daemon lifecycle

The lifespan contract is:

startup:
  scheduler.start()
  run_startup_reconciliation()       # readiness gate
  optional Claude mirror task
  create _startup_warmup task         # WAL checkpoint, not a readiness gate
  yield ready

shutdown:
  settle/cancel warmup task
  stop mirror task
  shutdown_launches()
  scheduler.stop()

Exact semantics:

  • Scheduler startup happens before reconciliation so the daemon owns one initialized scheduler throughout its ready lifetime.
  • Reconciliation is awaited before yield; stateful API reads do not race the repair of stale session and invocation rows.
  • The WAL checkpoint runs in a named background task. Any failure is logged and does not make startup fail.
  • Optional transcript mirroring is controlled by LIONAGI_STUDIO_MIRROR_CLAUDE (default enabled), starts with a default 24h catch-up window, and polls every 5 seconds. These values are inherited operating defaults; the source records bounded catch-up and live tailing as the reasons, but no measurement selecting exactly 24 hours or 5 seconds.
  • Mirror shutdown signals its stop event and waits up to 10 seconds, then cancels the task. Ten seconds is a backstop inherited from the implementation; no recorded measurement justifies the exact value.
  • An unexpectedly failed mirror is logged by a done callback and does not stop Studio.
  • Warmup is cancelled and awaited on shutdown so no task is left pending.
  • Owned launches stop before the scheduler. Resources the daemon did not launch are not claimed by this lifecycle.

The split is intentional: reconciliation changes the truth read by API handlers and therefore gates readiness; checkpointing is maintenance and does not.

D4 — Central local security and request boundary

The middleware execution order for an incoming request is fixed by Starlette's LIFO wrapping:

Host validation
  → CORS
    → JSON content-type / CSRF guard
      → optional bearer-token gate
        → route handler

The relevant configuration fields and defaults are:

STUDIO_PORT = int(env.get("LIONAGI_STUDIO_PORT", "8765"))
HOST = env.get("LIONAGI_STUDIO_HOST", "127.0.0.1")
CORS_ORIGINS = configured comma-separated origins or [
    "http://localhost:5173",
    "http://localhost:3000",
    "http://localhost:3765",
    "https://lion-studio.khive.ai",
]

Exact semantics:

  • Host validation runs first, including for preflight. It accepts localhost, 127.0.0.1, ::1, and a configured non-wildcard bind host. Malformed authorities, unlisted hosts, suffix tricks, and unbracketed IPv6 fail with HTTP 400.
  • CORS allowed methods are derived after all routes and optional mounts exist. OPTIONS is always added. This avoids silently omitting FastAPI-generated HEAD methods.
  • With no LIONAGI_STUDIO_AUTH_TOKEN, requests are not bearer-gated. Startup emits a warning, with a stronger warning for unauthenticated 0.0.0.0.
  • With a token, every /api and /api/* path plus /openapi.json, /docs, /redoc, and /docs/oauth2-redirect requires the exact Authorization: Bearer <token> value. Failure returns {"detail":"Unauthorized"} and HTTP 401.
  • /health remains public. Non-API GET and HEAD requests other than schema/docs remain public so the SPA document and hashed assets can load.
  • OPTIONS passes the inner guards because valid-host preflight is answered by CORS.
  • A non-GET/HEAD/OPTIONS /api request with a non-empty body must declare media type application/json; otherwise it returns HTTP 415. Bodyless mutation requests do not acquire a synthetic content-type requirement.
  • The rule is central. Individual services cannot opt out by omitting a dependency.

This is defense in depth for the shipped local daemon; it is not a claim that one static bearer secret supplies user-level authorization.

D5 — Endpoint-specific SSE over authenticated fetch

The shared transport helper is deliberately small:

SSE_HEADERS = {
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
    "X-Accel-Buffering": "no",
}

def sse_response(generator: AsyncGenerator[str]) -> StreamingResponse:
    return StreamingResponse(
        generator,
        media_type="text/event-stream",
        headers=SSE_HEADERS,
    )

All current producers emit unnamed frames of the form data: <JSON>\n\n, but their contracts are distinct:

Endpoint Cursor/source Poll/heartbeat Terminal and miss semantics
/api/sessions/{id}/stream local after_ts, messages after timestamp poll 0.5s; heartbeat after 5s idle preflight 404; done only after terminal status is stable more than 60s
/api/sessions/{id}/signals persisted per-session seq, starting at 0 rows limited to 500 per poll; poll 0.5s; heartbeat after 5s preflight 404; same 60s stable terminal test
/api/shows/{topic}/stream in-memory (mtime,size) map over sorted files scan every 0.5s; no heartbeat route preflight 404; generator emits done on invalid/missing directory or terminal DB status after 60s without file change
/api/leo/sessions/{id}/messages one in-memory turn, no replay cursor no heartbeat/reconnect state missing/expired session 404; concurrent turn 409; model error emits error then done; success emits effects, text, then done

The 0.5-second polls, 5-second heartbeats, and 60-second stability windows are shipped compatibility values. Their qualitative reasons are fast local feedback, keeping idle connections visibly alive, and avoiding closure on a transient terminal write. The source contains no measurement selecting the exact numbers. The signal batch cap of 500 bounds each database read while allowing replay to advance over repeated polls; no recorded measurement selects exactly 500.

Fetch-based consumers are required when bearer auth is enabled because native EventSource cannot attach the authorization header. There is no id: field, Last-Event-ID handling, universal cursor, common error envelope, or central replay log.

Consequences

  • The daemon has one enumerable composition root and one central request boundary. Adding a public service requires a decorated handler and a manifest decision.
  • API-only, daemon-hosted SPA, and Vite-development modes share the same FastAPI API.
  • Stateful readers see reconciled lifecycle data at readiness, while optional mirror and checkpoint failures degrade observability or maintenance rather than availability.
  • A contributor changing a route path, trailing slash, response model, or stream frame changes a compatibility surface consumed by both web and VS Code clients.
  • Reversing D1 requires rebuilding enumeration, ordering, and deduplication around another router composition mechanism. Reversing D4 requires a replacement central trust boundary; service-local checks are not equivalent.
  • Manual manifest maintenance can omit a service. The present dedup key also cannot reject two different handlers for the same method/path.
  • Tokenless mode is intentionally possible and becomes unsafe if the bind boundary expands.
  • Endpoint-specific SSE keeps each service simple but makes reconnect and terminal behavior client knowledge. A shared envelope is current debt, not a shipped guarantee.

Current-vs-ideal delta

# Delta Size Issue
1 Generate and verify the mounted Studio route manifest and OpenAPI snapshot in CI; fail when an intended public service is absent or an existing method/path changes without an explicit compatibility update. S (filled at issue-open time)
2 Define a shared SSE frame envelope, error frame, terminal frame, and reconnect-cursor rule for session, signal, show, and Leo streams while preserving endpoint-specific payloads. M (filled at issue-open time)
3 Decide whether filesystem show import is a browser-admin mutation or a CLI maintenance operation, then enforce one policy with matching authorization and confirmation behavior. S (filled at issue-open time)
4 Document and test API-only, daemon-hosted SPA, Vite development, and container/reverse-proxy modes against the same host, CORS, token, and API-base rules. S (filled at issue-open time)

Alternatives considered

One APIRouter per service

This would use familiar FastAPI composition and make each module mountable independently. It lost because the shipped services are already small, metadata-rich decorated handlers; adding routers would create a second inclusion and prefix mechanism without eliminating the need for an application-level manifest. It would also make current registry enumeration a migration requirement rather than a simplification.

Filesystem or package auto-discovery

Scanning every services/*.py module would prevent a forgotten manifest entry and ease extension. It lost because import becomes publication: a helper or unfinished service could be exposed merely by existing. Explicit publication is safer for a control plane, provided CI addresses omission risk.

Read-only Studio with mutations left to the CLI

This would narrow the browser trust boundary and preserve the early CLI-primary shape. It lost because launches, cancellation, definition and schedule mutation, approvals, show import, and maintenance are already shipped API responsibilities. Calling the daemon read-only would hide rather than remove those contracts.

Catch-all SPA route

/{full_path:path} is the conventional SPA fallback and is simpler than an exception handler. It lost because it captures /api paths before FastAPI can perform trailing-slash redirects. The 404 handler preserves route resolution and keeps API misses JSON.

Require bearer auth for every path

This would give one simple rule. It lost because browser document navigation and asset loads do not carry the runtime bearer header; protecting the shell would make authenticated mode unloadable. The chosen split keeps data under guarded /api while public static bytes contain no application state.

General Studio event bus and replay log

A central taxonomy could unify reconnect, observability, and cross-entity subscriptions. It would also require a durable event model, cursor policy, retention, and compatibility rules that no current cross-entity use case demonstrates. It lost to bounded per-endpoint streams; the smaller shared-envelope delta remains justified by the two current clients.

WebSocket as the default live transport

A duplex socket would support server and client events on one connection. Current session, signal, and show consumers are server-to-client only, and authenticated fetch already supports them. WebSocket therefore adds heartbeat, ticketing, and replay work without removing the need for ordinary HTTP commands. ADR-0083 may revisit transport only for a demonstrated operator-command need.