Vibe Coding for Production
Scaling automationRalph Loops

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

PathPurpose
specs/*.mdOne 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.shThe 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 logs
mkdir -p specs scripts logs
New-Item -ItemType Directory -Force -Path specs, scripts, logs | Out-Null

Download 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.sh
chmod +x scripts/ralph-loop.sh

chmod 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: COMPLETE

The 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 remains

Git 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 12

Each 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 to git 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 diff and log output for the commands the model claimed it ran; the done token is only a signal, not proof it told the truth.

On this page