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" | "codex" | "pi" }The three mount points
Section titled “The three mount points”Exactly three places in core consult the registry:
What ships: the seam, and one name that resolves to nothing
Section titled “What ships: the seam, and one name that resolves to nothing”BUILTIN_EXTENSION_CLASSES names exactly one class — PtyTransportExtension — and that class’s
code is not in this repository, deliberately. The seam is live: the registry, the base class, the
three mount points, and the Settings → Experimental rendering all work. But in this build the name
resolves to nothing, register_builtins! skips it, Settings → Experimental shows no extension, and
every seam falls back to native.
That is how the removability mechanism is meant to behave. Registration is a class name; whether a name resolves is a property of the tree the app was built from.
pty_transport fulfils one-off headless inference — session titles and notification
summaries — by driving the interactive Claude TUI inside a pseudo-terminal and scraping the
transcript, instead of shelling out to claude -p. It can therefore fill the usage slot on
ClaudePrintRunner::Result, which print mode cannot; today every caller of
HeadlessInferenceService discards that slot, so the slot is there for a backend that can populate
it rather than for a consumer that reads it. The technique depends on internal-only methods we do
not publish, which Zimmer::Extension’s own docstring says in as many words, so its code lives
outside this repository and reaches the deployment that has it as a
bind mount rather than as a merged directory.
A standalone install of Zimmer therefore gets the native path for headless inference and gives up
nothing observable: NativeClaudePrintRunner is the historically-proven backend, and it returns the
same text for the same prompt.
The extension 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!In a build where no registered name resolves — this one — that section of the page renders only the first-class experimental settings.
Install — for an extension whose code is in the repository, 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.
An extension can also arrive from outside the image, which is how a deployment carries a private one. Production mounts it read-only over the directory Zeitwerk would have loaded it from:
volumes: - /opt/zimmer/extensions/pty_transport:/rails/app/extensions/pty_transport:roThree things about that line are load-bearing. It mounts the extension’s own subdirectory, never
/rails/app/extensions — mounting the parent would shadow app/extensions/CLAUDE.md and the
image_canary/ directory that
the build guardrail looks for. It is
read-only and on a persistent host path, so it survives kamal deploy, unlike the writable
container layer the deleted install-extension.sh wrote into. And a missing or empty host
directory is inert — Docker creates the path, Zeitwerk finds no files, safe_constantize returns
nil, and the registry skips the name — so the mount is safe to carry before anything populates it.
Production eager-loads, so the files have to be present at container boot; populating the host
directory is therefore picked up by a health-gated Kamal cutover, not by restarting a container.
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 — PtyTransportExtension is the standing proof, since the name has
never resolved in this repository and the app has never noticed.
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.