Skip to content

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.

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"
}
FieldMeaning
url / default_branchWhich repo to clone, and from where
subdirectoryScopes the agent to a subtree of the repo (monorepo case)
user_invocableWhether it appears in the “new session” picker
default_goalSeeds session.goal
default_runtime / default_modelSeeds 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/sessionsmcp_servers/skills/plugins/hooks has three distinct states, not two:

What the caller sendsWhat the session gets
the parameter omittedthe root’s defaults, in full
an explicit []none of that artifact
a non-empty listexactly 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) treats nil and [] alike as “take the defaults”. A Trigger’s mcp_servers column is default: [], 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 by SelfSessionInjector, not by this resolution. A session spawned with mcp_servers: [] still receives them, by design.
RootInvocableRepoNotes
zimmertadasant/zimmerWork on Zimmer itself. Every skill but awaken-waiting-sessions defaults here.
zimmer-orchestratortadasant/zimmerThe 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-routertadasant/zimmerDeprecated alias of zimmer-orchestrator, kept so sessions created before the rename still resolve their root (how). Nothing new is created against it.
general-agenttadasant/zimmerThe catch-all. AgentRootsConfig::DEFAULT_ROOT.
fleet-maintenancetadasant/zimmerThe 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-managementtadasant/zimmerLead 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 phasedefault_in_roots: [catalog-management], model sonnet
catalog-mgmt-configs↳ subagent phasesame
catalog-mgmt-proctor↳ subagent phasesame
catalog-mgmt-save↳ subagent phasesame

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.

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.first
end

Resolving 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.

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.

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.

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.