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: 9 skills, all default-on for the zimmer root — 6 Zimmer-specific ones
(category: zimmer) plus 3 vendored generic workflow skills (category: workflow: open-pr,
wait-for-ci, recover-from-compaction-thrashing) — 14 MCP servers (only playwright-custom
default-on), 10 roots, 4 plugins, 1 hook, 5 references.
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.
An unparseable JSON file is retried on that same ladder. air-sdk JSON.parses the files the
adapter wrote into the target — .mcp.json and .claude/settings.json — without a guard, and
air-core does the same for air.json and the catalog indexes, so a failure there exits 1 with a bare
Node parse error and no path.
For the two files in the target directory, that is only reachable as a race. Neither can be a file
that was already broken on disk, because the Claude adapter rescues its own parse failure and
rewrites both from scratch before the SDK reads them. It takes a second writer changing one between
the adapter’s write and that later read — and air prepare re-runs on every follow-up, resume and
unarchive, over a directory a previous job for the same session may still be tearing down. A
malformed air.json or catalog index reaches the same signature and is not a race; retrying costs
one bounded ladder and the failure stays loud either way.
Because AIR names no file, AirPrepareService prepends what the target’s config files actually
looked like when it failed. In the race that description reports everything as parsing, which is
itself the confirmation; a file genuinely broken on disk shows up as UNPARSEABLE and is a different
bug. The enrichment is skipped when AIR’s message already carries a path of its own.
Requested skill ids get one more guard, before the invocation. A session’s catalog_skills
are validated against the catalog when the session is created, but the catalog moves on
independently: a local skill renamed (pr → open-pr) or removed leaves a stale id in a
long-lived session’s stored config. air prepare hard-rejects an unknown skill id with exit 1
(Error: Unknown skill ID "pr". …), which would brick startup. So AirPrepareService#scrubbed_catalog_skills
drops any id not in the live catalog, logs a warning, and raises a deduped “Session self-healed:
stale catalog skill(s) removed” alert — then prepares with the survivors. This mirrors
Trigger#heal_stale_catalog_skills! (which self-heals the trigger path) and gives an unknown
skill the same non-fatal degradation an unknown root already gets. The pruned list is
persisted (update_column, so no validation or updated_at touch): a session does not prepare
once — every resume, unarchive, and mid-run clone recreation re-runs air prepare, so an
in-memory-only scrub would re-discover the same stale id and re-alert forever.
Two guards keep that write from doing damage. If the catalog failed to load and left SkillsConfig
empty (so every id would look stale), the list is left untouched rather than stripped. And nothing is
persisted while the catalog is degraded — a failed resolve usually does not empty
SkillsConfig, it serves a last-known-good tree, which is non-empty and can predate a rename, so an
id that is perfectly valid today looks stale against it. Dropping such an id in memory costs one
prepare; writing that drop back would erase a valid id permanently and undo the backfill that
repointed it. A failed write likewise degrades to a warning, so the scrub can still keep the prepare
alive.
Renaming a skill in the catalog is what creates those stale ids in the first place, so a rename
should ship with a data backfill that repoints existing catalog_skills rows (sessions and
triggers) from the old id to the new one — the heal alone only drops the id, which silently strips
the skill from long-lived sessions and from every session a trigger spawns.
db/migrate/20260801120000_backfill_renamed_open_pr_skill_id.rb is the worked example for the
pr → open-pr rename. Keep such a migration pinned to the one known rename: the catalog is a
runtime dependency that can resolve differently at migration time, so pruning against it is not
deterministic. General staleness stays the runtime heal’s job.
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.
That degrade is deliberate, but on its own it is indistinguishable from a fresh install: every
picker on the session form renders empty and nothing says why
(#112). AirCatalogService.resolve_failure closes
that gap. It records the last failed resolve — including the case degraded? cannot see, where no
last-known-good tree exists, load! re-raises, and the façades rescue to [] — and the session form
renders it as a banner saying whether the lists below are empty or merely stale, with the resolve
error verbatim.
The agent side reads through the same façades, so it had the same blind spot: get_configs would
report “No MCP servers available” and start_session would happily build a session against a
catalog that never resolved. Mcp::Tools::GetConfigs now prepends the same fact — empty versus
stale, plus when the failure was seen.
Not the same fidelity, deliberately. The banner prints air resolve’s stderr verbatim, and that
process is handed AIR_GITHUB_TOKEN by AirPrepareService#air_env, so its output is not something
to echo onto an agent channel. What an agent needs in order not to act wrongly is the fact and its
age; the text stays with the operator, on the form and in the logs.
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).