Skills, plugins, hooks, references
Skills
Section titled “Skills”A skill is a markdown procedure the agent can invoke: “how to run tests here,” “how to deploy
staging.” It lives in skills/<id>/SKILL.md and is registered in skills/skills.json.
At prepare time AIR copies it into .claude/skills/<id>/ in the clone, along with any references it
declares.
The five that ship, all default_in_roots: ["zimmer"]:
| Skill | What it does |
|---|---|
sync-docs | Pre-PR step: update docs that the branch diff made stale. Always-on by convention. |
zimmer-run-tests | Run the test suite the way this repo expects. |
zimmer-start-dev-server | Bring up a local instance for e2e verification. |
zimmer-deploy-staging | Drive the staging deploy workflow. |
zimmer-change-ai-artifact | The guide to changing the catalog itself. |
awaken-waiting-sessions | The wake policy for quota-parked spot work. Defaults into fleet-maintenance only, and is not user-invocable — the quota_available trigger is what runs it. |
The generic workflow skills are vendored here too, under category: workflow:
| Skill | What it does |
|---|---|
open-pr | Commit, push, open the PR, self-review, subagent-review, wait for CI. Bundles the git-workflow reference. |
wait-for-ci | Block until CI passes or fails on the current PR. |
recover-from-compaction-thrashing | Delegate verbose tool calls to subagents so compaction doesn’t erase your work. |
References
Section titled “References”A markdown document that many skills can share. Broken out from skills deliberately — one reference (your git workflow, your engineering conventions) shouldn’t be copy-pasted into every skill that needs it.
Zimmer’s catalog ships four: engineering-practices, brand, brand-voice, and
anti-slop-rubric, mapping to references/ENGINEERING_PRACTICES.md, BRAND.md,
BRAND_VOICE.md, and ANTI_SLOP_RUBRIC.md. The sync-docs skill declares the last three.
At prepare time, each skill’s declared references are bundled into
.claude/skills/<skill-id>/references/.
Plugins
Section titled “Plugins”A named bundle that composes existing skills, MCP servers, and hooks. The index entry points at a
directory; the directory holds .plugin/plugin.json.
{ "name": "ci-workflow", "title": "CI Workflow", "version": "1.0.0", "skills": ["zimmer-run-tests"], "mcp_servers": [], "hooks": ["git-push-ci-reminder"]}The four that ship:
| Plugin | Default in | Bundles |
|---|---|---|
ci-workflow | agent-orchestrator | zimmer-run-tests + git-push-ci-reminder |
screenshots-videos | agent-orchestrator | screenshot/video capture tooling |
figma-design-workflow | — | Figma design tooling |
meeting-wrangling | — | meeting tooling |
A plugin is a macro: at prepare time AIR unions its constituents into the activated set, and they materialize through the same code path as directly-selected artifacts. Select both a plugin and a skill it bundles, and you get one copy.
A lifecycle script registered into the agent’s own settings — .claude/settings.json, tagged with
_airHookId so AIR knows which entries it owns. Fired on agent events (a tool call, a git push).
Zimmer’s catalog declares exactly one: git-push-ci-reminder. hooks/hooks.json registers it, and
hooks/git-push-ci-reminder/ holds the body:
hooks/git-push-ci-reminder/├── HOOK.json # when it fires and what it runs└── git-push-ci-reminder.mjs # the scriptHOOK.json is what the Claude adapter reads to write the .claude/settings.json entry:
{ "event": "post_tool_call", "matcher": "Bash", "command": "node", "args": ["./git-push-ci-reminder.mjs"], "timeout_seconds": 10}event is an AIR lifecycle name (session_start, pre_tool_call, post_tool_call, stop, …) or
the Claude event name directly (PostToolUse); an unrecognized one is warned about and skipped.
matcher filters by tool name. A ./-prefixed command or arg is rewritten at install time to
"$CLAUDE_PROJECT_DIR/.claude/hooks/<id>/…", so it resolves no matter where the agent has cd’d to.
This hook reads the PostToolUse payload on stdin, and when the Bash command that just ran was a
git push (a --dry-run isn’t), returns additionalContext reminding the agent to confirm CI
before calling the work done. Everything else is a no-op, and it always exits 0 — a hook must never
fail the tool call it observes.
plugins/ci-workflow/.plugin/plugin.json bundles this hook alongside zimmer-run-tests, and
ci-workflow is default_in_roots: ["agent-orchestrator"], so sessions on that root get it
automatically.
Don’t confuse these with transcript hooks, which are a Ruby-side plugin system that runs inside Zimmer when transcript messages arrive. Different thing, same word.
Adding an artifact
Section titled “Adding an artifact”The full procedure lives in skills/zimmer-change-ai-artifact/SKILL.md. The short version:
- Add the body (
skills/<id>/SKILL.md,hooks/<id>/HOOK.json,references/<file>.md). - Register it in the index (
skills/skills.json, etc.). - Add
default_in_roots: ["<root>"]to make it default-on for that root. - Verify with
air resolvebefore pushing. A dangling reference will not fail the resolve — it will exit 0, drop your artifact’s reference, and then break the test suite.
# From the repo rootWatch stderr, not the exit code. Lines containing references unknown and Dropping the reference are what Zimmer treats as a hard failure.
Pointing an instance at your own catalog
Section titled “Pointing an instance at your own catalog”The public image ships a small, self-contained catalog (air.production.json at the image root,
resolving ./roots.json, ./mcp.json, etc.). That is what a stock deployment — and staging —
serves. It’s deliberately minimal; it is not meant to be your catalog.
To run a deployment on your own catalog (private agent roots, MCP servers, skills), you deliver a
catalog onto the box and point the app at it with the AIR_CONFIG env var:
config/environments/production.rbreadsconfig.air_json_pathfromAIR_CONFIG, falling back to the in-imageair.production.jsonwhenAIR_CONFIGis unset or set to a path that doesn’t exist yet. So a not-yet-delivered catalog degrades to the in-image one instead of zero roots; once your catalog is on disk and the app restarts,AIR_CONFIGwins.- AIR resolves a catalog’s index paths relative to the
air.json/air.production.jsonfile’s own directory. So keep yourair.production.jsonand itsartifacts/(orroots.json,mcp.json, …) siblings in one directory, and pointAIR_CONFIGat that file.
How Tadas’s production does it (a worked example)
Section titled “How Tadas’s production does it (a worked example)”Production’s real catalog lives in a private companion repo (air.json +
artifacts/). It is delivered like this — the same pattern any self-hoster can copy:
- Mount two persistent host directories into both the
webandworkerroles (config/deploy.production.yml), and setAIR_CONFIG:Bind-mounting host paths (not the container’s writable layer) is what makes the catalog survive a Kamal deploy — a new container re-attaches the same mounts. Mounting on both roles is what makes agent sessions (which run in thevolume:- /opt/zimmer/catalog:/rails/catalog:ro # your air.production.json + artifacts/- /opt/zimmer/credentials:/rails/config/credentials:ro # your production.yml.enc (mcp_secrets)env:clear:AIR_CONFIG: /rails/catalog/air.production.jsonworker) see the same catalog andmcp_secretsas the web UI. - Deliver the catalog + credentials to those host paths with a workflow
(an
artifacts-sync-prod.ymlin that private companion repo): it SSHes to the box and writes yourair.json → /opt/zimmer/catalog/air.production.json,artifacts/ → /opt/zimmer/catalog/artifacts, andproduction.yml.enc → /opt/zimmer/credentials/, then restarts the app so the catalog cache refreshes. Re-run it whenever your catalog changes; it does not need to re-run after a normal deploy, because the mount persists.
mcp_secrets (the ${VAR} values your mcp.json references) come from
config/credentials/production.yml.enc, decrypted by RAILS_MASTER_KEY. Deliver the .enc file the
same way (mounted alongside), and pass RAILS_MASTER_KEY as a Kamal secret.
Private github:// catalog sources need AIR_GITHUB_TOKEN
Section titled “Private github:// catalog sources need AIR_GITHUB_TOKEN”If your air.json composes catalog content from a private github:// source (via the
@pulsemcp/air-provider-github extension), that source is fetched by shelling out to the AIR CLI
during catalog resolution (air resolve / air update), not just at session air prepare time.
The provider authenticates to GitHub by reading AIR_GITHUB_TOKEN from the resolve process’s
environment. Add that PAT (with repo read on the private catalog repo) to mcp_secrets under the
name AIR_GITHUB_TOKEN: Zimmer bridges just that one value from mcp_secrets into the air resolve /
air update subprocess environment so the fetch is authenticated. (Session air prepare already had
it — that path merges all of mcp_secrets into its subprocess env; the gap was specifically the
catalog resolve/update path, which built a minimal AIR_CONFIG-only env and merged no secrets.)
Without the token the fetch runs unauthenticated, GitHub returns 401, and AIR silently drops the
private source — leaving only your locally-indexed artifacts and none of the github-composed ones.