Extensions
An extension is a self-contained, individually-deletable bundle of optional behavior that alters how Zimmer itself drives a runtime. Core code never names a concrete extension.
The contract
Section titled “The contract”Ao::Extension (app/services/ao/extension.rb), API_VERSION = 1.
Identity:
id # required — raises NotImplementedError. This is the enablement key.title # default: id.humanizedescription # default: ""experimental? # default: truedefault_enabled? # default: falseenabled? # provided — reads AppSetting.extension_enabled?(id, default: default_enabled?)Hooks (all inert by default — override only what you need):
cli_adapter_override(runtime) # → an adapter class, or nilprovides_print_runner? # → Booleanprint_runner_backend(claude_binary:, model:, process_manager:, logger:) # → an object responding to #run(prompt:, timeout:)spawn_env_contribution(context = {}) # → Hash. context is { runtime: "claude_code" }The three mount points
Section titled “The three mount points”Exactly three places in core consult the registry:
What ships: exactly one
Section titled “What ships: exactly one”McpToolSearchExtension (app/extensions/mcp_tool_search/), id mcp_tool_search, experimental, off
by default.
Its only hook is spawn_env_contribution, returning {"ENABLE_TOOL_SEARCH" => "true"} — but only
when context[:runtime] == "claude_code". That flips Zimmer’s baseline ENABLE_TOOL_SEARCH=false, so
Claude Code loads tool schemas on demand instead of all of them up front.
Enable, install, remove
Section titled “Enable, install, remove”Enable — Settings → Experimental, which writes to AppSetting#extension_states (a JSONB map of
id → bool). No migration per extension. Or from a console:
AppSetting.first_or_create!.set_extension_enabled("mcp_tool_search", true)Install — here’s the wrinkle: the core Docker image ships with no extensions at all.
.dockerignore excludes /app/extensions/*/. Even mcp_tool_search is absent from a built image.
scripts/install-extension.sh <id> --container <name> # docker cp + restartscripts/install-extension.sh <id> --path <checkout> # for the next buildscripts/install-extension.sh --list # enumerate app/extensions/*Remove — rm -rf app/extensions/<id>/. ExtensionRegistry resolves builtins with
safe_constantize and skips anything that returns nil, so every seam falls back to native behavior.
Leaving the dead name in BUILTIN_EXTENSION_CLASSES is harmless. That is the removability
mechanism, and it’s a good one.
Writing one
Section titled “Writing one”class MyThingExtension < Ao::Extension def id = "my_thing" def title = "My Thing" def description = "Does the thing." def default_enabled? = false
def spawn_env_contribution(context = {}) return {} unless context[:runtime] == "claude_code" { "MY_FLAG" => "1" } endendThen add "MyThingExtension" to Ao::ExtensionRegistry::BUILTIN_EXTENSION_CLASSES.
Register it in config/initializers/ao_extensions.rb? No — that file only calls reset! and
register_builtins! inside a to_prepare block (so it survives dev reloads). Adding the class name
to BUILTIN_EXTENSION_CLASSES is the whole registration.
Tests go in test/extensions/<id>/. The generic registry test lives at
test/services/ao/extension_registry_test.rb.