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" | "codex" | "pi" }

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

config/deploy.production.yml
volumes:
- /opt/zimmer/extensions/pty_transport:/rails/app/extensions/pty_transport:ro

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

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.