Spec: Local config
- Status: Draft
- Last amended: 2026-06-26 (
[providers.<name>]credentials map cleanup — ADR-0049) - Constrained by: ADR-0010, ADR-0011, ADR-0007, ADR-0008, ADR-0023, ADR-0036, ADR-0043, ADR-0047, ADR-0049
- Implements:
packages/local-config/(planned)
Purpose
This spec defines local config: the operator-local configuration file that holds everything operationally local to this operator on this machine, separate from the daemon's operational config and from any project.
Local config holds:
- Model aliases — bindings from project-DSL alias names to concrete provider:model identifiers.
- Provider credentials — API keys and endpoints for LLM providers (Claude, OpenAI, local Ollama, etc.).
- Default tool overrides — operator-level enable/disable of built-in agent tools, applied system-wide before project-level overrides.
- Observability credentials — optional Langfuse keys and endpoint for agent-run tracing.
- Plugin store state — which plugins are installed, which are local-scope vs project-scope, per-plugin config.
- Project registry — known project directories the operator has opened on this machine.
- Operator preferences — UI theme, display timezone, locale, etc.
This document is normative for:
- The file format and location.
- The schema of every section.
- The alias resolution algorithm.
- The interaction between local config and projects (alias miss → prompt, plugin miss → install prompt).
- The CLI surface for editing local config (
kaged config alias,kaged plugin promote, etc.). - The audit and warning behavior when secrets are accessed.
It is not normative for:
- The daemon's operational config (
config.toml) — seedaemon.md. - The project DSL — see
project-dsl.md. - The plugin SDK or JSON-RPC contract — see
plugin-host.md. - HTTP API endpoints that read/write local config — see
http-api.md.
Constraints (from ADRs)
| Constraint | Source |
|---|---|
| Two deployment modes; local config lives in mode-appropriate paths | ADR-0010 |
| Projects must be portable; operator-local concerns live here, not in DSL | ADR-0011 |
| Provider auth is local to the operator (not project data) | ADR-0011 |
| Plugins are installed once locally; project activation is metadata-only | ADR-0008 amendment |
| Per-user mode uses XDG paths | ADR-0010 |
| Unified user identity, shared SSO, three-tier roles, and route-scope authorization | ADR-0036 |
File location
Local config lives per deployment mode:
| Mode | Path | Created at |
|---|---|---|
| Per-user | $XDG_CONFIG_HOME/kaged/local.toml (default: ~/.config/kaged/local.toml) |
First daemon start |
| System-wide | ${KAGED_HOME}/local/<user>.toml (one file per operator who has used this daemon) |
First request that uses local-config |
In system-wide mode, each operator who interacts with the daemon has their own local config file under ${KAGED_HOME}/local/. The daemon picks the right file by the resolved X-Kaged-User-Id. This is what makes per-operator aliases possible on a shared kaged.
KAGED_LOCAL_CONFIG environment variable overrides the path in either mode. Useful for testing and for operators with unusual setups.
Permissions
- Per-user mode:
local.tomlis mode0600, owned by the operator's UID. The daemon (running as the same UID) reads it directly. - System-wide mode:
${KAGED_HOME}/local/<user>.tomlis mode0600, owned by the daemon's system user (the operator does not have direct shell access in the typical deployment; they edit via the API). Daemons running with strict mount propagation may need extra ACLs; documented in the deployment guide.
Format
TOML, like config.toml. The same rationale: human-edited config, comments first-class, type-safe, distinct from YAML so operators don't confuse it with project DSLs.
Schema
The complete shape, with all sections optional unless noted:
# Local config for kaged.
# Owned by the operator. Edit by hand or via `kaged config` subcommands.
# Optional: a per-operator display name. Used in audit logs and UI.
# Defaults to $USER in per-user mode, X-Kaged-User-Id in system-wide.
operator_name = "operator"
[ui]
# Optional operator preferences. The daemon passes these to the UI on /api/v1/me.
theme = "dark" # "dark" | "light" | "system"
timezone = "Europe/Budapest" # IANA TZ; defaults to system TZ
locale = "en-US" # BCP-47 locale tag
# ---- Shared SSO --------------------------------------------------------
# Unified user identity and shared SSO bootstrap configuration.
# See ADR-0036 and docs/specs/sso-relay.md for details.
[auth.sharedsso]
enabled = true # default: false
issuer = "https://sso.kaged.dev" # no default; required when enabled
public_key = """-----BEGIN PUBLIC KEY-----
...PEM (P-256)...
-----END PUBLIC KEY-----""" # optional; presence means daemon NEVER fetches JWKS
user_creation = "disabled" # "enabled" | "disabled"; default "disabled"
pending_ttl_days = 7 # reap window for TOFU pending rows
# ---- Model aliases -----------------------------------------------------
# Bindings from project-DSL alias names to concrete provider:model IDs.
# Operators define these; projects refer to alias names only.
# See ADR-0011 for the portability rationale.
[aliases]
# Recommended starter aliases (ship with kaged as a suggestion, not enforced):
smart-generalist = "claude:sonnet-4.6"
smart-careful = "claude:opus-4.1"
low-cost-fast = "claude:haiku"
low-cost-coder = "claude:haiku"
local-only = "ollama:llama3.2"
tiny = "ollama:tinyllama"
# Operator-defined aliases (any name not in the starter set):
my-codereviewer = "claude:sonnet-4.6"
weekend-experiment = "ollama:mistral-nemo"
# ---- Provider credentials ----------------------------------------------
# Credentials and endpoints for LLM providers. NEVER appear in a project DSL.
# Keys are operator-chosen identifiers.
[providers.claude]
catalog_id = "anthropic" # links back to the catalog
npm = "@ai-sdk/anthropic" # the AI SDK package to load (required)
name = "Claude" # user-facing label (optional)
base_url = "https://api.anthropic.com" # optional override
credentials = { api_key = { env = "ANTHROPIC_API_KEY" } } # env var reference
[[providers.claude.models]]
id = "claude-sonnet-4-20250514"
name = "Claude Sonnet 4"
[[providers.claude.models]]
id = "claude-opus-4-20250514"
name = "Claude Opus 4"
[providers.claude-work] # second Anthropic account, different key
catalog_id = "anthropic"
npm = "@ai-sdk/anthropic"
name = "Claude Work"
credentials = { api_key = { env = "ANTHROPIC_WORK_KEY" } }
[providers.openai]
catalog_id = "openai"
npm = "@ai-sdk/openai"
credentials = { api_key = { env = "OPENAI_API_KEY" } }
base_url = "https://api.openai.com"
[providers.ollama]
npm = "@ai-sdk/openai-compatible" # custom provider using compatible package
base_url = "http://localhost:11434/v1"
discover_endpoint = "http://localhost:11434/v1/models"
default_options = { temperature = 0.7 }
[providers."local-vllm"] # quoted because the name has a dash
npm = "@ai-sdk/openai-compatible"
base_url = "http://192.168.1.50:8000/v1"
credentials = { api_key = { env = "VLLM_KEY" } }
header_mappings = { api_key = "Authorization" }
# ---- Plugin store ------------------------------------------------------
# One section per installed plugin.
[plugins.oh-my-pi]
installed = "1.4.2"
local = true # available to any project
source = "https://github.com/oh-my-pi/kaged-adapter"
config = { preset_dir = "/opt/oh-my-pi/presets" }
[plugins.ollama]
installed = "0.3.0"
local = true
config = { base_url = "http://localhost:11434" }
[plugins.deploy-helper]
installed = "0.1.0"
local = false # project-scoped; activated only for projects that declare it
projects = ["music-site", "infra-monitor"] # which projects activated this
source = "git+https://internal.example.com/plugins/deploy-helper"
# ---- System plugins ----------------------------------------------------
# In-process daemon extensions. Trusted, TypeScript only.
# See docs/specs/plugins/system-plugins.md for the full contract.
[system_plugins.webhook-notify]
enabled = true
path = "/home/operator/kaged-plugins/webhook-notify" # absolute path, or omit for default location
config = { webhook_url = "https://matrix.mail99.me/hookshot/webhooks/webhook/e6fee82a-5f86-4b15-bea5-bfb366afa8e2" }
# ---- Project registry --------------------------------------------------
# Known project directories. Daemon uses this to list projects in the UI
# without scanning the filesystem.
[[projects]]
id = "music-site"
path = "/home/operator/projects/music-site"
last_opened_at = 1716300000000 # millisecond epoch
status = "ready" # "ready" | "pending" | "invalid"
accent_color = "#e5a00d" # optional; CSS color for sidebar avatar
[[projects]]
id = "infra-monitor"
path = "/home/operator/projects/infra-monitor"
last_opened_at = 1716200000000
status = "pending" # has unresolved aliases or plugins
# accent_color omitted — UI derives from project ID hash
# ---- Default tool overrides ---------------------------------------------
# Operator-level enable/disable of built-in agent tools.
# Applied system-wide as the first override layer; project-level DSL
# `primary.tools` overrides come second (later layer wins).
# See docs/specs/project-dsl.md § Tool resolution for the full model.
[default_tools]
# Disable debug tool system-wide (no debug adapters on this machine):
"debug" = { enabled = false }
# Disable issue tools (operator doesn't use the issue tracker):
# "kaged.issue.list" = null # null = disable (ADR-0015 nullification)
# "kaged.issue.get" = null
# "kaged.issue.create" = null
# "kaged.issue.comment" = null
# "kaged.issue.transition" = null
# ---- Observability ------------------------------------------------------
[langfuse]
enabled = true
base_url = "http://langfuse.local:3000"
public_key_env = "KAGED_LANGFUSE_PUBLIC_KEY"
secret_key_env = "KAGED_LANGFUSE_SECRET_KEY"
# ---- Audit policy ------------------------------------------------------
[audit]
# What local-config-related events get audited.
# Defaults are conservative; operators can opt into more verbose auditing.
log_alias_use = false # record every alias resolution (loud)
log_provider_use = true # record every provider call (recommended)
log_alias_edit = true # record every alias add/remove
log_plugin_install = true # record every plugin install
# ---- Secrets (ADR-0043) ------------------------------------------------
# Never appear in a project DSL. Resolved by secret_ref at session-start.
# The key path matches the dot-separated secret_ref in the DSL's mcp: block.
# Example: secret_ref: api_keys.example → [secrets.api_keys] example = "..."
[secrets.api_keys]
example = "sk-live-abc123..."
github = "ghp_xxxx..."
[secrets.db]
readonly_url = "postgres://reader@db.local/app"
# ---- MCP server overrides (ADR-0043) -----------------------------------
# Operator-level overrides for project-declared MCP servers.
# Keys match server-keys in the project DSL's mcp: block.
[mcp.github]
disabled = false # operator can disable any project MCP server
timeout_ms = 30000 # per-call timeout override (default: 30s)
[mcp.database]
disabled = true # operator disables a server they don't want
The schema is extensible — sections beyond those listed (e.g. [experiments], [ui.editor]) may be added in minor versions and ignored by older daemons (with a warning, never an error).
Sections
Top-level
operator_name (string, optional)
A human-readable name for this operator. Used in:
- Audit log entries as the human label (the
user_idis still the machine-readable identifier). - The UI's "logged in as" label.
- Greetings in the empty-state UI.
Defaults:
- Per-user mode:
$USER(the OS username). - System-wide mode: the value of
X-Kaged-User-Id.
If operator_name is set, it overrides the default but does not change the machine-readable user_id — audit log entries record both.
[langfuse]
Optional Langfuse tracing config for the harness. When credentials resolve, the daemon initializes a singleton Langfuse client and agent runs emit traces. When the section is absent, disabled, or incomplete, no traces are sent.
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
boolean | true |
Explicit opt-out switch; when false, tracing stays off even if credentials exist |
public_key |
string | none | Inline Langfuse public key |
secret_key |
string | none | Inline Langfuse secret key |
base_url |
URL string | https://cloud.langfuse.com |
Langfuse API base URL |
public_key_env |
string | none | Environment variable name holding the public key |
secret_key_env |
string | none | Environment variable name holding the secret key |
Credential resolution order is direct value first (public_key, secret_key), then environment indirection (*_env). If either key is unresolved, kaged skips Langfuse initialization and continues normally.
[ui]
Operator preferences passed to the web UI. The daemon does not interpret these; it just hands them to the client on GET /api/v1/me.
| Field | Type | Default | Meaning |
|---|---|---|---|
theme |
enum | system |
dark, light, or system (follow OS) |
font_size |
enum | medium |
small, medium, large, x-large — scales --ui-font-scale on <html> for accessibility |
timezone |
string | system | IANA timezone for displayed timestamps |
locale |
string | en-US |
BCP-47 locale for number/date formatting |
The UI may add more keys without daemon-side changes. The daemon round-trips unknown keys.
[auth.sharedsso]
Unified user identity and shared SSO bootstrap configuration (see ADR-0036).
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
boolean | false |
Enable or disable the shared SSO bootstrap path |
issuer |
URL string | none | The base URL of the stateless SSO relay (required when enabled) |
public_key |
string | none | Optional P-256 SPKI PEM public key for signature verification |
user_creation |
enum | disabled |
enabled or disabled (TOFU user provisioning toggle) |
pending_ttl_days |
integer | 7 |
Reap window in days for TOFU pending rows |
- Bootstrap Orthogonality: Shared SSO is a session bootstrap mechanism, orthogonal to
auth.mode. It can be enabled alongsideloopbackorsidecarmodes. - Startup Validation: If
enabledistrue, theissuerfield is required. Omitting it is a startup error. A providedpublic_keymust parse as a valid P-256 SPKI PEM; otherwise, the daemon fails to start. - Zero-Contact (Pinned Key): When
public_keyis set, the daemon never fetches the JWKS from the issuer's endpoint, eliminating outbound network calls to the relay (seesso-relay.md). - TOFU Lifecycle: Unknown subjects are provisioned TOFU-style as
pendingrows in theuserstable (whenuser_creation = "enabled") and must be activated by an operator (seeusers.md). - Insecure Mode Interaction: Running the daemon with
--insecure(orauth.mode = "insecure") only waives the ambient operator check for requests without a session. Any request carrying a validkaged_user_sessioncookie still resolves as that specific user with their assigned role, and credentials (passwords, SSO tokens) are still verified at login.
[aliases]
Model alias bindings. Per ADR-0011:
- Keys are alias names (free-form strings, must not contain
:to avoid collision with provider:model identifiers). - Values are concrete provider:model identifiers in the form
<provider>:<model>. - The provider portion must match a key in
[providers.*]. - The model portion is opaque to kaged — it's whatever string the provider accepts.
Recommended starter aliases. kaged ships with a set of recommended alias names operators are encouraged (not required) to define:
| Alias | Intended use |
|---|---|
smart-generalist |
Default for the primary; a capable general-purpose model |
smart-careful |
A higher-quality (slower, more expensive) model for hard work |
low-cost-fast |
A cheap fast model for simple tasks |
low-cost-coder |
A cheap fast model with reasonable coding ability |
local-only |
Whatever local model the operator runs, if any |
tiny |
A minimal local model for trivial tasks |
If an operator's local config has none of these, projects using them will prompt at load time. The set is opinionated but not enforced — operators may add their own and projects may use any names.
Alias name rules:
- Pattern:
^[a-z][a-z0-9-]{0,62}[a-z0-9]$— lowercase letters, digits, hyphens; 2-64 chars; cannot start or end with hyphen. - Must not contain a colon (
:). The colon is reserved for provider:model. - Reserved names:
primary,subagent,operator,system,default. Aliases with these names are rejected.
[providers.<name>]
Credentials and endpoints for an LLM provider. The key (<name>) is an operator-chosen identifier, it can be anything the operator finds meaningful (e.g. claude, claude-work, my-local-gpu).
What's stored locally vs referenced live (per ADR-0049 Q7 + ADR-0026): local config stores the minimum required to keep a provider/model working even if the catalog snapshot loses the entry, credentials, npm (= the npm package name), base_url override, optional default_options, and per-model id + display name. Everything else (capabilities, pricing, context limits, deprecation dates) is read live from the bundled catalog snapshot when the entry is present, with operator DB overrides on top (ADR-0026). When the catalog lacks an entry, only overrides apply, same pattern as today's "model not in catalog" path. See llm.md § Catalog refresh and removal surfacing for the full data-flow model and the resilience property when entries disappear from the catalog.
npm(string, required): the AI SDK package name to dynamically load.catalog_id(string, optional): links the provider back to themodels.devcatalog. If absent, it is a custom provider.name(string, optional): the user-facing label. Defaults to the catalog name.base_url(string, optional): overrides the catalog's default API endpoint.credentials(table, optional): a map satisfying the catalog's environment requirements. Structured asRecord<string, { value?: string, env?: string }>.header_mappings(table, optional): maps environment variable names to request header names.discover_endpoint(string, optional): the/v1/modelsURL for custom discovery.default_options(table, optional): provider-specific defaults passed on every request (temperature, top_p, etc.). Overridden by project DSL'sparametersblock.models(array of tables, optional): the operator's known model catalog for this provider. Each entry hasid(string, required) andname(string, optional, human-readable display name). Whennameis absent, the UI auto-generates one from theidby replacing hyphens/underscores with spaces and title-casing. The list is populated via the "Refresh Models" action, which fetches from the provider's model-listing API and diffs against the persisted list. Models are persisted inlocal.tomlso kaged has an authoritative local catalog, it does not re-fetch on every use. See Model catalog management for the refresh/diff workflow.
Load-time Pruning (Pre-parse Pruning)
To prevent invalid or legacy configurations from causing parsing failures, the loader performs pre-parse pruning. Before passing the raw TOML object to the Zod parser, the loader scans the providers table. Any provider row that is missing the required npm field is silently dropped from the configuration. The loader writes one audit-log entry (or stderr log if the audit system is not yet initialized) per dropped row. This ensures that old-shape provider rows do not block the daemon from starting.
Common providers (illustrative — the catalog snapshot is the source of truth):
Per ADR-0049, the catalog snapshot bundled in the daemon image drives provider availability. The table below lists providers commonly present in models.dev at the time of writing; operators should treat GET /api/v1/local/providers as the authoritative list for their snapshot. Each provider resolves to the npm package named in its catalog entry (e.g. anthropic → @ai-sdk/anthropic).
| Provider | Default package | Default base URL |
|---|---|---|
anthropic |
@ai-sdk/anthropic |
https://api.anthropic.com |
openai |
@ai-sdk/openai |
https://api.openai.com |
google |
@ai-sdk/google |
https://generativelanguage.googleapis.com |
xai |
@ai-sdk/openai-compatible |
https://api.x.ai |
groq |
@ai-sdk/openai-compatible |
https://api.groq.com/openai |
deepseek |
@ai-sdk/openai-compatible |
https://api.deepseek.com |
mistral |
@ai-sdk/openai-compatible |
https://api.mistral.ai |
fireworks |
@ai-sdk/openai-compatible |
https://api.fireworks.ai/inference |
together |
@ai-sdk/openai-compatible |
https://api.together.xyz |
cerebras |
@ai-sdk/openai-compatible |
https://api.cerebras.ai |
openrouter |
@ai-sdk/openai-compatible |
https://openrouter.ai/api |
ollama |
@ai-sdk/openai-compatible |
http://127.0.0.1:11434 |
vllm |
@ai-sdk/openai-compatible |
http://127.0.0.1:8000 |
lm-studio |
@ai-sdk/openai-compatible |
http://127.0.0.1:1234 |
litellm |
@ai-sdk/openai-compatible |
http://localhost:4000 |
antigravity |
provider plugin | https://cloudcode-pa.googleapis.com |
New providers may appear in the catalog snapshot on operator-initiated refresh; operators can also install third-party provider plugins for non-catalog providers. Operators with custom OpenAI-compatible endpoints use any OpenAI-compatible provider (e.g. openai, vllm) and override base_url.
Backward compatibility: Provider entries written before the driver field existed omit driver and use the provider key as the implicit driver name (e.g. [providers.anthropic] → driver is anthropic). The daemon resolves driver as: explicit driver field if present, otherwise the provider key. This means old config files work without changes. New entries created through the UI or API always include an explicit driver field.
Secret hygiene:
- Inline
credentials.*.valueentries are treated as secrets. If any inline value is present and the file is not mode 0600, the daemon refuses to start with a clear error. There's no negotiation. - The daemon never logs API keys. The operational log records "called provider X" with no payload; the audit log records
provider.callwith method and model but never the key. - The
kaged config showcommand redacts inline values (prints<redacted>in place ofcredentials.*.value). Environment indirection (credentials.*.env) is never resolved or displayed.
[plugins."<package-name>"]
Per-plugin operator-local system config in the local plugin store. One section per installed project plugin. Per ADR-0023 the section is keyed by the plugin's package name (e.g. "@kaged/plugin-memory-hindsight"), not by the per-agent slot name in the project DSL.
This section carries operator-machine-wide concerns: secrets, machine-specific paths, debug flags, install-store metadata. The plugin's project-side config (storage paths, knob values, model aliases, isolation policy, tags) lives in the project DSL under AgentSpec.plugins.<name>.config and is never duplicated here. See plugin-host.md § Project and system config for the load-bearing split.
| Field | Type | Meaning |
|---|---|---|
installed |
string | Version of the plugin currently installed. |
local |
bool | true = available to any project; false = activated only for listed projects. |
projects |
list of strings | (present when local = false) Project IDs that activate this plugin. |
source |
string | The source the plugin was installed from (URL, path, or manual). Advisory; not used for upgrades. |
system_config |
table | Operator-local secrets and machine-specific config. Validated against the plugin manifest's system_config_schema at use time. Field names must not collide with the plugin's project-side config_schema (manifest validation enforces disjointness). Environment-variable substitution (${VAR}) is resolved at config-load time. Never committed to the project repo. |
Backward compatibility note. Prior versions of this spec named the local-side config field config. Per ADR-0023, it is renamed to system_config to make the trust boundary explicit. The keyword config at this level is reserved and a parse warning is emitted if found (it indicates a pre-ADR-0023 file).
Example (markdown plugin — no secrets needed):
[plugins."@kaged/plugin-memory-markdown"]
installed = "0.1.0"
local = true
source = "https://github.com/kaged/memory-markdown"
# no [plugins."@kaged/plugin-memory-markdown".system_config] table — backend has no secrets
Example (hindsight cloud — system config carries the token):
[plugins."@kaged/plugin-memory-hindsight"]
installed = "0.3.1"
local = true
source = "https://github.com/kaged/memory-hindsight"
[plugins."@kaged/plugin-memory-hindsight".system_config]
api_token = "${KAGED_HINDSIGHT_TOKEN}"
# debug = true
The daemon merges this system config with the plugin's project-side config (from project.yaml's per-agent plugins.<name>.config) at plugin initialization and sends the merged object to the plugin via config.update. Per ADR-0023, field-name collisions between the two schemas are a manifest-validation error.
When a plugin is project-scoped (local = false):
- Its
projectslist records which projects activated it. - Removing the last project from the list (e.g. via
kaged plugin deactivate <name> --project <id>) does NOT uninstall the plugin. The operator useskaged plugin uninstallfor that. - Activating the plugin for an additional project appends to the list (operator approves via prompt).
Per-plugin system config validation
At daemon startup and at each project load:
- The daemon reads the section's
system_configtable. - It looks up the plugin's manifest at
${KAGED_HOME}/plugins/<package-name>/kaged-plugin.yaml. - It validates
system_configagainst the manifest'ssystem_config_schema. - Required fields missing. If the manifest declares required fields in
system_config_schemaand the section omits them, the daemon emitsplugin.config_validation_failed(audit) and prevents the plugin from starting; the project that requires the plugin loadspendingwith a clear operator-facing error pointing to which secrets are missing. - Extra fields. Fields not in the manifest's
system_config_schemaare an error (strict mode), pointing the operator to the relevant manifest documentation. - Environment variable resolution.
${VAR}references resolve against the daemon's environment at config-load time. Missing env vars produce a clear "set $VAR before launching kaged" message; the daemon does not silently substitute empty strings.
[system_plugins.<name>]
Per-system-plugin configuration. System plugins are trusted, in-process TypeScript packages that extend the daemon via lifecycle hooks. See plugins/system-plugins.md for the full framework spec and ADR-0008 amendment for the decision.
| Field | Type | Required | Meaning |
|---|---|---|---|
enabled |
bool | yes | Whether the daemon loads this plugin at startup. |
path |
string | no | Absolute path to the plugin's package directory. When absent, the daemon looks in ${KAGED_HOME}/system-plugins/<name>/. |
config |
table | no | Plugin-specific configuration. Opaque to the daemon — passed to the plugin's setup() as ctx.config. Each plugin documents its own config shape in its spec. |
Key differences from [plugins.<name>]:
- No
installed,local,projects, orsourcefields. System plugins are always operator-local, never project-scoped, and never auto-installed from project DSL. - No
versionfield in config. The plugin reports its version at load time via itsSystemPlugin.versionproperty. - No sandbox. System plugins run in the daemon's own process with full trust.
enabledis explicit. Project plugins are enabled by being present; system plugins requireenabled = truebecause they run with elevated trust and the operator should be deliberate.
Example:
[system_plugins.webhook-notify]
enabled = true
path = "/home/operator/kaged-plugins/webhook-notify"
config = { webhook_url = "https://matrix.mail99.me/hookshot/webhooks/webhook/e6fee82a-5f86-4b15-bea5-bfb366afa8e2" }
[[projects]]
Array of projects this operator has opened. The daemon uses it to:
- List projects in the UI without scanning the filesystem.
- Remember per-project state (last-opened timestamp, alias-resolution status).
- Distinguish "I've used this project before" from "first-time load" for the UX.
Entries:
| Field | Type | Meaning |
|---|---|---|
id |
string | Project ID from the DSL's project: field. Must match. Immutable. |
path |
string | Absolute path to the project directory on this machine. |
last_opened_at |
int | Millisecond epoch of last session start. |
status |
enum | ready, pending, or invalid. See Status states. |
label |
string (optional) | Operator's local display name for the project. UI displays it instead of id when set. Editable via Project Settings (PUT /api/v1/projects/:slug); 1–80 characters, trimmed on save. |
nickname |
string (optional, deprecated) | Predecessor of label. Tolerated in existing files for backward compatibility but no longer read or written by the daemon. Operators wanting a display name must re-enter it via Project Settings — the legacy nickname value is intentionally NOT auto-migrated to label. |
accent_color |
string (optional) | CSS color value for the project's avatar in the sidebar. When absent, the UI derives a deterministic color from the project ID via a fixed palette. |
Adding to this array happens via the API's project-load endpoint, not by hand-editing (though hand-editing is supported for unusual setups). The label field is mutated via PUT /api/v1/projects/:slug; any other update path is operator hand-edit.
[audit]
Audit-log verbosity controls for local-config-related events.
| Field | Type | Default | Meaning |
|---|---|---|---|
log_alias_use |
bool | false |
Audit every alias resolution. Verbose; off by default. |
log_provider_use |
bool | true |
Audit every LLM provider call (without payload). |
log_alias_edit |
bool | true |
Audit every alias add/remove/change. |
log_plugin_install |
bool | true |
Audit every plugin install/promote/uninstall. |
Defaults are conservative — the things you want to know about (edits, installs, calls) are on; the high-volume signal (every alias use) is off.
[default_tools]
Operator-level tool overrides applied system-wide before any project-level primary.tools overrides. This lets the operator enable or disable built-in tools across all projects without editing each project's DSL.
Only kaged.* tools (checkpoint, issues, ask, form) are enabled by default. All other namespaces (file, search, code, debug) start disabled. Use this section to opt in the tools your environment needs.
Each key is a tool name (e.g. file.read, kaged.issue.create). Values follow the same shape as the DSL's ToolOverrideSchema:
| Value | Effect |
|---|---|
{ enabled = true } |
Enable the tool for all projects (the primary way to opt in file, search, code, or debug tools) |
{ enabled = false } |
Disable the tool for all projects (unless the project's primary.tools re-enables it) |
null |
Disable the tool (ADR-0015 nullification — equivalent to { enabled = false }) |
{} |
No change — the tool keeps its default state (enabled for kaged.*, disabled for all others) |
| (key absent) | No effect — the tool keeps its default state |
Resolution order (later layer wins):
- Available tools — the daemon's
ToolRegistrydetermines which tools exist at runtime. - Operator layer —
[default_tools]in this file. Applied first. - Project layer —
primary.toolsin the project's DSL. Applied second; overrides operator layer. - Cage layer — per-agent cage policy further restricts tools at dispatch time (not an override layer; enforced at runtime).
The resolution is performed by resolveRootTools() in @kaged/dsl during compileProjectDsl(). The daemon passes default_tools as operatorToolOverrides and the DSL's primary.tools as the project layer. See project-dsl.md § Tool resolution for the full model.
Example use cases:
- Enable file tools system-wide: set
"file.read","file.write","edit.text","file.create"each to{ enabled = true }. - Enable code intelligence for a development machine: set
"code.lsp"to{ enabled = true }. - Disable the issue tracker system-wide: set each
kaged.issue.*tool tonull. - A project can re-enable operator-disabled tools or enable additional tools by setting
enabled = truein itsprimary.tools— the project layer wins.
Plugin injection (future): Plugins will be able to inject additional tools into the available set. The [default_tools] layer applies to plugin-provided tools the same way — if a plugin registers myplugin.deploy and the operator sets "myplugin.deploy" = { enabled = false }, it is disabled unless a project re-enables it.
[secrets.<path>] (ADR-0043)
Secrets storage for MCP server authentication. Never appears in a project DSL (ADR-0011 — secrets are operator-local). The project DSL declares what auth a server needs via secret_ref; the concrete value lives here.
The key structure is [secrets.<path-segment>] where <path-segment> is the first dot-separated component of a secret_ref. Under each section, keys map to secret values.
# DSL has: secret_ref: api_keys.example
# This resolves to: [secrets.api_keys] → example
[secrets.api_keys]
example = "sk-live-abc123..."
github = "ghp_xxxx..."
# DSL has: secret_ref: db.readonly_url
[secrets.db]
readonly_url = "postgres://reader@db.local/app"
| Field | Type | Required | Meaning |
|---|---|---|---|
<key> |
string | yes | The secret value. Resolved at session-start and injected into the MCP server's auth headers. |
Resolution: At session-start, the daemon resolves each secret_ref by splitting on . — the first segment selects the [secrets.<segment>] section, the remainder selects the key within that section. Missing secret → the session refuses to start with a clear error naming the server and the missing ref.
Permissions: The [secrets.*] sections are never returned by the daemon's API. The redactSecrets function strips all secret values before any config is surfaced to the UI or logs.
[mcp.<server-key>] (ADR-0043)
Operator-level overrides for project-declared MCP servers. Keys match server-keys from the project DSL's mcp: block.
[mcp.github]
disabled = false
timeout_ms = 30000
[mcp.database]
disabled = true # operator disables a server they don't want
| Field | Type | Default | Meaning |
|---|---|---|---|
disabled |
boolean | false |
When true, the server is not contacted; its tools are not registered. |
timeout_ms |
integer | daemon default (30000) | Per-call timeout for this server's tools/call requests. |
Disabled servers are silently skipped at session-start — no error, no warning beyond the daemon's operational log. This lets the operator selectively disable servers without editing the project DSL.
[web] (optional)
Operator-level configuration for the web.search built-in tool (per ADR-0033 web namespace activation, see agent-tooling.md § Web search tool). Per ADR-0011, all provider credentials and preferences are operator-local — the project DSL only enables the tool per agent via its tools: block.
[web]
# All fields optional. Sensible defaults apply when omitted.
preferred_provider = "brave" # try this provider first
# provider_order = ["brave", "exa", "tavily"] # explicit ordered list (overrides preferred_provider + default order)
hard_timeout_ms = 60000 # per-provider hard timeout, default 60000
[web.searxng]
endpoint = "https://searxng.operator.local" # required for searxng provider only
[web.brave]
timeout_ms = 15000 # per-provider timeout override
# API keys — flat map under [secrets.web]. Key = provider id, value = API key.
# All MVP providers use a single API key; future providers needing multiple
# credentials will require a schema extension.
[secrets.web]
brave = "BSA-xxxxxxxxxxxxxxxx"
exa = "exa-xxxxxxxxxxxxxxxx"
tavily = "tvly-xxxxxxxxxxxxxxxx"
anthropic = "sk-ant-xxxxxxxxxxxxxxxx"
gemini = "AIzaXXXXXXXXXXXXXXXX"
# ---- Notifications (ADR-0047) ------------------------------------------
# Routing table for session notification events. The daemon's notification
# router consults this table (after project + per-session overrides) to decide
# which channels dispatch a given event class. See docs/specs/notifications.md
# for the full pipeline and docs/specs/plugins/{ntfy,pushover}.md for the
# reference tier-3 channels.
[notifications]
[notifications.routing.attention_required]
# in-app is always enabled and cannot be disabled — entry shown for documentation only.
in-app = { enabled = true }
ntfy = {} # eligible iff ntfy plugin channel is registered
web-push = {} # eligible iff web-push channel (VAPID) is registered
[notifications.routing.run_completed]
in-app = { enabled = true }
ntfy = {}
# web-push omitted by default — run completion is informational, not push-worthy
[notifications.web_push]
# subject = "mailto:operator@example.com" # optional; default mailto:noreply@kaged.local
# vapid_private_key_pem = """-----BEGIN PRIVATE KEY-----
# ...
# -----END PRIVATE KEY-----"""
# Or via env var: KAGED_VAPID_PRIVATE_KEY_PEM
# Or auto-generated at ${KAGED_HOME}/vapid.pem on first need
| Field | Type | Default | Meaning |
|---|---|---|---|
preferred_provider |
string | unset | Provider id tried first in the fallback chain. Must be a known provider (see agent-tooling spec § Provider catalog). Ignored if provider_order is set. |
provider_order |
array of strings | unset | Explicit ordered list of provider ids. Only listed providers are tried, in the given order. Overrides preferred_provider and the daemon's default order. |
hard_timeout_ms |
integer | 60000 |
Hard timeout applied to every provider call via AbortSignal.any([signal, AbortSignal.timeout(hard_timeout_ms)]). |
[web.<provider>] subsections are optional per-provider overrides:
| Field | Type | Default | Meaning |
|---|---|---|---|
timeout_ms |
integer | [web] hard_timeout_ms |
Per-provider timeout override. |
endpoint |
string | provider default | Provider endpoint URL (used by searxng for the self-hosted instance URL; ignored by providers with fixed endpoints). |
Provider availability: a provider is available when (a) its API key is present in [secrets.web] under the provider's id (e.g., secrets.web.brave = "BSA-...") and (b) it is not explicitly disabled. Unavailable providers are silently skipped by the fallback chain — they do not count as failures. Missing credentials is not a startup error.
Secret resolution: the daemon looks up a provider's API key in the flat [secrets.web] map — key is the provider id, value is the API key. This fits the existing 2-level secrets schema (Record<string, Record<string, string>>). Missing key → provider unavailable. All MVP providers use a single API key; providers needing multiple credentials in the future will require a schema extension.
Redaction: redactSecrets() already iterates all [secrets.*] sections generically, so [secrets.web] values are auto-redacted before any config is surfaced to the UI, API, or logs. No redactor change was needed.
[notifications] (ADR-0047)
Operator-level defaults for session notification routing. See notifications.md for the daemon-side pipeline and ui/notifications.md for the UI half.
The block has two sub-tables:
[notifications.routing.<event_class>]— a map ofchannel_id → channel_config. Defines which channels are eligible for each event class (attention_required,run_completed). Thein-appchannel is always enabled and cannot be disabled. Channels referencing unregistered channels are silently skipped at dispatch (warning logged).[notifications.web_push]— VAPID configuration for the optional Web Push channel. When this block resolves to a usable private key, the daemon registers theweb-pushchannel; otherwise it is not registered andweb-pushentries in routing are skipped.
| Field | Type | Default | Meaning |
|---|---|---|---|
routing.<class>.<channel>.enabled |
bool | true |
If false, the channel is not eligible for that event class. Absent entry = not eligible. |
routing.<class>.<channel>.<other> |
record | — | Opaque per-channel config passed to the channel's send() as context.config. Documented per channel (ntfy, pushover, etc.). |
web_push.subject |
string | mailto:noreply@kaged.local |
VAPID subject; used in push service abuse reports. |
web_push.vapid_private_key_pem |
string | — | Inline VAPID private key PEM. Mode 0600 protects it. Prefer env or auto-generation. |
⚠️ Overrides are set-not-merge. A notifications: block in <project>/.kaged/project.local.yaml fully replaces the system-default routing for that project; it does not deep-merge. Per ADR-0015 and ADR-0047 §5. Operators wanting to add a channel at the project layer must redeclare the full routing set.
The per-session bell override (set via PUT /api/v1/projects/:slug/sessions/:sid/notification-bell) is not stored here — it lives on the session row in the daemon's sessions table. See notifications.md § Per-session bell values.
Model catalog management
Each provider entry carries an optional models array — the operator's known model catalog for that provider. This is persisted config, not an ephemeral API response. The daemon never re-fetches models on its own; the operator controls when the catalog is refreshed.
Model entry shape
[[providers.claude.models]]
id = "claude-sonnet-4-20250514" # provider's model identifier (required)
name = "Claude Sonnet 4" # human-readable display name (optional)
[[providers.claude.models]]
id = "claude-opus-4-20250514"
name = "Claude Opus 4"
# Operator-defined model: added by hand, never retired by "Refresh Models".
[[providers.claude.models]]
id = "custom-local-llm"
name = "My Local LLM"
manual = true
| Field | Type | Required | Meaning |
|---|---|---|---|
id |
string | yes | The model identifier the provider accepts in API requests. Opaque to kaged. |
name |
string | no | Human-readable display name. When absent, the UI auto-generates one from id by replacing hyphens/underscores with spaces and title-casing (e.g. claude-opus-4-20250514 → "Claude Opus 4 20250514"). Operators may edit this to taste. |
manual |
boolean | no | When true, this model is operator-managed. The "Refresh Models" action never marks it as retired, and it survives saves that replace the auto-discovered portion of the catalog. Default false. |
Catalog sync workflow
The catalog snapshot bundled into @kaged/llm at build time is operator-refreshable from models.kaged.dev — never auto-fetched, never implied as a side effect of any other action. Sync is initiated explicitly (UI button or daemon API). It pulls a newer snapshot, computes a diff against the daemon's current snapshot, and surfaces it in the UI for operator confirmation:
- Fetch — the daemon pulls
https://models.kaged.dev/catalog.json(plus the newmanifest.jsonfor provenance). - Diff against the current snapshot:
- Added — providers/models present in the new snapshot but absent from the current.
- Removed — providers/models present in the current snapshot but absent from the new.
- Changed — entries whose metadata differs (the UI may summarise as "metadata updated" rather than enumerating every field).
- Surface — the daemon returns the diff to the UI. For each removed entry that matches an operator-configured provider or model row in
local.toml, the UI presents a "keep" checkbox (checked by default). - Operator confirms — the operator picks which additions to accept, which changes to apply, and for removals, whether to keep the local row (default) or uncheck to delete it as part of the apply step.
- Apply — the daemon swaps in the new snapshot and writes the operator-confirmed
local.tomldeletions. The only writes tolocal.tomlduring sync are the explicit operator-confirmed deletions from unchecking "keep."
Resilience property: an operator who keeps a removed entry (the default) continues to use it. The catalog simply no longer contributes a base metadata layer for it; resolveModelMeta falls back to overrides-only. The provider/model keeps working because local.toml still has the driver (npm package) and id/name, and the driver is bundled into the daemon binary (per ADR-0049 § Amendments 2026-06-29, which retired the on-disk store) — catalog sync cannot remove a bundled driver. See llm.md § When a whole provider disappears.
Custom provider discovery workflow
For custom providers, the operator can trigger model discovery via POST /api/v1/local/providers/:name/models/discover. This hits the provider's declared discover_endpoint (or defaults to /v1/models derived from base_url) to fetch the live model list.
- Fetch: the daemon calls the custom provider's discovery endpoint.
- Merge: the daemon merges the discovered models with any matching catalog entries (matched by the npm package name) to enrich them with metadata.
- Save: the operator can save the discovered models to
local.toml. The daemon preserves themanualflag on any persisted entry that already has it.
Alias impact on retired models
When a refresh shows retired models, the UI checks whether any aliases in [aliases] reference a retired model (i.e., an alias target whose model portion matches a retired model ID under that provider). If so, the UI flags these aliases for the operator's attention — the alias will still work (alias resolution doesn't consult the model catalog), but the operator should update it to a current model.
Why persisted, not ephemeral
- Offline use. Local/self-hosted providers (Ollama, vLLM) may not always be running when the UI loads. Persisted models are always available for alias configuration.
- Operator control. The operator decides which models they care about. A provider may list hundreds of models; the operator's catalog is curated.
- Retirement tracking. Without a persisted baseline, there's no way to diff against the provider's current offerings and surface retired models.
- No surprise API calls. The daemon never phones home to a provider without the operator's explicit action.
Empty catalog
A provider with no models array (or an empty one) is valid. The alias system works regardless — model IDs in alias targets are opaque strings. The catalog is a convenience for the UI's model picker, not a gatekeeper.
Status states
A project in the registry is in one of three states:
| State | Meaning | Allowed operations |
|---|---|---|
ready |
All aliases resolved, all required plugins installed and activated. | Sessions can start. |
pending |
DSL is valid, but some alias is unbound or some plugin is not installed. | Operator can edit local config to resolve; sessions refuse to start. |
invalid |
DSL fails validation (per project-dsl.md). |
Project is unusable until the DSL is fixed. |
State transitions:
pending → readywhen the operator defines a missing alias or installs a missing plugin. The daemon recomputes on every local-config write.ready → pendingwhen local config drops an alias the project needs (operator removed it).* → invalidwhen the project's DSL fails validation (DSL was edited on disk).invalid → pending/readyafter the DSL is corrected and revalidated.
Alias resolution algorithm
When the daemon needs to resolve a model alias (e.g., during session start, when the primary picks a subagent's model from its DSL declaration):
function resolve_alias(alias_name):
if alias_name contains ":":
# The DSL allows this only in special contexts (TBD); not for portable projects.
# See project-dsl.md for which fields accept direct provider:model.
return parse_provider_model(alias_name)
binding = local_config.aliases[alias_name]
if binding is undefined:
emit audit event "alias.miss" with { alias_name, project_id }
raise AliasUnresolved(alias_name)
provider_name, model_id = split(binding, ":", 1)
provider = local_config.providers[provider_name]
if provider is undefined:
emit audit event "alias.provider_missing" with { alias_name, provider_name }
raise ProviderMissing(provider_name)
return ResolvedModel(provider_name, model_id, provider.credentials)
The resolver runs at the moment the model is needed, not eagerly at project load. A project may load with all aliases bound to provider names that don't exist — the load succeeds, but the first invocation hits ProviderMissing and the daemon prompts the operator.
The reason: an operator may bind smart-generalist = "openai:gpt-5" and then realize they haven't put their OpenAI key in yet. The provider miss should be a small, fixable prompt — not a project-load-time error.
CLI surface
kaged config alias <name> <provider>:<model>
Define or update an alias.
kaged config alias smart-generalist claude:sonnet-4.6
✓ alias smart-generalist → claude:sonnet-4.6 (claude provider configured)
→ re-evaluating known projects... 1 became ready (music-site)
If the named provider isn't configured, the command warns and proceeds (the binding is recorded; usage will fail later).
kaged config alias <name> --remove
Remove an alias.
kaged config alias smart-generalist --remove
! 2 known projects use this alias and will become pending
✓ confirm with --yes to proceed
--yes skips the confirmation.
kaged config alias --list
List all aliases.
kaged config provider <name> set <key>=<value>
Set a provider field. Example:
kaged config provider claude set credentials.ANTHROPIC_API_KEY.env=ANTHROPIC_API_KEY
kaged config provider claude set base_url=https://api.anthropic.com
kaged config provider --list
List configured providers (keys redacted).
kaged plugin promote <name>
Mark a project-scoped plugin as local-scope (available to any project).
kaged plugin promote oh-my-pi
✓ oh-my-pi is now a local plugin (available to all projects)
kaged plugin deactivate <name> --project <id>
Remove a project from a project-scoped plugin's activation list.
kaged project list
List known projects from the registry, with their state.
PROJECT STATE LAST OPENED PATH
music-site ready 2026-05-21 14:32 /home/operator/projects/music-site
infra-monitor pending 2026-05-19 09:15 /home/operator/projects/infra-monitor
↳ unresolved aliases: low-cost-coder
↳ missing plugins: deploy-helper
kaged project load <path>
Walks the operator through loading a project: validates DSL, prompts for missing aliases interactively, prompts for plugin installs, and writes registry entry on success.
Non-interactive form for scripted setup:
kaged project load /path/to/project \
--alias smart-generalist=claude:sonnet-4.6 \
--alias low-cost-coder=ollama:llama3.2 \
--install-plugin oh-my-pi
kaged config show [--source]
Prints the effective local config with secrets redacted. --source annotates each value with the file path it came from (useful when multiple files override each other, e.g., system-wide deployment with per-operator overrides).
kaged config validate
Parses the local config and reports schema errors without making changes.
Interaction with daemon and projects
At daemon startup
The daemon does not read every operator's local config at startup. Local config is loaded per request when the operator is identified (via the auth header). This keeps system-wide daemons memory-efficient with many operators and means a corrupt local-config file affects only that operator, not the daemon.
The daemon caches loaded local-config files in memory for the duration of an active session. A SIGHUP to the daemon (per daemon.md) flushes the cache.
When loading a project (POST /api/v1/projects/load)
- The daemon reads the project's
.kaged/project.yaml. - Runs DSL validation. On failure:
dsl_invaliderror. - Collects all aliases referenced by the DSL. Compares to the requesting operator's local-config
[aliases]. Records each unresolved alias. - Collects all plugins declared in the DSL. Compares to the operator's local-config
[plugins.*]. Records each missing or non-activated plugin. - Responds with the project's state (
ready,pending) and, if pending, the list of missing items. - Writes (or updates) the
[[projects]]entry in local config.
The operator's UI then walks them through resolving missing items. Each resolution writes back to local config and re-evaluates state.
When starting a session
The session manager refuses to start a session for a pending project, returning a structured error naming what's unresolved. The UI surfaces this and links to the resolution dialog.
Recommended-alias offers
When the daemon detects a fresh local config (no [aliases] block, or fewer than 2 aliases defined), it offers the recommended starter set. The operator can:
- Accept the set, with each alias unbound (they'll prompt at use).
- Accept and bind interactively (the daemon walks through "what's your
smart-generalist?"). - Skip the offer entirely (operator writes aliases by hand or
kaged config alias).
The offer surfaces in the UI on first login and in kaged status if the operator never accepted it.
Audit events from local-config operations
Local-config-related events that hit the audit log (subject to the [audit] opt-ins):
| Event | When | Carries |
|---|---|---|
alias.added |
New alias defined | name, target |
alias.removed |
Alias deleted | name, previous target |
alias.changed |
Alias's target updated | name, old target, new target |
alias.miss |
A project asked for an unbound alias | name, project_id |
alias.provider_missing |
An alias's provider isn't configured | name, provider_name |
provider.added |
New provider configured | name, base_url |
provider.call |
Daemon called the provider | name, model, request_id (no payload, no key) |
plugin.installed |
Plugin install completed | name, version, source, local-bool |
plugin.promoted |
Plugin moved to local scope | name |
plugin.deactivated |
Plugin deactivated for a project | name, project_id |
plugin.uninstalled |
Plugin removed from local store | name, version |
project.registered |
New project added to local registry | id, path |
project.opened |
Project loaded (any time after the first) | id |
project.state_change |
Project state changed | id, old_state, new_state |
Per ADR-0007, every event carries user_id and request_id.
Failure modes
| Failure | Detection | Behavior |
|---|---|---|
| Local config file doesn't exist | First read | Daemon creates a minimal empty file (mode 0600); operator gets the recommended-alias offer in UI |
| Local config has invalid TOML | Parse | Operator's API requests return 500 with details.subsystem: "local_config"; daemon stays up for other operators |
Inline credentials.*.value with non-0600 mode |
Startup gate (per-user) or first read (system-wide) | Refuse to start (per-user) or return 500 to that operator (system-wide) with a clear remediation |
credentials.*.env references unset env var |
First call to provider | Error returned with details.var: "<name>" |
Provider base_url unreachable |
First call | 502 provider_unreachable (per http-api.md) |
Project at [[projects]].path no longer exists on disk |
Walked at startup or on kaged project list |
Entry marked path_missing in the UI; operator can fix or remove |
Migration story
For operators who already have a config.toml (operational config) and want to set up local config: the daemon does not auto-migrate. On first run after upgrading from a hypothetical pre-local-config kaged, the daemon:
- Detects no local config file.
- Creates an empty one at the appropriate path with comments explaining the format.
- Offers the recommended-alias starter via the UI.
Since v0 is the first release, there's nothing to migrate from. This section is forward-looking for future schema versions.
Testing notes
Per ADR-0003:
- Schema parsing tests: every field is exercised in a fixture; invalid types are rejected with named errors.
- Permissions tests: the 0600-or-refuse-startup guard fires on a deliberately mode-0644 fixture.
- Alias resolution tests: miss, provider-missing, success, recursive (alias points to alias — explicitly rejected).
- Multi-operator tests (system-wide mode): two different
X-Kaged-User-Idvalues resolve to different local configs and different alias bindings. - Project registry tests: load, transition states, missing-path detection.
- Audit tests: every
[audit]opt-in produces the documented event. - Secret-redaction tests:
kaged config showand any API endpoint that returns local config redact inlinecredentials.*.value(and never resolvecredentials.*.env). - CLI tests: every
kaged configandkaged plugin promotesubcommand exercises a happy path and a documented failure.
Open questions
- Encrypted secrets in inline credentials. Some operators want their API keys at rest in a kept config file but not in plain text. v0 ships plain text with mode 0600. Encrypted-at-rest (gpg, age, OS keyring integration) is a v0.x feature.
- OS keyring integration. macOS Keychain, Linux Secret Service, Windows Credential Manager — natural homes for API keys. Adapter layer; v0.x.
- Per-project local overrides. An operator might want to use a different provider for one specific project than their global alias suggests. Today they redefine the alias; per-project overrides are a v0.x idea.
- Alias inheritance/chaining.
medium = "claude:sonnet-4.6"anddefault = "medium". Considered and rejected for v0: adds complexity, masks errors, no compelling use case. Aliases are flat in v1. - Recommended-alias updates. Future kaged versions may ship updated recommended sets. How operators adopt updates (auto, prompt, manual) is open. v0 ships one set; future is TBD.
- Project registry sync across machines. An operator with kaged on a laptop and a workstation might want the project list synced. Out of scope for v0; the operator manages it through their own dotfiles.
Amendments
2026-06-26 — ADR-0049: finalize legacy removal
Follow-up to the 2026-06-25 ADR-0049 amendment. The remaining legacy provider surface is cleaned up:
driverfield fully removed from runtime. The daemon no longer computes an effectivedriverfromproviders.<name>.driver; it usescatalog_id(for catalog-linked metadata, tokenizer, and package resolution) andnpm(for dynamic loading).ProviderRoutein@kaged/llmno longer carriesdriver.- Legacy
api_key/api_key_envredaction dropped.redactSecrets()no longer touchesprovider.api_key; provider secrets live exclusively under[providers.<name>.credentials]. PluginEntrySchemawarning forconfig. The schema still toleratesconfigfor back-compat but emits a parse warning directing operators to rename it tosystem_configper ADR-0023.- Provider cost/usage fetcher deferred. The driver-keyed
DRIVER_FETCHER_MAP(e.g.zai) is removed. Provider-specific usage fetching is now an open topic; the catalog will later grow cost-methodology or fetcher metadata keyed by catalog ID.
2026-06-25 — ADR-0049: catalog-driven provider list
Driven by ADR-0049. The provider configuration schema is updated to use a catalog-linked shape, replacing the old driver field with npm and catalog_id.
npmfield is required. Thenpmfield specifies the AI SDK package name to dynamically load. The olddriverfield is removed.catalog_idfield is added. Thecatalog_idfield links the provider back to the catalog. If absent, it is a custom provider.credentialsmap is added. Thecredentialsmap replaces the oldapi_keyandapi_key_envfields with a structured map satisfying the catalog's environment requirements.- Load-time pruning is added. The loader silently prunes any provider rows missing the required
npmfield at load time. - Refresh workflow is replaced by discovery. The per-provider
listModelsandmodels/refreshendpoints are removed. Custom providers use the new model discovery flow (/providers/:name/models/discover). - Local config remains source of truth post-add. Per ADR-0049 Q7, when the operator adds a provider, the catalog metadata that drove the add is snapshotted into local config. Catalog refresh never modifies operator config or removes routes.
2026-06-26 — ADR-0049: remove legacy provider credential fields
The provider configuration schema no longer reads the legacy api_key or api_key_env fields. All provider runtime paths resolve credentials from the [providers.<name>.credentials] map. Legacy fields are tolerated at parse time (via passthrough) so old files do not fail to load, but they are ignored and never used for provider calls. Secret redaction, the 0600 mode guard, and the provider-list API response all operate on the credentials map.
2026-06-16 — [ui].font_size accessibility field + theme default change
UiSchemagainsfont_size. Optional enum:"small"|"medium"|"large"|"x-large". Default"medium". Maps to--ui-font-scaleCSS variable on<html>(0.875 / 1.0 / 1.125 / 1.25). Scales allrem-based UI text. Required for operators with deteriorating eyesight (operator feedback 2026-06-16).- Default
themechanges from"dark"to"system".Operators get OS-adaptive theme on first run. The brand's canonical theme remains dark (seedocs/brand/brand-guide.md§ Theme variants amendment). Thesystemvalue followsprefers-color-schemevia CSS media query. - Wire contract extended.
SetPreferencesRequestSchemain@kaged/wireacceptstheme(now enum-validated:"dark" | "light" | "system") andfont_size(enum-validated). ThePUT /api/v1/local/preferenceshandler returns both in the snapshot.
2026-06-16 — [web] section for web.search provider config (ADR-0033 web namespace activation)
Adds operator-level configuration for the web.search built-in tool (agent-tooling.md § Web search tool).
[web]section added. Operator-level preferences:preferred_provider(string),provider_order(array of provider ids),hard_timeout_ms(integer, default 60000). All fields optional; sensible defaults apply when omitted.[web.<provider>]subsections added. Per-provider overrides:timeout_ms,endpoint(used bysearxngfor self-hosted instance URL).- Credentials stored flat under
[secrets.web]. Provider API keys live in a flat map where the key is the provider id and the value is the API key (e.g.,secrets.web.brave = "BSA-..."). This fits the existing 2-levelsecretsschema (Record<string, Record<string, string>>) — no schema extension was needed. Missing key → provider unavailable (not a startup error). All MVP providers use a single API key; providers needing multiple credentials in the future will require a schema extension. redactSecrets()unchanged. The existing redactor already iterates all[secrets.*]sections generically, so[secrets.web]values are auto-redacted. No redactor code change was needed.- No DSL changes. Per ADR-0011, all web-search provider configuration is operator-local. The project DSL only enables
web.searchper agent via the standardtools:block (no project-levelweb:block).
2026-05-22 — accent_color on project entries
accent_colorfield added to[[projects]]. Optional CSS color string for the project's sidebar avatar. When absent, the UI derives a deterministic color from the project ID via a fixed 8-color palette. This is operator-local state (not in the DSL) per ADR-0011 — different operators may color the same project differently.
2026-05-22 — label on project entries; nickname deprecated
labelfield added to[[projects]]. Optional operator-local display name, 1–80 chars after trimming. Mutated viaPUT /api/v1/projects/:slug(see http-api.md). The UI displays it instead ofidwhen present; when absent, the UI falls back toid. This is operator-local state (per ADR-0011) — different operators may label the same DSL project differently.nicknamefield deprecated. The previousnicknamefield is still tolerated inlocal.toml(so existing files don't fail to parse) but is no longer read or written by the daemon. Operators wanting a display name must deliberately re-enter it aslabelvia Project Settings. The first save throughPUT /api/v1/projects/:slugdrops any leftovernicknamefrom the entry. This intentional "manual re-entry only" rule keeps project-rename a deliberate operator action.
2026-05-23 — Provider entries keyed by user-chosen ID; driver field added
- Provider keys are now operator-chosen identifiers. Previously,
[providers.<name>]used the key as both the identifier and the implicit driver name (e.g.[providers.anthropic]). This made it impossible to configure multiple accounts for the same provider (e.g. personal and work Anthropic keys). The key is now a free-form operator-chosen name; thedriverfield determines which API shape to use. driverfield added to[providers.<name>]. Required for new entries created through the UI or API. Omitted entries fall back to using the provider key itself as the driver — this preserves backward compatibility with existinglocal.tomlfiles. The daemon resolves the effective driver as: explicitdriverfield if present, otherwise the provider key.DELETE /api/v1/local/providers/:nameendpoint added. Removes a provider entry fromlocal.toml. Any aliases that reference the deleted provider will fail at resolution time withProviderMissing— consistent with existing alias-resolution behavior.GET /api/v1/local/providersresponse expanded. Now includesdriver(resolved, never null) andapi_key_env(if configured) per provider entry, plus a top-levelknown_driversarray listing all driver names recognized by@kaged/llm. (Legacy; thedriverfield andknown_driverslist were superseded by ADR-0049. The current provider shape usesnpmandcatalog_id.)- Alias target format unchanged. Alias values still use
<provider-key>:<model>format (e.g.claude-work:claude-sonnet-4-20250514). The provider key in the alias target must match a key in[providers.*]— it is the operator's identifier, not the driver name.
2026-05-23 — Persisted model catalog on provider entries
modelsarray added to[providers.<name>]. Optional array of{ id, name }tables. Each entry represents a model the operator knows about for this provider.idis required (the provider's model identifier);nameis optional (human-readable display name, auto-generated fromidwhen absent).- Model catalog is persisted config, not ephemeral. The daemon does not re-fetch models automatically. The operator triggers a "Refresh Models" action which fetches from the provider API, diffs against the persisted list (added/retired/unchanged), and lets the operator review before saving.
- New § Model catalog management added. Documents the model entry shape, the refresh/diff workflow, alias impact on retired models, and the rationale for persistence over ephemeral fetching.
- New API endpoints.
POST /api/v1/local/providers/:name/models/refresh(fetch + diff),PUT /api/v1/local/providers/:name/models(save updated list),GET /api/v1/local/providers/:name/models(read persisted list). Seehttp-api.md. - Backward compatible. Existing provider entries without
modelscontinue to work. The alias system does not consult the model catalog — model IDs in alias targets remain opaque strings. The catalog is a UI convenience for model selection and retirement tracking.
2026-05-25 — System plugins config section
[system_plugins.<name>]section added. New top-level config section for trusted, in-process system plugins. Each entry hasenabled(bool, required),path(string, optional — defaults to${KAGED_HOME}/system-plugins/<name>/), andconfig(table, optional — opaque to the daemon, passed to the plugin'ssetup()context). See ADR-0008 amendment (system plugins) for the decision andplugins/system-plugins.mdfor the framework spec.- Schema example updated. The full schema example now includes a
[system_plugins.webhook-notify]entry demonstrating the config shape. - Distinct from
[plugins.<name>]. System plugins have noinstalled,local,projects, orsourcefields. They are always operator-local, never project-scoped, never auto-installed from project DSL. Theenabledfield is explicit because system plugins run with full daemon trust — the operator must be deliberate.
2026-05-27 — Operator-level tool overrides (default_tools)
[default_tools]section added. Optional top-level config section for operator-level tool enable/disable overrides. Each key is a built-in tool name (e.g.debug,kaged.issue.create); values are either{ enabled = false }(disable),{ enabled = true }(enable),null(disable via ADR-0015 nullification), or{}(no change). Applied system-wide as the first override layer incompileProjectDsl()'s tool resolution — project-levelprimary.toolsoverrides come second (later layer wins).ToolOverrideEntrySchemaadded to@kaged/local-config. Zod schema matching the DSL'sToolOverrideSchemashape ({ enabled?: boolean, description?: string, parameters?: Record }). Thedefault_toolsfield isRecord<string, ToolOverrideEntrySchema | null>.- New §
[default_tools]section in spec. Documents the field shape, value semantics, resolution order (available → operator → project → cage), interaction withcompileProjectDsl(), example use cases, and future plugin injection point. - Schema example updated. The full schema example now includes a
[default_tools]section demonstrating DAP tool disablement. - Interaction with
compileProjectDsl(). The daemon passesconfig.default_toolsasoperatorToolOverridestocompileProjectDsl(). The compiler callsresolveRootTools(availableTools, [operatorLayer, projectLayer])and includes the result asresolvedRootToolson the compile result. Seeproject-dsl.md§ Tool resolution for the full model.
2026-05-27 — ADR-0023: plugin system config split
Per ADR-0023:
[plugins.<name>]section reframed. Section is now keyed by the plugin's package name (e.g."@kaged/plugin-memory-hindsight"), not by a per-agent slot. The section carries operator-machine-wide concerns (secrets, install metadata, machine-specific paths) — not the plugin's general configuration, which lives in the project DSL underAgentSpec.plugins.<name>.config.configtable renamed tosystem_config. Inside[plugins."<package>"], the configuration table is nowsystem_config(validated against the manifest'ssystem_config_schema). The keywordconfigat this level is reserved; a parse warning is emitted if found (pre-ADR-0023 file).- Field-name disjointness enforced. Per ADR-0023, plugin manifests must declare
config_schema(project-side) andsystem_config_schema(operator-local) with disjoint property names. The local-config validator does not need to re-check disjointness (the manifest validator does at install time) but emitsplugin.config_validation_failedif it finds local-side fields that aren't insystem_config_schema. - Environment-variable resolution.
${VAR}references insystem_configresolve at config-load time. Missing env vars produce an actionable error pointing the operator at the missing variable; no silent empty-string substitution. - Backward compatibility. Pre-ADR-0023 files using
[plugins.<name>]with aconfigtable can be migrated by renaming the file'sconfig = { ... }tosystem_config = { ... }and verifying all fields are in the manifest'ssystem_config_schema. There is no automatic migration tooling (pre-alpha; consistent with the established "no migration" pattern). - Constrained-by list extended with ADR-0023.
2026-06-11: Unified user identity and shared SSO (Phase 0)
Per ADR-0036:
- Shared SSO configuration. Added the
[auth.sharedsso]configuration block to the local config schema, allowing operators to configure the shared SSO bootstrap path. - Cross-references. Linked
sso-relay.mdandusers.mdto document the verification contract and TOFU user lifecycle.
2026-06-19 — [notifications] section for session notification routing (ADR-0047)
Per ADR-0047:
[notifications]section added. Operator-level defaults for session notification routing. Two sub-tables:routing.<event_class>.<channel_id>(eligibility + per-channel config) andweb_push(VAPID configuration for the optional Web Push channel).- Two event classes.
attention_required(actionable, persistent — checkpoint/ask/approval gate) andrun_completed(informational, transient). Each maps to a routing table; the daemon's notification router consults it after project + per-session overrides. in-appis always enabled. The built-inin-appchannel (events WS publish) cannot be disabled. The entry is shown for documentation only.- Set-not-merge semantics. A
notifications:block in<project>/.kaged/project.local.yamlfully replaces the system-default routing for that project; per-session bell overrides fully replace the project-resolved routing for that session. Per ADR-0015 and ADR-0047 §5. - Web Push VAPID. The
[notifications.web_push]block acceptssubject(mailto) andvapid_private_key_pem(inline PEM). When neither inline value norKAGED_VAPID_PRIVATE_KEY_PEMenv var resolves, the daemon auto-generates a keypair at${KAGED_HOME}/vapid.pem(mode 0600) on first need. - Per-session bell. Bell values (
"default" | "all" | "attention-only" | "silent") live on the session row, not in local config. Set viaPUT /api/v1/projects/:slug/sessions/:sid/notification-bell. Seenotifications.md § Per-session bell values. - Cross-references. Linked
notifications.md(daemon pipeline),ui/notifications.md(UI half),plugins/ntfy.mdandplugins/pushover.md(reference tier-3 channels).
References
- ADR-0010 — deployment modes; path defaults
- ADR-0011 — portability; alias mechanism rationale
- ADR-0007 and its amendments — how per-operator identity reaches the daemon
- ADR-0008 and its amendment — plugin store and project activation
- ADR-0023 — plugin lifecycle hooks; project/system config split
plugin-host.md § Project and system config— the load-bearing description of the splitdaemon.md— daemon side of local-config loadingproject-dsl.md— what aliases the DSL is allowed to usehttp-api.md— endpoints that read/write local config- TOML spec: https://toml.io/
- XDG Base Directory Specification: https://specifications.freedesktop.org/basedir-spec/