Spec: HTTP + WebSocket API

Purpose

This spec defines the operator-facing API surface of the kaged daemon: the HTTP endpoints, the WebSocket protocol, the request/response shapes, 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.
  • Every message that flows over the operator-facing WebSocket.
  • The header contract between the daemon and any front-of-daemon proxy (sidecar, ingress).
  • The CSRF, content-type, and error-shape contracts the web UI relies on.

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/).

This spec defines the shape of conversations between the operator's browser and the daemon. The components above implement what happens during those conversations.

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.
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.


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. This applies in both secure and insecure modes — --insecure is about who you trust, CSRF is about which page is making the call.

  • 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.

The cookie's SameSite=Lax is sufficient for typical sidecar deployments. SameSite=Strict is configurable for paranoid setups.

What the daemon does NOT validate

Per ADR-0007:

  • JWTs. 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.

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 operator + daemon mode
/api/v1/projects                        → projects in operator's registry (list)
/api/v1/projects/load                   → load a project from a directory (POST)
/api/v1/projects/:id                    → project (get, delete from registry)
/api/v1/projects/:id/status             → project status telemetry
/api/v1/projects/:id/reload             → re-read DSL from disk and re-evaluate state
/api/v1/projects/:id/unresolved         → list of unresolved aliases/plugins/prompts
/api/v1/projects/:id/dsl                → DSL file (get, put)
/api/v1/projects/:id/dsl/synthesized     → merged DSL after overlay (get)
/api/v1/projects/:id/subagents/init      → initialize a subagent project dir (post)
/api/v1/projects/:id/prompts            → prompts (list)
/api/v1/projects/:id/prompts/:name      → prompt (get, put, history)
/api/v1/projects/:id/logs/stream        → project log SSE stream (get)
/api/v1/projects/:id/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/models    → provider's persisted model catalog (get, put)
/api/v1/local/providers/:name/models/refresh → fetch live models, diff against persisted (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/:id/plugins           → resolved per-agent plugin map (get)
/api/v1/projects/:id/capabilities      → compiled cage capabilities (get)
/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

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 uses the UI base URL, not the daemon's bind address, because the UI proxies /api to the daemon. The URL is {ui_base_url}/api/v1/launch?token=<one-time-token>, where ui_base_url is resolved from KAGED_UI_URL env var > ui.url config > http://{daemon_bind} (see daemon.md Configuration).

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.
  • API mode (Accept: application/json): sets cookies and returns 200 with a JSON body. This is the flow when the UI is hosted on a different origin from the daemon (e.g., ui.foo.com calling api.foo.com/api/v1/launch?token=... via fetch). The UI needs a structured response to know whether authentication succeeded before navigating the operator to the dashboard.

The endpoint:

  1. Validates the token query parameter against the current launch token.
  2. On success:
    • Sets kaged_session cookie and CSRF cookie.
    • 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..."
      }
      
  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/:id; 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/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/projects/:id

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/:id/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/:id

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/:id/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/:id/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/:id/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/:id

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/:id/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/:id/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/:id/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/:id/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": "...", ... }
}

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" },
    { "id": "claude-haiku-3.5", "name": "Claude Haiku 3.5" }
  ]
}

Each model has id (the provider's model identifier) and name (operator-supplied display name, or auto-generated via humanizeModelId() from @kaged/llm when absent in config).

Returns 404 if the provider is not configured.

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

Fetch live models from the provider API and diff against the persisted catalog. This is the operator-initiated refresh workflow — the daemon never auto-fetches.

The daemon calls listModels() from @kaged/llm with the provider's driver, API key, and base URL. A 30-second timeout applies.

Response (200, success):

{
  "ok": true,
  "added": [
    { "id": "claude-opus-4-20250514", "name": "Claude Opus 4 20250514" }
  ],
  "retired": [
    { "id": "claude-2.1", "name": "Claude 2.1" }
  ],
  "unchanged": [
    { "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4 20250514" }
  ],
  "affected_aliases": [
    { "alias": "smart-generalist", "target": "claude:claude-2.1" }
  ]
}
  • added — models present in the live API but not in the persisted catalog.
  • retired — models in the persisted catalog but no longer in the live API.
  • unchanged — models present in both.
  • affected_aliases — aliases whose target references a retired model for this provider.

Response (200, provider API failure):

{
  "ok": false,
  "error": "HTTP 401: {\"error\":{\"message\":\"Invalid API key\"}}"
}

Provider API errors return 200 with ok: false — they are not daemon errors, they are upstream failures the operator needs to see and act on.

Returns 404 if the provider is not configured. Returns 400 if no API key is configured for the provider.

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

Save a model catalog to the provider's config in local.toml. Replaces the entire models array.

Request:

{
  "models": [
    { "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4" },
    { "id": "claude-opus-4-20250514" }
  ]
}

Each entry requires a non-empty id string. name is optional — entries without name use humanizeModelId() for display. Invalid entries (missing id, empty id, non-objects) are silently skipped.

Response (200):

{
  "ok": true,
  "count": 2
}

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": { "...": "..." }
  },
  "sources": {
    "maxInputTokens": "default",
    "pricing.input": "override",
    "pricing.output": "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.

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. These endpoints manage any OAuth-backed provider's lifecycle: browser-based login, token status, and logout. They are available for any provider whose driver has oauth in its authModes (e.g. antigravity, codex). Access tokens expire per-provider; the daemon refreshes them transparently before each LLM call via @kaged/llm/oauth.

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 driver from the provider config, then delegates to @kaged/llm/oauth using the driver's ProviderOAuthConfig.

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 driver, and checks the driver catalog for a ProviderOAuthConfig.
  2. Generates a PKCE verifier/challenge pair.
  3. Constructs the provider's OAuth authorization URL.
  4. Starts a temporary local HTTP server on the driver'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/<driver>-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": "[email protected]",
  "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/:id/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/:id/prompts/:name

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

PUT /api/v1/projects/:id/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/:id/sessions

Lists sessions for a project. Paginated. Includes state (active, paused, idle, ended).

POST /api/v1/projects/:id/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",
  "state": "running",
  "created_at": 1716300000000,
  "updated_at": 1716300100000,
  "model_override": "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
    }
  ]
}
  • model_override 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_override": "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_override (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_override, max_steps_override, or max_output_tokens_override must be present in the body.
  • model_override, 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": "..." }
  ],
  "created_at": 1716300000000
}

Messages are immutable once written. Edits are not supported — to revise, the operator posts a new 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_override": "claude:claude-sonnet-4-20250514"
}
  • content (string, required) — the operator's message text.
  • model_override (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).

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.

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/:id/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/:id/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/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).

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.

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/:id/logs/stream

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

Request:

GET /api/v1/projects/:id/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/:id/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": "[email protected]",
  "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.


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": 0, "events": 0 } } First frame after upgrade. Declares which sequence numbers the client has already seen.
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": "..." } Message done.
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"
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/:id/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.


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/:id/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. The blessed deployment has them at the same origin; cross-origin is opt-in via KAGED_CORS_ORIGINS=....

Amendments

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/:id/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_override: 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_override: 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/:id/issues, PATCH /projects/:id/issues/:number, POST /projects/:id/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/:id/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/:id/dsl endpoint, which writes to AgentSpec.plugins.<name>.config.<field> per the knob's binds_to.
  2. New endpoint: GET /api/v1/projects/:id/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/:id/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/:id/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/:id/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/:id/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/:id. 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/:id, POST /api/v1/projects/load, and POST /api/v1/projects/:id/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/:id/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/:id/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/:id 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_override: 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_override field in addition to label. Both fields are optional — omit to leave unchanged. model_override 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_override 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/:id/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