Architecture
System boundaries
The kit is inside the target repository: ralph_orchestrator/ is a folder of the repo it
orchestrates (installed with install.sh, committed to the repo). The git repository is the
system under control; everything the kit creates is either
-
source-controlled work — the
ralph/integrationbranch, per-ticketralph/<id>branches and their commits, -
local, git-excluded run state — the
.ralph/directory (state, worktrees, logs, bus, contracts, handoffs, inbox; added to gitinfo/excludeby the orchestrator itself), and -
reports —
out/run_report.htmland, when Playwright is available,out/run_report.pdf.
External systems the kit talks to:
-
the agent CLI (default
opencode run) — spawned per attempt with a prompt file and an environment contract (Worker — agent invocation); -
the model gateway (OpenAI-compatible HTTP) — via LangChain, for the planner, the review panel and contract negotiation (below);
-
SMTP/IMAP mail servers — status mails and reply capture (E-mail loop and watch agent);
-
npm — one-time installation of agent plugins (below).
There is no daemon, no database, no second state system: the dashboard and the report render
the same state the scheduler writes (.ralph/state.json), assembled into one JSON snapshot
(Dashboard HTTP API).
Component map
CLI · scheduler · merger · wizard · watch"] W["worker.py
per-ticket loop"] C["collab.py
bus + contracts wiring"] BUS["bus.py
message bus (JSONL)"] CON["contracts.py
interface negotiation"] CONSENSUS["consensus.py
review panel"] PLANNER["planner.py
spec → tickets.yaml"] STATE["state.py
.ralph/state.json"] GRAPH["graph.py
DAG, waves, scopes"] CFG["config.py
config.yaml + defaults"] PROC["proc.py
subprocess + git helpers"] REPORT["report.py
status mail / webhook"] MAILR["mail_reply.py
IMAP reply capture"] DASH["dashboard.py
HTTP server"] HTMLR["html_report.py
snapshot + renderer"] PDFR["pdf_report.py
optional PDF"] end AGENT["agent CLI
(opencode run)"] GW["model gateway
(OpenAI-compatible HTTP)"] GIT["git repo
base · ralph/integration · ralph/<id>"] SMTP["SMTP / IMAP
+ webhook"] BROWSER["browser
dashboard · run report"] CLI -->|launches, bounded by waves + scope overlap| W W -->|spawns per attempt| AGENT W -->|worktree / commit / merge| GIT CLI -->|serial merge + regression| GIT CLI --> C C --> BUS C --> CON W -.->|reads/writes prompt, handoff| BUS CONSENSUS -->|LangChain votes| GW CON -->|drafts + panel vote| GW PLANNER -->|plan call| GW C --> CONSENSUS CLI --> STATE CLI --> REPORT CLI --> MAILR REPORT --> SMTP MAILR --> SMTP DASH --> HTMLR HTMLR --> STATE HTMLR -->|writes| HTMLR2["out/run_report.html"] HTMLR2 --> PDFR CLI --> DASH BROWSER -->|GET /api/run| DASH BROWSER --> HTMLR2 CLI --> PROC W --> PROC
Intra-kit dependencies (import graph, verified): orch.py → {worker, collab, graph,
state, report, mail_reply, proc, config}; worker.py → {graph, proc};
collab.py → {bus, contracts, consensus, graph, models}; consensus.py →
models; contracts.py → {consensus, models}; planner.py → {graph, models,
proc}; report.py → graph (wave plan in the mail body); dashboard.py →
{html_report, graph, proc, config}; html_report.py → graph. models.py,
state.py, graph.py, proc.py, config.py, bus.py, mail_reply.py,
mail_check.py depend only on the stdlib (+ PyYAML), and models.py imports LangChain
lazily inside make_llm() so the scheduler runs without it installed.
Model access
models.py is the single gateway into the LLM world:
-
api_key(cfg)resolves the gateway key: environment variable (gateway.api_key_env, defaultPORTAL_API_KEY) →gateway.api_keyinconfig.yaml→ the file ingateway.api_key_file(default~/.config/opencode/portal.key). -
make_llm(cfg, spec)builds a LangChainChatOpenAIbound togateway.base_url.format(path=spec["path"])— one templated base URL, a differentpathper model — withtimeout=300,max_retries=2,temperaturefrom the spec (default 0.2).
The agent CLI does not go through this gateway: the opencode run process authenticates
with its own provider configuration. The kit passes the full parent environment through to it
(proc.py), which is how the spawned agent finds its own credentials.
Runtime execution flow
One orch.py run:
The main thread never blocks on an agent: workers run in a ThreadPoolExecutor, the loop
waits with a collaboration.poll_seconds timeout, processes the bus in between, and merges
are strictly serial (one integration worktree, one merge at a time).
Persistence and the .ralph/ layout
<repo>/.ralph/
state.json # THE state: tickets, counters, events, replies (single source of truth)
watch.json # watch-agent heartbeat (dashboard badge)
last_report.txt # last report mail (subject, message-id, body)
inbox.json # captured operator replies (deduped, seen-flagged)
dynamic.json # change-request follow-up tickets + added dependencies
prompts/<id>.md # the exact prompt given to the agent, per attempt
logs/<id>.log # agent output + guard decisions, per attempt
handoff/<id>.md # what a merged ticket changed (injected into dependents' prompts)
contracts/group<k>.json / .md # agreed interface contracts per parallel wave
bus/messages.jsonl # append-only inter-agent message log (flock-protected)
bus/cursor/<id> # per-agent "read up to message id" cursor
bin/bus # the `bus` wrapper placed on agents' PATH
wt/<id>/ # git worktree per ticket
wt/_integration/ # git worktree of ralph/integration (the merge target)
<repo>/out/
run_report.html # self-contained report (regenerated on every state change)
run_report.pdf # optional printable copy (Playwright Chromium)
state.json is written atomically (tmp file + rename) under a re-entrant lock, with a
heartbeat on every save. Its schema and event types are documented in
state.json.
Lifecycle
-
Run — resumable: re-running
orch.py runcontinues pending/blocked tickets; merged tickets stay merged; a crashed run’srunningtickets are reset topendingat state load. -
Watch — an always-on correction agent between runs (mail-loop.adoc#watch).
-
Reset —
orch.py resetremoves worktrees,ralph/*branches (integration branch kept unless--branches) and.ralph/. -
Dashboard — any time, reads only (never writes run state).