Config layering and precedence¶
The layering is usually described as one hierarchy — deb, then overlay, then something host-local. It is not one hierarchy. Three separate questions are answered by three different pieces of code, and they do not agree on which layer wins:
- Resolution — which file on disk backs the name
foo? Repo overlay wins. - Membership — is
fooswitched on for agenta? Nothing wins; the layers merge as additive and subtractive deltas. - Ownership — what about files the agent wrote in its own home? They are not in the contest at all, except for mini-apps, where the agent's copy wins over both.
Getting these confused is how "the overlay overrides the deb" becomes a wrong prediction about whether a newly-shipped default reaches an existing agent (it does, see Membership).
The roots on disk¶
| Root | Holds | Written by | In git |
|---|---|---|---|
/usr/share/agentctl/ |
the shipped catalog: skills/ agents/ hooks/ mcp/ tools/ cron/ |
the .deb — replaced wholesale on apt upgrade |
no |
/var/lib/agentctl/catalog, /var/lib/agentctl/tools |
MCP and CLI-tool catalog items added after install | mcp catalog add / tool wrap-mcp — but only on a host with no repo, or with an explicit --into; the default target is the overlay |
no |
/opt/agentctl/ (repoDir()) |
the overlay dirs skills/ agents/ hooks/ mcp/ tools/ apps/ cron/, plus defaults.yaml, hooks/registry.yaml and the ledger shards |
the CLI, auto-committed | yes |
~/.claude/{skills,agents,apps} (pi: ~/.pi/agent/{skills,apps}) |
whatever the agent authored for itself | the agent | no |
Mini-app bundles have no payload dir: the first-party ones are embedded in the agentctl
binary (appsFS), and the overlay dir is <repo>/apps/.
Resolution: overlay wins, at whole-item granularity¶
Every category resolves the same shape — take the repo overlay's copy if the path exists,
otherwise the shipped one. For skills and subagents that is
overlayPath() in internal/cli/repo.go and catalogTarget() in
internal/converge/manifest.go:
func catalogTarget(o options, kind, rel string) string {
if o.repoDir != "" {
p := filepath.Join(o.repoDir, kind, rel)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(o.share, kind, rel)
}
This is a full override of the whole item, never a merge of its contents. An overlay
skills/browser/ replaces the shipped skills/browser/ entirely — a file the shipped
version had and the overlay does not is simply gone, because only one directory is ever
reachable. The same holds for a subagent .md, an mcp.yaml item dir and a tool.yaml
item dir.
Three deviations from that shape are worth knowing:
- MCPs and CLI tools have a third root in the middle.
catalogItemDir()(internal/cli/manifest.go) andtoolCatalogBases()(internal/cli/tool.go) search<repo>/mcp→/var/lib/agentctl/catalog→/usr/share/agentctl/mcp. The runtime catalog is genuinely host-local — outside both the deb and the repo — and it exists so an item added after install survivesapt upgrade, which overwrites the payload. It still loses to the overlay. - Hooks merge per key, not per file. There is one registry file per layer
(
<share>/hooks/registry.yaml, then<repo>/hooks/registry.yaml) andloadHookRegistry()reads both into one map, later writes winning. So an overlay registry that defines one hook overrides that hook and leaves the other shipped hooks intact. Within a key the entry is still replaced whole. - Per-user cron templates are not overlayable.
discoverCronJobs()globsShareDir + "/cron/units"only, so a new per-agent cron job needs its unit templates in the payload. Only the descriptors are overlayable:hostCronDir()prefers<repo>/cron/<label>/over<share>/cron/host/<label>/, and<repo>/cron/users/<agent>/holds self-scope descriptors.
The cron descriptor override is also the one place the codebase warns about a shadow, for a
reason worth repeating. cron add --host <label> writes an overlay dir containing a copy
of the script, and cron remove deliberately leaves it behind. If a label of the same name
later ships as a default, the host keeps running the frozen copy and every later fix to the
shipped script silently stops arriving. hostCronShadowsShipped() prints a line on apply so
this stops being invisible; it does not take the overlay away, because an overlay may be a
deliberate local override and apply is not the place to decide that.
Membership: a merge of deltas¶
Whether an item is on for an agent is a different computation, in
internal/cli/catalogdefaults.go, and there is no override anywhere in it:
effective = ( deb_defaults ∪ overlay.add − overlay.remove ) + agent.add − agent.remove
deb_defaults— the compiled-in setsdefaultSkills,defaultSubagents,defaultCron,defaultHooks,defaultTools, and the channel-conditionaldefaultClitoolsForChannel.overlay.*—<repo>/defaults.yaml, per category,add:andremove:inline lists. Edited withagentctl defaults add|remove <category> <item>.agent.*— theactivate:/deactivate:lists inagents.d/<name>.yaml, written byagents skills|subagents|cron|hooks add|remove, bytool add|removefor pi clitools, and bymcp add|removefor the claudetoolsleg.
Both layers apply through the same primitive, applyDelta(base, add, remove) = (base ∪
add) − remove. The layered categories are skills, subagents, cron, hooks,
clitools and tools.
The ledger records only the deltas, never the resolved set. That is the point of the
design: a skill added to defaultSkills in a new deb reaches every existing agent on the
next apply, instead of being frozen out by a recorded list captured before it existed. On
every apply, reconcileAgentDeltas() recomputes the effective set from whatever the shard
holds and rewrites the smallest delta that reproduces it (minimalDelta: add = eff −
def, remove = def − eff). A legacy fully-resolved activate: list therefore shrinks to
the agent's genuine divergence, and collapses to nothing at all once a host blesses the
extra through defaults.yaml. The pass is idempotent, and it never grows a shard.
The same pass self-heals: healLeg() drops delta items that no longer name a catalog entry,
so a deleted or renamed skill strips itself off every agent fleet-wide. It is gated on
catalogPresent() so a dev box with no catalog installed never prunes deltas it cannot
verify, and the enumerators it checks against union the overlay, so an overlay-only item
counts as present.
Three scoping notes:
defaults.yamlhas no host key. It sits at the repo root and applies to every agent resolved through that repo, which on a repo shared by several hosts means more than one host.- The
tools(claude MCP) leg is skipped for pi agents and theclitoolsleg for claude agents, so a category is never materialized onto the wrong runtime. agentctl defaults add tools <mcp>does not work today, even thoughtoolsis a delta category and the reader accepts atools:block.defaultsCatalogEntries()has notoolscase, so item validation always rejects it. Blessing a default MCP for a host currently means hand-editingdefaults.yaml.
Ownership: the agent's own files¶
A skill the agent writes in ~/.claude/skills/<name>/ is not a catalog item and is not part
of either mechanism above. The reconcile only manages symlinks, and it prunes conservatively
(reconcileSymlinks in internal/converge/reconcile.go): inside the managed dirs it removes
an entry only if it is a symlink and its target points into one of Manifest.CatalogRoots
(the overlay root and the payload root) and it is not desired. A plain directory, a plain
file, or a symlink pointing anywhere else is left alone. A personal one-off is safe; promote
it to a shared item by dropping it in the overlay.
Name collision between the two is the sharp edge, and it fails rather than resolving. If the
agent owns ~/.claude/skills/foo/ as a real directory and foo is also an activated catalog
skill, the reconcile tries os.Remove on the link path and then os.Symlink. Verified
behaviour: a non-empty directory survives the Remove, the Symlink returns EEXIST,
and the whole reconcile errors out — the activation never lands and nothing repairs it. An
empty directory is removed and replaced by the symlink silently.
Mini-apps are the exception to the whole section, and the one case where the informal
"host-local wins" reading is correct. loadAppManifests() merges embedded then overlay
(overlay wins on id), and mergeUserApps() then merges the agent's own ~/.claude/apps over
that result — user wins on id. So an agent can shadow a shipped cockpit panel with its own,
and nothing on the launcher says so. What it cannot do is claim first-party status: the
reader stamps Source: "user" rather than reading it from manifest.json, and an
admin: true in a user manifest is discarded.
Worked example: a skill overridden in the overlay¶
Agent testbot on a host whose repo is /opt/agentctl. demo-skill and repo-skill both
ship in the deb; repo-skill also exists in the overlay. agentctl apply runs:
reconcileAgentDeltas("testbot")re-minimizes the shard's deltas against the current effective defaults.convergeAgent(internal/cli/converge.go) callseffectiveList("testbot", "skills"), which resolves( defaultSkills ∪ defaults.yaml add − remove ) + activate − deactivate→[demo-skill, repo-skill, …]. Note this step is pure membership: the two skills' locations have not been consulted yet.- That list is handed to
converge.BuildManifestasWithLists(...), along withWithPayloadShare(ShareDir)andWithRepoDir(repoDir()). buildSymlinkscallscatalogTargetper item.demo-skillis absent from<repo>/skills/, so it resolves to/usr/share/agentctl/skills/demo-skill.repo-skillis present, so it resolves to/opt/agentctl/skills/repo-skill— the shipped copy at/usr/share/agentctl/skills/repo-skillis never referenced again.reconcileSymlinkspoints/home/testbot/.claude/skills/{demo-skill,repo-skill}at those two targets and prunes any other catalog-owned symlink in that dir.
TestBuildManifestFixture in internal/converge/manifest_test.go pins exactly this, against
fixtures in internal/converge/testdata/ that carry repo-skill in both payload/skills/
and repo/skills/.
The MCP path differs only in step 4: mcp add resolves the item through catalogItemDir()
(overlay → runtime catalog → payload) and renders a socket unit plus an entry in the agent's
~/.claude.json. Because that file is read only at process start, the first apply after a
change to the default MCP set restarts every claude agent on the host.
Verbs¶
agentctl defaults list # per category: deb set, effective set, overlay legs
agentctl defaults add <category> <item> # write the overlay add leg (root; commits, does not apply)
agentctl defaults remove <category> <item> # write the overlay remove leg
agentctl agents skills add|remove <skill> <agent>|* # the per-agent delta
agentctl agents subagents|cron|hooks add|remove ... # same shape per category
agentctl tool add|remove <tool> <agent>|self # the pi clitools delta
agentctl mcp add|remove <mcp> <agent>|self # the claude tools delta
agentctl agents skills catalog add <name> [--from <dir>] # AUTHOR into the overlay
agentctl mcp catalog add <name> --npm <pkg@ver> # lands in the overlay by default
agentctl apply # recompute, re-minimize, converge