Spec: HTTP + WebSocket API
- Status: Draft
- Last amended: 2026-06-05 (concurrency throttle at message-send — system WebSocket, queued/resume endpoints, session creation unconstrained)
- Constrained by: ADR-0002, ADR-0004, ADR-0007, ADR-0009, ADR-0010, ADR-0011, ADR-0022, ADR-0023, ADR-0024, ADR-0026, ADR-0028, ADR-0030
- Implements:
packages/daemon/api/(planned)
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 onlyv1. - 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-8for JSON bodies. Other content types (text/plainfor prompts,application/octet-streamfor binary uploads) are documented per-endpoint and rare in v0. - Responses:
application/json; charset=utf-8for 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 byproject-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..."
}
}
codeis stable across patch and minor releases. Clients may switch on it.messageis human-facing English. Not stable; do not match on it.detailsis optional. Some endpoints add structured fields (e.g., DSL validation errors includefile,line,column).request_idis 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→ 401unauthenticated. - Requests with wrong or missing
X-Kaged-Auth-Nonce→ 401unauthenticated. 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_sessioncookie 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-modeto 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=falseso 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-CSRFheader on state-changing requests. - Validation: the daemon compares the header to the cookie. Mismatch → 403
forbiddenwithdetails.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
--insecureis 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 —
Acceptis absent,*/*, ortext/html): sets cookies and returns a302redirect to/. This is the flow when the operator clicks the launch URL directly. - API mode (
Accept: application/json): sets cookies and returns200with a JSON body. This is the flow when the UI is hosted on a different origin from the daemon (e.g.,ui.foo.comcallingapi.foo.com/api/v1/launch?token=...viafetch). The UI needs a structured response to know whether authentication succeeded before navigating the operator to the dashboard.
The endpoint:
- Validates the
tokenquery parameter against the current launch token. - On success:
- Sets
kaged_sessioncookie 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
302withLocation: /. - API mode: returns
200with body:{ "ok": true, "csrf_token": "01HXAB..." }
- Sets
- On failure:
401unauthenticatedwithdetails.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_modeis"user"or"system"(per ADR-0010).auth_modeis"loopback","sidecar", or"insecure"(per ADR-0007 amendments).sandbox_modeis"enabled"or"disabled".warningsis populated when any insecure mode is active. Possible values:"insecure-mode","no-sandbox".operator_namecomes from the operator's local config; falls back touser_idif unset.preferencesis 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 callPOST /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. sessionsis derived from session counts grouped by state forsessions.project_id = :id.totalis the sum acrossrunning,idle,paused, andended.activity.live_subagentsis the current count of live subagents across the project's non-ended sessions. v0 computes it from daemon runtime state when available and otherwise returns0.activity.tool_calls_24his the 24-hour assistant-activity proxy: the count ofmessages.role = "primary"rows for project sessions whosecreated_at >= now - 86_400_000.activity.budget_24his the 24-hour aggregate budget view for project runs whosecreated_at >= now - 86_400_000:SUM(r.tokens_in),SUM(r.tokens_out), andSUM(messages.cost_total)across sessions in the project. Empty sums return0.recent_runsis the 10 most recent runs for the project, ordered byruns.created_at DESC.cost_totalis the per-run aggregate cost derived from messages associated with that run; when no cost-bearing messages exist, it isnull.
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. Passnullor empty string to clear the label (UI then falls back toid). Maximum 80 characters after trimming.
Behavior:
- Writes through to
[[projects]]inlocal.toml. Never touches the database. - Drops any legacy
nicknamevalue from the on-disk entry on first save (manual re-entry semantics — operators must deliberately setlabel; legacy nicknames are not auto-migrated). - Preserves
accent_color,path,status, andlast_opened_atunchanged.
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.modeis"isolated"when the compiled cage exposes any filesystem mounts; otherwise"disabled".filesystem.mountsis the compiled mount allowlist in wire order. Empty when disabled.network.modeis"isolated"when the compiled cage exposes any network allowlist entries; otherwise"disabled".network.allowlistis the compiled host allowlist in wire order. Empty when disabled.tools.enabledis the effective root tool list after operator + project overrides have been applied during compilation.tools.disabledis 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 ofproject.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:
- Applies the local
.yamloverlay to the project's ownproject.yaml. - For each
AgentSpecin the recursivesubagentstree with apath:field (project reference), resolves the path, reads the nested project, applies its own overlay, applies the parent reference'soverrides:block, and flattens the nested project'sprimaryinto a plainAgentSpecwith its ownsubagentsmap inlined. The_sourceannotation tracks provenance; nopath:,_compiled, or wrapper fields remain. - Detects cycles across the project-reference graph; aborts at a depth of 16 levels.
- Validates the schema at every level (parent and nested).
- Materializes role-based tool defaults: the root agent (at
primary) getskaged.issue.*andkaged.workflow.*enabled; all other agents start with an empty tool set unless the operator'stools: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 uniformAgentSpecat every position. Project-reference entries are flattened to plainAgentSpecnodes with a_sourceannotation for traceability; nopath:or wrapper fields remain.has_overlay— whether.kaged/project.local.yamlwas 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_toolsfromlocal.toml) and project-level overrides (primary.toolsfrom the DSL) againstDEFAULT_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: disabledwarnings from any agent surface here, prefixed with the agent's tree-position path likeprimary.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:
- Looks up the provider by
:name, resolves its driver, and checks the driver catalog for aProviderOAuthConfig. - Generates a PKCE verifier/challenge pair.
- Constructs the provider's OAuth authorization URL.
- Starts a temporary local HTTP server on the driver's configured callback port to receive the OAuth redirect.
- Attempts to open the authorization URL in the system browser via
Bun.open()(oropen/xdg-openon Linux). - 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:namedoes not exist.400 bad_requestwithdetails.reason: "not_oauth_provider"— this provider's driver does not support OAuth.409 conflictwithdetails.reason: "login_in_progress"— another login flow is already active.503 unavailablewithdetails.reason: "callback_port_in_use"— the callback port is occupied.502 provider_unreachablewithdetails.reason: "browser_open_failed"— could not open the system browser. Theredirect_urlis 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:namedoes not exist.400 bad_requestwithdetails.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:namedoes not exist.400 bad_requestwithdetails.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 withdriver: "antigravity"GET /api/v1/local/providers/antigravity/auth/status→ samePOST /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_overrideisnullwhen the session uses the DSL's default alias, or a"provider:model"string when the operator has overridden the model for this session.cage_statusis one ofcaged,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'sprimary.modelalias for all runs in this session. Passnullto 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 withprovider_not_configured.max_steps_override(integer | null, optional) — overrides the DSL'sprimary.max_stepsfor this session. Range1–100. Passnullto clear the override and revert to the DSL default.max_output_tokens_override(integer | null, optional) — overrides the DSL'sprimary.max_output_tokensfor this session. Range1–65536. Passnullto clear the override and revert to the DSL default.- At least one of
label,model_override,max_steps_override, ormax_output_tokens_overridemust be present in the body. model_override, when present, must be a string in"provider:model"format (containing at least one:) ornull.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_requestif body is missing, empty, or contains neither field. - Returns
404 not_foundif 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'sprimary.max_stepsfor this run. Range1–100. 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'sprimary.max_output_tokensfor this run. Range1–65536. 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 queued → running, starts the pending run (transitions it from pending → running), 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
}
scopeis"local"(available to any project) or"project"(activated only for the listed project).for_projectis required whenscope == "project"; otherwise null.sourcevalues:- URL → daemon clones/downloads, validates manifest, copies to local store.
- Path (
/local/pathor./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.triggered → compaction.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 singleOperationalLogEntryas 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/logsand deduplicates byid.
Lifecycle:
- Client opens
EventSourceon the stream URL. - Daemon validates the project exists and filters are valid. Returns 404 or 400 (normal JSON error) before switching to streaming.
- Daemon streams matching log entries as they are written.
- Client calls
eventSource.close()to disconnect. - 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-streamresponses 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, orflush_interval -1in Caddy). - The
X-Accel-Buffering: noheader 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
conflicton 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": { ... }
}
channelroutes the frame to the right handler.seqis monotonic per channel per direction. Clients use it to detect dropped frames and replay on reconnect.typeis channel-specific.payloadis 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
eventschannel is session-scoped. When an issue is created/updated, the daemon publishesissue.created/issue.updatedto 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 (
0x01prefix + 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.endonoutputwhen 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.endonoutputmay arrive before the final PTY bytes of that subagent onpty. 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 perevents). Exceeding the limit closes the socket withclosing { 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. Sendscontrol { type: "hello", payload: { resume_from_seq: { ... } } }. - Server reply:
control { type: "welcome", payload: { server_seq: { ... } } }. Ifserver_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 viaGET /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
hellowith 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
systemchannel).
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:
totalis the daemon-wide count of sessions inrunningstate.per_projectmaps project IDs to their running count. Only projects with ≥1 running session are included.per_operatormaps operator user IDs to their running count. In per-user mode, this is always a single entry.limitsechoes the configured concurrency limits so the UI can render3/4or3/16without a separate fetch.
When it fires:
- Run starts (
idle→runningorqueued→running). - Run ends (
running→idlevia completion, failure, or cancellation). - Session ends while running (
running→endedorrunning→failed). - Daemon startup recovery (sessions found in
runningstate are counted after crash recovery marks themidle/failed).
When it does NOT fire:
- Session creation (no running count change).
- Checkpoint pause/resume (
running→pauseddecrements the count;paused→runningincrements it — these DO fire). - Queued message (
idle→queueddoes 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 tests —
X-Kaged-Warningis 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
- 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. - Server-Sent Events as a fallback. Some corporate networks break WebSockets. Whether kaged ships an SSE fallback for the
outputandeventschannels is open. Lean: not in v0; revisit if real-world deployments hit it. - 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.
- WebHooks. "Tell my external system when run.ended fires" is a reasonable v1.x ask. Not in v0.
- 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.
- 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.
- System WebSocket endpoint added.
GET /api/v1/socket— a daemon-wide WebSocket for events not scoped to a single session. Multiple concurrent connections allowed. Singlesystemchannel. See § System WebSocket. sessions.running_countevent. Broadcast on the system socket whenever a run starts, ends, or a session transitions in/out ofrunning. Payload includestotal,per_project,per_operator, andlimits. The UI uses this for the bottom bar running-count indicator.POST /api/v1/sessions/:id/resumeadded. 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.DELETE /api/v1/sessions/:id/queued-messageadded. Discards a queued message, cancels the pending run, returns session toidle. See § Queued session resume.POST /api/v1/sessions/:id/messagesresponse extended. When the concurrency limit is reached, the response now includesqueued: true,reason,running_count, andlimitfields (still 202). The per-session 409 conflict (posting while the same session isrunning) is retained as a separate concern.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.- 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:
PUT /api/v1/sessions/:idextended. Now acceptsmax_steps_overrideandmax_output_tokens_overridefields (integer or null). Same semantics asmodel_override: persisted to the session record, becomes sticky for all subsequent runs. Range validation:1–100formax_steps_override,1–65536formax_output_tokens_override.POST /api/v1/sessions/:id/messagesextended. Now acceptsmax_steps_overrideandmax_output_tokens_overridefields in the request body. Same per-message override semantics asmodel_override: persisted to the session record before dispatch.GET /api/v1/sessions/:idresponse extended. Addedmax_steps_override: number | nullandmax_output_tokens_override: number | nullfields to the session response.SessionSummarytype updated. All three new fields added to the session summary contract.- 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.
- New events:
issue.createdandissue.updated, payload{ project_id, number }. Seeeventschannel. - Project-scoped fan-out: issues are project-scoped but the
eventschannel 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. - 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:
- 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.
- New endpoints for provider usage:
GET /api/v1/local/providers/:name/usage— returns cachedUsageReport. Error values:"no_cache","no_fetcher".POST /api/v1/local/providers/:name/usage/refresh— force fresh fetch, update cache. Error:"fetch_failed"with detail.
- New endpoints for spend limits:
GET /api/v1/local/providers/:name/spend-limits— returns limit configuration plus current rolling-window spend computed fromprovider_spend_events.PUT /api/v1/local/providers/:name/spend-limits— set/update limits. Partial update supported. Set tonullto remove a limit. Validates: non-negative USD, percentage in 0.0–1.0.
- v1 route list updated with all new endpoints.
- Constrained-by list extended with ADR-0026.
2026-05-28 — Project status telemetry endpoint
- New endpoint:
GET /api/v1/projects/:id/statusreturns project-scoped aggregate telemetry for the Project Status UI. - Response shape added: session counts by state, 24-hour activity aggregates (
tool_calls_24h,budget_24h,live_subagents), andrecent_runsordered newest-first. - 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
- New endpoint:
GET /api/v1/plugins/:name/knobsreturns the plugin's knob schema (perplugin-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 existingPUT /api/v1/projects/:id/dslendpoint, which writes toAgentSpec.plugins.<name>.config.<field>per the knob'sbinds_to. - New endpoint:
GET /api/v1/projects/:id/pluginsreturns 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 insystem_configare never included. - 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 —flagandnotes). - New WS events on the
eventschannel:compaction.triggered,compaction.completed,compaction.failed,compaction.flagged. The UI subscribes for real-time compactor updates. - v1 surface route list updated with the five new routes and one new event channel emission.
- Project-level
plugins:removed from DSL. ThePUT /api/v1/projects/:id/dslendpoint now rejects DSL bodies containing a top-levelplugins:key (parse error with clear migration message pointing to ADR-0023). The endpoint accepts per-agentplugins:blocks underAgentSpecpaths. - Constraints table extended with ADR-0023 and ADR-0024 references.
2026-05-28 — audit project filter + project capabilities endpoint
GET /api/v1/auditquery contract amended. The project filter is nowproject_id, notproject, and omitting it keeps the endpoint global. This establishes the public contract ahead of structured audit-event storage.- New endpoint:
GET /api/v1/projects/:id/capabilitiesreturns 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. - 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:
- Output shape is uniform
AgentSpectree. Project-reference subagents are flattened to plainAgentSpecnodes — no_compiledwrapper, no residualpath:field. A_sourceannotation carries provenance. The previous amendment's_compiledsubtree shape is superseded. cross_ref_errorsscope narrowed.can_be_called_byandinterconnectare 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).- Role-based tool defaults materialized. The synthesized output includes all tool defaults: the root agent shows
kaged.issue.*andkaged.workflow.*enabled; non-root agents show their explicitly declaredtools: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:
- Response gains
has_project_references: boolean. Indicates whether at least one project-reference subagent exists at any depth. yamlin the 200 response now contains_compiledsubtrees 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_compiledwrapper shape was subsequently removed by the ADR-0022 amendment above; project references now flatten to plainAgentSpecnodes with_sourceannotations.)warningsandcross_ref_errorsaggregate across all compiled layers; entries originating from nested projects carry the offending map-key path as a prefix.- 422 path extended to cover all compilation failures:
nested_project_missing,compile_cycle,compile_depth_exceeded, schema-validation failures of merged results, andoverridescontaining 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; nickname → label; project DSL GET wired
- New endpoint:
PUT /api/v1/projects/:id. Operator-editable project metadata (currently onlylabel). Writes through to[[projects]]inlocal.toml. Validates body (string or null, ≤80 chars after trim) and returns the updated project record. The projectidremains immutable. - Project response shape:
nickname→label.GET /api/v1/projects,GET /api/v1/projects/:id,POST /api/v1/projects/load, andPOST /api/v1/projects/:id/reloadnow returnlabel: string | nullinstead ofnickname: string | null. The on-disknicknamefield 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). POST /api/v1/projects/loadrequest body simplified. The optionalnicknamefield is removed from the request; setting a display name is now a separate, deliberatePUTafter load, to keep load idempotent.GET /api/v1/projects/:id/dslwired from stub to real handler. Previously returned 404 unconditionally; now reads.kaged/project.yamlfrom the project's registered path, runs it throughProjectDslSchema.safeParse, and returns the literal text plusdsl_status("valid"|"invalid"). Returns 200 with emptydslanddsl_status: "invalid"when the file is missing (instead of 500/404) so the Project Settings UI degrades cleanly.PUT /api/v1/projects/:id/dslremains 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:
- Auth section restructured to document three auth modes (
sidecar,loopback,insecure) — each with its own contract. Cookie semantics for loopback mode documented. POST /api/v1/projectsremoved; replaced withPOST /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. Newstatefield replacesdsl_statusand surfaces unresolved aliases/plugins/prompts.- New project endpoints for the load flow:
/reload,/unresolved.GET /api/v1/projects/:idnow returns resolved aliases, active plugins, and project state. - New
/api/v1/local/*endpoint family for operator local config: aliases, providers (keys redacted), preferences. CRUD shapes matchlocal-config.md. - Plugins endpoints overhauled: install with explicit
scope(localorproject), consent flow for installs that need operator approval (capability change, version mismatch), promote endpoint for elevating project-scope to local-scope. GET /api/v1/launchadded: the one-time launch endpoint that sets the session cookie in loopback mode.GET /api/v1/meextended withdeployment_mode, fullauth_modevalues,operator_name, andpreferences.
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
- Launch URL uses UI base URL. The
GET /api/v1/launchendpoint documentation updated: the launch URL printed at startup uses the UI's base URL (resolved fromKAGED_UI_URLenv >ui.urlconfig > daemon bind address), not the daemon's bind address directly. This is correct because the UI proxies/apito the daemon. - 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.
- Cookie contract updated. The loopback cookie contract section clarified that token regeneration happens on consumption, not only on
kaged auth rotateor restart.
2026-05-22 — Launch endpoint content negotiation (API mode)
- Content negotiation on
GET /api/v1/launch. The endpoint now supportsAccept: application/jsonfor cross-origin UI deployments. When the UI is hosted on a different origin from the daemon (e.g.,ui.foo.comcallingapi.foo.com), the UI calls the launch endpoint viafetchwithAccept: application/jsonand receives a structured200 { ok: true, csrf_token }response instead of a302redirect. Errors remain JSON in both modes. - Browser mode unchanged. Without
Accept: application/json(or withtext/html/*/*), the endpoint behaves as before: sets cookies and returns302to/.
2026-05-23 — Per-session model override
GET /api/v1/sessions/:idresponse extended. Addedmodel_override: string | nullfield to the session response. Returnsnullwhen using the DSL default; returns"provider:model"when the operator has overridden the model for this session.PUT /api/v1/sessions/:idextended. Now accepts optionalmodel_overridefield in addition tolabel. Both fields are optional — omit to leave unchanged.model_overridemust be"provider:model"format ornullto clear. At least one field must be present.POST /api/v1/sessions/:id/messagesextended. Now accepts optionalmodel_overridefield 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:
- New endpoint:
GET /api/v1/projects/:id/logs/stream— Server-Sent Events stream for live project-scoped log entries. Acceptslevelandsourcequery parameters for filtering. Returnstext/event-streamwithevent: logframes carryingOperationalLogEntryJSON payloads. Heartbeat via SSE comment lines every 30 seconds. - v1 route list updated with the new streaming endpoint.
- Constrained-by list extended with ADR-0030.
2026-05-22 — Session rename endpoint
PUT /api/v1/sessions/:idadded. Updates mutable session metadata (currently onlylabel). 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.- Route tree updated.
/api/v1/sessions/:idnow shows(get, update, delete)instead of(get, delete).
References
- ADR-0002 — the web-first commitment
- ADR-0004 —
Bun.serve()is the implementation - ADR-0007 and its amendment — the header contract and
--insecure - ADR-0009 and its amendment —
X-Kaged-Warning: no-sandbox - ADR-0022 — recursive agents; uniform
AgentSpectree in synthesized endpoint - ADR-0008 — plugins reached via
/api/v1/plugins/* project-dsl.md— the DSL endpoints' validation contractsession-manager.md— internal session stateplugin-host.md— the JSON-RPC the plugin endpoints proxyui/— what consumes this surface- JSON-RPC 2.0 spec (analogous wire shape on the plugin host side): https://www.jsonrpc.org/specification
- WebSocket RFC 6455: https://www.rfc-editor.org/rfc/rfc6455