Spec: Local config
- Status: Draft
- Last amended: 2026-05-27 (ADR-0023 — plugin system config split:
[plugins."<package>"]now carries operator-local secrets only) - Constrained by: ADR-0010, ADR-0011, ADR-0007, ADR-0008, ADR-0023
- 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 |
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
# ---- 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; `driver` selects the API shape.
[providers.claude]
driver = "anthropic" # which API shape to use (see known drivers below)
api_key_env = "ANTHROPIC_API_KEY" # read from env at use time, not stored here
# OR (less preferred):
# api_key = "sk-ant-..." # stored directly, mode 0600 protects it
base_url = "https://api.anthropic.com" # optional; default from driver
[[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
driver = "anthropic"
api_key_env = "ANTHROPIC_WORK_KEY"
[providers.openai]
driver = "openai"
api_key_env = "OPENAI_API_KEY"
base_url = "https://api.openai.com"
[providers.ollama]
driver = "ollama"
base_url = "http://localhost:11434"
# no api_key field — Ollama is local
default_options = { temperature = 0.7 }
[providers."local-vllm"] # quoted because the name has a dash
driver = "vllm"
base_url = "http://192.168.1.50:8000/v1"
api_key_env = "VLLM_KEY" # if the operator's vLLM requires one
# ---- 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
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) |
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 (e.g. font_size, density) without daemon-side changes. The daemon round-trips unknown keys.
[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). The driver field selects which API shape to use.
driver(string, required for new entries; see backward compatibility below) — the API driver that determines the wire protocol and default base URL. Must be one of the known driver names exported by@kaged/llm(see table below). When omitted on an existing entry, the daemon falls back to using the provider key itself as the driver name — this preserves backward compatibility withlocal.tomlfiles written before this amendment, where the key was the driver.api_key_env(string, recommended) — name of an env var the daemon reads at use time. The key never appears in the config file on disk. Preferred overapi_keyfor keys the operator considers sensitive enough to keep out of files.api_key(string, alternative) — the key inline. Mode 0600 on the config file protects it. Used when env-var management is more friction than the key warrants.base_url(string, optional) — provider endpoint URL. Default is the driver's public endpoint when known.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 theid(e.g.claude-opus-4-20250514→ "Claude Opus 4 20250514"). 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.- Provider-specific fields — providers may carry additional fields (
organizationfor OpenAI,tenant_idfor some Azure-style endpoints). Documented per provider.
Known drivers:
| Driver | API shape | Default base URL |
|---|---|---|
anthropic |
anthropic-messages |
https://api.anthropic.com |
openai |
openai-completions |
https://api.openai.com |
google |
google-generative-ai |
https://generativelanguage.googleapis.com |
xai |
openai-completions |
https://api.x.ai |
groq |
openai-completions |
https://api.groq.com/openai |
deepseek |
openai-completions |
https://api.deepseek.com |
mistral |
openai-completions |
https://api.mistral.ai |
fireworks |
openai-completions |
https://api.fireworks.ai/inference |
together |
openai-completions |
https://api.together.xyz |
cerebras |
openai-completions |
https://api.cerebras.ai |
openrouter |
openai-completions |
https://openrouter.ai/api |
ollama |
openai-completions |
http://127.0.0.1:11434 |
vllm |
openai-completions |
http://127.0.0.1:8000 |
lm-studio |
openai-completions |
http://127.0.0.1:1234 |
litellm |
openai-completions |
http://localhost:4000 |
New drivers may be added to @kaged/llm in minor versions. Operators with custom OpenAI-compatible endpoints use any OpenAI-compatible driver (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:
- If
api_key(inline) 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 keys (prints<redacted>in place ofapi_keyand never readsapi_key_env).
[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/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/memory-markdown"]
installed = "0.1.0"
local = true
source = "https://github.com/kaged/memory-markdown"
# no [plugins."@kaged/memory-markdown".system_config] table — backend has no secrets
Example (hindsight cloud — system config carries the token):
[plugins."@kaged/memory-hindsight"]
installed = "0.3.1"
local = true
source = "https://github.com/kaged/memory-hindsight"
[plugins."@kaged/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/:id); 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/:id; 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.
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"
| 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. |
Refresh workflow
The "Refresh Models" action (triggered via UI button or POST /api/v1/local/providers/:name/models/refresh) fetches the provider's live model list and returns a diff against the persisted catalog:
- Fetch — the daemon calls the provider's model-listing API (same
listModels()in@kaged/llmused by the existing models endpoint). - Diff — compares fetched model IDs against persisted model IDs:
- Added — models present in the API response but absent from the persisted list. These are new models the provider has made available.
- Retired — models present in the persisted list but absent from the API response. These may have been deprecated or removed by the provider.
- Unchanged — models present in both.
- Return — the daemon returns the diff to the caller. It does not auto-save. The operator reviews the diff and decides which models to accept.
- Save — the operator confirms and the UI sends
PUT /api/v1/local/providers/:name/modelswith the updated model list. This writes tolocal.toml.
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 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 api_key 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 |
api_key_env references unset env var |
First call to provider | Error returned with details.var: "ANTHROPIC_API_KEY" |
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 redactapi_key(and never readapi_key_env). - CLI tests: every
kaged configandkaged plugin promotesubcommand exercises a happy path and a documented failure.
Open questions
- Encrypted secrets in inline
api_key. 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-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/:id(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/:iddrops 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.- 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/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.
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/