Skip to content

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.

Ao::Extension (app/services/ao/extension.rb), API_VERSION = 1.

Identity:

id # required — raises NotImplementedError. This is the enablement key.
title # default: id.humanize
description # default: ""
experimental? # default: true
default_enabled? # default: false
enabled? # 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 nil
provides_print_runner? # → Boolean
print_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" }

Exactly three places in core consult the registry:

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

Terminal window
scripts/install-extension.sh <id> --container <name> # docker cp + restart
scripts/install-extension.sh <id> --path <checkout> # for the next build
scripts/install-extension.sh --list # enumerate app/extensions/*

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

app/extensions/my_thing/my_thing_extension.rb
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" }
end
end

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