Skip to content

Skills, plugins, hooks, references

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"]:

SkillWhat it does
sync-docsPre-PR step: update docs that the branch diff made stale. Always-on by convention.
zimmer-run-testsRun the test suite the way this repo expects.
zimmer-start-dev-serverBring up a local instance for e2e verification.
zimmer-deploy-stagingDrive the staging deploy workflow.
zimmer-change-ai-artifactThe guide to changing the catalog itself.
awaken-waiting-sessionsThe 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:

SkillWhat it does
open-prCommit, push, open the PR, self-review, subagent-review, wait for CI. Bundles the git-workflow reference.
wait-for-ciBlock until CI passes or fails on the current PR.
recover-from-compaction-thrashingDelegate verbose tool calls to subagents so compaction doesn’t erase your work.

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

A named bundle that composes existing skills, MCP servers, and hooks. The index entry points at a directory; the directory holds .plugin/plugin.json.

plugins/ci-workflow/.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:

PluginDefault inBundles
ci-workflowagent-orchestratorzimmer-run-tests + git-push-ci-reminder
screenshots-videosagent-orchestratorscreenshot/video capture tooling
figma-design-workflowFigma design tooling
meeting-wranglingmeeting 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 script

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

The full procedure lives in skills/zimmer-change-ai-artifact/SKILL.md. The short version:

  1. Add the body (skills/<id>/SKILL.md, hooks/<id>/HOOK.json, references/<file>.md).
  2. Register it in the index (skills/skills.json, etc.).
  3. Add default_in_roots: ["<root>"] to make it default-on for that root.
  4. Verify with air resolve before pushing. A dangling reference will not fail the resolve — it will exit 0, drop your artifact’s reference, and then break the test suite.
Terminal window
# From the repo root
AIR_CONFIG=$PWD/air.json npx @pulsemcp/[email protected] resolve --json --no-scope

Watch stderr, not the exit code. Lines containing references unknown and Dropping the reference are what Zimmer treats as a hard failure.

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.rb reads config.air_json_path from AIR_CONFIG, falling back to the in-image air.production.json when AIR_CONFIG is 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_CONFIG wins.
  • AIR resolves a catalog’s index paths relative to the air.json/air.production.json file’s own directory. So keep your air.production.json and its artifacts/ (or roots.json, mcp.json, …) siblings in one directory, and point AIR_CONFIG at 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:

  1. Mount two persistent host directories into both the web and worker roles (config/deploy.production.yml), and set AIR_CONFIG:
    volume:
    - /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.json
    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 the worker) see the same catalog and mcp_secrets as the web UI.
  2. Deliver the catalog + credentials to those host paths with a workflow (an artifacts-sync-prod.yml in that private companion repo): it SSHes to the box and writes your air.json → /opt/zimmer/catalog/air.production.json, artifacts/ → /opt/zimmer/catalog/artifacts, and production.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.