ADR-0011: Projects are portable; operator-local concerns live in local config
- Status: Accepted
- Date: 2026-05-21
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
A project file should be shareable. Operator A creates a kaged project for managing their music site, hands it to Operator B, and B should be able to load it on their own kaged and have it work — with whatever models, plugins, and infrastructure B has, not A's.
The earlier project-DSL spec (project-dsl.md) had several operator-local concerns baked in:
- Concrete model identifiers like
claude:sonnet-4.6. Operator A may pay for Claude; Operator B may run a local Llama. Forcing them to share the same provider means the project file is not actually shareable. - Absolute filesystem paths like
/srv/music/posts. These are A's paths; B's machine may not have them. - Plugin assumptions —
plugins: [oh-my-pi, ollama]assumes those plugins are installed. B may have neither, or different versions.
Beyond shareability, there's a deeper principle: local config is for local setup, not for operational decisions about projects. The operational behavior of a project — its agents, their cages, their interconnects, their prompts — is the project. The operator-local details of how that behavior is realized on this particular machine — which model fulfills "low-cost-coder", which paths host the project data, which plugins are installed — is local config.
If we conflate the two, projects become coupled to machines and sharing becomes impractical.
The question is load-bearing because it determines:
- The DSL schema (what's allowed in the project file).
- The local-config schema (what the operator declares per-machine).
- The project-load flow (what kaged does when a DSL references things the operator hasn't set up).
- Whether
cage.fs[].pathis absolute, aliased, or project-relative.
Decision
Project files contain only project-operational concerns. Anything operator-local — model bindings, host filesystem paths, plugin installation, auth credentials — lives in local config. Projects refer to operator-local concerns through declarative names (model aliases, plugin names) that the operator's local config resolves. Filesystem paths in a project file are always relative to the project root; absolute paths are not allowed.
This commits us to several concrete patterns:
Projects live in directories with .kaged/ at the root
Every project is a directory. That directory contains .kaged/project.yaml and may contain prompts, project-scoped data, and anything else the project needs. The project root is the directory containing .kaged/.
my-project/ ← project root (what the operator shares)
├── .kaged/
│ └── project.yaml
├── prompts/
│ ├── primary.md
│ └── scraper.md
├── data/ ← project-scoped data, mountable into cages
└── (anything else the project wants)
When an operator "shares a project", they share this directory. Git, tarball, NFS — kaged is agnostic about the delivery mechanism.
All paths in the DSL are project-root-relative
The DSL's cage.fs[].path and *.system_prompt fields are paths relative to the project root. Absolute paths (/etc/foo, /srv/anything) are rejected by the parser.
# CORRECT
cage:
fs:
- mode: rw
path: data # resolves to <project-root>/data
- mode: ro
path: prompts/templates # resolves to <project-root>/prompts/templates
# REJECTED
cage:
fs:
- mode: rw
path: /srv/music/posts # absolute path: parser error
This means: if a subagent needs to touch files outside the project root, that subagent cannot be cleanly portable. The operator either:
- Restructures the project so the data lives inside the project root.
- Symlinks the external path into the project root (a local-machine decision, invisible to the DSL).
- Declares the subagent
cage: disabled(visible in the file; operator-aware opt-out per ADR-0009 amendment).
There is no @alias mechanism for filesystem paths. The project root convention covers the common case and is honest about the uncommon one.
Models are referenced by alias
The DSL's model: fields contain alias names, not concrete provider+model identifiers. Aliases are bound to concrete models in the operator's local config.
# in .kaged/project.yaml — the project says what *kind* of model is wanted
primary:
model: smart-generalist
system_prompt: ./prompts/primary.md
subagents:
- name: scraper
model: low-cost-fast
...
# in local config — the operator binds aliases to what they have
[aliases]
smart-generalist = "claude:sonnet-4.6"
low-cost-fast = "claude:haiku"
# another operator on the same project might have:
# smart-generalist = "openai:gpt-5"
# low-cost-fast = "ollama:llama3.2"
A recommended alias set ships with kaged as a starting point: smart-generalist, smart-careful, low-cost-fast, low-cost-coder, local-only, tiny, and so on. The set is opinionated but not enforced — operators may define entirely different alias names and use them in their projects.
Recommended aliases are not validated against a fixed list. A project can use any alias name (weird-experimental-model-from-2027 is fine). The local config either has the alias or it doesn't.
Missing aliases prompt at project load
When an operator loads a project whose DSL references aliases the operator hasn't defined, the daemon does not silently fail and does not auto-resolve. Instead:
- The UI shows a dialog: "This project uses these aliases that you haven't defined:
smart-generalist,low-cost-fast. Pick a model for each." - The CLI equivalent (
kaged project load) walks the operator through it interactively, or accepts--alias=name:valueflags for scripted setup. - The alias bindings are saved to local config, scoped per-operator (NOT per-project — the same operator's "smart-generalist" should be consistent across projects).
A project that resolves all its aliases is ready. A project with unresolved aliases is pending and refuses to start sessions.
Plugins follow the same pattern
Projects declare plugin needs by name; local plugin store provides the binaries:
- Local plugins are plugins the operator installs for their own use, configured in local config, available to any project that wants them.
- Project plugins are plugins declared in the project DSL's
plugins:list. When the project loads, kaged checks each declared plugin against the local plugin store. Missing plugins prompt the operator: "This project needsplugin-x. Install it?" If accepted, the plugin is installed into the local store and activated for this project only. - Either way, plugins are installed once per operator's local store. Project scoping is about activation, not duplicate installation.
See ADR-0008 amendment for the detailed plugin model.
Local config is separate from operational config
The daemon already has a config.toml (daemon.md) for operational settings — bind address, storage URL, auth mode, log destinations. That stays.
Local config is a separate file, owned by the operator, holding:
- Model aliases (
[aliases]section). - Auth credentials for LLM providers (
[providers.claude],[providers.openai], etc. — secrets, not project data). - Plugin store directory and per-plugin local config.
- Known project locations (a registry of "projects I've opened on this machine").
- Operator preferences (default theme, default time zone for log display, etc.).
See local-config.md for the schema.
Consequences
What this commits us to
- A local-config file format and parser. Distinct from the daemon's operational config; lives in a different location.
- An alias resolver. Every model reference in a project DSL goes through it. Every alias miss is a deliberate event (audit log + operator prompt).
- A project-load flow. The daemon now has a concept of "loading a project" that's distinct from "having a project on disk." Load = parse + resolve aliases + check plugins + register + ready-or-pending.
- A project registry in local config. The daemon needs to know which directories on disk are kaged projects (so the UI can list them, switch between them, etc.) without scanning the entire filesystem.
- A recommended-alias starter set, shipped with kaged. Documented as a starting point, not a requirement.
- DSL parser enforcement of project-relative paths. Absolute paths in
cage.fs[].pathare a hard error with a clear message.
What this forecloses
- No absolute paths in the DSL. Operators who want absolute access use
cage: disabled(and lose the cage). No middle ground. - No model ID hardcoded in shared project files. A DSL hardcoding
claude:sonnet-4.6is rejected by the parser at load time. The parser cannot tell whetherclaude:sonnet-4.6is meant to be a provider:model or a slightly-unusual alias name — so we require aliases to follow a pattern that providers cannot match (no:characters), and we reject anything that looks like a provider:model. - No automatic alias creation. If the project says
model: fooand the operator has nofooalias, kaged does not invent one. The operator decides. - No project-local model overrides. The whole point of aliases is that the project doesn't pin a model. If an operator wants to override an alias per-project, they redefine the alias before loading the project (or alias a different name in the DSL itself).
- No operator-secret leakage into the project file. API keys, model endpoints, tunnel tokens — none of these can appear in a DSL. They live in local config, not in version control with the project.
What becomes easier
- Sharing projects. A
git cloneplus akaged project loadis the full path. Missing aliases are surfaced, the operator answers, work proceeds. - Reviewing projects. A PR that changes a project DSL is a review of operational behavior. It does not include the reviewer's API keys, paths, or local installs.
- Switching providers. An operator can re-bind
smart-generalistfrom Claude to a local Llama with one local-config edit. Every project using that alias picks up the change. - Multiple operators on a shared system-wide kaged. Each operator has their own local config; the daemon resolves aliases per the operator who's running the session.
What becomes harder
- Initial setup. A first-time operator gets a project, runs
kaged project load, sees aliases to define, has to think about what they want. More upfront work than "it just runs." We make the alias prompts good. - Documentation of aliases. Project authors should document what behavior their aliases assume ("
smart-generalistshould be a high-quality coding model"). This is a README convention, not a schema constraint. - Migration of existing concrete-model DSLs. Anything authored before this ADR (in the example yamls) needs updating. We update them.
- The reasoning the model can do about its own configuration changes. A subagent that's curious about "which model am I" gets the alias name, not the provider. (The audit log records the resolved provider; the model doesn't see it.) This is honest and prevents cross-operator leakage of model identity.
Alternatives considered
Alternative A — Allow both concrete model IDs and aliases in the DSL
Why tempting: Smoother migration; existing project files still work.
Why rejected: Defeats the portability promise. As soon as one DSL hardcodes claude:sonnet-4.6, it's not shareable to someone without Claude. We'd end up documenting "use aliases for portable projects" as a best practice that nobody follows. Better to require it.
Alternative B — Path aliases like @artifacts alongside project-root-relative paths
Why tempting: Symmetric with model aliases; supports projects that legitimately need to touch cross-project locations.
Why rejected: Operator explicitly chose project-root-relative as the simpler answer. Cross-project paths are rare and have the cage: disabled escape valve when needed. Adding path aliases would double the alias machinery for a minor use case.
Alternative C — Project files include a "machine binding" sub-file that's gitignored
Why tempting: Projects could ship with default aliases that operators override locally.
Why rejected: Splits the project across two files where one is "real" (the DSL) and the other is "fake / per-machine" (the bindings). Operators have to remember to gitignore the binding file. Local config is the cleaner home for "this is my machine's view of the world."
Alternative D — Defer portability to v0.x; v0 ships concrete IDs
Why tempting: Smaller v0.
Why rejected: Retrofit pain. Once projects accumulate with concrete IDs, the migration becomes a real cost. Better to bake portability in before code lands.
References
- ADR-0006 — the DSL format this ADR constrains
- ADR-0008 and its amendment — the plugin scoping mechanism
- ADR-0009 and its amendment — the
cage: disabledescape valve for non-portable cases - ADR-0010 — per-user vs system-wide deployment, the other half of the realization
docs/specs/project-dsl.md— the spec amended to follow this ADRdocs/specs/local-config.md— operator-local config (model aliases, plugin store, provider auth)- Original discussion: design conversation with colleagues, 2026-05-21