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.

Zimmer::Extension (app/services/zimmer/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:

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

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 < 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" }
end
end

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