Spec: HTTP + WebSocket API

Purpose

This spec defines the operator-facing API surface of the kaged daemon: the HTTP endpoints, the WebSocket protocol, the authentication and authorization contract with the OAuth sidecar, and the warning-header contract for insecure modes.

This document is normative for:

  • Every route the daemon exposes and its protocol-level semantics.
  • The WebSocket connection lifecycle, channel semantics, ordering, reconnection, and backpressure rules.
  • The header contract between the daemon and any front-of-daemon proxy (sidecar, ingress).
  • The CSRF, content-type, and versioning contracts the web UI relies on.

Request/response shapes (JSON bodies, error envelopes, pagination envelopes, WS frame payloads) are normatively defined by @kaged/wire as Zod schemas (ADR-0039). This spec describes protocol behavior; @kaged/wire defines data shapes. When a shape is referenced in this spec's endpoint descriptions, the @kaged/wire schema is authoritative.

It is not normative for:

  • The session manager's internal state machine (that's session-manager.md).
  • The agent orchestration logic that processes a message (that's daemon.md).
  • The plugin host's stdio JSON-RPC protocol (that's plugin-host.md).
  • The DSL file format (that's project-dsl.md).
  • The UI's IA or screen inventory (that's ui/).
  • Wire shapes — request bodies, response bodies, error envelopes (that's @kaged/wire).

This spec defines the protocol of conversations between the operator's browser and the daemon. @kaged/wire defines the shapes exchanged during those conversations. The components above implement what happens.

Constraints (from ADRs)

Constraint Source
Web UI is the primary surface; HTTP+WS is the load-bearing transport ADR-0002
Runtime is Bun; server is Bun.serve() with built-in WebSocket support ADR-0004
The daemon does no OIDC; auth is the sidecar's job ADR-0007
Daemon trusts X-Kaged-User-Id + X-Kaged-Auth-Nonce headers; nothing else ADR-0007
--insecure flag bypasses auth entirely; X-Kaged-Warning header on every response in that mode ADR-0007 amendment
--no-sandbox flag adds a parallel X-Kaged-Warning: no-sandbox ADR-0009 amendment
CSRF protection on state-changing endpoints, independent of auth mode ADR-0007 amendment
Terminal is a PTY over WebSocket; xterm.js in the browser ADR-0002

Wire conventions

Versioning

  • API version in URL. All endpoints live under /api/v1/.... v2 will be a parallel /api/v2/... tree. Mixing versions on a request is not supported.
  • The daemon publishes its supported API versions at GET /api/versions (unauthenticated). v0 publishes only v1.
  • Breaking changes to v1 are not allowed once shipped. New optional fields, new endpoints, new event types are additive and do not bump the version. Removing a field, changing a type, or repurposing a route requires a v2 tree.

Content types

  • Requests: application/json; charset=utf-8 for JSON bodies. Other content types (text/plain for prompts, application/octet-stream for binary uploads) are documented per-endpoint and rare in v0.
  • Responses: application/json; charset=utf-8 for success and error bodies. The daemon never returns HTML to API clients — HTML is served only on UI routes (see UI routes).
  • WebSocket messages: UTF-8 text frames carrying JSON. Binary frames are reserved for PTY data (see PTY channel).

Identifiers

  • Project ID: the project slug from the DSL (a-z0-9-, validated by project-dsl.md). Used in URL paths.
  • Session ID: ULID. Sortable, opaque to the client, generated by the daemon.
  • Message ID, run ID, checkpoint ID, audit-event ID: ULIDs.
  • Subagent invocation ID: ULID. Distinct from the subagent's name (which is the slug from the DSL).

ULIDs are 26 characters, base32, sortable by creation time. They are not secrets. They are not sequential integers either — clients cannot enumerate them by incrementing.

Timestamps

  • Wire format: integer milliseconds since Unix epoch (UTC).
  • Field naming: created_at, updated_at, started_at, ended_at, etc. — verb in past tense + _at.
  • Rationale: unambiguous, monotonic-friendly, no timezone surprises. Matches the storage layer (ADR-0005).

Pagination

  • Cursor-based, not offset. Endpoints that paginate accept ?limit=N&cursor=<opaque> and return:
    {
      "items": [...],
      "next_cursor": "01HXAB...",
      "has_more": true
    }
    
  • Default limit: 50. Maximum: 200.
  • Cursors are opaque to the client. The daemon may change their internal format without bumping the API version.

Errors

All errors follow a single shape:

{
  "error": {
    "code": "snake_case_machine_readable",
    "message": "Human-readable, single sentence.",
    "details": { "...": "optional, endpoint-specific" },
    "request_id": "01HXAB..."
  }
}
  • code is stable across patch and minor releases. Clients may switch on it.
  • message is human-facing English. Not stable; do not match on it.
  • details is optional. Some endpoints add structured fields (e.g., DSL validation errors include file, line, column).
  • request_id is set by the daemon for correlation with audit-log entries. Always present.

Standard error codes:

HTTP Code Meaning
400 bad_request Generic client error. Use only when no more specific code applies.
400 validation_failed Body or query parameter failed schema validation. details carries the offending field paths.
422 invalid_input Body shape was valid, but a route rejected a semantically invalid value (for example an unsupported compaction override or oversized compaction notes).
401 unauthenticated Missing or invalid auth headers. (Never returned in --insecure mode.)
403 forbidden Authenticated but not permitted. v0 has only one user, so this is rare.
404 not_found Resource doesn't exist (or caller can't see it; same response either way).
409 conflict State conflict (e.g., session already attached to another connection).
410 gone Resource existed but was deleted.
422 dsl_invalid DSL validation failure with line/col detail in details.
429 rate_limited Daemon's own rate limit (rare in v0; reserved).
500 internal Daemon bug. Always logged with a stack trace; request_id correlates.
502 provider_unreachable An upstream (LLM provider, plugin) is down. details.provider names it.
503 unavailable Daemon is starting up, shutting down, or otherwise not ready.

The daemon does not return 5xx as a way to signal application-level failures (e.g., a subagent's task failed). That's a 200 OK with a structured state: "failed" in the body.

Auth-related details.reason values (ADR-0036)

Several auth/authorization failures carry a machine-readable details.reason so the UI can render the right surface. The HTTP status and code are as in the table above; reason disambiguates:

HTTP code details.reason Meaning
403 forbidden csrf_mismatch CSRF token header/cookie mismatch (pre-existing).
404 not_found sso_disabled POST /api/v1/auth/sso called while auth.sharedsso.enabled = false.
401 unauthenticated invalid_token SSO token failed any verification step. The response never says which step failed (sso-relay.md).
403 forbidden user_creation_disabled Unknown SSO subject + user_creation = "disabled". No row is created.
403 forbidden user_pending Known/just-provisioned subject whose status = 'pending' (awaiting operator activation).
403 forbidden user_disabled Subject's status = 'disabled'.

Authentication and authorization

Three auth modes

Per ADR-0007 amendments, the daemon runs in one of three auth modes:

Mode How identity is established Default for
sidecar X-Kaged-User-Id + X-Kaged-Auth-Nonce headers set by an upstream OAuth sidecar System-wide mode
loopback Per-startup nonce in a cookie (kaged_session); client gets the nonce via a one-time launch URL printed at daemon startup Per-user mode
insecure No checks — everything is accepted Explicit opt-in via --insecure

Internally, all three modes resolve to the same identity shape — X-Kaged-User-Id and friends — so downstream code paths never branch on mode. The auth gate at the request entry point is the only mode-aware layer.

Header contract (sidecar mode)

The daemon reads these incoming headers on every authenticated request:

Header Required Meaning
X-Kaged-User-Id Yes Operator identifier from the sidecar.
X-Kaged-User-Email No Display only.
X-Kaged-User-Groups No Reserved for v2 multi-operator. Ignored in v0.
X-Kaged-Auth-Nonce Yes Per-startup shared secret between sidecar and daemon.

In sidecar mode:

  • Requests missing X-Kaged-User-Id → 401 unauthenticated.
  • Requests with wrong or missing X-Kaged-Auth-Nonce → 401 unauthenticated. The daemon does not distinguish the two cases in the response (both look the same to the caller; the distinction is in the audit log).

Cookie contract (loopback mode)

In loopback mode (per-user default), no sidecar headers are required. Authentication is via a session cookie:

  • kaged_session cookie carries a token derived from the per-startup nonce.
  • The cookie is set when the operator visits /launch?token=<one-time-token> — the URL the daemon prints at startup.
  • The cookie has HttpOnly, SameSite=Lax, Secure=false (loopback only — there's no TLS).
  • Subsequent requests carry the cookie; the daemon validates it against the in-memory nonce.

The daemon synthesizes X-Kaged-User-Id = the OS username, X-Kaged-User-Email = <username>@localhost, X-Kaged-User-Groups = empty.

Loss of the cookie (browser cleared cookies, new browser) means the operator visits the launch URL again. The launch token is single-use; after consumption the daemon regenerates a new token and logs a fresh launch URL. The operator can also get a new token via kaged auth rotate or by restarting the daemon.

Insecure mode

In insecure mode (--insecure flag, see ADR-0007 amendment):

  • Both sidecar headers and the cookie are optional. Missing auth produces a synthetic identity (user_id: insecure-mode).
  • The daemon attaches X-Kaged-Warning: insecure-mode to every response.
  • Audit log records every request with auth_mode: insecure.

Outgoing headers (response)

The daemon sets these on every response:

Header Always Meaning
X-Kaged-Request-Id Yes Echoes the request_id used in audit logs.
X-Kaged-Daemon-Version Yes Semver of the running daemon.
X-Kaged-Warning Only in insecure modes One or more of insecure-mode, no-sandbox. Comma-separated if multiple.
Cache-Control: no-store On JSON endpoints Prevents cookie/auth replay from cache.

The X-Kaged-Warning header is the machine-readable counterpart to the UI banner and CLI warnings. Tools polling the daemon can detect the state. See ADR-0007 amendment for the operator-facing warning UX.

CSRF

State-changing endpoints (POST, PUT, PATCH, DELETE) require a CSRF token when authenticated via an ambient cookie. CSRF is about which page is making the call; it protects the cookie-bearing browser context.

  • Token issuance: the daemon issues a CSRF token in a cookie (kaged_csrf, SameSite=Lax, Path=/, HttpOnly=false so JS can read it) on every successful GET to /api/v1/me (the bootstrap call the UI makes on load).
  • Token submission: the client echoes the token in the X-Kaged-CSRF header on state-changing requests.
  • Validation: the daemon compares the header to the cookie. Mismatch → 403 forbidden with details.reason: "csrf_mismatch".
  • Lifetime: tokens are stable for the daemon's lifetime. They rotate on daemon restart.
  • WebSocket: the CSRF check applies to the initial HTTP upgrade; messages over an established WS do not re-verify.
  • Bearer exemption (ADR-0040): a request authenticated via the Bearer transport (transport === "bearer") is CSRF-exempt. There is no ambient cookie in that path, so there is nothing for a cross-site page to ride — the explicitly-sent Authorization header is itself the anti-CSRF property. The CSRF cookie/header are retained only for the same-origin cookie path. Insecure-mode behavior is unchanged (CSRF already waived).

The cookie follows the same attribute matrix as kaged_user_session (see Cookie attributes by deployment shape) — SameSite=Lax for loopback/same-origin, SameSite=None; Secure for cross-origin HTTPS — so the cookie actually reaches the daemon when the UI's bootstrap GET reads it back. SameSite=Strict remains unsupported for the same reason kaged_user_session doesn't offer it: it would break the SSO-callback redirect chain.

What the daemon does NOT validate

Per ADR-0007:

  • JWTs from the sidecar. The sidecar handled the OIDC dance.
  • Token expiry. The sidecar refreshes.
  • Group membership against an external directory. The sidecar attaches groups; the daemon trusts them.
  • The sidecar's identity. The nonce is the trust anchor. Anything reaching the daemon with the correct nonce IS the operator.

Narrow amendment (ADR-0036 §9): when auth.sharedsso.enabled = true, the daemon does verify ES256 token signatures from the configured SSO issuer, but only at the session-bootstrap endpoint (POST /api/v1/auth/sso). It still implements zero OAuth/OIDC flows — no redirects, token exchange, or refresh. The verification contract is in sso-relay.md.


Unified user identity (ADR-0036)

Per ADR-0036, the daemon resolves a single principal type for all non-ambient users (operators, members, guests) backed by the users table (see users.md). The ambient loopback/sidecar identities described above are unchanged and live outside that table.

The kaged_user_session cookie

A single cookie tracks all storage-backed user sessions (SSO-bootstrapped and password/invite-bootstrapped alike): kaged_user_sessionHttpOnly, Path=/, with SameSite and Secure chosen per the matrix below. This replaces kaged_guest_session. At migration, all sessions under the old cookie are invalidated; guests re-login once.

The kaged_session (loopback nonce) cookie and the sidecar header contract are untouched and continue to resolve to the ambient operator identity.

Cookie attributes by deployment shape

Attributes for kaged_user_session and kaged_csrf depend on whether the UI is served same-origin with the daemon or cross-origin. The daemon derives both flags at startup from the resolved ui.url (env KAGED_UI_URL) and daemon.public_url (env KAGED_PUBLIC_URL):

Deployment Detection kaged_user_session kaged_csrf
Loopback HTTP dev ui.url unset, or http: scheme HttpOnly; SameSite=Lax; Path=/ SameSite=Lax; Path=/
Same-origin HTTPS ui.url https: and same origin as daemon.public_url HttpOnly; Secure; SameSite=Lax; Path=/ Secure; SameSite=Lax; Path=/
Cross-origin HTTPS ui.url https: and different origin from daemon.public_url (or daemon.public_url unset) HttpOnly; Secure; SameSite=None; Path=/ Secure; SameSite=None; Path=/

The kaged_session loopback nonce cookie is unchanged by this matrix — it is only ever set in loopback mode over HTTP and keeps HttpOnly; SameSite=Lax; Path=/.

Why SameSite=None for cross-origin. <img src="…"> and new WebSocket(…) cannot carry an Authorization: Bearer header; they rely on the cookie being sent. On a cross-origin deployment (e.g. UI at ui.kaged.dev, daemon at silvs.kaged.dev), mobile browsers' ITP / tracking-prevention heuristics treat a SameSite=Lax cookie set via a CORS fetch response as third-party and purge or refuse it — so avatars 401 and WebSockets fail their upgrade even though the bearer-authenticated fetch() API path (which does not depend on the cookie) keeps working. SameSite=None; Secure is the spec-defined escape hatch; it requires HTTPS, which cross-origin production deployments already are.

Why Secure for same-origin HTTPS too. The pre-amendment spec said "Secure when served over TLS" but the code never set it; this amendment closes that drift. Secure is a hard prerequisite for SameSite=None and a defense-in-depth on SameSite=Lax HTTPS deployments.

SameSite=Strict not offered. Strict would block the cookie from being sent on the top-level redirect into the daemon's /launch and /auth/sso/callback flows, breaking both bootstraps. Lax (or None for cross-origin) is the floor.

session_token in the bootstrap response (ADR-0040). POST /api/v1/auth/sso returns, in addition to the cookie + csrf_token, a session_token of the form kaged.v1.us.<sessionId> — the bearer credential for the cross-origin UI. Its <sessionId> is the just-created user_sessions row id, so it shares that row's 30-day expiry and revocation. The response body becomes { "ok": true, "user": { … }, "csrf_token": "…", "session_token": "kaged.v1.us.…" }. The token is body-only and never logged. See Bearer transport.

Gate resolution order (exact, first match wins)

For each request the auth gate resolves identity in this order:

  1. Sidecar headers present and valid (mode sidecar) → ambient operator (role = operator), transport sidecar.
  2. kaged_session loopback nonce valid (mode loopback) → ambient operator, transport cookie.
  3. Authorization: Bearer <session_token> present (any mode) → resolved per Bearer transport, transport bearer. If the header is present but the token is malformed or invalid, the gate returns 401 unauthenticated and does not fall through to cookie resolution.
  4. kaged_user_session valid → load user row → require status = 'active' → principal {user_id, handle, display_name, role}, transport cookie.
  5. Mode insecure → synthesized ambient operator (see below), transport insecure.
  6. Otherwise → 401 unauthenticated.

Past the gate, every code path sees one identity shape (the synthesized X-Kaged-User-* header model, extended with role). Mode- and credential-awareness dies at the gate — the ADR-0007 invariant is preserved. No handler below the gate branches on how the principal authenticated. The resolved transport is visible only to the gate's CSRF decision (see CSRF); it is never passed to handlers.

Ordering note (ADR-0040). The Bearer path is inserted after sidecar/loopback ambient resolution but before the kaged_user_session cookie. Because the seed/same-origin co-located entry sends no Authorization header, steps 1–2 and 4–5 behave byte-identically to pre-ADR-0040 when no bearer token is present.

Bearer transport (ADR-0040)

Per ADR-0040, the daemon accepts a daemon-minted opaque session_token as Authorization: Bearer <token>, as a first-class identity path alongside cookies/sidecar. This is the transport the cross-origin UI uses (no cross-site cookies).

  • Format. Versioned, opaque to clients: kaged.v1.lb.<secret> (loopback transport) or kaged.v1.us.<sessionId> (SSO/user-session transport). The prefix is server-side routing only.
  • Resolution.
    • kaged.v1.lb.<secret><secret> is compared timing-safe against the in-process loopback session secret (the value behind the kaged_session cookie). Match → the ambient operator identity, identical to the loopback cookie path.
    • kaged.v1.us.<sessionId><sessionId> is looked up in user_sessions and subjected to that row's existing expiry/active-user/revocation rules → the same {user_id, handle, display_name, role} principal the kaged_user_session cookie would yield.
  • Scoping (cross-daemon rejection). A loopback token only matches the minting daemon's per-process secret; a user-session token only resolves against the minting daemon's local user_sessions table. A token minted by daemon A is therefore rejected by daemon B. This holds only while user_sessions is daemon-local.
  • TTL / lifetime. The loopback bearer is valid for the daemon process lifetime (a restart rotates the secret and invalidates it). The user-session bearer inherits the existing user_sessions.expiresAt (30 days). There is no separate refresh endpoint: on 401, the client re-runs /launch (loopback) or the SSO flow.
  • CSRF. A request authenticated via the bearer transport is CSRF-exempt (no ambient cookie is exercised). See CSRF.
  • Insecure mode. Inert: identity is header-derived and CSRF already waived, so Authorization neither helps nor harms.

--insecure interaction

insecure waives the ambient operator check only: a request with no user session resolves to the synthesized operator identity. A request carrying a valid kaged_user_session still resolves as that user with that user's role, and user credentials are still verified at login. Insecure means "I vouch for whoever reaches this port as me," not "everyone is everyone."

GET /api/v1/me response change

GET /api/v1/me gains role and the user's profile fields (handle, display_name, email, has_avatar). For ambient identities, role is "operator" and user_id is the ambient username; the shape is otherwise unchanged.


Route-scope authorization (ADR-0036 §7)

Authorization is no longer implicit in which cookie reached which path. Every route carries an access annotation; the gate enforces it after identity resolution.

The annotation

RouteDefinition (packages/daemon/src/api/routes.ts) gains a REQUIRED access field:

access:
  | "system"        // role === 'operator'. Full stop.
  | "project"       // operator bypasses; member requires a project_user_grants row
                    //   (permission_set = 'member') on the RESOLVED project. Guests: 403.
  | "guest-realm"   // any active user; project-scoped entries within /g/ additionally
                    //   require a grant on the resolved project (operator bypasses,
                    //   member's 'member' grant qualifies, guest's grant per its permission_set).
  | "account"       // any active user (or ambient operator); operates on SELF only.
  | "public";       // no auth.

Default-deny (load-bearing)

A route without an access annotation is treated as system. Enforced two ways:

  1. The TypeScript type makes access required (compile-time completeness).
  2. The gate applies system at runtime to any route object lacking access (drift, codegen, dynamic routes). A startup self-check logs every route and its resolved scope; any route that fell through to the default logs at warn.

A forgotten annotation can only ever over-restrict. New endpoints fail closed.

Project resolution registry

project (and project-scoped guest-realm) routes whose path lacks :id-as-a-project (sessions, runs, checkpoints, issues by id, workflow invocations, task instances, todos) must resolve the owning project in the gate, before the handler runs. A single exhaustive PROJECT_RESOLVERS registry maps path shape → resolver. Adding a project-scoped route without a resolver entry is a startup error, not a silent pass. A resolver returning null (resource not found) → 404 before any grant logic, preserving "404 whether it doesn't exist or you can't see it."

Streams are routes. SSE and WebSocket endpoints carry access annotations and pass the same gate at connection time. A member may attach to a session stream in a granted project; may not attach to a project-log stream of an ungranted project; may never attach to global log/system streams (system).

Filtering vs gating

Endpoints that list across projects are filtered, not gated:

  • GET /api/v1/projects → operators: all; members: only projects with a grant. (Guests use /api/v1/g/projects.)
  • A member must never learn an ungranted project exists — not its id, label, or count.

Exhaustive route classification (the Phase 3 implementation contract)

Every existing route gets exactly one access value. This table is normative; the route table in routes.ts must match it. (csrf and authenticated columns are unchanged from prior behavior and omitted here for brevity; publicauthenticated: false.)

Method Path access
GET /healthz public
GET /readyz public
GET /api/versions public
GET /api/v1/launch public
GET /api/v1/me account
GET /api/v1/status system
GET /api/v1/audit system
GET /api/v1/logs system
POST /api/v1/dsl/validate system
GET /api/v1/dsl/schema public
GET /api/v1/projects project (filtered list — see "Filtering vs gating")
POST /api/v1/projects/load system
GET /api/v1/system/directories system
GET /api/v1/projects/:slug project
PUT /api/v1/projects/:slug system
DELETE /api/v1/projects/:slug system
GET /api/v1/projects/:slug/status project
POST /api/v1/projects/:slug/reload system
GET /api/v1/projects/:slug/unresolved project
GET /api/v1/projects/:slug/capabilities project
GET /api/v1/projects/:slug/dsl project
PUT /api/v1/projects/:slug/dsl project
GET /api/v1/projects/:slug/dsl/synthesized project
GET /api/v1/projects/:slug/prompts project
GET /api/v1/projects/:slug/prompts/:name project
PUT /api/v1/projects/:slug/prompts/:name project
GET /api/v1/projects/:slug/files project
GET /api/v1/projects/:slug/files/:path project
PUT /api/v1/projects/:slug/files/:path project
GET /api/v1/projects/:slug/lsp/socket project
GET /api/v1/projects/:slug/sessions project
POST /api/v1/projects/:slug/sessions project
POST /api/v1/projects/:slug/subagents/init project
GET /api/v1/sessions/:id project
PUT /api/v1/sessions/:id project
DELETE /api/v1/sessions/:id project
GET /api/v1/sessions/:id/messages project
POST /api/v1/sessions/:id/messages project
PATCH /api/v1/sessions/:id/messages/:mid project
GET /api/v1/sessions/:id/checkpoints project
POST /api/v1/sessions/:id/checkpoints project
GET /api/v1/sessions/:id/checkpoints/:cid project
POST /api/v1/sessions/:id/checkpoints/:cid/resume project
POST /api/v1/sessions/:id/checkpoints/:cid/rollback project
GET /api/v1/sessions/:id/runs project
GET /api/v1/sessions/:id/runs/:rid project
POST /api/v1/sessions/:id/runs/:rid/cancel project
POST /api/v1/sessions/:id/resume project
DELETE /api/v1/sessions/:id/queued-message project
POST /api/v1/sessions/:id/interrupt project
PUT /api/v1/sessions/:id/bind project
DELETE /api/v1/sessions/:id/bind project
POST /api/v1/sessions/:id/compact project
GET /api/v1/sessions/:id/context-estimate project
GET /api/v1/sessions/:id/compactions project
GET /api/v1/sessions/:id/compactions/:cid project
PATCH /api/v1/sessions/:id/compactions/:cid project
GET /api/v1/sessions/:id/logs project
GET /api/v1/sessions/:id/socket project
GET /api/v1/socket system
GET /api/v1/projects/:slug/logs project
GET /api/v1/projects/:slug/logs/stream project
GET /api/v1/local/aliases system
PUT /api/v1/local/aliases/:name system
DELETE /api/v1/local/aliases/:name system
GET /api/v1/local/providers system
PUT /api/v1/local/providers/:name system
PUT /api/v1/local/providers/:name/custom system
DELETE /api/v1/local/providers/:name system
POST /api/v1/local/providers/:name/test system
GET /api/v1/local/providers/:name/models system
POST /api/v1/local/providers/:name/models/discover system
PUT /api/v1/local/providers/:name/models system
GET /api/v1/local/catalog system
POST /api/v1/local/catalog/sync system
POST /api/v1/local/catalog/sync/apply system
GET /api/v1/local/providers/:name/models/:modelId/meta system
PUT /api/v1/local/providers/:name/models/:modelId/overrides system
DELETE /api/v1/local/providers/:name/models/:modelId/overrides system
DELETE /api/v1/local/providers/:name/models/:modelId/overrides/:field system
GET /api/v1/local/providers/:name/usage system
POST /api/v1/local/providers/:name/usage/refresh system
GET /api/v1/local/providers/:name/spend-limits system
PUT /api/v1/local/providers/:name/spend-limits system
POST /api/v1/local/providers/antigravity/auth/login system
GET /api/v1/local/providers/antigravity/auth/status system
POST /api/v1/local/providers/antigravity/auth/logout system
POST /api/v1/local/providers/:name/auth/login system
GET /api/v1/local/providers/:name/auth/status system
POST /api/v1/local/providers/:name/auth/logout system
GET /api/v1/local/preferences system
PUT /api/v1/local/preferences system
GET /api/v1/plugins system
GET /api/v1/plugins/:name system
POST /api/v1/plugins/install system
POST /api/v1/plugins/install/consent system
POST /api/v1/plugins/:name/enable system
POST /api/v1/plugins/:name/disable system
POST /api/v1/plugins/:name/promote system
GET /api/v1/plugins/:name/config system
GET /api/v1/plugins/:name/knobs system
PUT /api/v1/plugins/:name/config system
PUT /api/v1/plugins/:name/system-config system
DELETE /api/v1/plugins/:name system
GET /api/v1/projects/:slug/tasks project
POST /api/v1/projects/:slug/tasks/run project
GET /api/v1/projects/:slug/tasks/instances project
POST /api/v1/projects/:slug/tasks/cleanup project
GET /api/v1/projects/:slug/tasks/socket project
GET /api/v1/tasks/:tid project
POST /api/v1/tasks/:tid/stop project
POST /api/v1/tasks/:tid/restart project
DELETE /api/v1/tasks/:tid project
GET /api/v1/projects/:slug/workflows project
GET /api/v1/projects/:slug/workflows/:name project
POST /api/v1/projects/:slug/workflows/:name/upload project
POST /api/v1/projects/:slug/workflows/:name/invoke project
GET /api/v1/projects/:slug/workflows/:name/runs project
GET /api/v1/projects/:slug/workflows/invocations/:iid project
POST /api/v1/projects/:slug/workflows/invocations/:iid/confirm project
POST /api/v1/projects/:slug/workflows/invocations/:iid/cancel project
GET /api/v1/projects/:slug/issues project
POST /api/v1/projects/:slug/issues project
GET /api/v1/projects/:slug/issues/:number project
PATCH /api/v1/projects/:slug/issues/:number project
PATCH /api/v1/projects/:slug/issues/by-id/:issueId project
POST /api/v1/projects/:slug/issues/:number/updates project
GET /api/v1/projects/:slug/issues/:number/todos project
GET /api/v1/projects/:slug/issues/:number/links project
GET /api/v1/users system
POST /api/v1/users system
GET /api/v1/users/:uid system
PATCH /api/v1/users/:uid system
DELETE /api/v1/users/:uid system
POST /api/v1/users/:uid/reinvite system
POST /api/v1/users/:uid/unlock system
GET /api/v1/users/:uid/grants system
GET /api/v1/users/:uid/activity system
GET /api/v1/users/:uid/avatar account
GET /api/v1/users/lookup account
GET /api/v1/projects/:slug/users system
POST /api/v1/projects/:slug/users system
PUT /api/v1/projects/:slug/users/:uid system
DELETE /api/v1/projects/:slug/users/:uid system
GET /api/v1/account account
PATCH /api/v1/account account
POST /api/v1/account/password account
GET /api/v1/auth/methods public
POST /api/v1/auth/sso public
POST /api/v1/auth/logout account
GET /api/v1/g/setup/validate public
POST /api/v1/g/setup public
POST /api/v1/g/login public
GET /api/v1/g/me guest-realm
POST /api/v1/g/logout account (alias of /api/v1/auth/logout for one release)
POST /api/v1/g/account/password account
GET /api/v1/g/projects guest-realm (filtered to grants)
GET /api/v1/g/projects/:slug guest-realm (grant required on :id)
GET /api/v1/g/projects/:slug/issues guest-realm (grant required)
POST /api/v1/g/projects/:slug/issues guest-realm (grant required)
GET /api/v1/g/projects/:slug/issues/:number guest-realm (grant required)
POST /api/v1/g/projects/:slug/issues/:number/updates guest-realm (grant required)
GET /api/v1/g/projects/:slug/workflows guest-realm (grant required)
GET /api/v1/g/projects/:slug/workflows/:name guest-realm (grant required)
POST /api/v1/g/projects/:slug/workflows/:name/upload guest-realm (grant required)
POST /api/v1/g/projects/:slug/workflows/:name/invoke guest-realm (grant required)
GET /api/v1/g/projects/:slug/workflows/:name/runs guest-realm (grant required)
POST /api/v1/g/workflows/invocations/:iid/confirm guest-realm (grant on resolved project)
POST /api/v1/g/workflows/invocations/:iid/cancel guest-realm (grant on resolved project)
GET /api/v1/g/workflows/invocations/:iid guest-realm (grant on resolved project)
GET /api/v1/g/sessions/:id/socket guest-realm (grant on resolved project)

Migration note: the /api/v1/guests* and /api/v1/projects/:slug/guests* admin routes are renamed to /api/v1/users* and /api/v1/projects/:slug/users* (all system). The guest-facing /api/v1/g/* paths keep their names. The old /api/v1/g/logout aliases the new /api/v1/auth/logout for one release.


URL structure

Top level

/                              → UI (HTML)
/static/*                      → UI static assets
/api/versions                  → version manifest (unauthenticated)
/api/v1/...                    → v1 API (authenticated unless noted)
/healthz                       → liveness probe (unauthenticated)
/readyz                        → readiness probe (unauthenticated)

v1 surface, by resource

/api/v1/me                              → current principal (incl. role) + daemon mode
/api/v1/auth/methods                    → available login methods (public)
/api/v1/auth/sso                        → verify SSO token, run TOFU lifecycle, mint session (public, POST)
/api/v1/auth/logout                     → delete caller's user session (account, POST)
/api/v1/account                         → own profile (get, patch)
/api/v1/account/password                → set/change own password (account, POST)
/api/v1/users                           → user administration (operators only; list, create)
/api/v1/users/:uid                      → user (get, patch=activate/elevate/disable, delete)
/api/v1/users/:uid/reinvite             → invalidate sessions + new invite URL (post)
/api/v1/users/:uid/unlock               → unlock account (post)
/api/v1/users/:uid/grants               → user's project grants (get)
/api/v1/users/:uid/activity             → user activity feed (get)
/api/v1/users/:uid/avatar               → render a user's avatar (account)
/api/v1/users/lookup                    → batched created_by resolution (account)
/api/v1/projects/:slug/users              → project grant management (operators only)
/api/v1/projects/:slug/users/:uid         → grant (put=replace, delete=revoke)
/api/v1/projects                        → projects in operator's registry (list)
/api/v1/projects/load                   → load a project from a directory (POST)
/api/v1/system/directories               → list host directories for the folder picker (GET)
/api/v1/projects/:slug                    → project (get, delete from registry)
/api/v1/projects/:slug/status             → project status telemetry
/api/v1/projects/:slug/reload             → re-read DSL from disk and re-evaluate state
/api/v1/projects/:slug/unresolved         → list of unresolved aliases/plugins/prompts
/api/v1/projects/:slug/dsl                → DSL file (get, put)
/api/v1/projects/:slug/dsl/synthesized     → merged DSL after overlay (get)
/api/v1/projects/:slug/subagents/init      → initialize a subagent project dir (post)
/api/v1/projects/:slug/prompts            → prompts (list)
/api/v1/projects/:slug/prompts/:name      → prompt (get, put, history)
/api/v1/projects/:slug/logs/stream        → project log SSE stream (get)
/api/v1/projects/:slug/sessions           → sessions (list, create)
/api/v1/sessions/:id                    → session (get, update, delete)
/api/v1/sessions/:id/messages           → messages (list, post)
/api/v1/sessions/:id/checkpoints        → checkpoints (list, post)
/api/v1/sessions/:id/checkpoints/:cid   → checkpoint (get, resume, rollback)
/api/v1/sessions/:id/runs               → agent runs (list)
/api/v1/sessions/:id/runs/:rid          → run (get, cancel)
/api/v1/sessions/:id/context-estimate   → live context usage estimate for the session
/api/v1/sessions/:id/socket             → WebSocket upgrade (the multiplex)
/api/v1/sessions/:id/resume             → resume a queued session (post)
/api/v1/sessions/:id/queued-message      → discard a queued message (delete)
/api/v1/socket                          → system WebSocket upgrade (global events)
/api/v1/local/aliases                   → operator's model aliases (list, set)
/api/v1/local/aliases/:name             → individual alias (get, put, delete)
/api/v1/local/providers                 → configured LLM providers (list)
/api/v1/local/providers/:name           → provider (get, put, keys redacted in get)
/api/v1/local/providers/:name/custom    → add custom provider (put)
/api/v1/local/providers/:name/models    → provider's persisted model catalog (get, put)
/api/v1/local/providers/:name/models/discover → discover models for custom provider (post)
/api/v1/local/catalog                   → get bundled catalog snapshot (get)
/api/v1/local/catalog/sync              → fetch latest catalog diff (post)
/api/v1/local/catalog/sync/apply        → apply catalog sync with keep decisions (post)
/api/v1/local/providers/:name/models/:modelId/meta → merged model metadata (defaults + overrides) (get)
/api/v1/local/providers/:name/models/:modelId/overrides → model metadata overrides (put, delete)
/api/v1/local/providers/:name/models/:modelId/overrides/:field → single override field (delete)
/api/v1/local/providers/:name/usage → provider usage report, cached (get)
/api/v1/local/providers/:name/usage/refresh → force fresh usage fetch (post)
/api/v1/local/providers/:name/spend-limits → per-provider spend limits (get, put)
/api/v1/local/providers/:name/auth/login   → initiate provider OAuth flow (post)
/api/v1/local/providers/:name/auth/status   → provider auth status (get)
/api/v1/local/providers/:name/auth/logout    → delete provider tokens (post)
/api/v1/local/preferences               → operator UI preferences (get, put)
/api/v1/plugins                         → installed plugins (list)
/api/v1/plugins/:name                   → plugin (get, enable, disable, configure)
/api/v1/plugins/:name/knobs             → plugin knob schema (ADR-0024) (get)
/api/v1/plugins/install                 → install a plugin (POST, returns prompt-state)
/api/v1/plugins/:name/promote           → promote project-scoped plugin to local-scope
/api/v1/projects/:slug/plugins           → resolved per-agent plugin map (get)
/api/v1/projects/:slug/capabilities      → compiled cage capabilities (get)
/api/v1/projects/:slug/workflows          → workflow catalog (list)
/api/v1/projects/:slug/workflows/:name    → workflow (describe, invoke, upload, runs)
/api/v1/projects/:slug/workflows/invocations/:iid → workflow invocation (get, confirm, cancel)
/api/v1/sessions/:id/compact            → manually trigger compaction (post)
/api/v1/sessions/:id/compactions        → compaction history (list)
/api/v1/sessions/:id/compactions/:cid   → compaction event (get, patch — operator feedback)
/api/v1/audit                           → audit log (query)
/api/v1/dsl/validate                    → standalone DSL validation
/api/v1/dsl/schema                      → published JSON Schema
/api/v1/launch                          → loopback-mode one-time launch endpoint
/api/v1/g/projects/:slug/workflows        → guest workflow catalog (list)
/api/v1/g/projects/:slug/workflows/:name  → guest workflow (describe, invoke, upload, runs)
/api/v1/g/workflows/invocations/:iid    → guest workflow invocation (get, confirm, cancel)
/api/v1/g/sessions/:id/socket           → guest workflow session WebSocket

Sessions are reached by ID after creation. The project a session belongs to is in its body; the daemon does not require clients to thread the project ID through every session URL.


Endpoints

Service endpoints

GET /healthz (unauthenticated)

Liveness probe. Returns 200 with body {"status": "ok"} if the daemon process is alive. Does not check storage, plugins, or LLM reachability.

GET /readyz (unauthenticated)

Readiness probe. Returns 200 with {"status": "ready"} only if:

  • Storage is reachable.
  • The sidecar nonce is configured (or --insecure is set).
  • The daemon's startup migration has completed.

Returns 503 with {"status": "starting"} otherwise.

GET /api/versions (unauthenticated)

{
  "versions": ["v1"],
  "current": "v1",
  "daemon_version": "0.1.0"
}

GET /api/v1/launch (unauthenticated, loopback mode only)

The one-time launch endpoint that sets the session cookie in loopback mode (per ADR-0007 amendment). The daemon prints a launch URL at startup; the operator opens it in their browser.

The launch URL points at the UI base URL, not the daemon's bind address. Pre-ADR-0040 it was {ui_base_url}/launch?token=<one-time-token>. Per ADR-0040 the printed loopback launch URL is {ui_base_url}/connect?api={public_url}/api/v1&token=<one-time-token> so a single URL both registers the daemon in the UI's registry and authenticates it. ui_base_url resolves from KAGED_UI_URL env var > ui.url config > http://{daemon_bind}, and public_url from daemon.public_url / KAGED_PUBLIC_URL (see daemon.md Configuration). The endpoint path itself is unchanged (GET /api/v1/launch?token=…); only the printed bootstrap URL changed.

Content negotiation. The endpoint supports two response modes based on the Accept header:

  • Browser mode (default — Accept is absent, */*, or text/html): sets cookies and returns a 302 redirect to /. This is the flow when the operator clicks the launch URL directly against the daemon (co-located).
  • API mode (Accept: application/json): sets cookies (co-located) and returns 200 with a JSON body that now also carries a session_token (per ADR-0040). This is the flow /connect uses: the UI stores session_token on the daemon's registry entry and uses it as the Authorization: Bearer credential for all subsequent requests to that daemon.

The endpoint:

  1. Validates the token query parameter against the current launch token.
  2. On success:
    • Sets kaged_session cookie and CSRF cookie (co-located cookie path).
    • The one-time token is invalidated and a new token is generated immediately — the daemon logs a fresh launch URL to the operational log.
    • Browser mode: returns 302 with Location: /.
    • API mode: returns 200 with body:
      {
        "ok": true,
        "csrf_token": "01HXAB...",
        "session_token": "kaged.v1.lb.9f3c..."
      }
      
      session_token is the bearer credential for this daemon (a kaged.v1.lb.<secret> loopback token; see Bearer transport). It is body-only — never set in a URL or logged.
  3. On failure: 401 unauthenticated with details.reason: "invalid_launch_token" (same shape in both modes — errors are always JSON).

In sidecar or insecure mode this endpoint returns 404.

GET /api/v1/me (authenticated)

Returns the current operator, the daemon's mode, and a freshly-issued CSRF token (also set as a cookie).

{
"user_id": "operator",
"email": "operator@localhost",
  "groups": [],
"operator_name": "operator",
  "daemon": {
    "version": "0.1.0",
    "deployment_mode": "user",
    "auth_mode": "loopback",
    "sandbox_mode": "enabled",
    "warnings": []
  },
  "preferences": {
    "theme": "dark",
    "timezone": "Europe/Budapest",
    "locale": "en-US"
  },
  "csrf_token": "01HXAB..."
}
  • deployment_mode is "user" or "system" (per ADR-0010).
  • auth_mode is "loopback", "sidecar", or "insecure" (per ADR-0007 amendments).
  • sandbox_mode is "enabled" or "disabled".
  • warnings is populated when any insecure mode is active. Possible values: "insecure-mode", "no-sandbox".
  • operator_name comes from the operator's local config; falls back to user_id if unset.
  • preferences is the operator's UI preferences from local config.

In insecure mode, auth_mode is "insecure" and warnings includes "insecure-mode". Same shape, values change.

Projects

GET /api/v1/projects

List projects in the calling operator's registry (per local-config.md projects). Paginated.

{
  "items": [
    {
      "id": "music-site",
"path": "/home/operator/projects/music-site",
      "label": null,
      "description": "...",
      "last_opened_at": 1716300000000,
      "state": "ready",
      "session_count": 3
    }
  ],
  "next_cursor": null,
  "has_more": false
}

state is one of:

  • ready — every alias resolved, every required plugin installed, all prompt files present. Sessions can start.
  • pending — DSL is valid, but at least one alias is unbound, plugin missing, or prompt file missing. Resolution via local-config edits or plugin install. Sessions refuse to start.
  • invalid — DSL fails validation. Fix the DSL on disk and call POST /reload.

See local-config.md Status states for the state machine.

POST /api/v1/projects/load

Register a project with the calling operator. The project must already exist on disk; this endpoint reads its DSL, resolves it against the operator's local config, and adds it to the registry.

Request:

{
"path": "/home/operator/projects/music-site"
}
  • path (string, required) — absolute path to the project directory. Must contain .kaged/project.yaml.

The project's display name (label) is set after load via PUT /api/v1/projects/:slug; it is not supplied at load time, by design. This keeps the load step idempotent and project-rename a deliberate operator action.

Response (201, ready):

{
  "id": "music-site",
"path": "/home/operator/projects/music-site",
  "state": "ready",
  "registered_at": 1716300000000
}

Response (200, pending — note the differentiation; project loaded successfully but needs operator action):

{
  "id": "music-site",
"path": "/home/operator/projects/music-site",
  "state": "pending",
  "registered_at": 1716300000000,
  "unresolved": {
    "aliases": [
      { "name": "smart-generalist", "used_by": ["primary"] },
      { "name": "low-cost-coder", "used_by": ["primary.subagents.scraper", "primary.subagents.writer"] }
    ],
    "plugins": [
      {
        "name": "memory",
        "package": "@kaged/plugin-memory-markdown",
        "source": "project:/plugins/memory-markdown",
        "status": "missing"
      }
    ],
    "prompts": [
      { "path": "prompts/deployer.md", "used_by": ["primary.subagents.deployer.system_prompt"] }
    ]
  }
}

Response (422, DSL invalid):

{
  "error": {
    "code": "dsl_invalid",
    "message": "Validation failed.",
    "details": {
      "errors": [
        { "path": "primary.subagents.scraper.model", "line": 22, "column": 5, "reason": "..." }
      ]
    },
    "request_id": "01HXAB..."
  }
}

Response (404, no DSL at path):

{
  "error": {
    "code": "not_found",
    "message": ".kaged/project.yaml not found at the given path.",
"details": { "path": "/home/operator/projects/music-site" },
    "request_id": "01HXAB..."
  }
}

Response (409, project ID collision — the path's DSL declares an ID already registered by this operator from a different path):

{
  "error": {
    "code": "conflict",
    "message": "A different project at /other/path is already registered with this ID.",
    "details": { "existing_path": "/other/path", "incoming_id": "music-site" },
    "request_id": "01HXAB..."
  }
}

GET /api/v1/system/directories

List directories on the host filesystem for the Load project folder picker. Operator-only (system access). Files are never returned; only directories are included, so the UI can present a VSCode-style Open Folder experience.

Query parameters:

Parameter Type Default Description
path string (see below) Absolute directory path to list.
showHidden "true" | "false" "true" Include directories whose names start with .. The folder picker always shows hidden directories, so the default is true.

When path is omitted, the daemon defaults to:

  • deployment_mode === "user": the running user's home directory (os.homedir()).
  • deployment_mode === "system": the host root (/).

Response:

{
  "items": [
    { "name": "projects", "path": "/home/operator/projects" },
    { "name": "dotfiles", "path": "/home/operator/.dotfiles" }
  ]
}
  • name (string) — the directory's basename.
  • path (string) — the absolute, normalized path.

Errors:

  • 400 bad_requestpath is not an absolute path, or it is not a directory, or it points outside the filesystem roots the daemon is permitted to access.
  • 404 not_found — the directory does not exist or cannot be read.

GET /api/v1/projects/:slug

Returns the registered project, its DSL summary, the resolved aliases (with their bindings as the operator has them), the active plugin set, and the project state.

{
  "id": "music-site",
"path": "/home/operator/projects/music-site",
  "label": "Music Site",
  "description": "...",
  "state": "ready",
  "registered_at": 1716200000000,
  "last_opened_at": 1716300000000,
  "dsl_version": 1,
  "resolved_aliases": {
    "smart-generalist": "claude:sonnet-4.6",
    "low-cost-coder": "claude:haiku"
  },
  "active_plugins": [
    { "name": "oh-my-pi", "version": "1.4.2", "scope": "project" }
  ],
  "session_count": 3
}

GET /api/v1/projects/:slug/status

Returns aggregate telemetry for the project's sessions and recent runs. Used by the Project Status screen to render live counters without reconstructing them client-side from multiple endpoint families.

{
  "sessions": {
    "running": 2,
    "idle": 1,
    "paused": 0,
    "ended": 4,
    "total": 7
  },
  "activity": {
    "live_subagents": 3,
    "tool_calls_24h": 18,
    "budget_24h": {
      "total_cost": 0.0375,
      "total_tokens_in": 15230,
      "total_tokens_out": 2488
    }
  },
  "recent_runs": [
    {
      "id": "01HXAB...",
      "session_id": "01HXAB...",
      "state": "completed",
      "duration_ms": 4821,
      "tokens_in": 1200,
      "tokens_out": 210,
      "cost_total": 0.0042,
      "created_at": 1716300000000
    }
  ]
}

Behavior:

  • Validates that the project is registered and loaded before querying telemetry. Unknown project ID returns 404 not_found.
  • sessions is derived from session counts grouped by state for sessions.project_id = :id. total is the sum across running, idle, paused, and ended.
  • activity.live_subagents is the current count of live subagents across the project's non-ended sessions. v0 computes it from daemon runtime state when available and otherwise returns 0.
  • activity.tool_calls_24h is the 24-hour assistant-activity proxy: the count of messages.role = "primary" rows for project sessions whose created_at >= now - 86_400_000.
  • activity.budget_24h is the 24-hour aggregate budget view for project runs whose created_at >= now - 86_400_000: SUM(r.tokens_in), SUM(r.tokens_out), and SUM(messages.cost_total) across sessions in the project. Empty sums return 0.
  • recent_runs is the 10 most recent runs for the project, ordered by runs.created_at DESC. cost_total is the per-run aggregate cost derived from messages associated with that run; when no cost-bearing messages exist, it is null.

Error responses: 404 (project not registered), 503 (storage unavailable).

PUT /api/v1/projects/:slug

Update the operator-editable project metadata. Currently only the display label is editable; the project id is immutable (it is the DSL key, used as the foreign key on sessions.project_id).

Request:

{
  "label": "Music Site"
}
  • label (string | null, required) — the new display name. Trimmed on save. Pass null or empty string to clear the label (UI then falls back to id). Maximum 80 characters after trimming.

Behavior:

  • Writes through to [[projects]] in local.toml. Never touches the database.
  • Drops any legacy nickname value from the on-disk entry on first save (manual re-entry semantics — operators must deliberately set label; legacy nicknames are not auto-migrated).
  • Preserves accent_color, path, status, and last_opened_at unchanged.

Response (200):

{
  "id": "music-site",
"path": "/home/operator/projects/music-site",
  "label": "Music Site",
  "description": "",
  "state": "ready",
  "last_opened_at": 1716300000000,
  "session_count": 0
}

Error responses: 400 (invalid body / type / length > 80), 404 (project not registered), 503 (no local-config path bound).

POST /api/v1/projects/:slug/reload

Re-read the DSL from disk and re-evaluate state. Used when the operator has edited the DSL outside of kaged (e.g., via their editor). Returns the same shape as POST /api/v1/projects/load.

GET /api/v1/projects/:slug/unresolved

Returns the current unresolved items for a pending project. Shape identical to the unresolved block in the load response. Returns 200 with an empty unresolved block for ready projects.

GET /api/v1/projects/:slug/capabilities

Returns the loaded project's effective cage capabilities derived from the compiled DSL. The daemon reads the compiled project graph for the registered project, uses the primary agent's compiled cagePolicy, and returns the resolved root tool permissions from the same compilation pass.

{
  "filesystem": {
    "mode": "isolated",
    "mounts": ["project:/src", "project:/.kaged"]
  },
  "network": {
    "mode": "isolated",
    "allowlist": ["github.com", "api.openai.com"]
  },
  "tools": {
    "enabled": ["file.read", "search.grep"],
    "disabled": ["file.write", "shell.exec"]
  }
}
  • filesystem.mode is "isolated" when the compiled cage exposes any filesystem mounts; otherwise "disabled".
  • filesystem.mounts is the compiled mount allowlist in wire order. Empty when disabled.
  • network.mode is "isolated" when the compiled cage exposes any network allowlist entries; otherwise "disabled".
  • network.allowlist is the compiled host allowlist in wire order. Empty when disabled.
  • tools.enabled is the effective root tool list after operator + project overrides have been applied during compilation.
  • tools.disabled is the complement against the daemon's canonical root-tool catalog for the same compilation pass.

Returns 404 not_found if the project is not registered.

DELETE /api/v1/projects/:slug

Removes the project from the operator's registry and ends any active sessions for it. Does not delete files on disk. Requires ?confirm=true query parameter.

Audit log retains the unregistration event indefinitely.

To delete actual files, the operator uses their normal filesystem tools — kaged is not a file manager.

GET /api/v1/projects/:slug/dsl

Returns the raw DSL file (text/plain) and metadata.

{
  "dsl": "version: 1\n...",
  "dsl_status": "valid",
  "schema_version": 1,
  "validated_at": 1716300000000
}

PUT /api/v1/projects/:slug/dsl

Replace the project's DSL. Validates before persisting — invalid DSL is rejected without saving.

Request:

{ "dsl": "version: 1\n..." }
  • dsl (string, required) — the full YAML content of project.yaml.

Response (200, valid and saved):

{
  "valid": true,
  "diagnostics": [],
  "warnings": [...],
  "cross_ref_errors": [...],
  "saved": true
}

Response (422, DSL invalid — not saved):

{
  "valid": false,
  "diagnostics": [
    { "kind": "schema_violation", "message": "...", "line": 14, "col": 9 }
  ],
  "warnings": [],
  "cross_ref_errors": [],
  "saved": false
}

On successful save, the project's status in the local registry is updated to ready.

Error responses: 400 (missing dsl field, invalid JSON), 404 (project not registered), 503 (no local-config path).

GET /api/v1/projects/:slug/dsl/synthesized

Returns the fully compiled DSL: project.yaml + project.local.yaml overlay merged (per ADR-0015), with every project-reference subagent (per project-dsl.md § Project-reference subagents) resolved recursively into uniform AgentSpec subtrees (per ADR-0022). All per-agent tools: overrides are applied, all role-based defaults materialized, and no path: or wrapper fields remain. The response includes resolved_tools — the effective root-agent tool list after applying operator-level (default_tools) and project-level (primary.tools) override layers against the canonical DEFAULT_ROOT_TOOLS set. Read-only; used by the UI's "Synthesized Config" tab as the single source of truth for what the daemon will actually execute.

The compilation pass:

  1. Applies the local .yaml overlay to the project's own project.yaml.
  2. For each AgentSpec in the recursive subagents tree with a path: field (project reference), resolves the path, reads the nested project, applies its own overlay, applies the parent reference's overrides: block, and flattens the nested project's primary into a plain AgentSpec with its own subagents map inlined. The _source annotation tracks provenance; no path:, _compiled, or wrapper fields remain.
  3. Detects cycles across the project-reference graph; aborts at a depth of 16 levels.
  4. Validates the schema at every level (parent and nested).
  5. Materializes role-based tool defaults: the root agent (at primary) gets kaged.issue.* and kaged.workflow.* enabled; all other agents start with an empty tool set unless the operator's tools: block overrides.

See project-dsl.md § Compilation and cycles for the full algorithm and failure-mode taxonomy.

Response (200):

{
  "yaml": "version: 1\nproject: ...\nprimary:\n  model: smart-generalist\n  system_prompt: project:/prompts/primary.md\n  cage: disabled\n  subagents:\n    builder:\n      model: low-cost-fast\n      system_prompt: project:/prompts/builder.md\n      cage:\n        fs: [{mode: ro, path: project:/src}]\n        net: {allow: []}\n        state: ephemeral\n      tools:\n        \"file.read\": {enabled: true}\n      _source: {project_ref: project:/sub/builder}\n",
  "has_overlay": true,
  "has_project_references": true,
  "resolved_tools": ["file.read", "file.write", "edit.text", "file.create", "search.grep", "..."],
  "warnings": [...],
  "cross_ref_errors": [...]
}
  • yaml — the compiled DSL serialized to YAML. The agent tree is uniform AgentSpec at every position. Project-reference entries are flattened to plain AgentSpec nodes with a _source annotation for traceability; no path: or wrapper fields remain.
  • has_overlay — whether .kaged/project.local.yaml was present and merged.
  • has_project_references — whether the project contains at least one project-reference subagent (at any depth).
  • resolved_tools — the effective root-agent tool list after applying operator-level overrides (default_tools from local.toml) and project-level overrides (primary.tools from the DSL) against DEFAULT_ROOT_TOOLS. Array of tool name strings in canonical order. Tools disabled by any layer are excluded.
  • warnings — non-fatal diagnostics aggregated across all compiled layers (e.g. cage: disabled warnings from any agent surface here, prefixed with the agent's tree-position path like primary.subagents.builder).
  • cross_ref_errors — tool-name-collision or principal-scope errors aggregated across all layers. Empty array on a fully valid project.

Response (422, merge or compile failed):

{
  "yaml": null,
  "has_overlay": true,
  "has_project_references": true,
  "resolved_tools": null,
  "diagnostics": [
    {
      "kind": "nested_project_missing",
      "message": "primary.subagents.builder: no project.yaml found at /home/op/proj/sub/builder",
      "docLink": "docs/specs/project-dsl.md#compilation-and-cycles"
    }
  ],
  "warnings": [],
  "cross_ref_errors": []
}

The 422 path covers all compilation failures: missing nested project.yaml, parse errors at any depth, schema-validation failures of merged results, overrides containing forbidden keys, project-reference cycles (compile_cycle), depth-limit excess (compile_depth_exceeded), and principal-scope violations (kaged.issue.* or kaged.workflow.* on a non-root agent).

Error responses: 404 (project not registered or top-level project.yaml missing on disk).

POST /api/v1/projects/:slug/subagents/init

Initialize a subdirectory as a kaged subagent project. Creates .kaged/project.yaml and .kaged/prompts/default.md in the target directory.

Request:

{ "path": "agents/sub1" }
  • path (string, required) — relative path within the project root. Must not escape the project root via .. segments.

Response (200, created):

{
  "path": "/absolute/path/to/agents/sub1",
  "project": "sub1",
  "created": true
}

Response (200, already exists):

{
  "path": "/absolute/path/to/agents/sub1",
  "project": "sub1",
  "created": false
}

Error responses: 400 (missing path, path traversal), 404 (parent project not registered), 503 (no local-config path).

POST /api/v1/dsl/validate

Standalone validation. Used by the UI's editor for live linting and by external tooling. Does not persist anything.

Request:

{ "dsl": "version: 1\n..." }

Response (200, valid):

{
  "valid": true,
  "diagnostics": [],
  "warnings": [...],
  "cross_ref_errors": [...]
}

Response (422, invalid DSL):

{
  "valid": false,
  "diagnostics": [
    { "kind": "schema_violation", "message": "...", "line": 14, "col": 9 }
  ],
  "warnings": [],
  "cross_ref_errors": []
}

Error responses: 400 (missing dsl field, invalid JSON body).

GET /api/v1/dsl/schema?version=N

Returns the published JSON Schema. Unauthenticated for ease of editor integration.

{
  "version": 1,
  "schema": { "$schema": "...", "$id": "...", ... }
}

Workflows

Workflow endpoints serve the operator-facing workflow catalog, invocation, and management surface per workflows.md. Guest-facing equivalents are documented in § Guest workflow endpoints below.

GET /api/v1/projects/:slug/workflows

List the workflow catalog for a project. Returns all workflows declared in the project's DSL.

Response (200):

{
  "items": [
    {
      "name": "deploy",
      "description": "Deploy the project to production",
      "inputs": [ { "name": "environment", "type": "string", "required": true } ],
      "confirm_required": true,
      "step_count": 3
    }
  ]
}

GET /api/v1/projects/:slug/workflows/:name

Describe a single workflow — its inputs, steps, and configuration.

Response (200):

{
  "name": "deploy",
  "description": "Deploy the project to production",
  "inputs": [ { "name": "environment", "type": "string", "required": true } ],
  "confirm_required": true,
  "steps": [
    { "id": "validate", "kind": "agent", "description": "Validate deployment config" },
    { "id": "approve", "kind": "confirm", "message": "Proceed with deploy?" },
    { "id": "run-deploy", "kind": "task", "task": "deploy-script" }
  ]
}

Returns 404 not_found if the workflow name does not exist in the project DSL.

POST /api/v1/projects/:slug/workflows/:name/upload

Upload a file for a workflow input of type file. The request body is the raw file bytes (not JSON). The Content-Type header should reflect the file's MIME type. Size is capped at 10 MB (UPLOAD_HARD_CAP_KB).

The daemon sniffs the first bytes for magic-byte MIME detection (ZIP, GZIP, TAR, PDF) and validates against supported upload MIME types. The file is staged to <daemonHome>/workflow-staging/<projectId>/<token>/data.

Response (200):

{
  "token": "upload_abc123",
  "input_name": "config_file",
  "mime_type": "application/pdf",
  "size_bytes": 204800,
  "expires_at": 1716300600000
}

Returns 400 bad_request if the workflow or input does not exist, the input is not of type file, or the upload exceeds the size cap.

POST /api/v1/projects/:slug/workflows/:name/invoke

Invoke a workflow. The request body contains the input values.

Request:

{
  "inputs": {
    "environment": "production"
  }
}

Response (202):

{
  "invocation_id": "inv_abc123",
  "workflow_name": "deploy",
  "state": "dispatching",
  "created_at": 1716300000000
}

Concurrency is capped at 4 per project. Returns 429 rate_limited when the cap is reached. Returns 422 validation_failed when inputs fail schema validation. Agent invokers are refused with 422 confirm_requires_operator if the workflow contains confirm steps.

GET /api/v1/projects/:slug/workflows/:name/runs

List invocations for a workflow. Supports pagination via ?cursor= and ?limit=N (default 20, max 100).

Response (200):

{
  "items": [
    {
      "invocation_id": "inv_abc123",
      "workflow_name": "deploy",
      "state": "succeeded",
      "invoker_kind": "operator",
      "invoker_id": "operator",
      "created_at": 1716300000000,
      "completed_at": 1716300060000,
      "steps": [
        { "id": "validate", "kind": "agent", "status": "succeeded" },
        { "id": "approve", "kind": "confirm", "status": "succeeded" },
        { "id": "run-deploy", "kind": "task", "status": "succeeded" }
      ]
    }
  ],
  "next_cursor": null
}

GET /api/v1/projects/:slug/workflows/invocations/:iid

Get detail for a single workflow invocation, including step state and session IDs.

Response (200):

{
  "invocation_id": "inv_abc123",
  "workflow_name": "deploy",
  "state": "running",
  "invoker_kind": "operator",
  "invoker_id": "operator",
  "created_at": 1716300000000,
  "inputs": { "environment": "production" },
  "current_step": "run-deploy",
  "steps": [
    { "id": "validate", "kind": "agent", "status": "succeeded", "session_id": "ses_001" },
    { "id": "approve", "kind": "confirm", "status": "succeeded" },
    { "id": "run-deploy", "kind": "task", "status": "running" }
  ]
}

Returns 404 not_found if the invocation does not exist.

POST /api/v1/projects/:slug/workflows/invocations/:iid/confirm

Confirm a workflow invocation's pending gate or confirm step. The endpoint resolves the current pending confirmation — either the pre-dispatch confirm gate (confirm_required: true) or a confirm step mid-execution.

Response (200):

{
  "invocation_id": "inv_abc123",
  "state": "dispatching",
  "confirmed_at": 1716300030000
}

Returns 409 conflict with reason: "workflow_nothing_to_confirm" if the invocation has no pending confirmation.

POST /api/v1/projects/:slug/workflows/invocations/:iid/cancel

Cancel a workflow invocation. Marks the invocation as cancelled, skips remaining steps, and aborts any active agent run.

Response (200):

{
  "invocation_id": "inv_abc123",
  "state": "cancelled",
  "cancelled_at": 1716300045000
}

Returns 409 conflict if the invocation is already in a terminal state.

Local config

These endpoints read and write the calling operator's local config (per local-config.md). In per-user mode, this is the operator's own file. In system-wide mode, it's the per-operator file the daemon resolves via X-Kaged-User-Id.

GET /api/v1/local/aliases

List all model aliases this operator has defined.

{
  "aliases": {
    "smart-generalist": "claude:sonnet-4.6",
    "low-cost-coder": "claude:haiku",
    "local-only": "ollama:llama3.2"
  },
  "recommended": [
    "smart-generalist", "smart-careful", "low-cost-fast", "low-cost-coder", "local-only", "tiny"
  ]
}

recommended is the starter set kaged ships with — names operators are encouraged to define if they fit. Already-defined aliases appear in aliases; missing recommended ones can be added via PUT.

PUT /api/v1/local/aliases/:name

Define or update an alias.

Request:

{ "target": "claude:sonnet-4.6" }

The target must be <provider>:<model> form. The provider must be configured in [providers.*]; if it isn't, the response includes a warning and the binding is still recorded (it will fail at use time until the provider is configured).

Response (200):

{
  "name": "smart-generalist",
  "target": "claude:sonnet-4.6",
  "newly_ready_projects": ["music-site"]
}

newly_ready_projects lists projects in the operator's registry that transitioned from pending to ready because this alias was the last unresolved item.

DELETE /api/v1/local/aliases/:name

Remove an alias.

Response (200):

{
  "name": "smart-generalist",
  "newly_pending_projects": ["music-site", "infra-monitor"]
}

newly_pending_projects lists projects that transitioned to pending because they used this alias.

GET /api/v1/local/providers

List configured LLM providers. API keys are redacted as "<redacted>" (or "<from-env: ANTHROPIC_API_KEY>" for keys read from env).

PUT /api/v1/local/providers/:name

Configure a provider. Request body matches the [providers.<name>] schema in local-config.md.

GET /api/v1/local/providers/:name/models

List the provider's persisted model catalog. Models are operator-curated entries stored in local.toml (see local-config.md § Model catalog management). The daemon does not fetch from the provider API — this returns only what the operator has saved.

Response (200):

{
  "ok": true,
  "models": [
    { "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4 20250514", "manual": false },
    { "id": "claude-haiku-3.5", "name": "Claude Haiku 3.5", "manual": false },
    { "id": "custom-local-llm", "name": "My Local LLM", "manual": true }
  ]
}

Each model has id (the provider's model identifier), name (operator-supplied display name, or the raw id when absent in config), and manual (operator-managed flag; manual models are never retired by refresh).

Returns 404 if the provider is not configured.

PUT /api/v1/local/providers/:name/custom

Add a custom provider that is not present in the catalog.

Request:

{
  "npm": "@ai-sdk/openai-compatible",
  "base_url": "http://localhost:11434/v1",
  "credentials": {
    "api_key": { "value": "my-secret-key" }
  },
  "header_mappings": {
    "api_key": "Authorization"
  },
  "discover": true
}
  • npm (string, required): the AI SDK package name to load.
  • base_url (string, optional): the custom API endpoint.
  • credentials (object, optional): a map of environment variables to header values for custom authentication.
  • header_mappings (object, optional): maps environment variable names to request header names.
  • discover (boolean, optional): if true, triggers immediate model discovery.

Response (200):

{
  "ok": true,
  "name": "ollama",
  "kind": "custom"
}

POST /api/v1/local/providers/:name/models/discover

Discover models for a custom provider by hitting its declared discovery endpoint.

Response (200):

{
  "ok": true,
  "discovered": [
    { "id": "llama3", "name": "Llama 3" }
  ],
  "catalog_matched": [],
  "merged": [
    { "id": "llama3", "name": "Llama 3" }
  ]
}

GET /api/v1/local/catalog

Return the bundled catalog snapshot containing providers, models, and manifest metadata.

Response (200):

{
  "schemaVersion": "1",
  "sourceCommit": "abcdef",
  "fetchedAt": 1716300000000,
  "providers": {},
  "models": {}
}

POST /api/v1/local/catalog/sync

Fetch the latest catalog snapshot from models.kaged.dev and return a diff against the current bundled snapshot. This does not apply the changes.

Response (200):

{
  "added": [],
  "removed": [],
  "changed": [],
  "added_models": [],
  "removed_models": [],
  "changed_models": [],
  "previous_manifest": {},
  "new_manifest": {}
}

POST /api/v1/local/catalog/sync/apply

Apply a confirmed catalog sync with keep decisions for configured rows that the new snapshot drops.

Request:

{
  "keep_providers": ["old-provider"],
  "keep_models": ["old-provider/old-model"]
}

Response (200):

{
  "ok": true,
  "applied_at": 1716300000000,
  "dropped_providers": [],
  "dropped_models": []
}

PUT /api/v1/local/providers/:name/models

Save a model catalog to the provider's config in local.toml. Replaces the entire models array, but the daemon preserves the manual flag from any persisted entry that already has it.

Request:

{
  "models": [
    { "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4" },
    { "id": "claude-opus-4-20250514" },
    { "id": "custom-local-llm", "name": "My Local LLM", "manual": true }
  ]
}

Each entry requires a non-empty id string. name is optional — entries without name use the raw id for display. manual is optional and defaults to false. Invalid entries (missing id, empty id, non-objects) are silently skipped.

Response (200):

{
  "ok": true,
  "models": [
    { "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4", "manual": false },
    { "id": "claude-opus-4-20250514", "name": "Claude Opus 4 20250514", "manual": false },
    { "id": "custom-local-llm", "name": "My Local LLM", "manual": true }
  ]
}

Returns 404 if the provider is not configured. Returns 400 if the request body is missing or models is not an array.

Model metadata overrides

Per ADR-0026.

GET /api/v1/local/providers/:name/models/:modelId/meta

Returns the merged model metadata for a specific model — LiteLLM defaults + operator overrides. The response includes per-field source tracking so the UI can distinguish default values from overrides.

Response (200):

{
  "provider": "anthropic",
  "model_id": "claude-sonnet-4-20250514",
  "meta": {
    "key": "anthropic/claude-sonnet-4-20250514",
    "litellmProvider": "anthropic",
    "mode": "chat",
    "maxInputTokens": 200000,
    "maxOutputTokens": 64000,
    "pricing": {
      "input": 0.000003,
      "output": 0.000015,
      "reasoning": null,
      "cacheRead": 0.0000003,
      "cacheWrite": 0.00000375
    },
    "capabilities": { "...": "..." }
  },
  "package": "@ai-sdk/anthropic",
  "sources": {
    "maxInputTokens": "default",
    "pricing.input": "override",
    "pricing.output": "default",
    "package": "default"
  }
}

The sources map only includes fields that appear in the response. Fields with "override" source are rendered distinctly in the UI. If no LiteLLM entry exists and no overrides are set, meta is a null-default object and sources is empty. The package field is the effective npm package for this model; the per-model packageOverride field is stored in the override table and surfaced here as sources.package. If no package can be resolved, package is null.

Returns 404 if the provider is not configured.

PUT /api/v1/local/providers/:name/models/:modelId/overrides

Upsert one or more field overrides. Existing overrides for fields not mentioned in the request are preserved.

Request:

{
  "overrides": [
    { "field": "maxInputTokens", "value": 128000 },
    { "field": "pricing.input", "value": 0.000003 }
  ]
}

Values are the actual typed values (number, boolean, string, null), not JSON strings. The daemon serializes them to JSON for storage.

Response (200):

{
  "ok": true,
  "overrides_applied": 2,
  "total_overrides": 5
}

total_overrides is the count of all overrides now stored for this provider+model (including previously existing ones).

Returns 404 if the provider is not configured. Returns 400 if overrides is missing, not an array, or contains entries with missing/invalid field names.

DELETE /api/v1/local/providers/:name/models/:modelId/overrides

Delete all overrides for a model, reverting entirely to LiteLLM defaults.

Response (200):

{
  "ok": true,
  "deleted": 3
}

deleted is the number of override rows removed. If no overrides existed, deleted is 0 and the response is still 200.

Returns 404 if the provider is not configured.

DELETE /api/v1/local/providers/:name/models/:modelId/overrides/:field

Delete a single override field, reverting that field to its LiteLLM default.

Response (200):

{
  "ok": true,
  "field": "maxInputTokens",
  "deleted": true
}

deleted is false if the override did not exist (no-op).

Returns 404 if the provider is not configured.

Provider usage and spend limits

Per ADR-0026.

GET /api/v1/local/providers/:name/usage

Returns the cached provider usage report. The report is a UsageReport as defined in llm.md § Provider usage reporting.

Response (200):

{
  "ok": true,
  "report": {
    "provider": "antigravity",
    "fetchedAt": 1716300000000,
    "limits": [ "..." ]
  }
}

Response when no cache exists or provider has no usage fetcher:

{
  "ok": false,
  "error": "no_cache"
}

Error values: "no_cache" (never fetched), "no_fetcher" (provider does not support usage reporting).

Returns 404 if the provider is not configured.

POST /api/v1/local/providers/:name/usage/refresh

Forces a fresh usage fetch from the provider, regardless of cache state. Updates the cache with the new report.

Response (200):

{
  "ok": true,
  "report": {
    "provider": "antigravity",
    "fetchedAt": 1716300060000,
    "limits": [ "..." ]
  }
}

Response when fetch fails:

{
  "ok": false,
  "error": "fetch_failed",
  "detail": "HTTP 401 from provider"
}

Returns 404 if the provider is not configured. Returns 400 if the provider has no usage fetcher.

GET /api/v1/local/providers/:name/spend-limits

Returns the per-provider spend limit configuration.

Response (200):

{
  "provider": "anthropic",
  "limits": {
    "max_spend_5h_usd": 10.0,
    "max_spend_7d_usd": 100.0,
    "max_window_pct_5h": null,
    "max_window_pct_7d": null
  },
  "current_spend": {
    "spent_5h_usd": 3.42,
    "spent_7d_usd": 47.18
  },
  "updated_at": 1716300000000
}

current_spend is computed from the provider_spend_events table for the current rolling windows. limits fields that are null are not enforced.

Returns 404 if the provider is not configured. Returns an empty limits object (all null) if no limits have been set.

PUT /api/v1/local/providers/:name/spend-limits

Set or update spend limits for a provider. Partial updates are supported — only mentioned fields are changed.

Request:

{
  "max_spend_5h_usd": 10.0,
  "max_spend_7d_usd": 100.0
}

Response (200):

{
  "ok": true,
  "limits": {
    "max_spend_5h_usd": 10.0,
    "max_spend_7d_usd": 100.0,
    "max_window_pct_5h": null,
    "max_window_pct_7d": null
  }
}

To remove a limit, set it to null. To set a percentage-based limit:

{
  "max_window_pct_5h": 0.5
}

This restricts kaged to 50% of the provider's 5-hour rolling window.

Returns 404 if the provider is not configured. Returns 400 if any value is out of range (negative, or percentage not in 0.0–1.0).

OAuth provider auth

Per ADR-0028 as amended by ADR-0049. These endpoints manage any OAuth-backed provider's lifecycle: browser-based login, token status, and logout. They are available for any provider whose backing module (catalog package or provider plugin) exposes an OAuth flow — for plugins, this is via the plugin's contract; for catalog packages, OAuth-bearing packages handle bearer-token injection inline and the daemon resolves a fresh token via the harness's existing token-resolution path. Access tokens expire per-provider; the daemon refreshes them transparently before each LLM call.

Post-ADR-0049, the OAuth machinery (PKCE, callback server, token store, refresh) is owned by provider plugins, not by @kaged/llm/oauth (which is deleted). The pre-ADR-0049 module migrates wholesale into the Antigravity plugin and serves as the template for future OAuth plugins (Copilot, Codex, etc.). The routes below are unchanged at the HTTP contract level — the :name routing and response shapes are stable across the migration — but the implementation each route delegates to is the plugin's, not a kaged-core module.

The routes are generic:name is the provider name (the user-chosen key in local.toml, e.g. "my-codex" or "antigravity"). The handler resolves the provider's backing module (catalog package or plugin) and delegates to its auth interface.

POST /api/v1/local/providers/:name/auth/login

Initiates the provider's OAuth authorization code flow with PKCE. The daemon:

  1. Looks up the provider by :name, resolves its backing module (catalog package or provider plugin).
  2. Loads the plugin's auth module (or the catalog OAuth path) and generates a PKCE verifier/challenge pair.
  3. Constructs the provider's OAuth authorization URL.
  4. Starts a temporary local HTTP server on the provider's configured callback port to receive the OAuth redirect.
  5. Attempts to open the authorization URL in the system browser via Bun.open() (or open/xdg-open on Linux).
  6. Returns immediately so the UI can show a "waiting for browser" state.

The callback server runs for a maximum of 5 minutes. On successful callback, it exchanges the authorization code for tokens, runs the provider's postLoginHook (if any, e.g. Antigravity's Google Cloud project discovery), persists tokens to $XDG_CONFIG_HOME/kaged/oauth/<provider>-tokens.json, and serves a success HTML page to the browser.

Response (200, flow initiated):

{
  "ok": true,
  "redirect_url": "https://auth.openai.com/oauth/authorize?...",
  "callback_port": 1455
}

Error responses:

  • 404 not_found — provider :name does not exist.
  • 400 bad_request with details.reason: "not_oauth_provider" — this provider's driver does not support OAuth.
  • 409 conflict with details.reason: "login_in_progress" — another login flow is already active.
  • 503 unavailable with details.reason: "callback_port_in_use" — the callback port is occupied.
  • 502 provider_unreachable with details.reason: "browser_open_failed" — could not open the system browser. The redirect_url is still provided; the operator can open it manually.

GET /api/v1/local/providers/:name/auth/status

Returns the current authentication state for the provider. Does not expose tokens — only metadata.

Response (200, authenticated):

{
  "authenticated": true,
  "email": "operator@example.com",
  "expires_at": 1716303600000,
  "obtained_at": 1716300000000,
  "metadata": {}
}

Response (200, not authenticated):

{
  "authenticated": false,
  "email": null,
  "expires_at": null,
  "obtained_at": null,
  "metadata": null
}

expires_at is the absolute Unix timestamp (ms) when the current access token expires. The daemon refreshes tokens proactively before each LLM call; this field reflects the currently-stored token's expiry. The metadata object is provider-specific (e.g. Antigravity includes projectId, Codex may include account info).

Error responses:

  • 404 not_found — provider :name does not exist.
  • 400 bad_request with details.reason: "not_oauth_provider".

POST /api/v1/local/providers/:name/auth/logout

Deletes the provider's token store. The provider becomes unauthenticated until the operator logs in again. Does not revoke tokens with the upstream provider — that requires visiting the provider's account permissions page.

Response (200):

{
  "ok": true
}

Response (200, already logged out):

{
  "ok": true,
  "note": "not_authenticated"
}

Error responses:

  • 404 not_found — provider :name does not exist.
  • 400 bad_request with details.reason: "not_oauth_provider".

Legacy routes

The following routes are retained as backward-compatible aliases. They are functionally identical to the generic routes above with :name set to the provider name:

  • POST /api/v1/local/providers/antigravity/auth/login → resolves the first provider with driver: "antigravity"
  • GET /api/v1/local/providers/antigravity/auth/status → same
  • POST /api/v1/local/providers/antigravity/auth/logout → same

GET /api/v1/local/preferences and PUT /api/v1/local/preferences

Operator's UI preferences (theme, timezone, locale). Get returns the current values; PUT replaces them. Per local-config.md [ui].

Prompts

GET /api/v1/projects/:slug/prompts

Lists prompt files referenced by the project's DSL. Each entry has the prompt's path, last-modified timestamp, and version count (every edit is versioned per ADR-0007 and the manifesto).

GET /api/v1/projects/:slug/prompts/:name

Returns the current prompt body (text/plain) and version metadata. ?version=N returns a historical version.

PUT /api/v1/projects/:slug/prompts/:name

Replaces the prompt body. Daemon writes a new version, never overwrites. The version number is in the response.

Request body is text/plain; charset=utf-8. The daemon does not parse markdown — opaque text.

Audit log records prompt.edit with the diff.

Sessions

GET /api/v1/projects/:slug/sessions

Lists sessions for a project. Paginated. Each item is a session summary (shape below). Includes state (idle, running, paused, ended), kind (chat, workflow, edit), and created_by_user (the resolved creator identity, batch-resolved server-side).

Query parameters:

  • limit (integer, optional) — page size.
  • cursor (string, optional) — pagination cursor (session ULID).
  • kind (chat | workflow | edit, optional) — filter the list to a single session kind. Omit to return all kinds.

Each item:

{
  "id": "01HXAB...",
  "project_id": "music-site",
  "name": "deploy attempt 3",
  "kind": "chat",
  "state": "idle",
  "created_by": "01HXUSER...",
  "created_by_user": {
    "user_id": "01HXUSER...",
    "handle": "rick",
    "display_name": "Rick",
    "has_avatar": true
  },
  "created_at": 1716300000000,
  "updated_at": 1716300100000,
  "model": null,
  "max_steps_override": null,
  "max_output_tokens_override": null,
  "bound_issue": null,
  "notification_bell": "default",
  "current_run": null,
  "live_subagents": []
}
  • kind is the session kind (chat default, workflow for recipe sessions per workflows.md, edit for file-editing sessions). Persisted in sessions.kind.
  • created_by is the raw creator user ULID (retained for back-compat).
  • created_by_user is the resolved creator identity — the same { user_id, handle, display_name, has_avatar } shape used by message authors. It is null when the creator does not resolve to a known user record (e.g. system- or agent-created sessions). Resolution is batched per page (one lookupUsers call), not per row.
  • model is null when the session uses the DSL's default alias, or a "provider:model" string when overridden. The list endpoint does not resolve the DSL default — null means "project default".

POST /api/v1/projects/:slug/sessions

Creates a new session. Body is optional:

{
  "name": "deploy attempt 3",   // optional, operator-supplied label
  "resume_from": "01HXAB..."     // optional; another session ID to fork from
}

Response (201):

{
  "id": "01HXAB...",
  "project_id": "music-site",
  "state": "idle",
  "created_at": 1716300000000
}

Session creation does not start any agent work. The primary becomes active when the first message is posted.

No concurrency limit at creation. Sessions can always be created regardless of how many running sessions exist. The per-project (4) and per-operator (16) concurrency limits are enforced at message-send time, not session-creation time (per session-manager.md § Concurrency).

GET /api/v1/sessions/:id

Returns session state, message count, current run (if any), and cage statuses of any live subagents.

{
  "id": "01HXAB...",
  "project_id": "music-site",
  "name": "deploy attempt 3",
  "kind": "chat",
  "state": "running",
  "created_by": "01HXUSER...",
  "created_by_user": {
    "user_id": "01HXUSER...",
    "handle": "rick",
    "display_name": "Rick",
    "has_avatar": true
  },
  "created_at": 1716300000000,
  "updated_at": 1716300100000,
  "model": "claude:claude-sonnet-4-20250514",
  "current_run": "01HXAB-RUN...",
  "live_subagents": [
    {
      "invocation_id": "01HXAB-INV...",
      "name": "scraper",
      "state": "running",
      "cage_status": "caged",
      "started_at": 1716300050000
    }
  ]
}
  • kind is the session kind (chat | workflow | edit).
  • created_by_user is the resolved creator identity, or null when the creator does not resolve to a known user record. Same shape as message authors.
  • model is null when the session uses the DSL's default alias, or a "provider:model" string when the operator has overridden the model for this session.
  • cage_status is one of caged, uncaged, pending (per glossary).

PUT /api/v1/sessions/:id

Updates mutable session metadata: the operator-supplied label and the model override.

Request body:

{
  "label": "deploy attempt 3",            // string; set or replace the display label
  "model": "claude:claude-sonnet-4-20250514"  // string | null; set or clear the model override
}

Both fields are optional — omit a field to leave it unchanged.

  • label (string, optional) — 0–120 characters after trimming. Empty string clears the label.
  • model (string | null, optional) — a "provider:model" string that overrides the DSL's primary.model alias for all runs in this session. Pass null to clear the override and revert to the project's default alias. The provider must exist in the operator's local config; if it doesn't, the override is still recorded but dispatch will fail at use time with provider_not_configured.
  • max_steps_override (integer | null, optional) — overrides the DSL's primary.max_steps for this session. Range 1100. Pass null to clear the override and revert to the DSL default.
  • max_output_tokens_override (integer | null, optional) — overrides the DSL's primary.max_output_tokens for this session. Range 165536. Pass null to clear the override and revert to the DSL default.
  • At least one of label, model, max_steps_override, or max_output_tokens_override must be present in the body.
  • model, when present, must be a string in "provider:model" format (containing at least one :) or null.
  • max_steps_override, when present, must be an integer between 1 and 100 inclusive.
  • max_output_tokens_override, when present, must be an integer between 1 and 65536 inclusive.
  • Returns 400 bad_request if body is missing, empty, or contains neither field.
  • Returns 404 not_found if the session does not exist.

DELETE /api/v1/sessions/:id

Ends the session, kills any live subagents, persists the final state. Requires ?confirm=true.

Messages

GET /api/v1/sessions/:id/messages

Lists messages in chronological order. Paginated.

Each message:

{
  "id": "01HXAB...",
  "session_id": "01HXAB...",
  "role": "operator" | "primary" | "subagent" | "system",
  "subagent_invocation_id": "01HXAB...",  // present when role == subagent
  "content": "...",                         // text; structured content is in `parts`
  "parts": [                                // present for multi-part messages
    { "type": "text", "text": "..." },
    { "type": "tool_call", "name": "...", "input": { ... } },
    { "type": "tool_result", "input": "...", "output": "..." }
  ],
  "stop_reason": "stop",                    // null or omitted for non-model messages / errors
  "error_message": "Anthropic 429: ...",    // null or omitted unless the model run failed
  "run_id": "01HXAB...",                    // the run that produced this message; null for messages with no run
  "code": "rate_limited",                   // run error code when the run failed; null otherwise
  "retryable": true,                        // whether Tier 2 (frontend) retry may auto-arm; null unless failed
  "retry_after_until": 1716303600000,       // absolute epoch-ms the provider advised waiting until; null/omitted if none
  "created_at": 1716300000000
}
  • run_id (string | null) — the id of the run this message belongs to. Operator, primary, and subagent messages produced within a run carry its id; messages with no associated run are null. run_id is not a stable regeneration anchor: a reply's run_id changes each time it is regenerated. Use the operator message id to anchor regeneration.
  • code (string | null) — when the message's run failed, the normalized run error code from kaged's error taxonomy (rate_limited, provider_error, network_error, context_too_long, auth_failed, model_not_found, spend_limit_exceeded, aborted, parse_error, empty_response, …; see llm.md §Error taxonomy). null when the run did not fail.
  • retryable (boolean | null) — whether the frontend Tier 2 retry loop may auto-arm for this failure (see ADR-0052). true only for transient classes (rate_limited, provider_error, network_error); false for context_too_long (already compaction-retried), auth_failed, model_not_found, spend_limit_exceeded, aborted, and other non-transient classes. null when the run did not fail. The UI must gate auto-retry on this field, not on the coarse presence of an error alone.
  • retry_after_until (number | null, optional) — when the provider advised a backoff (Retry-After) longer than Tier 1's in-run window, the absolute epoch-ms time before which a retry should not be attempted. The UI shows a scheduled "retry after HH:MM" (countdown paused) rather than a blind countdown, so it does not hammer a provider during a long cooldown. null/omitted when no provider backoff advice applies.

Messages are immutable once written. Edits are not supported — to revise, the operator posts a new message, regenerates from an operator message, or rolls back to a checkpoint.

POST /api/v1/sessions/:id/messages

Posts an operator message to the session. The daemon enqueues it and returns 202 Accepted immediately; the resulting work streams over the WebSocket.

Request:

{
  "content": "Scrape new releases and prep a deploy.",
  "model": "claude:claude-sonnet-4-20250514"
}
  • content (string, required) — the operator's message text.
  • model (string, optional) — a "provider:model" string. When present, it is persisted to the session record before dispatch, becoming the session's model override for this and all subsequent messages until changed or cleared. This enables per-message model switching while maintaining the "session-level override" semantic — the last-used model is sticky. Omit to use the session's existing override (or the DSL default if no override is set).
  • max_steps_override (integer, optional) — overrides the DSL's primary.max_steps for this run. Range 1100. When present, it is persisted to the session record before dispatch. Omit to use the session's existing override (or the DSL default if no override is set).
  • max_output_tokens_override (integer, optional) — overrides the DSL's primary.max_output_tokens for this run. Range 165536. When present, it is persisted to the session record before dispatch. Omit to use the session's existing override (or the DSL default if no override is set).
  • continuation (boolean, optional, default false) — marks this post as an auto-continue. When true and the session's previous run ended at a tool boundary (the active message tail is a tool result or an unresolved tool call), the daemon starts a new run on the existing history without persisting this operator message — avoiding a redundant "continue" turn in the transcript. When the previous run ended on text, or when continuation is absent/false, the message is persisted and dispatched as a normal operator turn. The suppression decision is the message-tail shape, not content string-matching. See session-manager.md § Continue-suppression at tool boundaries.

Response (202):

{
  "id": "01HXAB...",
  "session_id": "01HXAB...",
  "accepted_at": 1716300000000
}

Concurrency throttle. If the operator's or project's running-session count is at the limit (4 per project, 16 per operator), the message is accepted but the session enters queued state. The run is created in pending and no primary dispatch occurs. The response indicates the queued status:

Response (202, queued — concurrency limit reached):

{
  "id": "01HXAB...",
  "session_id": "01HXAB...",
  "accepted_at": 1716300000000,
  "queued": true,
  "reason": "per_project",
  "running_count": 4,
  "limit": 4
}

reason is "per_project" or "per_operator" depending on which limit was hit. running_count is the current count for that scope; limit is the maximum. The operator sees the queued indicator in the UI and can resume via POST /api/v1/sessions/:id/resume when a slot frees.

If the session itself is already running (a previous message is being processed in this same session), returns 409 conflict with details.current_message: "01HXAB...". This is a per-session conflict (one run per session), distinct from the cross-session concurrency throttle. Operators must either wait or force a checkpoint.

POST /api/v1/sessions/:id/messages/:mid/regenerate

Re-runs the agent from an operator message. :mid must reference an existing operator message in the session. The daemon supersedes every message created after :mid (the failed or unwanted reply and anything following it), then dispatches a fresh run from :mid's existing content. See ADR-0052.

This endpoint creates no new message row and does not mutate the preserved :mid row — message immutability is intact. It reports the new run via a receipt rather than a message item.

Preconditions: the session must be idle. The request has no body.

Semantics:

  • Supersede all messages after :mid (sets superseded = true; the rows are retained but excluded from the active transcript). :mid itself is preserved unchanged.
  • Create a new run and dispatch the primary from :mid's content, reusing the same concurrency logic as posting a message: if the per-project (4) or per-operator (16) running-session limit is hit, the session enters queued, the run is created pending, and no dispatch occurs until a slot frees (POST /api/v1/sessions/:id/resume).

Response (202):

{
  "run_id": "01HXAB...",
  "accepted_at": 1716300000000,
  "queued": false
}

Response (202, queued — concurrency limit reached):

{
  "run_id": "01HXAB...",
  "accepted_at": 1716300000000,
  "queued": true,
  "reason": "per_project",
  "running_count": 4,
  "limit": 4
}

reason is "per_project" or "per_operator"; running_count/limit describe the scope that throttled, mirroring POST .../messages.

Errors:

  • 404 not_found — the session or :mid does not exist.
  • 409 conflict:mid is not an operator message, or the session is not idle (a run is already running, queued, paused, or the session is ended/failed).

Retry is two-tier (see ADR-0052). Tier 1 (fast, automatic, in-run) is the @kaged/llm retry middleware wired into resolveModel (see llm.md §Retry policy); it retries the open-call only, never after the first streamed delta, and honors short Retry-After values. Tier 2 (slow, visible, operator-driven) is the frontend loop that calls this endpoint on a backoff after a run fails — arming only when the failed message's retryable is true, respecting retry_after_until for long provider cooldowns, and cancellable by the operator between attempts. The daemon's single automatic context_overflow compaction retry (ADR-0024) is a third, separate mechanism and is unchanged; Tier 2 must not arm on it.

Checkpoints

Checkpoints are first-class per the manifesto. The model can request one; the operator can force one.

POST /api/v1/sessions/:id/checkpoints

Operator-initiated pause. Returns immediately with the checkpoint ID; the daemon halts the primary and any subagents at the next safe point.

Request (optional):

{ "reason": "want to inspect the deploy plan" }

Response (202):

{
  "id": "01HXAB...",
  "state": "pausing",        // becomes "paused" when the agent halts
  "initiator": "operator",
  "created_at": 1716300000000
}

The transition from pausing to paused is reported over the WebSocket.

GET /api/v1/sessions/:id/checkpoints

Lists checkpoints. Includes operator-initiated and model-initiated.

GET /api/v1/sessions/:id/checkpoints/:cid

Returns the checkpoint with a snapshot of the session state at the pause point: the messages so far, the active subagents' state, the current plan or tool calls, the prompts in effect.

POST /api/v1/sessions/:id/checkpoints/:cid/resume

Resume from a checkpoint. Optional body:

{ "edited_prompts": { "primary": "..." } }   // prompts to apply on resume

The daemon applies the edits, then resumes. If the checkpoint is no longer the current pause point (e.g., the session was rolled back), returns 409 conflict.

POST /api/v1/sessions/:id/checkpoints/:cid/rollback

Roll the session back to the state at the checkpoint and end the current run. Subsequent messages start from the rolled-back state. The original messages after the checkpoint remain visible in the message history but are marked superseded: true.

Runs

A "run" is one operator message and the work it produces — the primary's reasoning, subagent dispatches, tool calls, the final response.

GET /api/v1/sessions/:id/runs

Lists runs in chronological order. Each entry summarizes the trigger message, subagent invocations, duration, and outcome (completed, paused, failed, cancelled).

GET /api/v1/sessions/:id/runs/:rid

Returns full run detail: every subagent invocation with its per-agent cage policy, its resolved tool surface, its inputs, its outputs, its exit state.

POST /api/v1/sessions/:id/runs/:rid/cancel

Cancel a running run. Daemon sends SIGTERM to live subagents and finalizes the run as cancelled.

Queued session resume

POST /api/v1/sessions/:id/resume

Resumes a queued session — starts the pending run when a concurrency slot is available.

Request body: none (empty POST).

Response (200, resumed):

{
  "session_id": "01HXAB...",
  "run_id": "01HXAB...",
  "state": "running",
  "resumed_at": 1716300000000
}

The daemon transitions the session from queuedrunning, starts the pending run (transitions it from pendingrunning), and dispatches the primary agent. The run.started and session.state events fire on the session's WebSocket as normal.

Response (409, no slot available):

{
  "error": {
    "code": "conflict",
    "message": "Concurrency limit still reached; no slot available.",
    "details": {
      "reason": "per_project",
      "running_count": 4,
      "limit": 4
    },
    "request_id": "01HXAB..."
  }
}

The operator can retry after a running session ends (the system WebSocket broadcasts sessions.running_count on every change).

Response (409, session not queued):

{
  "error": {
    "code": "conflict",
    "message": "Session is not in queued state.",
    "details": { "current_state": "idle" },
    "request_id": "01HXAB..."
  }
}

DELETE /api/v1/sessions/:id/queued-message

Discards the queued message and cancels the pending run. The session returns to idle.

Response (200):

{
  "session_id": "01HXAB...",
  "state": "idle",
  "message_id": "01HXAB...",
  "run_id": "01HXAB...",
  "discarded_at": 1716300000000
}

The pending run is marked cancelled, the queued message is marked superseded: true (retained in history for audit), and the session transitions to idle.

Response (409, session not queued): same shape as resume's 409 for non-queued state.

Plugins

Per ADR-0008 amendment, plugins live in a local store and are either local-scope (available to any project) or project-scope (activated only for specific projects).

GET /api/v1/plugins

Lists plugins in the operator's local store.

{
  "items": [
    {
      "name": "oh-my-pi",
      "version": "1.4.2",
      "kaged_api": 1,
      "scope": "local",
      "active_for_projects": [],
      "last_error": null
    },
    {
      "name": "deploy-helper",
      "version": "0.1.0",
      "kaged_api": 1,
      "scope": "project",
      "active_for_projects": ["music-site", "infra-monitor"],
      "last_error": null
    }
  ]
}

GET /api/v1/plugins/:name

Full plugin manifest (per ADR-0008) plus scope info.

POST /api/v1/plugins/install

Install a plugin into the local store. Used both for operator-initiated local installs (kaged plugin install <path>) and for project-load-driven installs (the operator accepting the install prompt after loading a project that declares a plugin).

Request:

{
  "name": "oh-my-pi",
  "version": "1.4.2",
  "source": "https://github.com/oh-my-pi/kaged-adapter",
  "scope": "local",
  "for_project": null
}
  • scope is "local" (available to any project) or "project" (activated only for the listed project).
  • for_project is required when scope == "project"; otherwise null.
  • source values:
    • URL → daemon clones/downloads, validates manifest, copies to local store.
    • Path (/local/path or ./relative-to-project) → daemon copies from path.
    • manual → operator already placed the files in the plugin store; daemon just validates the manifest.

Response (200, installed):

{
  "name": "oh-my-pi",
  "version": "1.4.2",
  "scope": "local",
  "installed_at": 1716300000000
}

Response (202, awaiting consent — only when the daemon needs operator confirmation, e.g. version mismatch or wide capability allowlist):

{
  "consent_required": true,
  "consent_id": "01HXAB...",
  "name": "oh-my-pi",
  "version": "1.4.2",
  "capabilities": ["read:fs:/opt/oh-my-pi", "exec:bash:/opt/oh-my-pi"],
  "diff": {
    "existing_version": "1.3.0",
    "incoming_version": "1.4.2",
    "capability_changes": ["+ exec:bash:/opt/oh-my-pi"]
  }
}

The client then POST /api/v1/plugins/install/consent with the consent_id to confirm or reject. (This split is important: install prompts may want operator review in the UI before any files are written to disk.)

POST /api/v1/plugins/install/consent

Approve or reject a pending install.

{ "consent_id": "01HXAB...", "approve": true }

POST /api/v1/plugins/:name/enable and /disable

Enable or disable a plugin daemon-wide (local-scope) or for a specific project (project-scope). Body indicates scope:

{ "scope": "local" }

or

{ "scope": "project", "project_id": "music-site" }

POST /api/v1/plugins/:name/promote

Promote a project-scoped plugin to local-scope. After this, the plugin is available to any project, not just the ones that originally declared it.

GET /api/v1/plugins/:name/config

Return the plugin's current project-side config object. In v0 this may be an empty object when no runtime config store is wired yet; the route exists so the UI has a stable read path for plugin configuration.

PUT /api/v1/plugins/:name/config

Update the plugin's local-store config. Plugin-specific schema; daemon validates against the plugin's declared config schema before applying. Note: per-project config overrides come from the project's DSL plugins.<name>.config block, not from this endpoint.

PUT /api/v1/plugins/:name/system-config

Update the plugin's operator-local system config in local.toml [plugins.<name>.system_config]. This surface is for secrets and machine-specific settings only; project-side config stays in the DSL.

DELETE /api/v1/plugins/:name

Uninstall the plugin. Removes files from the local store. Refuses if any active session is using the plugin (returns 409 with details.using_sessions).

GET /api/v1/plugins/:name/knobs

Return the plugin's knob schema (per plugin-host.md § Plugin knob schema) so the UI can render operator-tunable controls. Per ADR-0024.

Response:

{
  "name": "memory-markdown",
  "knobs": [
    {
      "id": "store",
      "type": "path",
      "label": "Storage location",
      "description": "Where memory files are stored.",
      "prefixes": ["config:", "project:"],
      "default": "config:/memory",
      "binds_to": "config.store"
    },
    {
      "id": "isolation",
      "type": "enum",
      "label": "Isolation scope",
      "description": "Per-agent or per-project memory.",
      "values": ["agent", "project"],
      "labels": null,
      "default": "agent",
      "binds_to": "config.isolation"
    }
  ]
}

Each knob entry mirrors the manifest's knobs: declaration. The UI fetches once per plugin per session; knobs are static (they don't change at runtime).

Knob writes flow through PUT /api/v1/projects/:slug/dsl (the standard DSL update endpoint), which writes to the appropriate AgentSpec.plugins.<name>.config.<field> path. The daemon then re-validates the project DSL and re-issues config.update to the plugin per plugin-host.md.

GET /api/v1/projects/:slug/plugins

Return the resolved set of plugins active per agent for the loaded project. Per ADR-0023, this is the "what's active where" view that the UI uses to render plugin badges and the Compactor surface.

Response:

{
  "project_id": "music",
  "agents": [
    {
      "agent_path": "primary",
      "plugins": [
        {
          "slot": "memory",
          "package": "@kaged/plugin-memory-markdown",
          "version": "0.1.0",
          "isolation": "agent",
          "hooks": ["on_session_start", "on_session_idle"],
          "roles": ["observer"],
          "tools_registered": ["memory-markdown.retain", "memory-markdown.recall"],
          "status": "active",
          "config_summary": { "store": "config:/memory" }
        }
      ]
    },
    {
      "agent_path": "primary.subagents.researcher",
      "plugins": []
    }
  ]
}

config_summary is a redacted view — secrets from system_config are never included. Used for the per-agent plugin badges in the UI.

Compaction

Endpoints related to context compaction per ADR-0024. The full firing semantics are in agent.md § Compaction; these endpoints are the operator-facing surface.

POST /api/v1/sessions/:id/compact

Manually trigger compaction for an agent in the session.

Request:

{
  "agent_path": "primary",
  "strategy": "summarize",
  "dry_run": false
}
Field Required Description
agent_path no Defaults to "primary". The agent whose window to compact.
strategy no Override the configured strategy for this one event. Otherwise uses AgentSpec.compaction.strategy for the agent.
dry_run no If true, computes the proposed compaction without committing. Returns the proposed CompactionRecord and the proposed message list. Used by the UI's strategy-preview.

Response (202, accepted — compaction runs async):

{
  "compaction_id": "01HXCD...",
  "session_id": "ses_abc123",
  "agent_path": "primary",
  "trigger": "operator_manual",
  "strategy": "summarize",
  "dry_run": false
}

The actual compaction event progresses asynchronously; the UI subscribes to the events WebSocket channel (per § WebSocket protocol) for compaction.triggeredcompaction.completed (or compaction.failed) events with this compaction_id.

For dry_run: true, the response is synchronous (200) and includes the proposed result inline:

{
  "session_id": "ses_abc123",
  "agent_path": "primary",
  "strategy": "summarize",
  "dry_run": true,
  "proposed": {
    "trigger": "operator_manual",
    "threshold_estimate": 0.91,
    "after_estimate": 0.55,
    "superseded_message_ids": ["msg_001", "msg_002", "..."],
    "summary_message": {
      "role": "system",
      "content": "Summarized 12 messages covering JWT auth implementation...",
      "metadata": { "kind": "compaction_summary" }
    },
    "plugins_fired": [
      { "name": "memory-markdown", "role": "observer", "duration_ms": 42, "result_kind": "inject" }
    ]
  }
}

The dry-run does not write to storage and does not actually invoke the summarizer model (it estimates cost from the model metadata catalog).

If agent_path, strategy, or dry_run are omitted, the daemon defaults them in-handler ("primary", the agent's configured strategy, and false, respectively). A semantically invalid strategy value returns 422 invalid_input rather than 400 validation_failed so clients can distinguish body-shape failures from compaction-override failures.

GET /api/v1/sessions/:id/compactions

List compaction events for a session.

Query parameters:

  • ?agent_path=<path> — filter to a specific agent's compactions. Default: all agents.
  • ?limit=N (default 50, max 200).
  • ?cursor=<id> — pagination cursor.

Response:

{
  "items": [
    {
      "id": "01HXCD...",
      "session_id": "ses_abc123",
      "run_id": "run_...",
      "agent_path": "primary",
      "created_at": 1716300000000,
      "trigger": "threshold_crossed",
      "strategy": "summarize",
      "threshold_estimate": 0.87,
      "after_estimate": 0.58,
      "superseded_count": 12,
      "summary_message_id": "msg_summary_001",
      "summary": "Summarized 12 messages...",
      "plugins_fired": [
        { "name": "memory-markdown", "role": "observer", "duration_ms": 42, "result_kind": "inject" }
      ],
      "plugin_cost": {
        "provider": "anthropic",
        "model": "claude-haiku-4",
        "input_tokens": 3400,
        "output_tokens": 280,
        "cost_usd": 0.0042
      },
      "fallback_occurred": false,
      "fallback_reason": null,
      "operator_flag": null,
      "operator_notes": null
    }
  ],
  "next_cursor": null
}

GET /api/v1/sessions/:id/context-estimate

Return the daemon's current context-window estimate for the session's primary agent. The daemon resolves the session's project, loads the live project DSL, resolves the primary model alias through local config, reconstructs the compactable message list, and runs the shared token estimator from @kaged/llm.

Response:

{
  "input_tokens": 48321,
  "context_window": 200000,
  "fraction": 0.261,
  "algorithm": "tiktoken"
}
Field Type Meaning
input_tokens integer Estimated input tokens for the current reconstructed session context.
context_window integer | null The model's advertised max input window, or null if the model catalog has no metadata.
fraction number (input_tokens + reserved_output_tokens) / context_window; 0 when no window metadata is available.
algorithm enum "tiktoken" when model metadata declares a tiktoken tokenizer; otherwise "fallback".

GET /api/v1/sessions/:id/compactions/:cid

Full detail for a single compaction event, including the full message-superseded list and the summary message content (for the Compactor UI's audit drill-down).

Response includes everything from the list endpoint plus:

{
  "superseded_messages": [
    { "id": "msg_001", "role": "user", "content": "...", "created_at": 1716200000000 },
    "..."
  ],
  "summary_message": {
    "id": "msg_summary_001",
    "role": "system",
    "content": "...",
    "metadata": { "kind": "compaction_summary", "compactor_cost": { ... } }
  }
}

PATCH /api/v1/sessions/:id/compactions/:cid

Attach operator feedback to a compaction event (per ADR-0024's feedback-loop requirement).

Request:

{
  "flag": "bad",
  "notes": "Lost the deploy-config context that was 8 messages back."
}
Field Required Description
flag no One of "good", "bad", "neutral", or null to clear.
notes no Free-text operator notes (max 4096 chars).

Response (200): the updated CompactionRecord.

Emits compaction.flagged audit event. Feedback is invaluable during compactor-plugin development; the Compactor UI surfaces aggregate flag stats per plugin.

If flag is outside the allowed set or notes exceeds 4096 characters, the daemon returns 422 invalid_input.

Compaction WebSocket events

Compaction events stream on the existing events channel (per § WebSocket protocol):

Event type Payload
compaction.triggered { session_id, compaction_id, agent_path, trigger, strategy, threshold_estimate }
compaction.completed { session_id, compaction_id, agent_path, superseded_count, summary_message_id, after_estimate, duration_ms }
compaction.failed { session_id, compaction_id, agent_path, attempted_strategy, fallback_strategy, reason }
compaction.flagged { session_id, compaction_id, flag, notes_length } (broadcast so other operators see the flag if multi-operator)

The UI uses these to invalidate compaction history and then re-fetch GET /api/v1/sessions/:id/context-estimate for the live footer indicator and Compactor view.

Audit log

Log streaming

Per ADR-0030. The daemon exposes a Server-Sent Events endpoint for live log streaming, scoped to a project. This is complementary to the paginated log endpoints (which return historical entries). The SSE endpoint streams only new entries written after subscription.

GET /api/v1/projects/:slug/logs/stream

Opens an SSE connection that receives new log entries in real time.

Request:

GET /api/v1/projects/:slug/logs/stream
Accept: text/event-stream

Query parameters (filtering, same semantics as the paginated endpoints):

Parameter Type Default Description
level string Filter to this level and above. One of: debug, info, warn, error.
source string Filter to a single source. One of: daemon, plugin, session, subagent.

No pagination parameters — SSE is a forward-only live tail.

Response:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

event: log
data: {"id":"01JX...","ts":1748700000000,"level":"error","source":"plugin","message":"...","projectId":"my-project","sessionId":"ses_abc","pluginName":"memory-markdown","context":{...}}

event: log
data: {"id":"01JY...","ts":1748700001000,"level":"info","source":"daemon","message":"Session created",...}

: keepalive
  • Event type log: carries a single OperationalLogEntry as JSON (same shape as the paginated endpoints).
  • Comment lines (: keepalive): sent every 30 seconds to prevent proxy idle-timeout disconnects. Browsers ignore SSE comment lines.
  • No replay. The stream only contains entries written after the connection is established. The client fetches backlog via GET /projects/:slug/logs and deduplicates by id.

Lifecycle:

  1. Client opens EventSource on the stream URL.
  2. Daemon validates the project exists and filters are valid. Returns 404 or 400 (normal JSON error) before switching to streaming.
  3. Daemon streams matching log entries as they are written.
  4. Client calls eventSource.close() to disconnect.
  5. Daemon cleans up the subscription on disconnect.

Error responses (before stream starts):

Status Code When
400 invalid_parameter level or source is not a valid value
404 not_found Project ID does not exist
503 unavailable Daemon is starting up or shutting down

Once the stream is active (headers sent), errors are not sent as SSE events — the daemon closes the connection.

Proxy considerations:

  • Vite's dev proxy passes text/event-stream responses through without buffering.
  • Reverse proxies (nginx, Caddy) may buffer SSE by default. Operators must disable proxy buffering for /api/v1/projects/*/logs/stream (e.g., proxy_buffering off; in nginx, or flush_interval -1 in Caddy).
  • The X-Accel-Buffering: no header is set on SSE responses to instruct nginx-compatible proxies to disable buffering.

GET /api/v1/audit

Query the audit log. Parameters:

  • ?since=<timestamp> — millisecond epoch.
  • ?until=<timestamp>.
  • ?project_id=<id> — optional project filter. When omitted, returns global events.
  • ?session=<id>.
  • ?event_type=auth.*,prompt.edit,subagent.spawn.uncaged — comma-separated event-type filter, glob-friendly.
  • ?cursor=<opaque> and ?limit=N — pagination.

Each entry:

{
  "id": "01HXAB...",
  "ts": 1716300000000,
  "event_type": "subagent.spawn.uncaged",
"user_id": "operator@example.com",
  "request_id": "01HXAB...",
  "project_id": "music-site",
  "session_id": "01HXAB...",
  "payload": { "subagent_name": "deployer", ... }
}

Audit log entries are append-only. The endpoint does not support delete or update.

When project_id is provided, the response is scoped to audit entries for that project only. If no events exist yet, the daemon returns the normal empty paginated shape for that project context.


Notifications (ADR-0047)

Session notification endpoints — VAPID public key, push subscriptions, per-session bell override, channel registry, test dispatch. The daemon-side notification pipeline is specified in notifications.md; the UI half in ui/notifications.md.

All endpoints require authentication. Push-subscription endpoints additionally require an operator-scoped session (the subscription is bound to the operator, not the project or session).

GET /api/v1/notifications/channels

Returns the channels currently registered with the daemon's notification router. Identifies built-ins (in-app, web-push) and plugin channels (ntfy, pushover, etc.).

{
  "channels": [
    { "id": "in-app", "label": "In-app", "kind": "builtin" },
    { "id": "web-push", "label": "Web Push", "kind": "builtin" },
    { "id": "ntfy", "label": "ntfy", "kind": "plugin", "plugin": "ntfy" }
  ]
}

GET /api/v1/notifications/vapid-public-key

Returns the daemon's Web Push VAPID public key as a base64url string. The UI uses this when calling pushManager.subscribe({ applicationServerKey }).

  • 200: { "public_key": "<base64url>", "fingerprint": "<sha256:hex>" } — the fingerprint lets the UI display which key subscriptions are bound to.
  • 503: { "error": "web_push_unavailable", "reason": "vapid_not_configured" | "vapid_invalid" } — Web Push is not registered on this daemon.

POST /api/v1/notifications/subscriptions

Register a new push subscription for the current operator. The body is the JSON-serialised PushSubscription from the browser, plus the operator's user-agent (advisory):

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/...",
  "keys": { "p256dh": "BNa...", "auth": "tB..." },
  "expiration_time": null,
  "user_agent": "Mozilla/5.0 ..."
}
  • 201: { "id": "<uuid>", "endpoint": "...", "user_agent": "...", "created_ms": ... } — subscription stored. keys.auth and keys.p256dh are never returned.
  • 400: { "error": "invalid_subscription" } — missing required fields or malformed endpoint URL.
  • 400: { "error": "user_visible_only_required" } — the subscription lacks userVisibleOnly: true. The daemon refuses to store subscriptions that don't honour ADR-0047 §6.
  • 403: { "error": "web_push_unavailable" } — VAPID not configured on the daemon.

GET /api/v1/notifications/subscriptions

List the current operator's push subscriptions. Returns only safe fields:

{
  "subscriptions": [
    { "id": "...", "endpoint": "...", "user_agent": "...", "created_ms": ..., "expiration_ms": null }
  ]
}

The endpoint value is returned verbatim so the UI can identify which push service (FCM, Apple, Mozilla) each subscription targets.

DELETE /api/v1/notifications/subscriptions/:id

Delete a push subscription. 204 on success. 404 if not found or owned by another operator.

PUT /api/v1/projects/:slug/sessions/:sid/notification-bell

Set the per-session bell override (per notifications.md § Per-session bell values). Set-not-merge — the override fully replaces the project-resolved routing for this session.

{ "bell": "default" | "all" | "attention-only" | "silent" }
  • 200: returns the updated session object (the bell value is on the session row).
  • 400: invalid bell value.

POST /api/v1/notifications/test

Emit a synthetic NotificationEvent for end-to-end verification. Used by the UI's "Test dispatch" button and the kaged notifications test CLI. Requires operator role.

{
  "event_class": "attention.required" | "run.completed",
  "channel": "ntfy" | "web-push" | "in-app" | "...",
  "project_id": "<optional, defaults to a synthetic project>",
  "session_id": "<optional, defaults to a synthetic session>"
}
  • 200: { "outcomes": [{ "channel": "...", "status": "delivered" | "failed", "reason"?: "..." }] } — the synthetic event fanned out to the eligible channels and these were the results.
  • 400: invalid arguments.

The synthetic event bypasses the presence gate so the operator can verify tier-3 channels even when their own session is live.


WebSocket protocol

The WebSocket is the daemon's streaming surface. One socket per session, multiplexed over named channels.

Upgrade

GET /api/v1/sessions/:id/socket
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: ...
Sec-WebSocket-Protocol: kaged.v1
  • The WebSocket subprotocol is kaged.v1.
  • Auth headers are checked on the upgrade request. The CSRF token must be present on the upgrade. Once upgraded, no further auth checks happen on the socket — disconnection is the only auth-revocation path.
  • The daemon accepts at most one operator connection per session at a time. A second connection attempt with the session already attached returns 409 conflict on the upgrade. To reattach from a new device, the operator first closes the old socket (or the daemon detects an idle timeout, see Reconnection).

Frame structure

All non-PTY frames are UTF-8 JSON. Each frame has a top-level shape:

{
  "channel": "control" | "output" | "pty" | "events",
  "seq": 1234,
  "type": "...",
  "payload": { ... }
}
  • channel routes the frame to the right handler.
  • seq is monotonic per channel per direction. Clients use it to detect dropped frames and replay on reconnect.
  • type is channel-specific.
  • payload is type-specific.

PTY data uses binary frames (see PTY channel).

Channels

control channel

Bidirectional. Session-level control flow.

Client → Server:

type payload Meaning
hello { "resume_from_seq": { "output": N, "events": M } } (optional) First frame after upgrade. Declares which sequence numbers the client has already seen. Omitted on a fresh attach (no prior state); the server then sends live frames without replay. Present only on a reconnect where the client has non-zero seq values.
ping {} Keepalive. Server responds with pong.
subscribe { "channels": ["output", "events"] } Opt into channels the client wants. PTY is opt-in by name ({ "channels": ["pty:<invocation_id>"] }).
unsubscribe { "channels": [...] } Drop channels.

Server → Client:

type payload Meaning
welcome { "session_id": "...", "server_seq": { ... } } Reply to hello. Server's current sequence per channel. Client uses this to know what was missed.
pong {} Reply to ping.
closing { "reason": "...", "code": "..." } Server is closing the socket (daemon shutdown, session ended, idle timeout).

output channel

Server → Client only. Streamed model output, tool calls, subagent dispatches.

type payload Meaning
message.start { "message_id": "...", "role": "primary", "started_at": ... } A new message is starting.
message.delta { "message_id": "...", "delta": "...text..." } A chunk of streamed text.
message.tool_call { "message_id": "...", "name": "subagent.invoke", "input": { ... } } The model called a tool (subagent dispatch is one of them).
message.tool_result { "message_id": "...", "input": "...", "output": "..." } The tool returned.
message.end { "message_id": "...", "ended_at": ..., "stop_reason": "...", "run_id": "...", "error_message": "..."?, "code": "..."?, "retryable": true?, "retry_after_until": ...? } Message done. run_id identifies the run; on failure, error_message, the normalized code, retryable (Tier 2 arming gate), and optional retry_after_until are present.
subagent.start { "invocation_id": "...", "name": "scraper", "cage_status": "caged", "started_at": ... } A subagent was dispatched.
subagent.output `{ "invocation_id": "...", "stream": "stdout" "stderr", "text": "..." }`
subagent.end { "invocation_id": "...", "exit": 0, "ended_at": ... } Subagent finished.

events channel

Server → Client only. Session lifecycle and checkpoint events.

type payload Meaning
session.state `{ "state": "running" "paused"
checkpoint.requested `{ "checkpoint_id": "...", "initiator": "operator" "model", "reason": "..." }`
checkpoint.paused { "checkpoint_id": "...", "paused_at": ... } The agent has halted.
checkpoint.resumed { "checkpoint_id": "...", "resumed_at": ..., "edits_applied": [...] } Resume succeeded.
run.started { "run_id": "...", "message_id": "..." } A new run is processing a message.
run.ended `{ "run_id": "...", "outcome": "completed" "failed"
attention.required NotificationEvent (see notifications.md § Payload) Operator action requested (checkpoint / ask / approval gate). Emitted alongside the existing interaction.requested / compaction.checkpoint telemetry. Drives the in-session bell + tier-2/3 fan-out.
run.completed NotificationEvent (see notifications.md § Payload) Run reached a terminal state. Emitted alongside the existing run.ended telemetry with class: "run.completed", run_outcome: <outcome>.
audit.warning `{ "warning": "insecure-mode" "no-sandbox", "since": ... }`
issue.created { "project_id": "...", "number": ... } An issue was filed in this session's project (by operator or by an agent via kaged.issue.create). The UI invalidates the project's issue list so the sidebar refreshes live.
issue.updated { "project_id": "...", "number": ... } An issue in this session's project was updated, transitioned, or commented on. Same UI invalidation.

Project-scoped fan-out (issue events). Issues are project-scoped, but the events channel is session-scoped. When an issue is created/updated, the daemon publishes issue.created / issue.updated to every session socket of that project (it enumerates the project's sessions and emits to each). A client open on any session of the project therefore receives the event and refreshes its issue list. This avoids a separate project-scoped socket in v0; if a session-less project surface needs live issue events later, a dedicated project socket can supersede this fan-out.

pty channel

The PTY broker. One PTY per subagent invocation that requests one (per ADR-0002, the terminal is a capability).

PTY channels are addressed by invocation ID: pty:<invocation_id>.

  • Subscription: client sends subscribe { channels: ["pty:01HXAB..."] } on control.
  • Data frames: binary WebSocket frames. The first byte is the channel discriminator (0x01 = stdout/stderr from PTY, 0x02 = control event). The remaining bytes are raw PTY output (UTF-8 but may contain partial multibyte sequences mid-frame; clients buffer).
  • Input: client sends binary frames in the same shape (0x01 prefix + bytes) to write to the PTY's stdin.
  • Resize: client sends a JSON frame on control:
    { "channel": "control", "type": "pty.resize", "payload": { "invocation_id": "...", "cols": 80, "rows": 24 } }
    
  • End: server emits a subagent.end on output when the PTY closes. No further PTY frames after that.

Channel ordering and back-pressure

  • Within a channel, frames are ordered (TCP guarantees in-order; sequence numbers detect drops on reconnect).
  • Across channels, frames are not ordered relative to each other. (A subagent.end on output may arrive before the final PTY bytes of that subagent on pty. Clients reconcile via the invocation ID.)
  • The daemon applies per-channel back-pressure: if the client is slow, the server buffers up to a configurable limit per channel (default: 4MB per output, 1MB per PTY, 64KB per events). Exceeding the limit closes the socket with closing { code: "backpressure" }.

Reconnection

The expected case is "operator's phone dropped Wi-Fi mid-task." Per the vision doc, sessions survive the operator disconnecting.

  • Server state: the daemon keeps the session and all its in-flight work alive. Frames intended for the (now-disconnected) client are buffered up to a deadline (default: 10 minutes).
  • Reconnect: client opens a new socket to /api/v1/sessions/:id/socket. Sends control { type: "hello", payload: { resume_from_seq: { ... } } }.
  • Server reply: control { type: "welcome", payload: { server_seq: { ... } } }. If server_seq.output > resume_from_seq.output, the daemon replays the missed frames in order.
  • If the buffer overflowed (gap too large to replay), the server sends closing { code: "resume_failed" } and the client must do a full re-fetch via GET /api/v1/sessions/:id/messages?since=<last-seen-message-id>.
  • Idle disconnect: if the client is gone for >10 minutes with no reconnect, the session remains active but unattached. The next attach is a fresh hello with no resume.

The PTY channel does not replay on reconnect. PTY scrollback is recovered via GET /api/v1/sessions/:id/runs/:rid which includes the saved transcript up to a recent moment.

Closing

Either side may close. Standard WS close codes apply, plus kaged-specific reason codes in the closing control frame:

code When
server_shutdown Daemon is restarting.
session_ended Session was deleted or ended elsewhere.
idle_timeout Client was unresponsive past the limit.
backpressure Buffer overflow.
auth_revoked (Future) auth revocation while connected.
policy_violation Client sent a malformed frame.
replaced Another client attached and took over.

The server gives the client a 1-second window to flush before closing the underlying socket.


System WebSocket

A daemon-wide WebSocket endpoint for events that are not scoped to a single session. The UI connects at app-shell level to receive live running-session counts for the bottom bar indicator.

Upgrade

GET /api/v1/socket
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: kaged.v1
  • Same auth and CSRF contract as the per-session socket (checked on upgrade, not per-message).
  • Multiple connections allowed. Unlike the per-session socket (one operator connection per session), the system socket accepts multiple concurrent connections. This is the global event bus — every connected UI instance receives the same events.
  • The daemon accepts the upgrade as long as auth is valid. No session binding, no channel subscription (the system socket has a single system channel).

Frame structure

Same WsFrame envelope as the per-session socket:

{
  "channel": "system",
  "seq": 1,
  "type": "...",
  "payload": { ... }
}

Events

type payload Meaning
sessions.running_count { "total": 3, "per_project": { "music-site": 2, "infra-monitor": 1 }, "per_operator": { "operator": 3 }, "limits": { "per_project": 4, "per_operator": 16 } } Running session count changed. Fires on every run start, run end, session end (if was running), or session failure (if was running).
system.hello { "daemon_version": "0.1.0", "server_seq": 0 } Sent immediately after upgrade. Confirms connection and provides initial sequence number.

sessions.running_count details:

  • total is the daemon-wide count of sessions in running state.
  • per_project maps project IDs to their running count. Only projects with ≥1 running session are included.
  • per_operator maps operator user IDs to their running count. In per-user mode, this is always a single entry.
  • limits echoes the configured concurrency limits so the UI can render 3/4 or 3/16 without a separate fetch.

When it fires:

  • Run starts (idlerunning or queuedrunning).
  • Run ends (runningidle via completion, failure, or cancellation).
  • Session ends while running (runningended or runningfailed).
  • Daemon startup recovery (sessions found in running state are counted after crash recovery marks them idle/failed).

When it does NOT fire:

  • Session creation (no running count change).
  • Checkpoint pause/resume (runningpaused decrements the count; pausedrunning increments it — these DO fire).
  • Queued message (idlequeued does not change running count).

Reconnection

The system socket does not buffer frames for disconnected clients. On reconnect, the client receives a fresh system.hello and then events as they occur. The client should fetch the current count via GET /api/v1/projects/:slug/status (or derive it from session list queries) to fill the gap between disconnect and reconnect. In practice the gap is negligible — the count changes infrequently relative to human perception.

Closing

Same closing control frame as the per-session socket. Reason codes: server_shutdown, idle_timeout, policy_violation.


Guest workflow endpoints

Guest workflow endpoints mirror the operator workflow surface under the /api/v1/g/ prefix. All guest endpoints use cookie-based guest auth (kaged_guest_session); no CSRF token is required. Grant gating enforces that guests can only see and invoke workflows they have been granted access to. Enumeration is indistinguishable — a workflow the guest has no grant for returns 404 not_found, never a distinguishable "forbidden."

See users.md for the grant model and workflows.md for execution semantics. Guest-specific constraints: per-guest concurrency cap of 1 (vs. project cap of 4), no inline confirm skip, own-runs-only listing.

GET /api/v1/g/projects/:slug/workflows

List workflows the guest has been granted access to. Same shape as the operator endpoint, filtered by grant.

GET /api/v1/g/projects/:slug/workflows/:name

Describe a single workflow. Returns 404 if the guest has no grant for this workflow.

POST /api/v1/g/projects/:slug/workflows/:name/upload

Upload a file for a workflow input. Same semantics as the operator upload endpoint.

POST /api/v1/g/projects/:slug/workflows/:name/invoke

Invoke a workflow as a guest. Per-guest concurrency cap of 1; returns 429 when exceeded. Guests cannot invoke workflows with confirm_required: true gates — those require operator confirmation after invocation.

GET /api/v1/g/projects/:slug/workflows/:name/runs

List the guest's own invocations for a workflow. Only runs created by this guest are returned (own-runs-only).

GET /api/v1/g/workflows/invocations/:iid

Get detail for a single workflow invocation. The guest must be the invoker; returns 404 otherwise.

POST /api/v1/g/workflows/invocations/:iid/confirm

Confirm a pending confirm step on a guest invocation. Guests cannot skip the pre-dispatch confirm gate (confirm_required); this endpoint handles mid-execution confirm steps only.

POST /api/v1/g/workflows/invocations/:iid/cancel

Cancel a guest's own workflow invocation.

GET /api/v1/g/sessions/:id/socket

WebSocket upgrade for a guest workflow session. The guest can only subscribe to sessions belonging to their own invocations. Same frame protocol as the operator session socket, scoped to the output channel only.


UI routes

Routes that serve HTML/JS for the web UI. Per ADR-0002 the UI is bundled and served by the daemon.

GET /                          → app shell (HTML)
GET /static/*                  → JS, CSS, fonts, SVG, etc.
GET /static/manifest.json      → PWA manifest
GET /static/sw.js              → service worker

UI routes are unauthenticated (the sidecar handles auth in front of the daemon for both UI and API). The CSRF cookie is set on the first GET /api/v1/me call after the page loads.

Bun's HTML imports serve these directly (per AGENTS.bun.md):

import index from "./ui/index.html";

Bun.serve({
  routes: { "/": index, "/static/*": ... },
  ...
});

Rate limits

v0 ships with these limits, all per-operator (which in single-user mode is per-daemon):

Endpoint shape Limit Reason
POST /api/v1/sessions/:id/messages 30 / minute Prevents loops where the UI repost-loops on transient errors.
POST /api/v1/dsl/validate 120 / minute Editor lints can be chatty.
PUT /api/v1/projects/:slug/dsl 10 / minute Saves to disk.
GET /api/v1/audit 30 / minute Heavy queries.
All other endpoints 600 / minute combined Generous default.

Limit exceedance: 429 rate_limited with details.retry_after_ms. WebSocket frames are not rate-limited at the API layer; the per-channel back-pressure limits cover that.


Failure modes

Failure Detection Response
Daemon overloaded Internal queue depth check 503 unavailable with Retry-After header
Storage unreachable First DB call fails 503 with details.subsystem: "storage"
LLM provider down Provider plugin call times out 502 provider_unreachable for the affected message; session enters [BLOCKED] state
Plugin crashed Plugin host detects EOF on stdio 502 for calls touching that plugin; daemon restarts the plugin (per ADR-0008)
Cage spawn failed Supervisor failed to apply cage policy Session-side: subagent marked failed. API: subsequent GET /run shows the failure.
WebSocket buffer overflow Per-channel limit exceeded Close with closing { code: "backpressure" }
Daemon shutting down SIGTERM received Existing WS sockets get closing { code: "server_shutdown" }. New requests get 503.

The daemon does not auto-restart subagents. A failed subagent stays failed; the operator's next message can trigger a retry.


Worked example: a session from message to PTY

1. Client: POST /api/v1/projects/music-site/sessions
   Server: 201 { id: "01HX-S...", state: "idle" }

2. Client: GET /api/v1/sessions/01HX-S.../socket (upgrade)
   Server: 101 Switching Protocols
   Client: { channel: "control", type: "hello", payload: { resume_from_seq: { output: 0, events: 0 } } }
   Server: { channel: "control", type: "welcome", payload: { server_seq: { output: 0, events: 0 } } }
   Client: { channel: "control", type: "subscribe", payload: { channels: ["output", "events"] } }

3. Client: POST /api/v1/sessions/01HX-S.../messages
            body: { content: "Scrape new releases." }
   Server: 202 { id: "01HX-M...", accepted_at: ... }

4. Server WS pushes (on `events`):
   { type: "run.started", payload: { run_id: "01HX-R...", message_id: "01HX-M..." } }
   { type: "session.state", payload: { state: "running" } }

5. Server WS pushes (on `output`):
   { type: "message.start", payload: { message_id: "01HX-A...", role: "primary", started_at: ... } }
   { type: "message.delta", payload: { message_id: "01HX-A...", delta: "I'll dispatch the scraper..." } }
   { type: "message.tool_call", payload: { name: "subagent.invoke", input: { name: "scraper", task: "..." } } }
   { type: "subagent.start", payload: { invocation_id: "01HX-I...", name: "scraper", cage_status: "caged", started_at: ... } }
   { type: "subagent.output", payload: { invocation_id: "01HX-I...", stream: "stdout", text: "..." } }
   ...
   { type: "subagent.end", payload: { invocation_id: "01HX-I...", exit: 0, ended_at: ... } }
   { type: "message.tool_result", payload: { ... } }
   { type: "message.delta", payload: { message_id: "01HX-A...", delta: "Found 3 releases." } }
   { type: "message.end", payload: { message_id: "01HX-A...", ended_at: ..., stop_reason: "end_turn" } }

6. Server WS pushes (on `events`):
   { type: "run.ended", payload: { run_id: "01HX-R...", outcome: "completed" } }
   { type: "session.state", payload: { state: "idle" } }

If the operator had wanted a terminal to a subagent during step 5:

5a. Client: { channel: "control", type: "subscribe", payload: { channels: ["pty:01HX-I..."] } }
5b. Server: (binary frames) 0x01 <bytes of terminal output>
5c. Client: { channel: "control", type: "pty.resize", payload: { invocation_id: "01HX-I...", cols: 120, rows: 40 } }
5d. Client: (binary frame) 0x01 <bytes of user keystrokes>

Testing notes

Per ADR-0003, the test corpus this spec implies:

  • One contract test per endpoint — request shape, response shape, error shape. Schema-validated against a Zod definition derived from this spec.
  • One auth test per protected endpoint — missing header, wrong nonce, wrong CSRF, insecure mode each return the documented response.
  • One pagination test — every paginated endpoint exercises the cursor.
  • WebSocket session simulator — drives a mock session through hello → subscribe → message post → output stream → end → reconnect → resume.
  • Reconnect tests — buffer-full → resume_failed; clean disconnect → resume succeeds; idle timeout → fresh hello.
  • Rate-limit tests — exceed each documented limit, assert 429 with retry_after_ms.
  • Error-shape tests — every error code listed above has a path that produces it.
  • Header-leak testsX-Kaged-Warning is present iff the daemon is in the corresponding mode. Never present otherwise.
  • CSRF tests — state-changing endpoints reject missing/mismatched tokens in both auth modes.

The DSL-related endpoints are tested against the same fixtures as project-dsl.md's schema tests, so a single canonical-error change updates both surfaces.


Open questions

  1. Streaming uploads / downloads. Some operator workflows want to upload a tarball or stream a large log. v0 keeps everything JSON-shaped. If uploads become needed before v1, they'll be a new endpoint family (/api/v1/blobs/...) with multipart bodies.
  2. Server-Sent Events as a fallback. Some corporate networks break WebSockets. Whether kaged ships an SSE fallback for the output and events channels is open. Lean: not in v0; revisit if real-world deployments hit it.
  3. GraphQL or other RPC layer. Considered and rejected for v0. REST + WS is the lingua franca; we don't pay the GraphQL complexity tax until a real need shows up.
  4. WebHooks. "Tell my external system when run.ended fires" is a reasonable v1.x ask. Not in v0.
  5. OpenAPI publication. We should publish a machine-readable OpenAPI 3.1 description of v1 once the surface stabilizes. Open whether the spec is generated from this doc or vice versa.
  6. Pre-flight CORS. If the operator runs the daemon behind one origin and the UI behind another, CORS becomes a concern. Resolved by ADR-0040 (see Cross-origin (CORS) and advertised base). The daemon echoes the configured ui_url origin in Access-Control-Allow-Origin, allows Authorization/Content-Type/X-Kaged-CSRF on preflight, and sets Access-Control-Allow-Credentials: true only on the cookie-fallback path. Cross-origin auth uses the bearer transport, not cross-site cookies.

Cross-origin (CORS) and advertised base (ADR-0040)

When the UI is hosted on a different origin from the daemon (the ui.kaged.dev ↔ remote-daemon split), the daemon enables CORS for its configured UI origin:

  • ui_url configured (split mode): the daemon echoes the ui_url origin in Access-Control-Allow-Origin (it does not use *). Preflight (OPTIONS) responses allow the request headers Authorization, Content-Type, and X-Kaged-CSRF. Access-Control-Allow-Credentials: true is set only on the same-origin cookie-fallback path; the bearer path needs no credentialed CORS because it sends no cookies.
  • ui_url empty (co-located): unchanged — the daemon serves the SPA from the same origin and CORS is moot.
  • Advertised base. The browser-reachable origin of the daemon is daemon.public_url (env KAGED_PUBLIC_URL); behind a tunnel the daemon cannot infer it, so it is explicit, with Host + X-Forwarded-Proto used only as a fallback. The unauthenticated HTML GET / (Accept: text/html) 302s to ${ui_url}/connect?api=${public_url}/api/v1 when ui_url is set, and serves the SPA when unset. /connect itself is a UI route, not an API endpoint. See daemon.md.

Amendments

2026-07-02 — hello resume_from_seq is optional on fresh attaches

The hello control frame's resume_from_seq field is optional. A fresh attach (first connect, page reload, or reconnect after a resume_failed reset) sends hello with an empty payload — no resume_from_seq. The server treats this as "no replay requested" and sends live frames from the current point onward; the client recovers history via HTTP re-fetch.

Previously the spec example showed { "resume_from_seq": { "output": 0, "events": 0 } } unconditionally. A client sending { 0, 0 } into a session whose replay buffer had evicted old frames triggered closing { code: "resume_failed" } on every reconnect — an infinite loop, because the resume_failed handler clears the client's seq store, so the next attempt sends { 0, 0 } again. Omitting the field entirely when the client has no prior state breaks the loop; the server already handles its absence correctly (ws.ts skips replay when resume_from_seq is absent).

This aligns with §Reconnection: "The next attach is a fresh hello with no resume."

2026-07-01 — POST /api/v1/sessions/:id/messages: continuation flag

Backs the UI auto-continue affordance (ui/README.md § 2026-07-01) and the daemon rule in session-manager.md § Continue-suppression at tool boundaries.

  1. continuation field added to the POST /api/v1/sessions/:id/messages request body (boolean, optional, default false; PostMessageRequestSchema in @kaged/wire). When true and the previous run ended at a tool boundary, the daemon starts a new run on the existing history without persisting the operator message; otherwise the message is persisted and dispatched normally.
  2. No new endpoint, state, or response shape. The response envelope (202, plus the concurrency queued/reason/running_count/limit fields) is unchanged. Suppression only affects whether one messages row is written.
  3. Not content-matched. The flag — not the literal string "continue" — triggers suppression, so an operator typing "continue" by hand is unaffected.
  4. Test notes. Covered by the session-manager tests: tool-boundary tail → no operator row + new run; text tail → persisted + dispatched; flag absent → baseline; suppression respects the concurrency throttle.

2026-06-29 — Session summary: kind, resolved created_by_user, list ?kind= filter

Driven by the operator session-sidebar rework. The session summary already carried state, name, and created_by (raw id) but not the session kind (which exists in storage as sessions.kind, see workflows.md §Storage) nor a resolved creator identity. The UI needs both to (a) filter the sidebar by session type and (b) render a creator handle without N per-row lookups.

  1. kind added to the session summary. "chat" | "workflow" | "edit", sourced from sessions.kind (default chat). Returned by both GET /api/v1/projects/:slug/sessions items and GET /api/v1/sessions/:id.
  2. created_by_user added to the session summary. The resolved creator identity { user_id, handle, display_name, has_avatar } — identical to the message-author shape — or null when the creator does not resolve to a known user record. Resolution is batched per page (one lookupUsers call across the page's created_by ids), not per row. The raw created_by ULID is retained for back-compat.
  3. GET /api/v1/projects/:slug/sessions gains an optional ?kind= query param. chat | workflow | edit. Filters the list to one kind; omitting it returns all kinds. Forwarded into the storage listSessions({ kind }) filter, which already supports it.
  4. No ADR. kind is an already-decided field (workflows.md, session-manager.md); this amendment only exposes it (and the existing user-resolution machinery) over the wire. No new load-bearing decision.

2026-06-25 — ADR-0049: provider store + dynamic loading

Driven by ADR-0049. HTTP contract changes are minimal — the routes are stable; what backs them shifts from kaged-core modules to the resolver + provider plugins.

  1. POST /api/v1/local/providers/:name/models/refresh removed. Replaced by the catalog sync endpoints (/catalog/sync and /catalog/sync/apply) for catalog providers, and the model discovery endpoint (/providers/:name/models/discover) for custom providers.
  2. New custom provider endpoints added. Added PUT /api/v1/local/providers/:name/custom to configure custom providers, and POST /api/v1/local/providers/:name/models/discover to discover models for custom providers.
  3. New catalog endpoints added. Added GET /api/v1/local/catalog to retrieve the bundled catalog snapshot, POST /api/v1/local/catalog/sync to fetch the latest catalog diff, and POST /api/v1/local/catalog/sync/apply to apply the sync with keep decisions.
  4. OAuth provider auth section updated. The OAuth machinery (PKCE, callback server, token store, refresh) is now owned by provider plugins, not by @kaged/llm/oauth (which is deleted). The HTTP contract for /auth/login, /auth/status, /auth/logout is unchanged at the response-shape level, the :name routing and JSON contracts are stable across the migration, but the implementation each route delegates to is the plugin's, not a kaged-core module. Token storage location unchanged: $XDG_CONFIG_HOME/kaged/oauth/<provider>-tokens.json.
  5. Implications for GET /api/v1/local/providers response shape. The response now includes catalog-derived data: providers: CatalogProvider[] and models: CatalogModel[] drawn from the bundled snapshot. The pre-ADR-0049 known_drivers: DriverInfo[] field is removed. (Amended 2026-06-29: per ADR-0049 § Amendments 2026-06-29, the standard drivers are bundled into the binary — there is no on-disk provider store. The previously-planned installed: string[] field is dropped; availability is determined by the bundled-driver registry, which the daemon can expose as a static list. A provider whose package is not bundled requires a provider plugin.) Detailed response shape is in the daemon implementation; the UI consumes catalog entries to render provider config forms dynamically.
  6. Spend limits / usage endpoints unchanged. ADR-0026's pipeline is preserved by ADR-0049; the spend-limit gate moves into the middleware stack but the HTTP contract for /usage, /spend-limits, /models/:modelId/meta is stable.

2026-06-21 — Cookie attribute matrix for cross-origin deployments

Mobile browsers (Safari ITP, Chrome Android) drop SameSite=Lax cookies that arrive via cross-origin CORS fetch responses, breaking the cookie fallback path that <img> and WebSocket rely on (neither can carry Authorization: Bearer). The bearer-authenticated fetch() API path was unaffected, which is why "me" kept succeeding while avatars and sockets 401'd until manual re-login.

  1. New attribute matrix for kaged_user_session and kaged_csrf, derived at startup from the resolved ui.url and daemon.public_url:
    • Loopback HTTP dev (ui.url unset or http:): HttpOnly; SameSite=Lax; Path=/ — unchanged.
    • Same-origin HTTPS (ui.url https: and same origin as daemon.public_url): HttpOnly; Secure; SameSite=Lax; Path=/. (kaged_csrf omits HttpOnly.)
    • Cross-origin HTTPS (ui.url https: and different origin, or daemon.public_url unset): HttpOnly; Secure; SameSite=None; Path=/. (kaged_csrf omits HttpOnly.)
  2. SameSite=None is the cross-origin escape hatch, not a global default — it widens CSRF surface and requires HTTPS, so it is selected only when the deployment actually splits UI and daemon origins.
  3. Secure added to same-origin HTTPS to close the pre-existing drift where the spec said "Secure when served over TLS" but the code omitted it.
  4. kaged_session (loopback nonce) untouched. Loopback mode is HTTP-only and same-origin by definition; the matrix does not apply.
  5. SameSite=Strict explicitly not offered. Strict would block the cookie on the top-level redirect into /launch and /auth/sso/callback, breaking both bootstraps.
  6. No ADR. This is a bug fix (cookies didn't survive mobile ITP) constrained to the existing auth contract in ADR-0007 and the cross-origin hosting shape in ADR-0040; it changes cookie attributes only, not the auth model or surface.

2026-06-19 — Notification endpoints + events channel (ADR-0047)

Per ADR-0047:

  1. New events channel frames. attention.required and run.completed ride the existing per-session events channel with the NotificationEvent payload defined in notifications.md. They are emitted alongside the existing telemetry events (interaction.requested, compaction.checkpoint, run.ended), not as replacements.
  2. Notification endpoints. New section ## Notifications: GET /api/v1/notifications/channels (channel registry), GET /api/v1/notifications/vapid-public-key (Web Push public key + fingerprint), POST /api/v1/notifications/subscriptions (register push subscription), GET /api/v1/notifications/subscriptions (list own), DELETE /api/v1/notifications/subscriptions/:id, PUT /api/v1/projects/:slug/sessions/:sid/notification-bell (per-session bell override), POST /api/v1/notifications/test (synthetic dispatch for end-to-end verification).
  3. userVisibleOnly: true mandatory. POST /api/v1/notifications/subscriptions returns 400 with user_visible_only_required if the subscription lacks the flag (the daemon independently checks what the browser set).
  4. Push subscription storage. keys.auth and keys.p256dh are write-only — stored for outbound push, never surfaced. GET /api/v1/notifications/subscriptions returns only { id, endpoint, user_agent, created_ms, expiration_ms }.
  5. Bell set-not-merge. PUT /api/v1/projects/:slug/sessions/:sid/notification-bell fully replaces the project-resolved routing for that session (per ADR-0015 and ADR-0047 §5).
  6. Cross-references. notifications.md (daemon pipeline), ui/notifications.md (UI half).

2026-06-16 — Secrets write-only API + preferences theme/font_size extension

Secrets API (write-only):

  • GET /api/v1/local/secrets — returns section/key entries with set: true | false. Never returns values. Body: { secrets: Array<{ section: string, key: string, set: boolean }> }.
  • PUT /api/v1/local/secrets/:section/:key — sets a secret value. Body: { value: string }. Response: { section, key, set: true }. Write-only — there is no GET equivalent for an individual secret value. Removing a secret is done by editing local.toml directly.

Both endpoints are system scope (operator-only). The GET endpoint walks [secrets.*] in local.toml and reports each (section, key) pair's set/unset status. This lets UI panels show which providers are configured without ever exposing the credentials themselves. The PUT endpoint mutates local.toml via writeLocalConfigFile.

Preferences extension (PUT /api/v1/local/preferences):

  • theme now enum-validated to "dark" | "light" | "system" (was freeform string). Default system.
  • New font_size field: enum "small" | "medium" | "large" | "x-large". Default medium. Scales --ui-font-scale CSS variable on <html> for accessibility.
  • GET /api/v1/local/preferences returns theme, font_size, timezone, locale, interrupt_suffix, forward_prefix.

2026-06-15 — WebSocket reconnection replay implemented (§Reconnection)

The resume_from_seq reconnection protocol described in §Reconnection is now implemented:

  1. Session-scoped sequence counters. Output and events sequence numbers are tracked per session (not per socket). Reconnecting clients see the same sequence space they left.
  2. Replay buffer. The daemon buffers published frames (output + events channels) per session, even when no subscriber is connected. Buffer cap is WS_BUFFER_LIMITS.output + WS_BUFFER_LIMITS.events (~4.06 MB). Oldest frames are evicted when the buffer exceeds the cap.
  3. hello handler replays. When the client sends resume_from_seq in the hello payload, the daemon replays all buffered frames with seq > resume_from_seq in chronological order. If the gap is unrecoverable (evicted frames), the daemon sends closing { code: "resume_failed" } and closes the socket.
  4. Client-side sequence persistence. The UI persists sequence numbers at module scope (not React hook scope), so navigating away and back preserves the last-seen seq across unmount/remount cycles.
  5. resume_failed handling. On receiving closing { code: "resume_failed" }, the UI clears its sequence store for that session and invalidates the session + messages queries, triggering a full HTTP re-fetch.

2026-06-14 — Multi-daemon operator UI: bearer transport, CORS, advertised base (ADR-0040)

Per ADR-0040 (multi-daemon operator UI — runtime daemon registry, cross-origin hosting, bearer transport):

  1. Bearer transport added. The auth gate accepts Authorization: Bearer <session_token> as a first-class identity path. Token format is a versioned opaque kaged.v1.lb.<secret> (loopback → timing-safe compare against the in-process loopback secret) or kaged.v1.us.<sessionId> (→ user_sessions lookup). See Bearer transport.
  2. Gate order amended. Bearer is resolved after sidecar/loopback and before the kaged_user_session cookie; a present-but-invalid Authorization header is a hard 401 (no cookie fallback). The gate now resolves an internal { identity, transport }; transport is visible only to the CSRF decision. Cookie/sidecar/insecure paths are byte-identical when no bearer header is present.
  3. CSRF exemption. Requests with transport === "bearer" are CSRF-exempt (no ambient cookie). CSRF cookie/header retained only for the same-origin cookie path.
  4. session_token in bootstrap responses. GET /api/v1/launch (JSON mode) and POST /api/v1/auth/sso now return session_token in the body (body-only, never logged/URL'd). Loopback mints kaged.v1.lb.…; SSO mints kaged.v1.us.<sessionId> sharing the row's 30-day TTL.
  5. CORS resolved (open question #6). Per-origin allowlist echoing the configured ui_url; preflight allows Authorization/Content-Type/X-Kaged-CSRF; Access-Control-Allow-Credentials only on the cookie path. See Cross-origin (CORS) and advertised base.
  6. Advertised base + bootstrap redirect. daemon.public_url / KAGED_PUBLIC_URL advertises the daemon's browser-reachable origin; unauth HTML GET / 302s to ${ui_url}/connect?api=${public_url}/api/v1 in split mode. The printed loopback launch URL becomes ${ui_url}/connect?api=…&token=…. /connect is a UI route (see ui/README.md), not an API endpoint.

2026-06-11 — Unified user identity, gate resolution, and route-scope authorization (ADR-0036)

  1. kaged_user_session cookie replaces kaged_guest_session; one session table for SSO- and password-bootstrapped sessions. Migration invalidates old guest sessions once.
  2. Gate resolution order documented exactly (sidecar → loopback nonce → kaged_user_session active user → insecure ambient → 401). The --insecure rule generalized: a valid user session still resolves as that user.
  3. Route-scope authorization added: a required access field on RouteDefinition (system/project/guest-realm/account/public), runtime + compile-time default-deny (system), a PROJECT_RESOLVERS registry (resolver-null → 404 before grant logic), streams gated at connect time, and cross-project lists filtered not gated. The exhaustive per-route classification table is the Phase 3 implementation contract.
  4. New endpoints: GET /api/v1/auth/methods (public), POST /api/v1/auth/sso (public, bootstraps SSO sessions), POST /api/v1/auth/logout (account), GET/PATCH /api/v1/account, POST /api/v1/account/password, GET /api/v1/users/:uid/avatar (account), GET /api/v1/users/lookup (account).
  5. Renames: /api/v1/guests*/api/v1/users* (gains role, provider_sub badge, pending filter; PATCH activates/elevates/disables); /api/v1/projects/:slug/guests*/api/v1/projects/:slug/users*. /api/v1/g/logout aliases /api/v1/auth/logout for one release.
  6. GET /api/v1/me gains role and profile fields; ambient identities report role: "operator".
  7. New error details.reason values: sso_disabled, user_creation_disabled, user_pending, user_disabled, invalid_token.
  8. ADR-0007 narrowing: the daemon verifies ES256 SSO token signatures at POST /api/v1/auth/sso only (no OIDC flows). Contract in sso-relay.md; identity model in users.md.

2026-06-11 — Workflow endpoints (operator + guest) per workflows.md

17 workflow routes added (8 operator, 9 guest) implementing the full workflow invocation surface per workflows.md §API.

  1. Operator workflow endpoints added. GET /projects/:slug/workflows (catalog), GET /projects/:slug/workflows/:name (describe), POST /projects/:slug/workflows/:name/upload (file input staging), POST /projects/:slug/workflows/:name/invoke (invoke with input validation + concurrency cap), GET /projects/:slug/workflows/:name/runs (list invocations), GET /projects/:slug/workflows/invocations/:iid (invocation detail with steps), POST .../invocations/:iid/confirm (confirm gate or step), POST .../invocations/:iid/cancel (cancel invocation). See § Workflows.
  2. Guest workflow endpoints added. Same surface under /api/v1/g/ with grant gating: GET/POST /g/projects/:slug/workflows(/:name)/(upload|invoke|runs), GET/POST /g/workflows/invocations/:iid/(confirm|cancel), GET /g/sessions/:id/socket (guest WS for workflow sessions). Guest confirm/cancel routes omit the project prefix. See § Guest workflow endpoints.
  3. v1 route summary updated. Workflow routes added to both operator and guest sections of the route listing.
  4. Route count. Total wired routes now 72+ (was 55 before workflows).

2026-06-05 — Concurrency throttle at message-send: system WebSocket, queued/resume endpoints, session creation unconstrained

The concurrency model is restructured: limits are checked at message-send time, not session-creation time, and exceeded messages queue rather than reject. A system-wide WebSocket broadcasts live running counts for the UI bottom bar.

  1. System WebSocket endpoint added. GET /api/v1/socket — a daemon-wide WebSocket for events not scoped to a single session. Multiple concurrent connections allowed. Single system channel. See § System WebSocket.
  2. sessions.running_count event. Broadcast on the system socket whenever a run starts, ends, or a session transitions in/out of running. Payload includes total, per_project, per_operator, and limits. The UI uses this for the bottom bar running-count indicator.
  3. POST /api/v1/sessions/:id/resume added. Resumes a queued session when a concurrency slot is available. Returns 200 on success, 409 if no slot or session not queued. See § Queued session resume.
  4. DELETE /api/v1/sessions/:id/queued-message added. Discards a queued message, cancels the pending run, returns session to idle. See § Queued session resume.
  5. POST /api/v1/sessions/:id/messages response extended. When the concurrency limit is reached, the response now includes queued: true, reason, running_count, and limit fields (still 202). The per-session 409 conflict (posting while the same session is running) is retained as a separate concern.
  6. POST /api/v1/projects/:slug/sessions — no concurrency limit. Session creation always succeeds regardless of running-session count. The per-project (4) and per-operator (16) limits are enforced at message-send time only.
  7. v1 route list updated with /api/v1/socket, /api/v1/sessions/:id/resume, /api/v1/sessions/:id/queued-message.

2026-06-03 — Session execution overrides: max_steps_override and max_output_tokens_override

Per agent execution limit work:

  1. PUT /api/v1/sessions/:id extended. Now accepts max_steps_override and max_output_tokens_override fields (integer or null). Same semantics as model: persisted to the session record, becomes sticky for all subsequent runs. Range validation: 1100 for max_steps_override, 165536 for max_output_tokens_override.
  2. POST /api/v1/sessions/:id/messages extended. Now accepts max_steps_override and max_output_tokens_override fields in the request body. Same per-message override semantics as model: persisted to the session record before dispatch.
  3. GET /api/v1/sessions/:id response extended. Added max_steps_override: number | null and max_output_tokens_override: number | null fields to the session response.
  4. SessionSummary type updated. All three new fields added to the session summary contract.
  5. Test notes updated. Added tests for: valid override persistence, range validation (422 for out-of-range), null clearing (reverts to DSL default), and override precedence (session-level override beats DSL default).

2026-06-02 — issue.created / issue.updated on the events channel (project-scoped fan-out)

Added two server→client events to the events channel so the operator UI's issue list (sidebar + issue screens) refreshes live when an issue is filed or changed — including by agents via kaged.issue.* tools, not just by the operator.

  1. New events: issue.created and issue.updated, payload { project_id, number }. See events channel.
  2. Project-scoped fan-out: issues are project-scoped but the events channel is session-scoped, so the daemon publishes these events to every session socket of the issue's project (enumerate the project's sessions, emit to each). No dedicated project socket is introduced in v0.
  3. Emitted from: the operator issue mutation handlers (POST /projects/:slug/issues, PATCH /projects/:slug/issues/:number, POST /projects/:slug/issues/:number/updates). The UI invalidates ["projects", projectId, "issues"] on receipt.

These are distinct from the audit events kaged.issue.created / kaged.issue.updated in agent-tooling.md — those record tool invocations in the audit log; these are live UI-refresh signals on the session WebSocket.

2026-05-30 — ADR-0026: Model metadata override CRUD, provider usage endpoints, spend limits

Per ADR-0026:

  1. New endpoints for model metadata overrides:
    • GET /api/v1/local/providers/:name/models/:modelId/meta — returns merged metadata (LiteLLM defaults + operator overrides) with per-field source tracking ("override" or "default").
    • PUT /api/v1/local/providers/:name/models/:modelId/overrides — upsert one or more field overrides. Values are typed (number, boolean, string, null), stored as JSON in the DB.
    • DELETE /api/v1/local/providers/:name/models/:modelId/overrides — delete all overrides for a model (revert to defaults).
    • DELETE /api/v1/local/providers/:name/models/:modelId/overrides/:field — delete a single override field.
  2. New endpoints for provider usage:
    • GET /api/v1/local/providers/:name/usage — returns cached UsageReport. Error values: "no_cache", "no_fetcher".
    • POST /api/v1/local/providers/:name/usage/refresh — force fresh fetch, update cache. Error: "fetch_failed" with detail.
  3. New endpoints for spend limits:
    • GET /api/v1/local/providers/:name/spend-limits — returns limit configuration plus current rolling-window spend computed from provider_spend_events.
    • PUT /api/v1/local/providers/:name/spend-limits — set/update limits. Partial update supported. Set to null to remove a limit. Validates: non-negative USD, percentage in 0.0–1.0.
  4. v1 route list updated with all new endpoints.
  5. Constrained-by list extended with ADR-0026.

2026-05-28 — Project status telemetry endpoint

  1. New endpoint: GET /api/v1/projects/:slug/status returns project-scoped aggregate telemetry for the Project Status UI.
  2. Response shape added: session counts by state, 24-hour activity aggregates (tool_calls_24h, budget_24h, live_subagents), and recent_runs ordered newest-first.
  3. v1 route list updated with the new project status endpoint.

2026-05-27 — ADR-0023 & ADR-0024: plugin knob endpoint, resolved-plugins endpoint, compaction endpoints + events

Per ADR-0023 and ADR-0024:

  1. New endpoint: GET /api/v1/plugins/:name/knobs returns the plugin's knob schema (per plugin-host.md § Plugin knob schema). The UI fetches once per plugin per session and renders operator-tunable controls from the response. Knob writes flow through the existing PUT /api/v1/projects/:slug/dsl endpoint, which writes to AgentSpec.plugins.<name>.config.<field> per the knob's binds_to.
  2. New endpoint: GET /api/v1/projects/:slug/plugins returns the resolved per-agent plugin map for the loaded project — which plugins are active on which agents, their hooks, their roles, their registered tools, and a redacted config summary. Used by the UI for per-agent plugin badges and the Compactor surface. Secrets in system_config are never included.
  3. New endpoints: POST /api/v1/sessions/:id/compact (manual compaction trigger, supports dry-run), GET /api/v1/sessions/:id/context-estimate (live session context estimate), GET /api/v1/sessions/:id/compactions (history list), GET /api/v1/sessions/:id/compactions/:cid (full detail), PATCH /api/v1/sessions/:id/compactions/:cid (operator feedback — flag and notes).
  4. New WS events on the events channel: compaction.triggered, compaction.completed, compaction.failed, compaction.flagged. The UI subscribes for real-time compactor updates.
  5. v1 surface route list updated with the five new routes and one new event channel emission.
  6. Project-level plugins: removed from DSL. The PUT /api/v1/projects/:slug/dsl endpoint now rejects DSL bodies containing a top-level plugins: key (parse error with clear migration message pointing to ADR-0023). The endpoint accepts per-agent plugins: blocks under AgentSpec paths.
  7. Constraints table extended with ADR-0023 and ADR-0024 references.

2026-05-28 — audit project filter + project capabilities endpoint

  1. GET /api/v1/audit query contract amended. The project filter is now project_id, not project, and omitting it keeps the endpoint global. This establishes the public contract ahead of structured audit-event storage.
  2. New endpoint: GET /api/v1/projects/:slug/capabilities returns the compiled project cage policy for the primary agent plus the resolved root tool permission split (enabled / disabled). This is the audit screen's authoritative source for filesystem, network, and tool permissions.
  3. Route index updated to include the project-capabilities endpoint.

2026-05-27 — Synthesized endpoint gains resolved_tools

GET /api/v1/projects/:slug/dsl/synthesized response now includes resolved_tools: string[] | null. On 200, it contains the effective root-agent tool list after applying operator-level overrides (default_tools from local.toml) and project-level overrides (primary.tools from the DSL) against DEFAULT_ROOT_TOOLS (17 canonical tools across 9 namespaces). On 422, it is null. The endpoint passes DEFAULT_ROOT_TOOLS as availableTools and the operator's default_tools as operatorToolOverrides to compileProjectDsl(), so the tool resolution is always performed — no live ToolRegistry required.

2026-05-26 — ADR-0022: synthesized endpoint returns uniform AgentSpec tree

ADR-0022 collapses PrimaryAgent and Subagent into a single recursive AgentSpec. This changes the synthesized endpoint in three ways:

  1. Output shape is uniform AgentSpec tree. Project-reference subagents are flattened to plain AgentSpec nodes — no _compiled wrapper, no residual path: field. A _source annotation carries provenance. The previous amendment's _compiled subtree shape is superseded.
  2. cross_ref_errors scope narrowed. can_be_called_by and interconnect are removed from the DSL (ADR-0022 rules 6–7). The only cross-ref errors the endpoint can surface are tool-name collisions and principal-scope violations (kaged.issue.* / kaged.workflow.* on a non-root agent).
  3. Role-based tool defaults materialized. The synthesized output includes all tool defaults: the root agent shows kaged.issue.* and kaged.workflow.* enabled; non-root agents show their explicitly declared tools: block (or empty).

Other response fields (has_overlay, has_project_references, warnings, 422 diagnostics) are unchanged in shape. The 422 path gains principal_scope_violation as a new diagnostic kind.

2026-05-26 — /dsl/synthesized covers nested-project compilation

GET /api/v1/projects/:slug/dsl/synthesized is amended to surface the fully compiled DSL, not just the local-overlay merge. The endpoint now resolves every project-reference subagent (per project-dsl.md § Project-reference subagents) by walking the reference tree, reading each nested .kaged/project.yaml, applying the nested overlay, applying the parent's overrides: block, and recursing — to a hard depth limit of 16 and with cycle detection. The result is the YAML the daemon will actually execute.

Changes:

  1. Response gains has_project_references: boolean. Indicates whether at least one project-reference subagent exists at any depth.
  2. yaml in the 200 response now contains _compiled subtrees under each project-reference entry. The original declared fields (path, name, description, overrides) are retained alongside, so the synthesized output is traceable back to each declaration. (Note: the _compiled wrapper shape was subsequently removed by the ADR-0022 amendment above; project references now flatten to plain AgentSpec nodes with _source annotations.)
  3. warnings and cross_ref_errors aggregate across all compiled layers; entries originating from nested projects carry the offending map-key path as a prefix.
  4. 422 path extended to cover all compilation failures: nested_project_missing, compile_cycle, compile_depth_exceeded, schema-validation failures of merged results, and overrides containing forbidden keys (version, project).

Implementation reuses @kaged/dsl's new compileProjectDsl() function (per project-dsl.md § Compilation and cycles). The handler signature is unchanged from the operator's perspective; the endpoint still returns the full synthesized YAML and the same set of diagnostic fields.

2026-05-22 — Project label PUT endpoint; nicknamelabel; project DSL GET wired

  1. New endpoint: PUT /api/v1/projects/:slug. Operator-editable project metadata (currently only label). Writes through to [[projects]] in local.toml. Validates body (string or null, ≤80 chars after trim) and returns the updated project record. The project id remains immutable.
  2. Project response shape: nicknamelabel. GET /api/v1/projects, GET /api/v1/projects/:slug, POST /api/v1/projects/load, and POST /api/v1/projects/:slug/reload now return label: string | null instead of nickname: string | null. The on-disk nickname field is dropped on the first save through the new endpoint — operators must deliberately re-enter their display name (see local-config.md amendment of the same date).
  3. POST /api/v1/projects/load request body simplified. The optional nickname field is removed from the request; setting a display name is now a separate, deliberate PUT after load, to keep load idempotent.
  4. GET /api/v1/projects/:slug/dsl wired from stub to real handler. Previously returned 404 unconditionally; now reads .kaged/project.yaml from the project's registered path, runs it through ProjectDslSchema.safeParse, and returns the literal text plus dsl_status ("valid" | "invalid"). Returns 200 with empty dsl and dsl_status: "invalid" when the file is missing (instead of 500/404) so the Project Settings UI degrades cleanly. PUT /api/v1/projects/:slug/dsl remains a stub.

2026-05-21 — Portability + local-config endpoints + three auth modes

Driven by ADR-0010, ADR-0011, and the ADR-0007 per-user mode amendment:

  1. Auth section restructured to document three auth modes (sidecar, loopback, insecure) — each with its own contract. Cookie semantics for loopback mode documented.
  2. POST /api/v1/projects removed; replaced with POST /api/v1/projects/load (a project is loaded from a directory path, not created from a DSL body). The DSL is now a property of the project directory, not API input. New state field replaces dsl_status and surfaces unresolved aliases/plugins/prompts.
  3. New project endpoints for the load flow: /reload, /unresolved. GET /api/v1/projects/:slug now returns resolved aliases, active plugins, and project state.
  4. New /api/v1/local/* endpoint family for operator local config: aliases, providers (keys redacted), preferences. CRUD shapes match local-config.md.
  5. Plugins endpoints overhauled: install with explicit scope (local or project), consent flow for installs that need operator approval (capability change, version mismatch), promote endpoint for elevating project-scope to local-scope.
  6. GET /api/v1/launch added: the one-time launch endpoint that sets the session cookie in loopback mode.
  7. GET /api/v1/me extended with deployment_mode, full auth_mode values, operator_name, and preferences.

The endpoint URL structure section was updated to reflect all new endpoints. The spec is now also constrained by ADR-0010 and ADR-0011 (added to the frontmatter).

2026-05-22 — Launch URL uses UI base URL + token regeneration

  1. Launch URL uses UI base URL. The GET /api/v1/launch endpoint documentation updated: the launch URL printed at startup uses the UI's base URL (resolved from KAGED_UI_URL env > ui.url config > daemon bind address), not the daemon's bind address directly. This is correct because the UI proxies /api to the daemon.
  2. Token regeneration after consumption. After a launch token is consumed, the daemon generates a new token immediately and logs a fresh launch URL. This allows the operator to authenticate from a new browser without restarting the daemon.
  3. Cookie contract updated. The loopback cookie contract section clarified that token regeneration happens on consumption, not only on kaged auth rotate or restart.

2026-05-22 — Launch endpoint content negotiation (API mode)

  1. Content negotiation on GET /api/v1/launch. The endpoint now supports Accept: application/json for cross-origin UI deployments. When the UI is hosted on a different origin from the daemon (e.g., ui.foo.com calling api.foo.com), the UI calls the launch endpoint via fetch with Accept: application/json and receives a structured 200 { ok: true, csrf_token } response instead of a 302 redirect. Errors remain JSON in both modes.
  2. Browser mode unchanged. Without Accept: application/json (or with text/html / */*), the endpoint behaves as before: sets cookies and returns 302 to /.

2026-05-23 — Per-session model override

  1. GET /api/v1/sessions/:id response extended. Added model: string | null field to the session response. Returns null when using the DSL default; returns "provider:model" when the operator has overridden the model for this session.
  2. PUT /api/v1/sessions/:id extended. Now accepts optional model field in addition to label. Both fields are optional — omit to leave unchanged. model must be "provider:model" format or null to clear. At least one field must be present.
  3. POST /api/v1/sessions/:id/messages extended. Now accepts optional model field in the request body. When present, it is persisted to the session record before dispatch, becoming the session's override for this and all subsequent messages. This enables per-message model switching while keeping the last-used model sticky at the session level.

2026-06-02 — ADR-0030: Log streaming SSE endpoint

Per ADR-0030:

  1. New endpoint: GET /api/v1/projects/:slug/logs/stream — Server-Sent Events stream for live project-scoped log entries. Accepts level and source query parameters for filtering. Returns text/event-stream with event: log frames carrying OperationalLogEntry JSON payloads. Heartbeat via SSE comment lines every 30 seconds.
  2. v1 route list updated with the new streaming endpoint.
  3. Constrained-by list extended with ADR-0030.

2026-05-22 — Session rename endpoint

  1. PUT /api/v1/sessions/:id added. Updates mutable session metadata (currently only label). Returns the full session object. Body: { "label": "..." }. Validation: label must be 0–120 chars after trim; empty string clears the label. 400 on invalid body, 404 on missing session.
  2. Route tree updated. /api/v1/sessions/:id now shows (get, update, delete) instead of (get, delete).

References