How Zimmer consumes AIR
Zimmer touches AIR in exactly two places: a read path that asks “what artifacts exist?” and a write path that says “prepare this directory.”
The catalog is self-contained and offline
Section titled “The catalog is self-contained and offline”Zimmer’s air.json declares a catalog named zimmer-catalog with no catalogs field and no
github:// URIs — only six local index paths, gitProtocol: "https", and two extensions
(@pulsemcp/air-adapter-claude, @pulsemcp/air-secrets-env).
Everything lands under @local/, which is why --no-scope is safe: there can’t be a cross-scope
shortname collision when there’s only one scope.
The catalog’s own description states the intent: “resolves fully offline (no private GitHub catalogs, no network), so the app’s config services always resolve non-empty data.”
What’s in it: 5 skills (all default-on for the zimmer root), 14 MCP servers (only
playwright-custom default-on), 9 roots, 4 plugins, 1 hook, 1 reference.
air.json vs air.production.json
Section titled “air.json vs air.production.json”They are content-identical today. The split is a seam — it lets the
production image pin its own catalog sources without touching the dev/test config. Selection is
per-environment: development/test use air.json, production/staging use
air.production.json. AIR_CONFIG always wins.
A dangling reference is treated as a failed resolve
Section titled “A dangling reference is treated as a failed resolve”This is the sharpest coupling between the two systems, and the most brittle thing in Zimmer.
AIR exits 0 when it drops an
unresolvable reference, printing a warning to stderr. So Zimmer scans stderr for two literal
strings ("references unknown" and "Dropping the reference") and, if both appear, raises
CatalogError despite the exit code being 0.
Why so aggressive? Because a dropped reference is exactly what strips a root’s default_skills,
default_mcp_servers, and default_hooks. Persisting that tree would misconfigure every session
created against it — and overwrite the good snapshot with degraded data. So a degraded resolve
never reaches persist_snapshot.
The two-marker test exists because references dropped intentionally by air.json#exclude share the
second marker but are expected.
The blast radius is the entire test suite
Section titled “The blast radius is the entire test suite”test/test_helper.rb pre-warms the catalog at boot, before parallelize forks its workers.
So a catalog that fails to resolve does not fail one test — it fails every test that creates a
session, all at once, with ActiveRecord::RecordInvalid.
A single dangling reference (a plugin bundling a skill that no longer exists, a default_in_roots
naming an unknown root) reddens the whole suite. CONTRIBUTING.md says it: if you see a sudden
wave of RecordInvalid across unrelated session tests, suspect the catalog before you suspect
your change.
Three cache layers
Section titled “Three cache layers”- 60-second in-memory TTL on the parsed tree, per process (
CATALOG_CACHE_TTL). CatalogSnapshot— a Postgres-persisted last-known-good tree, written after every successful resolve. Survives restarts, shared across web and worker.- AIR’s own
~/.air/cache/githubprovider clones (dormant for an all-local catalog).
On failure, load! walks down: in-memory tree → CatalogSnapshot.latest → re-raise. It sets
@degraded = true, logs at error once and info thereafter (no alert spam), and surfaces
degraded? / last_known_good_at to health checks and the settings UI.
Only a first-ever cold boot with a broken catalog and no snapshot raises. The consequence: a broken catalog can be invisible until restart.
The write path: AirPrepareService
Section titled “The write path: AirPrepareService”Invoked synchronously from AgentSessionJob on waiting → running:
air prepare <adapter> \ --target <clone> \ --no-subagent-merge \ --without-defaults \ [--root <name>] \ --skill <id>... --mcp-server <id>... --hook <id>... --plugin <id>...Two decisions here are load-bearing:
--without-defaults is deliberate. Zimmer already stores the final resolved per-session
artifact lists in the database — the UI’s PATCH endpoints mutate them directly. AIR 0.0.30 flipped
--skill semantics from “replace defaults” to “add to defaults.” Without --without-defaults, a
user removing a default artifact in the UI would watch AIR silently re-add it from the root
defaults. So Zimmer uses AIR’s root-defaults machinery at read time (to seed a new session) and
explicitly bypasses it at prepare time.
Secrets flow through the environment. SecretsLoader.all is merged into the
subprocess env; @pulsemcp/air-secrets-env substitutes the ${VAR} placeholders into .mcp.json;
AIR then fails the prepare if any ${VAR} survived, which Zimmer catches as a graceful,
non-paging SecretResolutionError.
Resilience
Section titled “Resilience”The whole invocation runs under BoundedSubprocess with a hard wall-clock timeout (it SIGKILLs the
process group), with retry-and-backoff on transient failures. There’s a special case for “Root
not found”: it triggers one inline bounded air update (cache bust) and a retry — because a
freshly-merged root can legitimately be absent from a worker’s up-to-15-minutes-stale cache. If
it’s still absent, it raises a graceful RootResolutionError.
The AIR CLI is installed lazily, at runtime
Section titled “The AIR CLI is installed lazily, at runtime”AirPrepareService.ensure_air_installed! runs npm install into AIR_INSTALL_DIR on first use,
pinned to AIR_CLI_VERSION = "0.13.0" — the CLI plus both adapters, the secrets-env transform, and
the GitHub provider. Guarded by a version marker file, a binary health check (air --version), and
a cross-process install lock.
The six façades
Section titled “The six façades”SkillsConfig, AgentRootsConfig, ServersConfig, PluginsConfig, HooksConfig, and
ReferencesConfig are thin read-models over AirCatalogService.entries_for(:type). Each shapes
raw resolve output into a Ruby value object, and each swallows CatalogError into an empty array
with a warning — so a catalog failure degrades the UI instead of returning a 500.
Never parse the index files directly. That’s the rule in AGENTS.md and it’s a good one: the
indexes are AIR’s input; the resolved tree is Zimmer’s data model. The resolved tree is what Zimmer consumes, and it
differs from the raw index (references canonicalized, default_in_roots inverted and deleted, paths
absolutized).