A reusable Ralph loop harness: drive an LLM coding agent through a task list one turn at a time, in a sandbox container, using the commit graph as the objective progress signal. Install it once, point it at any project.
Distilled from the Keep on the Borderlands build and generalized. Scope is deliberate: Linux + podman, single architecture, team-shared via GitHub. Not multi-arch, not macOS/Windows, not Docker-tested.
The harness splits by where each piece runs:
IN-CONTAINER HOST-SIDE (Claude Code) IN-REPO (data)
──────────── ────────────────────── ──────────────
the loop runner → /ralph-init (scaffold) → ralph.conf PROMPT.md
(ralph.sh, /ralph-status (report) tasks.md STATUS.md
until_reset.py) /ralph-build-base (build img) scripts/gate.sh + hook
⇒ base/ image ⇒ skills/ (this plugin) .github/workflows/ci.yml
specs/ tests/ decisions/
thin Containerfile + Makefile
⇒ generated by /ralph-init
base/— the podman base image. It bakesralph.sh+until_reset.pyonto PATH, so a consuming project carries no machinery; its Containerfile justFROMs the base. One source of the runner → no drift.skills/— the Claude Code plugin:/ralph-initscaffolds a project's config fromtemplates/;/ralph-statusreports loop state from.ralph/;/ralph-build-basebuildsralph-base:v1from the plugin's bundledbase/(clone-free);/ralph-upgradebrings an already-scaffolded project's config up to the current templates (see Upgrading).templates/+example/— the per-project config surface and a fully resolved sample.extras/— unsupported odds and ends (see its README).
Install the plugin (Claude Code):
/plugin marketplace add swinney/keep-on-ralphing
/plugin install ralph-harness@keep-on-ralphing
Build the base image once — no clone, it ships with the plugin:
/ralph-build-base # builds ralph-base:v1 from the plugin's bundled base/ (registry-free)
The plugin bundles base/ + the Makefile, so the image builds straight from the
install. Re-run /ralph-build-base after a /plugin update that touched the runner.
(Contributors working from a keep-on-ralphing checkout can make build-base instead.)
In your project:
/ralph-init # scaffolds config + an ENFORCED gate (scripts/gate.sh, a
# pre-commit hook, CI) + the specs/tests/decisions skeleton
make build # your loop image, FROM ralph-base (also installs the gate hook)
make login # one-time Claude Code auth
make loop # run the loop (Ctrl-C to stop) | make loop-once for a single turn
/ralph-status # check on it anytime
The loop stops when a turn writes a reason to STATUS.md, after
RALPH_MAX_STALLS consecutive no-commit turns, or on Ctrl-C.
The scaffolded gate is enforced: scripts/gate.sh is the single source of the
gate command, a pre-commit hook runs it and blocks red commits, and a CI workflow
runs the same script. A red gate therefore yields no commit — which the loop counts
as a stall — so broken work halts the loop for review instead of compounding. The
gate also includes a coverage threshold (a global floor by default), so "tests
pass" can't stand in for "the code is tested" — a supporting check alongside the
review gate above.
The hook is installed via core.hooksPath (the Makefile hooks target), which is
repo-wide: once set, it gates every commit, host or container. The gate runs where
its toolchain lives — the container — so host-side committers need that toolchain
locally or should commit via make shell.
The local gate above is the inner loop. There is also an outer loop
(RALPH_REVIEW_GATE, on by default): after a committing turn the runner pushes
the branch, opens/uses a PR, asks an independent reviewer (GitHub Copilot by
default) to review it, and treats zero findings + green CI as the only PASS.
Findings are written to review-findings.md, which the agent must resolve before any
new task; persistent findings halt the loop for a human after
RALPH_REVIEW_MAX_ROUNDS. RALPH_AUTO_MERGE (separate, default off) controls whether
a clean PR auto-merges or is parked for you. All GitHub work is the runner's — the
container agent never touches git/gh — so the gate works with any coding agent.
Independent review catches the "wired-wrong but green" class that tests, types, and
lint structurally cannot (the lesson the field log earned the hard way).
This makes the loop GitHub-dependent by default: loop mode refuses to start
without a git remote, an authenticated gh, and a non-base feature branch.
/ralph-init ensures these during setup. If you want a purely offline loop with no
review, set RALPH_REVIEW_GATE=0.
ralph-base:v1 is built locally from base/Containerfile (no registry). It is
python:3.12-slim + git/node/gh/Claude Code + the runner machinery on PATH + a
UID/GID-matched non-root user (so bind-mounted files are host-owned under rootless
podman). gh is included because the review-gate runner runs in the container; the
consumer Makefile forwards a host-derived GH_TOKEN to authenticate it. A consumer's
Containerfile is just FROM ralph-base:v1 plus its own toolchain. Pushing to a registry
(GHCR) is an optional later add — never required.
The build material ships inside the plugin (base/ + the Makefile), so
/ralph-build-base builds the image clone-free via
make -C "$CLAUDE_PLUGIN_ROOT" build-base; a keep-on-ralphing checkout can run
make build-base directly. Because the runner travels with the plugin, a
/plugin update + /ralph-build-base keeps the image in lockstep with the
installed plugin — resolve $CLAUDE_PLUGIN_ROOT at build time (the versioned plugin
cache is pruned after an update), never a frozen cache path.
Freshness. The :v1 tag is content-mutable, so the build bakes a content-hash
provenance stamp into the image. You don't have to track rebuilds by hand:
/ralph-build-base rebuilds only when the image is actually stale (and skips otherwise),
/ralph-status flags a drifted base, the runner prints its baked stamp at startup, and
make loop refuses a loop image built on a superseded base (run make build). Freshness
keys on the content hash (plus the host UID/GID), not the plugin version.
Upgrading spans all three channels (see Three channels): the plugin (skills +
templates) updates via /plugin update, the base image (the runner) via /ralph-build-base, and your
in-repo config (the files /ralph-init generated) via /ralph-upgrade (steps 1–3 below). The config
channel needs its own step because re-running /ralph-init does NOT upgrade an existing project — it is
no-overwrite (it only scaffolds missing files, never changes ones you already have), so propagating template
changes is /ralph-upgrade's job, not init's.
1. Update the plugin (latest skills + templates):
/plugin marketplace update keep-on-ralphing
/plugin update ralph-harness@keep-on-ralphing
/reload-plugins
2. Rebuild the base image when the runner changed (most releases) — the loop keeps running the old runner until you do:
/ralph-build-base # rebuilds ralph-base:v1 from the freshly-updated plugin
then make build in each project. /ralph-build-base is idempotent (it skips the rebuild when already
current), and /ralph-status plus the make loop check-base preflight flag a stale base, so you don't
have to track this by hand.
3. Adopt config changes with /ralph-upgrade. Because /ralph-init never overwrites, new template
content doesn't reach a project that already has those files — so run /ralph-upgrade, which detects
what's missing and proposes a confirm-gated merge that preserves your customizations. It covers the whole
in-repo config channel:
Makefile— the one that can break the loop. It forwardsGH_TOKENinto the container (the default-on review gate needs it) and adds acheck-basepreflight; aMakefilemissing theGH_TOKENforwarding makes a review-gated loop refuse to start./ralph-upgradeflags this as high-stakes and inserts the missing blocks (or setRALPH_REVIEW_GATE=0for an offline loop).ralph.conf— appends new keys commented / at defaults (behavior unchanged until you tune them).PROMPT.md— offers to append new contract clauses, preserving your edits.- New files (e.g.
docs/operator-checklist.md) — created if absent.
/ralph-upgrade is precise when the project carries a tracked .ralph-scaffold.json (written by
/ralph-init: it classifies each file as pristine-since-scaffold vs. customized) and falls back to
feature-detection on older projects that lack it. It is config only — it never rebuilds the base image
(that's step 2 / /ralph-build-base). To merge by hand instead, diff against the plugin's bundled
templates/ (under $CLAUDE_PLUGIN_ROOT, resolvable inside Claude Code) or example/.
See CHANGELOG.md for the per-version feature list — entries that change the runner are
marked "rebuild the base image."
Everything project-specific lives in one ralph.conf (env vars override it).
See templates/ralph.conf.example for every key. /ralph-init fills it for you.
The loop is a log source: it writes .ralph/status.jsonl (structured, one record per turn) and,
with RALPH_LIVE_LOG=1 (default), an append-only .ralph/log/live.log that interleaves runner
narration with agent output, every line turn-prefixed — one stable tail -f target. Point your own
aggregator at the bind-mounted files. See
docs/recipes/vector-console.md for a zero-backend Vector recipe
(console sink + vector top) that also centralizes several loops via one glob.
For a glanceable built-in view, set RALPH_DASHBOARD=1 (opt-in, default off) and make loop
auto-launches an ephemeral localhost web dashboard — a churn-weighted Commit Ribbon, an X/N task arc,
and a stakes strip (rate-limit countdown, stall-pressure meter, review-round pips) that lights only
while the loop is live, tearing itself down when the loop ends. It runs host-side against the
bind-mounted .ralph/, so the loop container stays port-free — the "log source, not a service"
stance is intact. See docs/recipes/dashboard.md.
Watching is the pull half; RALPH_NOTIFY_CMD is the push half. Set it to a command and the runner
(never the agent) pings you at every needs-human halt — stop, stall, review-exhausted, or blocked
(a new docs/questions.md entry, which now stops the loop immediately instead of burning stall turns).
It is opt-in (empty = off) and non-fatal (a failing/slow notifier never changes the loop's flow). See
docs/recipes/slack-notify.md for a Slack-webhook recipe and a zero-dep
gh pr comment alternative.
One model for everything wastes the strong model on trivial tasks and stalls the
cheap one on hard tasks. So the runner can match the model to each task's work
class: tag a task in tasks.md with a trailing class token —
- [ ] 2.3 migrate the session store (stateful)
— and map that class to a model in ralph.conf with RALPH_MODEL_<CLASS> (e.g.
RALPH_MODEL_STATEFUL="claude-opus-4-8"). That task's turn runs on the mapped
model; an untagged task uses RALPH_MODEL exactly as before, so the feature is
inert until you both tag tasks and set keys. Classification is the operator's call —
the runner never auto-classifies. It is a cost dial, not a correctness lever:
the gate and commit-as-truth remain the correctness guarantee, so a misclassified
task can at worst stall (caught by the stall detector), never ship a bad commit.
The runner takes a flock on $RALPH_STATE_DIR/lock on startup and refuses to
start a second concurrent loop on the same workspace — competing loops corrupt
shared state (.ralph/, the branch). Because the loop runs as PID 1 in its
container, the lock uses flock (not a PID file): the kernel releases it when the
holder dies — including a podman stop/SIGKILL/OOM that skips the exit handler — so
a leftover lock self-heals and the next start reacquires it cleanly, rather than a
stale "PID 1" blocking the loop forever. (Where flock is unavailable, outside the
supported Linux+podman scope, the runner warns and runs without the lock.)
Unattended (walk-away) operation is an opt-in mode, not the headline — in
evidence it was catalytic and narrow-band, not a general accelerator. Walk away
only when all four preconditions hold: (1) the work is well-specified, (2) the
model is matched from turn 1 (tag the class, don't "start cheap, escalate"), (3) you
are genuinely absent (if you'll watch anyway, run it supervised — supervision
catches more), and (4) for fan-out only, single-unit build time dominates per-unit
coordination cost. If any is false, build supervised-direct. Parallel content
fan-out (extras/) is serial-by-default / unsupported and was net-negative at
small unit size — not a turnkey speedup.
To go faster, target serial latency, not turn throughput: the bottleneck is the human-gate cycle (PR → CI → review → fixes → merge), so batch a milestone per PR, auto-merge on a clean review, and match the model to the work class — rather than adding containers/agents to the same milestone (systems-layer work shares files and collides; turn throughput was never the constraint).
/ralph-init scaffolds an operator checklist (docs/operator-checklist.md)
carrying these preconditions plus the pre-action checklists — and the caution that a
clean commit graph is not evidence of a well-run session ("resilience masks
sloppiness"); discipline needs its own signal, not the green graph.
make test # or: bash base/tests/run.shRuns the until_reset.py unit tests, the ralph.sh behavioural scaffold
(git-fixture based, stubs the claude binary), the gate-hook test (renders the gate
templates into a fixture repo and asserts a red gate blocks the commit), and the
review-gate test (stubs gh + a bare remote to drive the outer loop with no network).
Self-contained — needs only bash, git, python3 (+ pytest), and timeout.
The methodology behind the loop (why it's shaped this way) and its field log live alongside the harness; the original incubator was keep-on-the-borderlands, which is now this harness's first consumer.