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”Zimmer::Extension (app/services/zimmer/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: the seam, and no extensions
Section titled “What ships: the seam, and no extensions”BUILTIN_EXTENSION_CLASSES is empty. The seam is live — the registry, the base class, the three
mount points, and the Settings → Experimental rendering all work — but no extension is registered.
The one that used to ship was McpToolSearchExtension (id mcp_tool_search), whose only hook
returned {"ENABLE_TOOL_SEARCH" => "true"} for Claude Code. It is gone, because at the time it could
never do its job: .dockerignore then excluded /app/extensions/*/, so the class did not exist in
any built image, the registry skipped it, and the ENABLE_TOOL_SEARCH=false baseline always stood in
production. MCP tool search is now a first-class AppSetting column, on by default — see
Spawning a session. The mcp_tool_search key
is dropped from extension_states by the same migration, so there is only ever one control.
That exclusion is gone (#91). An extension
directory added to app/extensions/ now reaches every built image, and the build fails if it does
not — see Extensions do ship in the image.
So the choice between an extension and an AppSetting column is back to being about what the thing
is: an extension changes how Zimmer drives a runtime, a column is a value the app reads.
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!.tap { |s| s.set_extension_enabled("my_thing", true) }.save!With no extension registered, that section of the page renders only the first-class experimental settings.
Install — there is nothing to install. Dockerfile blanket-copies the repository into /rails
and nothing in .dockerignore takes app/extensions/ back out, so an extension merged to main is
in the next image and in every container that image starts. The only operating step is the toggle
above.
There used to be a scripts/install-extension.sh, which docker cp’d a directory into a running
container and restarted it. It is deleted. It needed a shell on the production host — which
the invariants say is a defect to design out,
not a procedure to document — and whatever it installed was gone at the next deploy.
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 < Zimmer::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 Zimmer::ExtensionRegistry::BUILTIN_EXTENSION_CLASSES.
Register it in config/initializers/zimmer_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/zimmer/extension_registry_test.rb.