# Stop Prompting And Start Looping. A Claude Code Engineer’s Guide to /goal and /loop

> Source: <https://pub.towardsai.net/stop-prompting-and-start-looping-a-claude-code-engineers-guide-to-goal-and-loop-e7662aaaafd6?source=rss----98111c9905da---4>
> Published: 2026-07-09 13:31:01+00:00

TL;DR:/goal gives Claude Code a finish line and a second model to check if it’s been crossed - Claude keeps working autonomously until the condition is provably met. /loop runs a prompt on a timer, polling on a schedule without you present. Together they are the foundation of loop engineering. The paradigm shift Boris Cherny, creator of Claude Code, described in Jun 2026 as :This article covers both commands from first principles, and also practical demonstration by building loops on simple application end to end.“I don’t prompt Claude anymore. I have loops running that prompt Claude and figure out what to do. My job is to write loops.”

For most of Claude Code’s user, the interaction model looked like this :

Here if you observe carefully, you were the loop. Every iteration required your attention. You read the output, decided if it was good enough, and either approved or corrected. Claude was fast, but you were still the bottleneck.

In June’2026, Boris Cherny the creator and head of Claude Code at Anthropic described how his own workflow had changed he said that *“I don’t prompt Claude anymore. I have loops running that prompt Claude and figuring out what to do. My job is to write loops.”*

Google’s Addy Osmani put a name to it : **Loop Engineering**

The shift is conceptual before it’s technical. The question stops being *“how do I write a good prompt?” *and becomes *“how do I write a finish line precise enough that a machine can tell when it’s been crossed?”*

That finish line is /goal . The clock that keeps the look ticking is /loop .

Before diving into the commands, it’s worth understanding what Claude Code is doing under the hood, because both commands are built on top of it.

The official docs describe it simply :

“When you start an agent, the SDK runs the same execution loop that powers Claude Code : Claude evaluates your prompt, calls tools to take action, receives the results, and repeats until the task is complete.”

In each full cycle, claude produces output, tools execute, results feedback is one turn. A simple question might take 1–2 turns. A complex refactor can chain dozens.

The loop runs until claude produces a text-only responses with no tool calls. That’s the natural stopping point. But ** “Claude decides it’s done” **is a problem in this process : without an external checks, Claude is grading its own homework.

And /goal solves this at the architecture level.

The official definition from the Claude code docs :

"/goal works by wrapping a session-scoped prompt-based Stop hook. Each time Claude finishes a turn, the condition and the conversation so far are sent to a small fast model which defaults to Haiku which returns a yes-or-no decision and a short reason. A 'no' tells Claude to keep working and includes the reason as guidance for the next turn. A 'yes' clears the goal and records an achieved entry in the transcript.”

Let that sink in. There are now **two models running:**

The evaluator model does not call tools. It can only judge what claude has already surfaced in the conversation. This is the key architectural insight : the checker is intentionally blind to the codebase and it can only verify what Claude has demonstrated, which forces claude to surface proof of completion, not just claim it.

The /goal Command Interface

```
/goal <condition>          # Set or replace the active goal (starts immediately)/goal                      # Show status: condition, turns, tokens, last evaluator reason/goal clear                # Clear the goal (aliases: stop, off, reset, none, cancel)
# Run non-interactively (completes in a single invocation)claude -p "/goal <condition>"
# For unattended runs, pair with auto modeclaude --auto -p "/goal all tests in @src/ pass and coverage is above 80%"
```

**Requirements:**

One goal per session. A new /goal replaces the current one. The condition can be up to 4,000 characters.

This is the skill. The official docs are direct about it:

Bad : ‘improve the dashboard.’

Good : ‘one measurable end state, a stated check (how Claude proves it), constraints that must hold, and optionally a turn or time limit.’

The evaluator model can only read the conversation. So your condition must be written as something Claude can demonstrate in conversation, not just claim.

**The four components of a good condition examples:**

**Bad Conditions (vague, unmeasurable):**

```
❌ "Fix the bugs in the code"❌ "Improve code quality"❌ "Make the tests better"❌ "Clean up the auth module"
```

**Good conditions (verifiable by a second model reading the transcript):**

```
✓ "pytest src/ exits with code 0. Claude must run pytest and    show the full output. No files in tests/ are modified.    Stop after 25 turns."✓ "CHANGELOG.md has an entry for every PR merged this week.    Claude must run git log --oneline and show which PRs were    found, then show the CHANGELOG diff. Stop after 10 turns."✓ "All TODO comments in src/ are resolved. Claude must run    grep -r TODO src/ and show it returns no results.    Stop after 15 turns."
```

Notice the pattern : you’re not just describing the end state, you are specifying the ** evidence **the evaluator will look for in the conversation.

Our counterintuitive insight from practitioners : running /goal with a weaker main model often demonstrates the loop’s power more clearly than using Opus also it help us to manage token expenditure.

When Opus runs /goal , it often solves problems in one or two turns. The loop barely shows. When Haiku or Sonnet runs the same goal on a hard problem, you see the loop in action: tying an approach, getting the evaluator’s “no+reason”, trying again differently, spawning subagents, brute-forcing search strategies. The same goal, the same commands, but the loop’s autonomous persistence becomes visible.

For production, use the best model for the task but of course using best model as evaluator come with a some cost attached to it. For learning the system, deliberately run a medium-difficulty problem with a mid-tier model.

/loog and /goal are often mentioned together, but they solve different problems:

The official docs describe /loop precisely:

“/loop runs on a specified interval; without an interval,it self-paces based on output.”

**The command interface:**

```
/loop 30m                  # Run the current prompt every 30 minutes/loop 1h                   # Run every hour/loop                      # Self-paced: re-runs as soon as previous run completes/loop stop                 # Stop the active loop
```

/loop is designed for recurring work that arrives on a schedule; the kind of task where there is no single “done” state, just on ongoing stream:

Now, you might have question that then why not to run corn job rather spending money on LLM models. But there is huge difference from a corn job, a corn job runs a fixed script. /loop runs a model that reads the current state and decides what to do next. The action adapts to the situation.

The real architecture emerges when you combine them:

/loop provides the cadence. /goal handles completion logic for each unit or work. Together they form a continuous, self-driven engineering agent.

The Claude code team’s own documentation describes this combination directly for handling incoming feedback:

“Use /schedule to run a routine that checks for new reports, /goal to define what done looks like, and auto mode so the routine runs without stopping to ask for permissions.”

**What it is:** A cycle where AI acts on user inputs, continuing until the conversation ends. This is every standard Claude Code session you’ve already run.

**How it starts:** A standard user prompt. **How it ends:** You close the chat, go idle, or switch to a new topic. **Best for:** General queries, exploratory dialogue, basic assistance anything where you want to stay in the driver’s seat.

This is where most engineers live today. It’s useful, but you are the loop. Every iteration costs your attention.

**What it is:** A continuous cycle focused on achieving a specific, user-defined objective. Claude doesn’t stop until the goal is provably met verified by a separate evaluator model.

**How it starts:** Give a clear, verifiable objective “all tests pass,” “no linter errors,” “CHANGELOG updated.” **How it ends:** The evaluator model confirms the condition is met, or you explicitly cancel. **Best for:** Project management, fixing bugs, creating complex reports, iterative design any task with a checkable finish line.

This is /goal. The evaluator is the key innovation: it separates "doing the work" from "deciding if the work is done."

**What it is:** Actions executed automatically at predefined times, dates or intervals regardless of what was done in the previous run.

**How it starts:** Provide a schedule *“every 30 minutes,” “daily at 9 AM,” “every Tuesday.” ***How it ends:** Stop instructions given, duration expires, or schedule removed. **Best for:** Scheduled reporting, health monitoring, reminders, time-sensitive updates for recurring work that doesn’t have a single “done” state.

This is /loop. It's the clock layer. The work repeats; only the schedule determines cadence.

**What it is:** AI initiates actions based on detected triggers or changes in external conditions. The AI is watching, not waiting.

**How it starts:** Set conditions *“notify me if the CI fails,” “respond when a PR is opened,” “alert if error rate exceeds 5%.” ***How it ends:** You stop monitoring or the trigger condition is permanently resolved. **Best for:** Opportunity detection, threat monitoring, automated incident response, event-driven workflows.

This is implemented via **Claude Code Routines **GitHub event triggers, webhooks, and MCP connectors that fire when something changes externally.

The progression is a maturity ladder. Most engineers start at Turn-Based and never move. Loop engineering means deliberately choosing Goal-Based or Time-Based for the right tasks — and building Proactive loops for the ones that shouldn’t need human initiation at all.

Theory without demonstration is incomplete. Let’s build something real.

Project : A Python data processing module with intentionally broken code. We use /goal to autonomously fix all failing tests without touching the test files. Then we add /loop to monitor the suite on a recurring schedule.

This project is designed to be reproducible or you can clone it, break the code and run the exact same commands to watch the loop work.

```
# create a directory and change to itmkdir self-healing-suite && cd self-healing-suite# Initialize git in same directorygit init# create a virtual environment & activate itpython -m venv .venv && source .venv/bin/activate# install pytest environmentpip install pytest pytest-cov
```

Directory structure we’re building:

```
self-healing-suite/├── .claude/│   ├── settings.json       ← Hook: block test file modifications│   └── commands/│       ├── goal.md         ← /goal command template│       └── loop.md         ← /loop command template├── src/│   ├── __init__.py│   ├── data_cleaner.py     ← 4 intentional bugs live here│   └── stats_engine.py     ← 2 more bugs here├── tests/│   ├── __init__.py│   ├── test_data_cleaner.py│   └── test_stats_engine.py├── CLAUDE.md               ← Project rules└── pytest.ini
```

**src/data_cleaner.py** four bugs planted:

```
# src/data_cleaner.py"""Data cleaning utilities for ingestion pipelines.Bug count: 4 (deliberately introduced for the Self-Healing Suite demo)"""from typing import List, Optionalimport redef remove_duplicates(records: List[dict]) -> List[dict]:    """Remove duplicate records based on 'id' field."""    seen = set()    result = []    for record in records:        # BUG 1: should be record["id"], not record.get("name")        key = record.get("name")        if key not in seen:            seen.add(key)            result.append(record)    return resultdef normalize_email(email: str) -> str:    """Lowercase and strip whitespace from email."""    if not isinstance(email, str):        raise TypeError("email must be a string")    # BUG 2: strip() removes whitespace, but we also need to lowercase    # Missing: .lower()    return email.strip()def validate_phone(phone: str) -> bool:    """Return True if phone matches E.164 format (+1234567890)."""    # BUG 3: Pattern is wrong - \d{10,14} should be \d{7,14}    # (E.164 allows 7–15 digits total; the + counts as 1 char)    pattern = r"^\+\d{10,14}$"    return bool(re.match(pattern, phone))def fill_missing(    records: List[dict],    field: str,    default_value) -> List[dict]:    """Fill missing or None values for a given field with default_value."""    result = []    for record in records:        new_record = record.copy()        # BUG 4: condition is inverted - should be `if field not in new_record`        # Currently fills records that HAVE the field, ignoring ones that don't        if field in new_record and new_record[field] is None:            new_record[field] = default_value        result.append(new_record)    return result
```

**src/stats_engine.py** Two more bugs planted in src/stats_engine.py file:

```
# src/stats_engine.py"""Statistical aggregation engine.Bug count: 2 (deliberately introduced for the Self-Healing Suite demo)"""from typing import List, Optionalfrom statistics import median, StatisticsErrordef compute_mean(values: List[float]) -> Optional[float]:    """Return the arithmetic mean, or None for empty input."""    if not values:        return None    # BUG 5: Integer division - should be / not //    return sum(values) // len(values)def compute_median(values: List[float]) -> Optional[float]:    """Return the median, or None for empty input."""    if not values:        return None    return median(values)def bucket_by_percentile(    values: List[float],    buckets: int = 4) -> List[List[float]]:    """Split sorted values into equal-sized percentile buckets."""    if not values:        return []    sorted_vals = sorted(values)    bucket_size = len(sorted_vals) // buckets    if bucket_size == 0:        return [sorted_vals]    result = []    for i in range(buckets):        start = i * bucket_size        # BUG 6: Last bucket misses remainder - should use None slice for last bucket        end = (i + 1) * bucket_size        result.append(sorted_vals[start:end])    return result
```

**tests/test_data_cleaner.py:**

```
# tests/test_data_cleaner.pyimport pytestfrom src.data_cleaner import (    remove_duplicates,    normalize_email,    validate_phone,    fill_missing,)class TestRemoveDuplicates:    def test_removes_by_id_field(self):        records = [            {"id": 1, "name": "Alice"},            {"id": 1, "name": "Alice"},  # duplicate id            {"id": 2, "name": "Bob"},        ]        result = remove_duplicates(records)        assert len(result) == 2        assert result[0]["id"] == 1        assert result[1]["id"] == 2    def test_empty_list(self):        assert remove_duplicates([]) == []    def test_no_duplicates(self):        records = [{"id": i} for i in range(5)]        assert len(remove_duplicates(records)) == 5class TestNormalizeEmail:    def test_lowercases_email(self):        assert normalize_email("USER@EXAMPLE.COM") == "user@example.com"    def test_strips_whitespace(self):        assert normalize_email("  user@example.com  ") == "user@example.com"    def test_combined(self):        assert normalize_email("  USER@EXAMPLE.COM  ") == "user@example.com"    def test_raises_on_non_string(self):        with pytest.raises(TypeError):            normalize_email(123)class TestValidatePhone:    def test_valid_us_number(self):        assert validate_phone("+12125551234") is True    def test_valid_short_number(self):        # 7-digit number with country code is valid E.164        assert validate_phone("+1234567") is True    def test_invalid_no_plus(self):        assert validate_phone("12125551234") is False    def test_invalid_too_short(self):        assert validate_phone("+123") is Falseclass TestFillMissing:    def test_fills_absent_field(self):        records = [{"id": 1}, {"id": 2, "score": 5}]        result = fill_missing(records, "score", 0)        assert result[0]["score"] == 0        assert result[1]["score"] == 5    def test_fills_none_value(self):        records = [{"id": 1, "score": None}]        result = fill_missing(records, "score", 0)        assert result[0]["score"] == 0    def test_does_not_overwrite_existing(self):        records = [{"id": 1, "score": 42}]        result = fill_missing(records, "score", 0)        assert result[0]["score"] == 42    def test_empty_list(self):        assert fill_missing([], "score", 0) == []
```

**tests/test_stats_engine.py:**

``` python
# tests/test_stats_engine.pyimport pytestfrom src.stats_engine import compute_mean, compute_median, bucket_by_percentileclass TestComputeMean:    def test_integer_values(self):        assert compute_mean([1, 2, 3]) == 2.0    def test_float_precision(self):        # Integer division would return 1, not 1.5        assert compute_mean([1, 2]) == 1.5    def test_single_value(self):        assert compute_mean([5]) == 5.0    def test_empty_returns_none(self):        assert compute_mean([]) is Noneclass TestComputeMedian:    def test_odd_count(self):        assert compute_median([1, 3, 5]) == 3    def test_even_count(self):        assert compute_median([1, 2, 3, 4]) == 2.5    def test_empty_returns_none(self):        assert compute_median([]) is Noneclass TestBucketByPercentile:    def test_four_equal_buckets(self):        values = list(range(8))  # [0,1,2,3,4,5,6,7]        result = bucket_by_percentile(values, buckets=4)        assert len(result) == 4        # All 8 values should be present        flat = [v for bucket in result for v in bucket]        assert sorted(flat) == values    def test_remainder_in_last_bucket(self):        # 10 values, 4 buckets: last bucket should have 4 items (2+2+2+4)        values = list(range(10))        result = bucket_by_percentile(values, buckets=4)        flat = [v for bucket in result for v in bucket]        assert sorted(flat) == values  # No values lost    def test_empty_input(self):        assert bucket_by_percentile([]) == []    def test_fewer_values_than_buckets(self):        result = bucket_by_percentile([1, 2], buckets=4)        assert result == [[1, 2]]  # Falls back to single bucket
```

A critical constraint of the demo: Claude must fix bugs in src/ only. The tests define the specification they are immutable.

**.claude/settings.json:**

```
{  "hooks": {    "PreToolUse": [      {        "matcher": "Edit|Write",        "hooks": [          {            "type": "command",            "command": "python3 -c \"\nimport json, sys\ndata = json.load(sys.stdin)\npath = data.get('tool_input', {}).get('file_path', '')\nif 'tests/' in path:\n    print(f'BLOCKED: {path} is a test file. Fix src/ only.', file=sys.stderr)\n    sys.exit(2)\nsys.exit(0)\n\""          }        ]      }    ]  }}
```

This is real policy enforcement. If Claude tries to modify a test file to make tests pass (a known shortcut LLMs attempt), the hook blocks the write at the system level and feeds the reason back to Claude.

**pytest.ini:**

```
[pytest]testpaths = testspython_files = test_*.pypython_classes = Test*python_functions = test_*addopts = -v --tb=short
```

Before running /goal, confirm the suite is broken:

**Expected output:**

Seven failures. Two source files. Six bugs. Let’s let /goal fix them.

Open Claude Code in your project directory:

```
cd self-healing-suiteclaude
```

Now set the goal:

**what happens next - a turn-by-turn trace:**

**Turn 1 :** Claude runs /pytest test/ -v and reads the all failures. It identifies the failure patterns and starts with test_removes_by_id_field .

**Turn 2 : **Claude reads src/data_cleaner.py , finds Bug 1 (record.get("name") → record.get("id")), edits the file, re-runs pytest. 6 failures remain.

**Turn 3 :** The evaluator checks the conversation. Reads: “6 tests still failing.” Returns: *“No. Tests still failing: 6 remaining. Continue.”* Claude continues.

**Turn 4 :** Claude tackles Bug 2 (normalize_email missing .lower()), fixes it, re-runs. 5 failures remain.

**Turn 5–7:** Bugs 3, 4, 5 fall in sequence. Each time Claude runs pytest and shows the output.

**Turn 8:** Bug 6 the bucket_by_percentile remainder bug. Claude rewrites the slice logic to use None for the final bucket. Runs pytest.

**Turn 9:** pytest tests/ -v26 passed, 0 failed. Claude shows the full output.

**Turn 10:** Evaluator reads the conversation. Sees the full pytest output with “26 passed.” Returns: *“Yes. All 26 tests pass. Goal achieved.”*

**Session ends.** You didn’t type a single thing after /goal.

The project has clean tests. Add /loop to keep it that way.

**Setup : Create a monitoring command.**

.claude/commands/monitor.md:

The project now has clean tests. Add /loop to keep it that way.

**Setup: Create a monitoring command**

.claude/commands/monitor.md:

**Running the monitor loop:**

```
# Monitor the test suite every 30 minutes/loop 5m /monitor
```

Or from a non-interactive terminal (useful for CI integration):

```
# Run continuously, log to fileclaude -p "/loop 30m /monitor" >> .claude/monitor.log 2>&1 &
```

This is how the loop will work, in our example it’ll start after every 5m and run the test validation.

Just for demo purpose I’m changing src/data_clearner.py and again planting same bugs.

**The health log it produces** (.claude/health.log):

```
[2026-07-09 13:23:36] STATUS: healthy TESTS: 26 passed/26 total[2026-07-09 13:28:26] STATUS: healthy TESTS: 26 passed/26 total[2026-07-09 13:33:18] STATUS: healthy TESTS: 26 passed/26 total[2026-07-09 13:38:51] STATUS: fixed TESTS: 26 passed/26 total[2026-07-09 14:30:58] STATUS: healthy TESTS: 26 passed/26 total
```

*The mini project (**self-healing-suite) is designed to be cloned and run. Every bug in this article is a real, valid Python bug that produces a real test failure. The fix requires understanding the code, not guessing which is exactly what makes it a good test for autonomous loop reasoning.*

The complete self-healing system for this project:

This is functioning, autonomous test maintenance system. The only time a human needs to look at it is when the system explicitly flags needs_human which happens when the bug is architectural, not cosmetic.

The XDA field guide on /goal captured the single most important insight from practitioners:

“If you can’t say what ‘done’ looks like, you don’t have a loop. You have a wish.”

Three principles that separate working /goal deployments from frustrating ones:

The test: if there’s a moment when *“it’s done” *can be verified programmatically, use /goal . If the work never ends and just repeats, use /loop

That’s loop engineering. One finish line. No babysitting.

/goal and /loop are powerful precisely because they run autonomously. That autonomy is also their risk. Token costs in agent loops compound differently than single-turn calls and an unattended loop without circuit breakers is a machine that ships bugs with high confidence and runs up your bill while doing it.

The next article in this series covers production-safe loop design: how to set max_budget_usd, when to use max_turns, the maker/checker pattern with separate verifier sub-agents, and how to design stopping criteria that prevent the four most common loop failure modes.

If you’ve ever woken up to a surprise API bill, that article is for you.

*Follow me for more practical engineering takes & usage of AI systems.*

[Stop Prompting And Start Looping. A Claude Code Engineer’s Guide to /goal and /loop](https://pub.towardsai.net/stop-prompting-and-start-looping-a-claude-code-engineers-guide-to-goal-and-loop-e7662aaaafd6) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
