Architecture
Zimmer is a Rails 8 monolith with an unusual job: its background workers spawn and supervise long-lived OS subprocesses that write to the filesystem and talk to the internet.
The whole system
Section titled “The whole system”The processes
Section titled “The processes”Web (Puma, fronted by Thruster in production). Serves the UI and the REST API. It runs
no cron and no background threads. It resolves the AIR catalog once at boot, then serves the
newest CatalogSnapshot the worker’s catalog refresh stored in Postgres, so it never needs
its own copy of the catalog cache to be fresh. See
Zimmer integration.
Worker (GoodJob). Everything that matters happens here: AgentSessionJob spawns agents
and monitors them, and roughly two dozen cron jobs poll GitHub, poll Slack, refresh OAuth
tokens, reap zombies, and clean up clones. In development GoodJob runs :async (in-process
with Puma); in production and staging it’s :external, meaning a separate bundle exec good_job start process is required.
The Kamal deploy runs that as a dedicated worker role (config/deploy.staging.yml), so cron
and pollers run on the deployed droplet.
Agent subprocess. A real headless claude, codex or pi process, spawned with
pgroup: true so the whole process group can be killed as a unit. Its stdin goes to
/dev/null; stderr goes to a log file inside the clone. The transcript file on disk carries
the conversation. See Runtimes.
Codex is always launched with --json, and its stdout event stream is captured into
codex_events.jsonl in the clone and read by CodexEventStream: its first line names the
thread UUID, which is how Zimmer identifies this session’s rollout in a tree shared by every
session on the host, and what codex exec resume targets. Claude’s stdout is still discarded —
--output-format stream-json is only passed on the image / large-prompt path, and Zimmer
already knows a Claude session’s id because it supplies it. Pi’s stdout is discarded too: it is
launched with --mode json, but Zimmer reads the session JSONL Pi writes into the clone rather
than the stream. See Spawning.
PostgreSQL holds everything: sessions, logs, transcripts, triggers, notifications, OAuth
credentials, and the catalog snapshot. A session’s JSONL transcript lives in
session_transcript_chunks — append-only, line-aligned slices whose concatenation is the whole
document — so a poll writes the bytes it added rather than rewriting the conversation
(#110). See
Where a transcript is stored.
It also backs Action Cable via solid_cable, on a second database (zimmer_<env>_cable) that
must exist before boot.
Redis is the Rails cache only. There is no Redis-backed queue — GoodJob uses Postgres.
The filesystem is load-bearing. Clones live in
~/.zimmer/clones/. A Claude Code session’s subscription credential is a database row, handed to
the process as CLAUDE_CODE_OAUTH_TOKEN at spawn — no file holds it; the session’s own
~/.zimmer/claude-config/<session>/ carries only its MCP OAuth map. Codex credentials live in
~/.codex/auth.json, read by the CLI, written by Zimmer, and also rewritten by the CLI behind
Zimmer’s back. See Agent harness credentials. Pi holds no
Zimmer-written harness credential — it reads a provider key out of its process environment — so
~/.pi/agent (a named volume) carries its own settings plus the MCP OAuth tokens Zimmer stages
there for it.
From prompt to running agent
Section titled “From prompt to running agent”This is the path a session takes on waiting → running, driven by AgentSessionJob:
The steps that most often surprise people:
- The clone happens before AIR runs, because AIR’s prepare step needs a target directory
and auto-detects the root from the git remote (though Zimmer passes
--rootexplicitly). - OAuth is a hard gate. If a remote MCP server needs OAuth and has no valid
credential, the session fails with
failure_reason: oauth_requiredand the UI renders Authorize buttons. Completing the flow resumes it. See MCP server OAuth. --without-defaultsis passed deliberately. Zimmer stores the final resolved artifact lists on the session row, so AIR must not re-add root defaults on top. See How Zimmer consumes AIR.
Runtimes are a bundle of seams
Section titled “Runtimes are a bundle of seams”Zimmer supports three agent harnesses today — claude_code, codex and pi — and a fourth
would be additive. A “runtime” is a RuntimeRegistry::Bundle struct rather than a class, with
fourteen slots, one per place where driving a vendor CLI differs: the CLI adapter, the retry
strategy, the transcript source and normalizer, the MCP status detector, the prompt
contribution, the config preparer and post-processor, the artifact bridge, the auth provider,
the credential writer, the usage ingestor.
Core code never says “Claude.” It asks the registry. See Runtimes for what the three are, and Adding an agent harness for the contract.
Extensions
Section titled “Extensions”A thin seam on top of that: Zimmer::Extension lets optional behavior override the CLI adapter,
supply a print-inference backend, or contribute spawn environment variables — without core
naming it. BUILTIN_EXTENSION_CLASSES names one — PtyTransportExtension — whose code is
deliberately not in this repository, so in this build the name resolves to nothing and every seam
falls back to native. The one that used to ship (mcp_tool_search) became a first-class setting
while the Docker image was still excluding app/extensions/*/. That exclusion is gone, so an
extension that is in the tree governs a deployed container like any other code. See
Extensions.