Skip to content

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.

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.

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 --root explicitly).
  • OAuth is a hard gate. If a remote MCP server needs OAuth and has no valid credential, the session fails with failure_reason: oauth_required and the UI renders Authorize buttons. Completing the flow resumes it. See MCP server OAuth.
  • --without-defaults is 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.

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.

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.