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. It does run one background thread, PeriodicCatalogRefresher, which re-runs
air update every 300 seconds, because the catalog cache lives on a per-container
filesystem and the web container would otherwise serve a catalog frozen at boot.
Tracked in #98.
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/. Agent credentials live in ~/.claude/.credentials.json and
~/.codex/auth.json, and are 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. None is registered today — BUILTIN_EXTENSION_CLASSES is empty, and 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 a registered extension governs a deployed container
like any other code. See Extensions.