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
data_namespace / data_collectionsOptional. The root’s persistent data store: which namespace it writes, and which collections (each with an optional JSON Schema and retention_days) it may write. Zimmer reads both; AIR passes them through unchanged

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”

Every way a session is created resolves a root’s defaults through one implementation, Sessions::ResolveSpawnDefaults: the MCP start_session tool, POST /api/v1/sessions, the new-session form, and Session.create_from_agent_root! (below). On the two surfaces where a caller can name any of the three states — start_session and POST /api/v1/sessions — mcp_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, and it never reaches the “omitted” row: it pre-fills every picker with the root’s defaults and submits what the picker shows, so what a human sees on screen is what the session gets. Its MCP picker submits a blank entry when nothing is selected; the skills, hooks and plugins pickers submit no key at all, and the form reads that as a cleared picker, not an omission.)

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

A rootless git_root spawn — over MCP or POST /api/v1/sessions — sets it without the caller naming a list at all: with no agent root there are no defaults for an omitted mcp_servers to fall back to, so omitted is none, and recording that keeps the heal from attaching the defaults of a root whose URL happens to equal the git_root the caller passed. Sessions::ResolveSpawnDefaults does it for every surface.

Two things are deliberately outside that rule:

  • Session.create_from_agent_root! (the dashboard quick prompt, the chat bubble, the work backlog, outcome analyses and triggers) treats nil and [] alike as “take the defaults” — it resolves through the same service, but tells it that only a non-empty list was named. A Trigger’s list columns are default: [], null: false, so [] there is an untouched trigger rather than a request for none, and reading it as “no servers” would strip every existing trigger’s servers at its next fire. There is no way to spell “none” on this path.
  • 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
zimmer✅tadasant/zimmerWork on Zimmer itself. Every skill but awaken-waiting-sessions defaults here.
zimmer-orchestrator❌tadasant/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-router❌tadasant/zimmerDeprecated 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/zimmerThe catch-all. AgentRootsConfig::DEFAULT_ROOT.
fleet-maintenance❌tadasant/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.
dashboard-reprioritizer❌tadasant/zimmerThe durable session behind the User view’s Reprioritize button. Reads the board with get_user_view and writes its order back with reorder_user_view; defaults to the zimmer-sessions server, which is what carries those two tools. Reused across presses rather than spawned per click — the button fires a reuse_session trigger.
catalog-management❌tadasant/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, on every surface, 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.

The rootless branch above is not a shortcut around that chain — it is the same chain with one fewer tier. Sessions::ResolveSpawnDefaults runs on every create — POST /api/v1/sessions, MCP start_session, the new-session form and Session.create_from_agent_root! — root or no root, so a session created without an agent_root still picks up the Settings-page runtime and model, and a blank branch is main. It was not always so: REST consulted the global defaults only from #263, MCP start_session could not create a rootless session at all until #265, and until #454 the form and create_from_agent_root! each carried their own copy. test/integration/spawn_defaults_conformance_test.rb drives all four with the same request and fails if they disagree.

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.