# Loops in Claude Code

Run a flaky-test goal loop with /goal and a counting Stop hook, and a nightly triage loop as a routine, in ticket-service.

Source: https://ai-sw-factory.mellicci.dev/fundamentals/loops/claude-code

## At a glance

| | Claude Code |
|---|---|
| Term | `/goal` (goal loop); `/loop`, routines, Desktop scheduled tasks (recurring); `Stop` hook (custom loop control) |
| Configured in | A slash command in the session; `hooks` in `.claude/settings.json`; optional `.claude/loop.md`; routines at claude.ai/code/routines or `/schedule` |
| Loads / runs | `/goal` and `Stop` hooks fire after every turn; `/loop` fires between turns; routines and Desktop tasks start a new session per run |
| Scope & precedence | `/goal` is per session, one at a time (a new one replaces the old); hooks follow settings scopes; a routine belongs to your claude.ai account |

## Build the scenario

| Step | What you write or run | Where |
|---|---|---|
| 1 | Write the gate script and register it as a `Stop` hook | `.claude/hooks/flaky-gate.sh`, `.claude/settings.json` |
| 2 | Raise the continuation cap above your round limit: `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=12` | Shell |
| 3 | `touch .agent/loop-active`, then start the goal in auto mode | Claude Code session |
| 4 | Create the nightly routine | `/schedule` |

**Goal loop.** A small model judges the `/goal` condition from what Claude shows in the conversation; it runs no commands itself.

```text
/goal pnpm test exits 0 on 3 consecutive runs, each shown in your output.
After every round append findings to .agent/loop-notes.md and commit.
Stop after 10 rounds or 2 hours, and report if you stop early.
```

The 10-round and 2-hour clauses are judged, not enforced. The enforced cap is a `Stop` hook that blocks stopping until the check passes and counts rounds in `.agent/loop-round`:

```bash
#!/bin/bash
# .claude/hooks/flaky-gate.sh  (Stop hook, no matcher)
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -f .agent/loop-active ] || exit 0          # only while armed
f=.agent/loop-round; n=$(( $(cat $f 2>/dev/null || echo 0) + 1 )); echo $n > $f
if [ "$n" -gt 10 ]; then echo "Round cap hit: stop and report." >&2; exit 0; fi
for i in 1 2 3; do
  pnpm test >/dev/null 2>&1 || {
    echo "Round $n/10: run $i of pnpm test failed. Fix, log in .agent/loop-notes.md, commit." >&2
    exit 2; }
done
rm -f .agent/loop-active $f                   # success: disarm, reset
```

Register it under `hooks.Stop` with `"type": "command"`, `"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/flaky-gate.sh"`, `"args": []` and a `"timeout"` long enough for three test runs. Exit 2 blocks the stop, and stderr becomes Claude's next instruction.

**Nightly loop.** Run `/schedule nightly at 2am: triage new failing tests`, with a prompt that makes the issue the state:

```text
Run pnpm test. Find the open issue labeled flaky; create it if missing.
Read its body for known flaky tests. Add newly failing tests with the
failing commit and error. Edit that issue; never open a second one.
```

Each run is a fresh session, so the issue is the only memory. A routine's GitHub access is your connected identity and connectors; the documentation doesn't specify which issue tooling the cloud session has.

## Specifics

### Goal loops

| | `/goal <condition>` | `Stop` hook | `claude -p "/goal ..."` |
|---|---|---|---|
| Start | Type it; a turn starts at once | Register in settings; applies to every session in scope | Shell; runs to completion in one call |
| Decides "done" | Small model (Haiku by default) reads condition and transcript | Your script or prompt | Same as `/goal` |
| Runs | Local session, desktop app, Remote Control | Wherever the hook is loaded | Local shell or CI |
| Limit | Condition up to 4,000 characters; one goal per session | Your logic | `--max-turns`, `--max-budget-usd` |

`/goal` is a wrapper around a session-scoped prompt-based `Stop` hook. In Manual permission mode goal turns still prompt, so use auto mode to run unattended.

### Stop conditions and limits

| Stop | Enforced by | Documented detail |
|---|---|---|
| Condition met | Evaluator | Goal clears; achieved entry in transcript |
| Judged impossible | Evaluator | Goal clears; failure and reason recorded |
| Round or time cap | You | Text in the condition (judged) or a counter in a hook (enforced) |
| Hook continuation cap | Harness | 8 consecutive continuations, then the turn ends; `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` changes it, `0` disables |
| Unrecoverable error | Harness | Auth failure, exhausted credits, context overflow or unavailable model clears the goal |
| Human stop | You | `/goal clear` (aliases `stop`, `off`, `cancel`); Ctrl+C in `-p` |
| Print-mode caps | Harness | `--max-turns N` exits with an error; `--max-budget-usd` counts subagent spend |

The documentation doesn't specify a built-in iteration cap or token budget for interactive `/goal`, nor how `--max-turns` interacts with a `/goal` run.

### Progress between iterations

| Mechanism | What persists | Notes |
|---|---|---|
| Same session | Conversation, with auto-compaction | The evaluator's reason guides the next turn |
| Files and commits | `.agent/loop-notes.md`, git history | You instruct it; the loop doesn't write them |
| Resume | Goal restored by `--continue` and `--resume` | Turn count, timer and token baseline reset |
| Status | `/goal` with no argument | Condition, time, turns, tokens, last reason |

### Recurring and scheduled runs

| Feature | Start | Runs where | Min interval | Stops / limits |
|---|---|---|---|---|
| `/loop 30m <prompt>` | Slash command | Your open session | 1 min | Esc (self-paced), `CronDelete`; expires after 7 days; 50 tasks per session |
| Routine (research preview) | `/schedule` or web form | Anthropic cloud, fresh clone | 1 hour | On/off switch or delete; 100 scheduled runs per hour per account |
| Desktop scheduled task | Routines page, **Local** | Your machine, app open and awake | 1 min | One catch-up run after downtime; optional worktree per run |
| `claude -p` in CI | Your CI's `schedule` trigger | CI runner | Your CI's | `--max-turns`, `--max-budget-usd`; add `--bare` |

Routines push to `claude/` branches, and every run is a new session. A self-paced `/loop` picks a delay of 1 minute to 1 hour and isn't restored on resume. `.claude/loop.md` replaces the default prompt of a bare `/loop`.

### Hooks as loop control

| Item | Behavior |
|---|---|
| Events | `Stop` (main agent finishes) and `SubagentStop` (subagent finishes) |
| Continue | Exit 2 with stderr, or JSON `{"decision":"block","reason":"..."}` |
| Soft feedback | `hookSpecificOutput.additionalContext` continues without a hook-error label |
| Allow stop | Exit 0, or omit `decision` |
| Input | `stop_hook_active`, `last_assistant_message`, `background_tasks`, `session_crons` |
| Prompt hook | Returns `ok` and `reason`; `impossible: true` allows the stop |
| Cap | 8 consecutive blocks, then Claude Code overrides the hook |

## Gotchas

- Hooks apply to every session in their scope. Without a guard like `.agent/loop-active`, the gate also blocks unrelated sessions.
- `/goal` can only judge what appears in the transcript. "Tests pass" holds only if Claude actually runs the tests and shows the result.
- The hook's three `pnpm test` runs must fit its `timeout` (default 600 seconds for commands).
- Session-scoped `/loop` tasks fire only while Claude Code is running and idle, with up to 30 minutes of jitter on recurring tasks. Missed fires aren't replayed.
- `claude -p` shows no trust dialog, so hooks committed in the repository run without consent. Review them, or add `--bare`.
