Agent roots
An agent root answers “what does this agent need to know before it starts?” It’s a named bundle of domain context: repo, branch, subdirectory, runtime, model, goal, and default artifacts.
The shape of a root
Section titled “The shape of a root”From roots.json:
"zimmer": { "name": "zimmer", "display_name": "Zimmer", "description": "The Zimmer orchestrator itself — self-hostable AI coding agent orchestration.", "url": "https://github.com/tadasant/zimmer.git", "default_branch": "main", "user_invocable": true, "default_goal": "open-reviewed-green-pr"}| Field | Meaning |
|---|---|
url / default_branch | Which repo to clone, and from where |
subdirectory | Scopes the agent to a subtree of the repo (monorepo case) |
user_invocable | Whether it appears in the “new session” picker |
default_goal | Seeds session.goal |
default_runtime / default_model | Seeds the runtime and model |
The default_skills, default_mcp_servers, default_hooks, default_plugins, and
default_subagent_roots fields you’ll see at runtime are not written in roots.json — AIR
computes them by inverting default_in_roots from
each artifact’s own entry.
A list you pass replaces the root’s defaults
Section titled “A list you pass replaces the root’s defaults”On the two surfaces that resolve a root’s defaults — the MCP start_session tool and POST /api/v1/sessions — mcp_servers/skills/plugins/hooks has three distinct states, not two:
| What the caller sends | What the session gets |
|---|---|
| the parameter omitted | the root’s defaults, in full |
an explicit [] | none of that artifact |
| a non-empty list | exactly that list — every default not named is dropped |
Omitted and [] are two different requests and Zimmer keeps them apart. A non-empty list is a
replacement, never a union: a caller that names one server on a root declaring two gets one, and
nothing warns it about the other. (The new-session form is the third surface that distinguishes an
explicit [] from an accident, but it never reaches the “omitted” row: its multi-selects always
submit a key, so what a human sees on screen is what the session gets.)
This matters most for MCP servers, and it cuts both ways. A root’s defaults can carry real privilege
(SSH access to a production host, a secrets store), so a caller that narrows to [] is asking for
least privilege and silently handing it the full default set instead is the failure mode this
distinction exists to prevent. But a root’s default skill can also depend on a root’s default
server, and dropping the server still loads the skill — the session then fails at the point of
use, mid-task, with no workaround. A caller narrowing the list has to start from the root’s
default_mcp_servers and subtract from it, rather than composing a fresh list from what the task
appears to need.
An empty mcp_servers column is otherwise ambiguous: it is also where a session lands when the
catalog resolve was incomplete at create time, which McpServerBackfill heals by restoring the
root’s current defaults. Zimmer therefore records a deliberate “none” on the session
(metadata.mcp_servers_explicitly_empty), and the heal skips those sessions — so an explicit []
survives to job start rather than being restored when the runtime config is regenerated. Every path
that lets someone name the list sets it, including the mid-life ones (change_mcp_servers, PATCH /api/v1/sessions/:id/mcp_servers, and the session page’s editor).
Two things are deliberately outside that rule:
Session.create_from_agent_root!(the dashboard quick prompt, the chat bubble, and triggers) treatsniland[]alike as “take the defaults”. ATrigger’smcp_serverscolumn isdefault: [], null: false, so[]there is an untouched trigger rather than a request for none — reading it as “no servers” would strip every existing trigger’s servers.- Injected servers (the self-session server, and the subagent-spawning server for roots that
declare
default_subagent_roots) are added bySelfSessionInjector, not by this resolution. A session spawned withmcp_servers: []still receives them, by design.
The ten roots that ship
Section titled “The ten roots that ship”| Root | Invocable | Repo | Notes |
|---|---|---|---|
zimmer | ✅ | tadasant/zimmer | Work on Zimmer itself. Every skill but awaken-waiting-sessions defaults here. |
zimmer-orchestrator | ❌ | tadasant/zimmer | The baseline router. AgentRootsConfig.router_root_name; every quick-router / chat-bubble submission and every work-backlog start is created against it. Ships with no default artifacts — it cannot yet dispatch downstream sessions (why). |
zimmer-router | ❌ | tadasant/zimmer | Deprecated alias of zimmer-orchestrator, kept so sessions created before the rename still resolve their root (how). Nothing new is created against it. |
general-agent | ✅ | tadasant/zimmer | The catch-all. AgentRootsConfig::DEFAULT_ROOT. |
fleet-maintenance | ❌ | tadasant/zimmer | The deployment’s own scheduler. The quota_available trigger dispatches it; it runs awaken-waiting-sessions and starts parked spot work in precedence order. Defaults to the zimmer-fleet server, which is the only thing that gives it the tools that skill calls. |
catalog-management | ❌ | tadasant/zimmer | Lead root; fans out to the four below. Maintains this repo’s own AIR catalog, and is the catalog’s worked example of default_subagent_roots. |
catalog-mgmt-research | ❌ | ↳ subagent phase | default_in_roots: [catalog-management], model sonnet |
catalog-mgmt-configs | ❌ | ↳ subagent phase | same |
catalog-mgmt-proctor | ❌ | ↳ subagent phase | same |
catalog-mgmt-save | ❌ | ↳ subagent phase | same |
That count, this table’s rows, and the Invocable column are asserted against a live resolve by
test/docs/roots_docs_catalog_test.rb, so adding a root to roots.json without updating them fails
CI rather than leaving the page quietly stale.
The router root’s two names
Section titled “The router root’s two names”zimmer-router was renamed to zimmer-orchestrator. The rename is additive: both names are in
roots.json, the old one described as a deprecated alias, and no session row was rewritten. A
session created before the rename still carries zimmer-router in metadata["agent_root_key"], and
unarchiving it resolves that name against the current catalog — which is exactly why the alias
stays.
The alias is necessary but it is not sufficient, and for one deploy it was not enough on its own.
A rename that also moves the root’s directory breaks every pre-existing session on that root,
because sessions.subdirectory is frozen onto the row at creation time and the clone callers used
to pass it verbatim: a fresh clone of main no longer has the old path, GitCloneService raised
Subdirectory '…' not found in repository, and the failure is not transient, so archived sessions
could not be unarchived and live ones could not resume once the reaper took their clone
(#921). Both clone callers now offer the root’s
current path alongside the stored one — Session#catalog_subdirectory, resolved through
metadata["agent_root_key"] — and GitCloneService takes it when, and only when, the stored path
is absent from the tree and the catalog’s is present. The corrected value is written back to the
row, so the next resume asks for it directly.
Session#catalog_subdirectory deliberately does not go through find_for_session: that resolver’s
fallback arm matches roots on ar.subdirectory == session.subdirectory, so it can only ever return
a root that agrees with the stale value. A root that is genuinely gone from the catalog still fails
the clone — re-resolution is about asking the catalog, not about making a missing directory soft.
Two things this asks of whoever performs the next rename. The alias is a separate entry, so a
directory move has to set the alias entry’s subdirectory to the new path as well — sessions
keyed on the old name resolve through that entry, and an alias left pointing at the old tree strands
exactly the population it exists to protect. And the recovery only works in one direction: a root
that moves its tree to the repo root declares no subdirectory at all, which is indistinguishable
from a root the catalog has dropped, so its existing sessions stay stranded —
a limitation.
The app does not hardcode either name. AgentRootsConfig::ROUTER_ROOT_NAMES lists them
most-preferred first and AgentRootsConfig.router_root_name returns the first one the resolved
catalog actually carries:
ROUTER_ROOT_NAMES = %w[zimmer-orchestrator zimmer-router].freeze
def router_root_name entries = AirCatalogService.entries_for(:roots) ROUTER_ROOT_NAMES.find { |name| entries.key?(name) } || ROUTER_ROOT_NAMES.firstendResolving rather than naming is what makes the rename safe to land. Zimmer’s own catalog lives in
this repo, but a deployment can point air.json at another one — and that catalog is a separate
repo on its own merge schedule. Naming only zimmer-orchestrator would break every routable
message for as long as the deployed catalog still had only zimmer-router, including when
AirCatalogService is serving a last-known-good snapshot resolved before the
rename. Falling back covers that window in both directions.
It sits on a hot path — every chat-bubble and quick-prompt submission — so it stays cheap: at most
two Hash#key? calls against the entry tree AirCatalogService has already parsed and caches for
60 seconds. Nothing is memoized on top of that, deliberately, so a catalog that gains
zimmer-orchestrator cuts over within one TTL rather than at the next restart.
A restricted MCP connection is granted the root under either name
(Mcp::Tool#enforce_any_allowed_root!). allowed_agent_roots is baked into a session’s
.mcp.json when it spawns, so a session started before the rename is still carrying the old name
on disk; both names denote the same root, so granting one grants the other.
Subagent roots
Section titled “Subagent roots”A root whose default_in_roots names another root becomes a subagent root of it. AIR computes
default_subagent_roots on the parent, and the lead root’s agent can then spawn sessions against
those phases.
This is how catalog-management decomposes into research → configs → proctor → save. A root never
becomes its own subagent, even via the "*" wildcard.
How a root seeds a session
Section titled “How a root seeds a session”At session creation (Session#create_from_agent_root!), the root supplies defaults that the caller
can override:
Once seeded, the session owns its own lists. The UI’s PATCH endpoints mutate them directly, and
air prepare is called with --without-defaults so AIR won’t re-add anything the user removed.
Changing roots
Section titled “Changing roots”Roots live in roots.json at the repo root and are resolved through AIR like every other artifact.
Adding one is a PR. Zimmer’s own zimmer-change-ai-artifact skill is the guide, and the invariant it
enforces is the one that matters:
no dangling references.