Implementing Ralph loops
A minimal Ralph loop for Claude Code, specs on disk, auto mode, and mandatory test/build/e2e verification before the model may continue.
This workshop ships a deliberately small Ralph-style loop: one shell script, specs/ markdown, an optional RALPH.md, and Claude Code in print mode (-p) with --permission-mode auto so you are not clicking through every tool call during unattended iterations. Feedback loops are the point: the loop prompt requires real tests, builds, and e2e or MCP-backed checks (for example Playwright or Playwright MCP) to pass before the model may emit a done token, see Verification and feedback loops below.
For a full-featured setup (extra runners, planning mode, plugins), see ralph-wiggum, the steps below trade richness for clarity.
Why auto mode here
Auto mode lets Claude Code run tools without a prompt on every step while
hooks, explicit rules, and the classifier apply the current safety policy.
That matches long Ralph runs much better than approving each Bash line by
hand.
Requirements and caveats (plans, models, provider) are spelled out in Auto mode. If auto mode is not available on your account, set PERM_MODE to whatever claude --help lists for --permission-mode on your build (for example default) when you run the loop script (./scripts/ralph-loop.sh on macOS/Linux, or bash scripts/ralph-loop.sh on Windows if you use Git Bash, see Run the loop below).
Pair auto mode with explicit permission denies and sandbox filesystem or credential protections for secrets, as with any high-autonomy workflow.
Verification and feedback loops (essential)
A Ralph loop without tight feedback is just an expensive way to stack unverified diffs. Automated checks are not optional decoration, they are what let the model know it may advance.
Treat every iteration like a mini-CI:
For .NET work in this workshop, use .NET 10+ (matching SDK on the machine running the loop).
- Unit / integration tests: whatever the repo already runs (
npm test,dotnet test,pytest, etc.). - Build and typecheck:
npm run build,tsc,cargo build,dotnet build, ... so "compiles" is a fact, not a hope. - E2E or browser-level proof when the spec touches UI or flows users actually click through, e.g. Playwright from the CLI, or Playwright MCP (or another browser/testing MCP server) if your project uses it, so behavior is observed not narrated.
The embedded prompt in ralph-loop.sh instructs the model: do not emit <promise>DONE</promise> until those gates pass. You still own the setup: write specs and RALPH.md so the right commands are explicit (and add MCP servers to Claude Code when e2e needs them). If the model skips checks or lies about green builds, treat the done token as invalid and fix prompts, permissions, or spec wording, same as you would if CI went red.
What you create in the repo
| Path | Purpose |
|---|---|
specs/*.md | One spec per file; mark finished with ## Status: COMPLETE. |
RALPH.md (optional) | Short project rules the agent reads every iteration. |
CLAUDE.md / AGENTS.md (optional) | Point at RALPH.md or your normal project instructions. |
scripts/ralph-loop.sh | The loop (download below). |
logs/ | Created automatically; one log file per iteration. |
No .specify/ tree, no interview flow, no generated PROMPT_*.md, the prompt is embedded in the script so there is less moving state.
Directories
mkdir -p specs scripts logsmkdir -p specs scripts logsNew-Item -ItemType Directory -Force -Path specs, scripts, logs | Out-NullDownload the loop script
Read the full script, ralph-loop.sh in the code block below (functionally corresponds to the download; expand to copy)
#!/usr/bin/env bash
# Minimal Ralph-style loop for Claude Code (workshop edition).
# Idea: fresh `claude -p` each iteration, specs on disk, grep for a done token.
# Heavier setups exist upstream; this script stays small on purpose.
set -o pipefail
set -u
usage() {
cat <<'EOF'
Minimal Ralph-style loop: repeated `claude -p` passes over specs/ until DONE tokens appear.
Usage: ./scripts/ralph-loop.sh [max_iterations]
max_iterations Optional cap (e.g. 10). Omit for unlimited until ALL_DONE or Ctrl+C.
Environment:
CLAUDE_CMD Default: claude
PERM_MODE Default: auto (Claude Code --permission-mode; see workshop Auto mode doc)
RALPH_NO_PTY Set to 1 to skip pseudo-tty wrapper (rare; only if `script` causes issues)
RALPH_NO_STREAM Set to 1 to disable stream-json streaming and fall back to plain `claude -p`
(only final answer is printed, at the end, mostly useful for debugging).
Streaming: by default Claude runs with `--verbose --output-format stream-json`, which emits one
JSON event per line (tool_use, tool_result, assistant text, etc.) as work happens. Those events
are pretty-printed to your terminal in real time and also written to logs/ralph_*.log. Without
this, `claude -p` stays silent until the end of each pass.
EOF
}
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
CLAUDE_CMD="${CLAUDE_CMD:-claude}"
PERM_MODE="${PERM_MODE:-auto}"
MAX_ITERATIONS=0
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
MAX_ITERATIONS="$1"
fi
mkdir -p logs specs
if ! command -v stdbuf >/dev/null 2>&1 && ! command -v gstdbuf >/dev/null 2>&1; then
echo "ralph-loop: GNU stdbuf/gstdbuf not on PATH (optional). If the console stays quiet until each pass finishes, try: brew install coreutils, then put gnubin ahead on PATH for gstdbuf." >&2
fi
if ! command -v "$CLAUDE_CMD" &>/dev/null; then
echo "error: '$CLAUDE_CMD' not found on PATH" >&2
exit 1
fi
# Line-buffer script's pipe output when libc supports it (macOS: brew install coreutils → gstdbuf).
script_lb() {
if command -v stdbuf >/dev/null 2>&1; then
stdbuf -oL -eL script "$@"
elif command -v gstdbuf >/dev/null 2>&1; then
gstdbuf -oL -eL script "$@"
else
script "$@"
fi
}
# With stream-json, claude emits one JSON line per event as work happens (tool calls, assistant
# text, etc.), this is the ONLY way to see live progress; plain `-p` stays silent until done.
# Kept as a plain string so it survives `export` into the bash child spawned by `script`.
CLAUDE_STREAM_FLAGS=""
if [[ "${RALPH_NO_STREAM:-}" != "1" ]] && ! command -v python3 >/dev/null 2>&1; then
echo "ralph-loop: python3 not found; disabling stream-json so final-result sentinels remain verifiable." >&2
RALPH_NO_STREAM=1
export RALPH_NO_STREAM
fi
if [[ "${RALPH_NO_STREAM:-}" != "1" ]]; then
CLAUDE_STREAM_FLAGS="--verbose --output-format stream-json"
fi
export CLAUDE_STREAM_FLAGS
run_claude_from_prompt_file() {
local pf="$1"
# $CLAUDE_STREAM_FLAGS is intentionally unquoted so it word-splits into separate args.
if command -v stdbuf >/dev/null 2>&1; then
cat "$pf" | stdbuf -oL -eL "$CLAUDE_CMD" -p --permission-mode "$PERM_MODE" $CLAUDE_STREAM_FLAGS
elif command -v gstdbuf >/dev/null 2>&1; then
cat "$pf" | gstdbuf -oL -eL "$CLAUDE_CMD" -p --permission-mode "$PERM_MODE" $CLAUDE_STREAM_FLAGS
else
cat "$pf" | "$CLAUDE_CMD" -p --permission-mode "$PERM_MODE" $CLAUDE_STREAM_FLAGS
fi
}
export -f run_claude_from_prompt_file
export CLAUDE_CMD PERM_MODE
invoke_claude_streaming() {
local pf="$1"
# Stream-json is already line-delimited and flushes per event, a pty wrapper only adds CRLF
# noise that corrupts terminal rendering. Skip `script` whenever streaming is enabled.
if [[ "${RALPH_NO_STREAM:-}" != "1" ]] || [[ "${RALPH_NO_PTY:-}" == "1" ]] || ! command -v script >/dev/null 2>&1; then
run_claude_from_prompt_file "$pf"
return
fi
if script -V 2>&1 | grep -qi util-linux; then
local q
q=$(printf '%q' "$pf")
script_lb -qec "bash -c 'run_claude_from_prompt_file \"\$1\"' _ $q" /dev/null
return
fi
script_lb -q /dev/null bash -c 'run_claude_from_prompt_file "$1"' _ "$pf"
}
# Pretty-print claude's stream-json events into readable lines. One JSON event per input line;
# each event becomes zero or more human-readable output lines. Passes through unrecognised lines
# verbatim. Only the final result event emits a completion marker.
format_stream() {
if [[ "${RALPH_NO_STREAM:-}" == "1" ]] || ! command -v python3 >/dev/null 2>&1; then
cat
return
fi
python3 -u -c '
import sys, json
def trunc(s, n=240):
s = str(s)
return s if len(s) <= n else s[:n] + "..."
for raw in sys.stdin:
# Strip both \n and \r, a pty wrapper upstream (e.g. macOS `script`) can inject \r
# which would otherwise make each printed line overwrite the previous one.
line = raw.strip("\r\n")
if not line.strip():
continue
try:
ev = json.loads(line)
except Exception:
print(line); continue
t = ev.get("type")
if t == "system":
sub = ev.get("subtype", "")
model = ev.get("model", "?")
cwd = ev.get("cwd", "?")
print("[system:%s] model=%s cwd=%s" % (sub, model, cwd))
elif t == "assistant":
for b in ev.get("message", {}).get("content", []) or []:
bt = b.get("type")
if bt == "text":
txt = b.get("text", "").rstrip()
if txt: print(txt)
elif bt == "tool_use":
name = b.get("name", "?")
inp = trunc(json.dumps(b.get("input", {}), ensure_ascii=False))
print("[tool:%s] %s" % (name, inp))
elif bt == "thinking":
th = b.get("thinking", "").strip().replace("\n", " ")
if th: print("[thinking] %s" % trunc(th))
elif t == "user":
for b in ev.get("message", {}).get("content", []) or []:
if b.get("type") == "tool_result":
c = b.get("content", "")
if isinstance(c, list):
c = " ".join(x.get("text", "") for x in c if isinstance(x, dict))
print("[result] %s" % trunc(c))
elif t == "result":
print("[done] turns=%s cost=$%s duration=%sms" % (
ev.get("num_turns", "?"),
ev.get("total_cost_usd", "?"),
ev.get("duration_ms", "?"),
))
r = ev.get("result")
if r:
print(r)
lines = r.splitlines()
if "<promise>ALL_DONE</promise>" in lines:
print("[ralph-final] ALL_DONE")
elif "<promise>DONE</promise>" in lines:
print("[ralph-final] DONE")
else:
print("[%s] %s" % (t, trunc(line)))
'
}
# Pipe claude → formatter → log + terminal. `tee` keeps output flowing live and on disk.
run_claude_with_live_log() {
local log="$1"
local pf="$2"
if [[ -w /dev/tty ]] 2>/dev/null; then
invoke_claude_streaming "$pf" 2>&1 | format_stream | tee "$log" /dev/tty >/dev/null
else
invoke_claude_streaming "$pf" 2>&1 | format_stream | tee "$log"
fi
}
PROMPT_FILE="$ROOT/logs/.ralph-loop-prompt.txt"
cat <<'PROMPT' > "$PROMPT_FILE"
You are running **one pass** of a Ralph-style autonomous loop in this repository.
**Verification is mandatory.** The outer loop only stays honest if you **close the feedback loop** with real commands, not a story about what “should” work. You **must not** print `<promise>DONE</promise>` or `<promise>ALL_DONE</promise>` until **every** check in step 5 has **actually succeeded** (exit code zero). If checks fail, **fix and re-run**; never claim done on a red build or failing tests.
1. If **RALPH.md** exists at the repo root, read it first (it may name required checks for this repo).
2. Under **specs/**, treat a markdown file as **complete** only if it contains a line exactly: `## Status: COMPLETE`
3. Pick **one** incomplete spec (prefer lower numeric prefix in the filename, e.g. `001-foo.md` before `002-bar.md`).
4. Implement that spec. **Commit** with a clear message when the work belongs together, **local commits only**.
- **Never `git push`** (no `push` to any remote, no `--force` pushes). Publishing is for a **human** after review.
- If the spec uses markdown task lists under **Acceptance criteria** (`- [ ]` …), **check them off as you verify each one**: change every finished line to `- [x]` so the file matches reality before you mark the phase complete.
5. **Run verification before any done token** (use what the spec and stack require, examples below):
- **Unit / integration tests:** e.g. `npm test`, `pnpm test`, `cargo test`, `dotnet test`, `pytest`, etc., whatever the repo already uses.
- **Build / typecheck:** e.g. `npm run build`, `tsc --noEmit`, `dotnet build`, `cargo build`, so the tree compiles.
- **E2E / UI behavior:** if the spec or project depends on browser flows, run the project’s e2e suite (e.g. **Playwright** via `npx playwright test` or equivalent), or use **Playwright MCP** (or another browser/testing MCP) if this workspace is wired for it, so “works in the UI” is **observed**, not guessed.
- Obey **RALPH.md** / **AGENTS.md** / **CLAUDE.md** if they name extra gates (lint, audit, etc.).
**All of the above that apply must pass.** If something is not applicable, say so briefly in your output, but never skip checks that the spec explicitly requires.
6. Only when acceptance criteria are **fully** met **and** every required verification command has **succeeded**: ensure **every** acceptance-criterion checkbox in that spec is `- [x]`, then add `## Status: COMPLETE`, then print `<promise>DONE</promise>` as an exact standalone line in your final result.
7. Only when **no** incomplete specs remain and you have **sanity-checked** that nothing is left: print `<promise>ALL_DONE</promise>` as an exact standalone line in your final result.
Do not print `<promise>DONE</promise>` or `<promise>ALL_DONE</promise>` until verification has truly passed.
**Git:** you may **commit**; you must **not push**, ever, in this loop.
PROMPT
iter=0
while true; do
iter=$((iter + 1))
if [[ "$MAX_ITERATIONS" -gt 0 && "$iter" -gt "$MAX_ITERATIONS" ]]; then
echo "ralph-loop: stopped after $MAX_ITERATIONS iteration(s)."
break
fi
LOG="$ROOT/logs/ralph_${iter}_$(date +%Y%m%d_%H%M%S).log"
echo ""
echo "ralph-loop: iteration $iter → $LOG"
run_claude_with_live_log "$LOG" "$PROMPT_FILE"
if [[ "${RALPH_NO_STREAM:-}" == "1" ]]; then
all_done_pattern='<promise>ALL_DONE</promise>'
done_pattern='<promise>DONE</promise>'
else
all_done_pattern='[ralph-final] ALL_DONE'
done_pattern='[ralph-final] DONE'
fi
if grep -qxF "$all_done_pattern" "$LOG" 2>/dev/null; then
echo "ralph-loop: ALL_DONE, no incomplete specs."
break
fi
if grep -qxF "$done_pattern" "$LOG" 2>/dev/null; then
echo "ralph-loop: DONE, next iteration (fresh context)."
sleep 1
continue
fi
echo "ralph-loop: no DONE/ALL_DONE in output, check $LOG (will retry)."
sleep 2
done
echo "ralph-loop: exiting after $iter iteration(s)."
Browser: Download ralph-loop.sh and save as scripts/ralph-loop.sh, then make it executable:
chmod +x scripts/ralph-loop.shchmod +x scripts/ralph-loop.shchmod is not used. If you run the script with Git Bash or WSL, use chmod there; in native PowerShell you can run bash scripts/ralph-loop.sh once Git's bash.exe is on your PATH.
From this repository's docs app (Next.js package root): public/downloads/ralph-wiggum/scripts/ralph-loop.sh
Optional: RALPH.md
Keep it short; the loop always tells the model to read this file when it exists. Pick one template below (tabs, same control as Practicing Ralph loops via workshop-ralph-stack) and save it as RALPH.md at the repo root. For Snippet Vault, prefer the exercise-tuned RALPH.md on the Practicing page.
# Ralph loop rules (TypeScript / Node)
## Product
[One paragraph: what this repo is]
## Required verification (before any done token)
- **`npm install`** when dependencies changed.
- **`npm run build`** and **`npm test`** (or your project’s documented equivalents).
- **`npx playwright test`** (or your e2e entry point) when the spec touches UI or user flows.
- Browser checks via **Playwright MCP** (or another testing MCP) when UI behavior must be proven and you use MCP in this repo.
Do not print `<promise>DONE</promise>` or `<promise>ALL_DONE</promise>` until **every** command above that applies has **exited successfully** for the current phase.
## While in the loop
- One spec per iteration; finish acceptance criteria before adding `## Status: COMPLETE` to that spec file.
- Fix failures and re-run verification; never skip tests to “move on”.
- Commit logical chunks **locally**; **never `git push`** (no remote push, no force-push); a human publishes after review.
## Out of scope
[What the agent should not do without a human]# Ralph loop rules (.NET 10+)
## Product
[One paragraph: what this repo is]
## Required verification (before any done token)
- **SDK:** **.NET 10** or later on `PATH` (`dotnet --version`).
- **`dotnet build`** for the solution or project.
- **`dotnet test`** once test projects exist (run every iteration; empty test runs are fine early on).
- **`npx playwright test`** or **`dotnet test`** integration/e2e projects when the spec touches UI or HTTP flows end-to-end.
- Browser checks via **Playwright MCP** (or another testing MCP) when UI behavior must be proven and you use MCP in this repo.
Do not print `<promise>DONE</promise>` or `<promise>ALL_DONE</promise>` until **every** command above that applies has **exited successfully** for the current phase.
## While in the loop
- One spec per iteration; finish acceptance criteria before adding `## Status: COMPLETE` to that spec file.
- Fix failures and re-run verification; never skip tests to “move on”.
- Commit logical chunks **locally**; **never `git push`** (no remote push, no force-push); a human publishes after review.
## Out of scope
[What the agent should not do without a human]Write a spec
Example specs/001-hello-cli.md while work is open (no status line yet):
# Spec: Hello CLI
## Acceptance criteria
- [ ] `npm test` passes (or `cargo test`, etc.), **must be run and green before COMPLETE**.
- [ ] `npm run build` passes (or your project’s compile check).
- [ ] CLI prints `hello` when run with `--greet`.
- [ ] If this spec touched UI flows: e2e passes (e.g. `npx playwright test` or checks via **Playwright MCP**).
## Verification commands (for the agent)
Explicitly list the commands this spec requires so the loop cannot “forget” them, e.g. `npm run build && npm test`.When the criteria are actually met, add a line exactly:
## Status: COMPLETEThe loop script treats a spec as incomplete until that heading appears in the file. The shell loop does not read - [ ] checkboxes, only the model can update them; the embedded ralph-loop.sh prompt now tells Claude to tick Acceptance criteria to - [x] as each item is verified, then add ## Status: COMPLETE.
Run the loop
./scripts/ralph-loop.sh # run until a log contains ALL_DONE or you Ctrl+C
./scripts/ralph-loop.sh 12 # stop after 12 iterations even if work remains./scripts/ralph-loop.sh # run until a log contains ALL_DONE or you Ctrl+C
./scripts/ralph-loop.sh 12 # stop after 12 iterations even if work remainsGit Bash / WSL: same as macOS / Linux from the repo root.
Native PowerShell, if bash is on your PATH (for example Git for Windows):
bash scripts/ralph-loop.sh
bash scripts/ralph-loop.sh 12Each iteration runs claude -p --permission-mode auto with the embedded prompt, writes logs/ralph_<n>_*.log, then:
Live output: by default the loop runs claude -p with --verbose --output-format stream-json so you get one JSON event per line as tools run. A small python3 formatter turns that into readable lines; the result goes to logs/ralph_*.log and /dev/tty via tee. With streaming on, the script pseudo-tty wrapper is skipped (it can mangle JSON lines). Set RALPH_NO_STREAM=1 to fall back to plain -p (quiet until each pass ends). RALPH_NO_PTY=1 still applies when streaming is off. GNU stdbuf / gstdbuf on PATH helps line-buffering when script is used.
- If the final result contains an exact
<promise>ALL_DONE</promise>line → exit (no incomplete specs). - If the final result contains an exact
<promise>DONE</promise>line → wait a second, start the next iteration (fresh context). - Otherwise → print a hint and retry after a short pause (treat as "not done yet").
The script validates exact final-result markers; it does not run your tests for
you. You wire specs, RALPH.md, and MCP (e.g. Playwright) so "continue the
loop" really means "verification passed."
Mental checklist
- Feedback loops: no trustworthy Ralph without automated proof, tests, build, e2e, or MCP-driven checks as your stack allows.
- Branch: use a feature branch, not unreviewed churn on
main. The script tells the agent never togit push, only you publish after review. - Auto mode: confirm you meet Auto mode requirements; otherwise set
PERM_MODE. - Specs: every spec should name concrete verification commands (and UI/e2e when relevant).
- Trust but verify: read
git diffand log output for the commands the model claimed it ran; the done token is only a signal, not proof it told the truth.
What is Ralph Wiggum?
The Ralph loop pattern, outer bash automation, spec-driven tasks, fresh context each iteration, and a model-emitted completion sentinel.
Practicing Ralph loops
Hands-on exercise, multi-phase Snippet Vault API using Ralph, with Node or .NET 10+ paths, specs, and verification gates.