Loop engineering is a control problem
· 20 min read
Kyle Mistele opens his AI Engineer World's Fair talk with a picture every team using coding agents will recognise: a 40,000-line pull request, surrounded by review agents, that no human wants to read. Both halves deserve to be taken seriously. The 40,000 lines really do exist – that is a year of genuine engineering progress in agents and harnesses. And nobody can review them, so whatever is wrong in there is wrong quietly.
The hype compresses the current mood into three lines: build loops, spend as many tokens as possible, don't read the code. I have no problem with the first two. This post is about why the third one fails, and what the version that works in a team looks like. We recorded a (Hungarian) Letscode.hu episode about it; this is the written version of my notes, built mostly on Mistele's talk, Geoffrey Huntley's original Ralph post, HumanLayer's write-ups and the Bun team's Rust rewrite.
The thesis: loop engineering is not a paradigm shift, it is a control problem. It works where the desired end state is measurable, each step is small and reversible, and a human stays on the loop. Where those are missing, a loop doesn't speed you up – it manufactures a time bomb, with interest.
Three things people call a loop
Three different things currently share one name. If you don't pull them apart, every conversation about "loops" blurs.
- The agentic loop is the inner loop, and it is always running. Inside Claude Code, Codex, Copilot CLI or any other agent the same cycle turns: gather context → act → verify, and on failure go around again with the new information. That isn't a technique, it is the agent. "I use an agentic loop" means "I use an agent".
- The Ralph loop is an outer loop: keep re-running the agent with the same prompt until the result matches what you asked for. Geoffrey Huntley published it in July 2025, and the original implementation is one line of bash.
- The control loop is not an AI concept at all. It is control engineering: measure, compare with a target, correct a little, measure again.
# Geoffrey Huntley's original, July 2025
while :; do cat PROMPT.md | claude-code ; donePractical Ralph setups add guard rails – this one is from Matt Pocock's getting-started guide, lightly trimmed:
# afk-ralph.sh <iterations>
for ((i=1; i<=$1; i++)); do
result=$(docker sandbox run claude --permission-mode acceptEdits -p "@PRD.md @progress.txt \
1. Find the highest-priority task and implement it. \
2. Run your tests and type checks. \
3. Update the PRD with what was done. \
4. Append your progress to progress.txt. \
5. Commit your changes. \
ONLY WORK ON A SINGLE TASK. \
If the PRD is complete, output <promise>COMPLETE</promise>.")
if [[ "$result" == *"<promise>COMPLETE</promise>"* ]]; then
echo "PRD complete after $i iterations."
exit 0
fi
doneEach detail is there for a reason. The sandbox, because this runs unattended with edit permissions. The iteration cap is a cost limit, not elegance. ONLY WORK ON A SINGLE TASK is the only defence against scope creep. And the stop condition is a string match on something the agent itself prints. Remember that last one.
The control loop, with or without AI
A control loop has six parts, and all six matter: a set point (the desired state), a sensor that measures where you are, the difference between the two – the error, meaning deviation rather than bug – a controller that decides what and how much to change right now, an actuator that applies the change, and the part people forget: disturbances, because the system keeps living while you correct it.
Software is already full of these. We just never called it loop engineering, because there was no AI in it:
| Thermostat | Kubernetes ReplicaSet | Agentic control loop | |
|---|---|---|---|
| Set point | 21 °C | replicas: 3 | "0 legacy .handler() procedures" |
| Sensor | thermometer | controller watching observed state in the API server | ast-grep scan of the repo |
| Error | 2 °C too cold | 2 pods running, 3 wanted | 150 violations |
| Controller | how long to heat | ReplicaSet controller: create 1 pod | pick 1 violation for this run |
| Actuator | boiler | scheduler + kubelet start the container | coding agent + skill → a pull request |
| Disturbance | open window | a node dies | teammates merging other work |
Terraform's plan/apply, PostgreSQL's autovacuum and React's reconciliation follow or approximate the same shape. Which answers the sceptical question – "aren't these loops just workflows that now have agents in them?" – with: yes, and that is exactly why this isn't a paradigm shift.
The architecture is fifty years old. The one new thing is that the actuator became general-purpose: until now, a step in the loop had to be something you could write as code. Now it can be anything you can describe in text. That is a big deal – but the architecture stayed.
Ralph loop vs. control loop
Put side by side, the two loops differ in one place that decides everything else: who measures.
If the sensor and the actuator are the same model, you aren't measuring – you are fooling yourself. The autonomous-codebase literature calls it evaluation gaming: a self-grading system reports success without quality. It is the old mistake of optimising for your own tests, except the tests are rewritten every iteration too.
| Ralph loop | Control loop | |
|---|---|---|
| Who measures | the model, itself | an external, independent sensor |
| End state | "the PRD looks done to me" | a machine-checkable assertion |
| Stop condition | string match + iteration cap | the measured error reaches zero |
| Step size | whatever the agent feels like | decided by the controller – a tuning parameter |
| Disturbances | assumes it owns the repo | built in: the team's ongoing work |
| When it is wrong | you find out at the end | every iteration, in a small PR |
| Worst case | overbaking, silent drift | no-op: nothing happens |
That last row is underrated. A control loop with nothing to correct does nothing. Ralph has no such mode – it always does something, because that is its job. Huntley describes Ralph's output as under-baked, baked, or "baked with unspecified latent behaviours": an agent that cannot tell when it is finished will find itself plausible-sounding work nobody asked for.
This is not a contest
Ralph discovered something real – just not what most people think. The trick was never the infinite loop. When an official Ralph plugin shipped in December 2025, Dex Horthy's critique was that it "misses the key point of ralph which is not 'run forever' but in 'carve off small bits of work into independent context windows'". Ralph doesn't scale with time, it scales with decomposition: every iteration gets a small, clean context and reads what it needs from files, instead of dragging thirty steps of debris along.
From here the control loop is one step away. If the work has to be carved into small independent pieces anyway, something must decide which piece is next. In Ralph the agent decides, by instinct, again in every iteration. In a control loop a separate component decides, from outside, based on measured data – that is the controller. Mistele says as much himself: the best Ralph implementations already apply control principles.
The control loop doesn't refute Ralph. It finishes it. A Ralph loop is a while; a control loop is a regulator. One hopes, the other measures.Why the steps are small
"One unit per run" is not there because the agent couldn't handle more. How much to change per iteration is the controller's core job, and anyone who has tuned a PID controller knows both failure modes: correct too timidly and you never arrive; correct too aggressively and the system overshoots and oscillates instead of converging. Mistele makes the same point about code: repeated oversized or incorrect corrections quickly destabilise a codebase.
HumanLayer's own history has a data point for the upper lane. One early Ralph experiment: 30 minutes writing a coding-standards document, 30 more refining it with a senior engineer, then a 6-hour autonomous refactor. The team liked the result – and the PR was never merged, because of conflicts. What worked better for them afterwards was a nightly run that lands one small refactor each morning. The realistic multiplier is also worth noting: an hour of human context-building bought six hours of machine work, not "say one sentence and it's done".
Building one: eight steps
What follows isn't a proposal. It is the system HumanLayer runs on its own production monorepo, as shown in the talk: incrementally migrating oRPC procedures to Effect, with real PRs and real review comments. The snippets are trimmed from the slides. Most people start the other way round – point an agent at something, like the result, then "automate it" – and that order is how you get the 40,000-line PR.
Build the thing that measures where you are first, and only then the thing that does the work.
Step 0 · Define the end state
One sentence that can be decided for any given file. Here: no classic .handler procedure remains in the API apps.
apiBase.procedure.handler(async () => { /* … */ }) // legacy: what the sensor looks for
apiBase.procedure.effect(function* () { /* … */ }) // end stateWhat you cannot use as an end state: "make the code better", "reduce tech debt", "make it faster". Those are directions, not set points – no sensor can measure them. If you can't say in one sentence when it's done, you don't have a loop. You just started something.
Step 1 · Build the sensor
The most important component is also the most boring, and its most surprising property is that it isn't AI. It is a rule file: one rule describes a procedure that hasn't been migrated, another one that was migrated wrong.
rules.yml
# "we haven't done this one yet": normal, it is why the loop runs
id: orpc-unmigrated-procedure
language: typescript
severity: warning
message: Classic .handler procedure, not yet migrated to Effect.
rule:
pattern: $BASE.handler($_)
---
# "we did this one wrong": an error, this stops the build
id: orpc-await-in-generator
language: typescript
severity: error
message: await is not allowed in Effect generators
rule:
pattern: $BASE.effect($_)
has: { pattern: await $_, stopBy: end }
files:
- 'apps/riptide-api/**/*.ts'
- 'apps/admin-dashboard-api/**/*.ts'
ignores:
- 'vendor/**/*'# ~50 keys per finding down to 4, in an order that never changes between runs
ast-grep scan -r rules.yml --json \
| jq '[.[] | {ruleId, file, message, severity}]
| sort_by(.severity, .file, .ruleId)'- Two severities. "Not done yet" is a warning – it is why the loop runs. "Done wrong" is an error – it stops the build. One rule file serves a process and a gatekeeper.
- Scoped.
filesandignorespin the rules to the apps being migrated. Without that the sensor measures noise, not signal. - Deterministically sorted. If the sensor returns the same findings in a different order each run, the controller picks something different every day and you will never be able to say why the loop did what it did. The
sort_byis what makes it debuggable. - Outside the agent's reach. Mistele keeps these rules out of the ESLint and TypeScript config on purpose: agents like to silence a check with an inline comment. Whatever tool you use, treat suppressions as findings too.
- Fast enough to run in every iteration. The talk's second sensor, Aiden Bai's react-doctor, scans 231 files in 5.7 seconds because it sits on a Rust linter.
react-doctor is interesting for another reason: how it words findings. Not "rule violated on line 64", but "Your users see a stale value when prop currentDefault changes because useState copies it once." That text goes straight into the agent's context, and a consequence is a much better control signal than a rule number: it tells the agent what to preserve, not only what to avoid. Sensors can also be agentic (agent + skill + natural-language rules) or hybrid – what matters is that the thing that measures is not the thing that was just asked to fix.
Step 2 · Stabilise the system: the ratchet
Before fixing anything, stop it getting worse. The goal isn't "everything is migrated today", it is "tomorrow there are no more violations than today". Commit a snapshot of the current findings – one (file, rule) key per line – and let CI compute a set difference on every pull request.
.github/workflows/no-regressions.yml (excerpt)
- name: Fail on NEW (file, rule) findings vs the main snapshot
run: |
ast-grep scan -r "$RULES" "$SCAN_PATH" --json=compact \
| jq -r '.[] | "\(.file)\t\(.ruleId)"' | sort -u > current.keys
sort -u "$BASELINE" > base.keys
new="$(comm -13 base.keys current.keys)" # only in current = newly introduced
if [ -n "$new" ]; then
echo "::error::New migration regressions introduced:"
echo "$new"
exit 1
fiIn control terms this is the disturbance dampener: the loop fixes, and the ratchet makes sure the rest of the team can't undo its progress in the meantime.
Step 3 · Build the controller
The controller answers "what, and how much, right now?" – and it can be the dumbest thing in the world.
The deterministic version – head -1 on a sorted list – contains no AI and works. The lesson generalises: not every component has to be an agent. Good systems separate the deterministic part from the part that needs judgement, and in most places deterministic is enough. One agent may even play controller and actuator in a single context window; the responsibilities stay separate, and limiting the size and direction of the change remains the controller's.
Step 4 · Build the actuator
Your coding agent, plus a skill describing how this kind of change is done here. HumanLayer doesn't try to perfect the skill upfront; they evolve it from observed results and feed it golden patterns – handwritten, idiomatic examples of what the repository expects. Two details are easy to miss. The skill ends with a final-answer template, and the agent's last message becomes the PR description – no separate summariser. And the agent commits but never merges: pushing and opening the PR are deterministic workflow steps, and the loop's output is always a pull request waiting for review.
Step 5 · Build the loop
Here it becomes almost disappointingly ordinary. Your CI already has repository access, secrets, scheduling and manual dispatch. The loop is a cron-triggered workflow that runs one sense → control → actuate iteration. No orchestration platform, no multi-agent framework.
.github/workflows/loop-effect-migration.yml (trimmed)
name: "Loop: Migrate oRPC Procedure to Effect"
on:
schedule:
- cron: '0 14 * * 1-5' # weekdays, 6am Pacific
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
iterate:
runs-on: ubuntu-latest
steps:
# flow control (step 7), checkout, install deps, create a branch
- name: Sensor - scan codebase for violations
run: ast-grep scan -r rules.yml --json > findings.json
- name: Controller - select the control signal
run: echo "CONTROL_SIGNAL=$(./run_controller.sh findings.json)" >> "$GITHUB_ENV"
- name: Actuator - apply the change with an agent
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
npx @humanlayer/cli@latest codelayer --prompt "
$(cat .agents/skills/effect-migration/SKILL.md)
$(cat .github/agent-memory/effect-migration.md)
Migrate the specified oRPC procedure to Effect:
$CONTROL_SIGNAL
" > agent_output.md
# deterministic: commit, push, open a labelled PR with agent_output.md as the bodyThe result is one pull request a day: small, readable, labelled, opened by a bot, review required. That is what works in a team – not the 40,000-line PR, but one you can read on the tram home.
Step 6 · Put a human on the loop
On the loop, not in it. HumanLayer's first version was frustrating: correcting the output meant checking out the branch, editing the skill, fixing the code, committing, pushing. The migration was small; steering the automation was the expensive part. The fix is to let the pull request be the steering wheel.
The example from the talk: a reviewer asks that successful DB lookups get logged too, "for this procedure and future ones". The agent fixes the PR and appends the lesson to a Markdown file in version control:
.github/agent-memory/effect-migration.md
## Guidance
- Keep migrations small and reviewable: migrate only one eligible procedure per run.
+ - Co-locate success logging with the DB lookup, mirroring the error path. Inside the
+ query .pipe(...), add Effect.tap(... Effect.logInfo(...) ...) alongside the
+ Effect.tapError(... logError ...) calls, rather than a separate logInfo after the query.This is where "fix the process, not the code" becomes a file operation. A review comment stops being a one-off repair and becomes a rule, so review effort amortises instead of growing linearly with the number of PRs. And because the file lives in git, the way the loop's behaviour evolves is itself reviewable and revertible.
Step 7 · Add flow control
A scheduled loop can outrun its reviewers even when every change is small – people travel, visit customers, have other work. The label gives you cheap backpressure: before checkout, before installing anything, before any tokens are spent, ask whether this loop already has an open PR.
# Scheduled runs no-op when this loop already has an open PR.
if [ "$EVENT_NAME" = "schedule" ]; then
OPEN=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open \
--label "$AGENT_LABEL" --json number --limit 1 | jq 'length')
if [ "$OPEN" -gt 0 ]; then
echo "Open PR already exists for $AGENT_LABEL; no-op."
echo "run_agent=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fiThis is a kanban WIP limit for loops. Without it you wake up to forty open PRs and the team stops; it is the difference between a loop and spam. Several loops can run side by side, each with its own label and its own limit – one for the migration, one for react-doctor findings, one for something else.
Want to go faster? The talk's own arithmetic: about 150 procedures at one per working day is roughly six months. You can raise the schedule frequency, let the controller select three or five units (ideally each migrated in its own context window), or run the workflow several times and assign each teammate one PR. The last option is the telling one:
A loop's throughput is set by review capacity, not token budget. Money for a hundred runs is useless if the team can absorb three PRs a day.
The same loop at industrial scale
The strongest real-world reference is Bun's rewrite from Zig to Rust, because it is not a demo app: 11 days in May 2026, 6,502 commits peaking at 695 per hour, about 64 parallel Claude instances across 4 worktrees, roughly $165,000 at API prices, a +1,009,272-line diff over 1,448 files, zero tests skipped or deleted, 128 bugs fixed against the previous release and a ~20% smaller binary. The viral summary – "three engineers, a year of work, done for under $200k" – has the number right. Look at what is underneath it.
The rewrite existed because of one bug class. In Jarred Sumner's write-up: use-after-free, double-free and forgot-to-free-on-an-error-path bugs are, in safe Rust, compiler errors – and "compiler errors are a better feedback loop than a style guide". AI didn't solve memory management; the type system did. The agents did the mechanical work. Map it onto Fig. 7: the compiler was the sensor, the ~16,000 compile errors after the mechanical port were the error signal, handed out crate by crate as a work queue, and the 64 agents were the actuators. Every phase repeated until its own target number was met.
The warning follows directly. If you have something rewritten into a language or framework that has no sensor for your bug class, there is nothing behind you. The rewrite is one large, unverified diff. The viral posts never mention that difference.
Ask for a review from inside the implementer's context and it will approve exactly the assumptions it wrote the code under. LLMs have the "I wrote it, let's merge" bias too; we just don't call it ego – the same context reproduces the same blind spot. Reviewers who saw only the diff caught a Box dropped while libuv still held the pointer (use-after-free), trunc() where negative timestamps needed floor(), and an eagerly evaluated unwrap_or() that panicked. No test suite catches those, and you won't spot them skimming a diff. It is the cheapest quality win in this whole post, and the review rule that came with it works without any AI: if you need a paragraph-long comment to justify why a workaround is OK, the code is wrong – fix the code.
The other principle was fixing the process that generates the code instead of hand-fixing the code. When parallel Claudes started wiping out each other's work with git stash and git reset, nobody restored files by hand; one line went into the instructions: never run a git command that doesn't commit a specific file. One process fix removes a bug class across a million generated lines; one manual fix repairs one line. But notice what that presupposes: you can only reverse-engineer the process from its output. Someone read the diff and understood why it came out wrong. If you don't read code, you have nothing to fix the process with.
Two sober footnotes. "Done" wasn't at the merge: 19 known regressions surfaced afterwards – checks that silently vanished in release builds because a Zig function had become a Rust debug_assert!, and bounds checks that Rust keeps where Zig's ReleaseFast dropped them, exposing old off-by-ones. And almost nothing that went wrong along the way was an AI problem: colliding git operations, a disk filled by four worktrees, cascading compile errors from cyclic crate dependencies, CI timeouts. Infrastructure and process, all of it.
What no sensor measures
A control loop is only as good as what it measures. These are the places I see it break – some from the sources above, some from my own work.
The sensor decides what gets optimised
Give a loop enough iterations on a TypeScript codebase and you will find any in it. Not because the model is dumb, but because when a type won't line up, any is the cheapest way out:
// Sensor: tsc --noEmit. Objective as the loop sees it: "make it compile".
const rows = (await db.query(sql)) as any; // compiles. error = 0. loop is happy.That is not a model failure, it is an objective-function failure. If your sensor is "does it compile", any is a perfect solution – it does exactly what you asked. The fix is the same as everywhere else in this post: put it in the sensor (a lint rule for explicit any, behind the ratchet) and it stops paying off.
The gap between intent and implementation
Agents do test, and when prompted they catch plenty of edge cases you wouldn't have thought of right away. But there is a class of finding only you produce while reading a diff: "fine, but this also needs X", or "it works – it's just not what I meant". That isn't a bug in the code, it is a deviation from intent, and no sensor measures it: the sensor knows the rules, not your intent. This is why you read the code. Not out of distrust, but because the diff is the only place where the gap between intent and implementation becomes visible.
The phantom security fix
The sneakiest failure class, and it isn't loop-specific. An agent works from a partial picture of the application, sees a fragment, and "handles" a security problem that doesn't exist: sanitising a field that never reaches a user, rate-limiting an internal call path, duplicating an auth check in an already protected layer, adding a defensive null check that hides a broken caller contract. Sometimes the fix is gone again an iteration later, once the context is rebuilt and the agent sees it was never viable. A loop can't catch this, because it looks at a delta per iteration and architectural coherence is not a local property – it isn't visible in any single diff, only in the whole picture. It is the same mechanism as Ralph's overbaking, and the strongest structural argument for keeping a human on the loop. A control loop helps only with what you can write down as a rule; "this doesn't fit the architecture" is not an ast-grep rule.
Subagent fan-out
Under a loop, agents spawn subagents much more eagerly, because context is tight – Fig. 5 again. Cost then scales with fan-out rather than with iterations, observability turns from one log into a hundred, and a hundred separate contexts means a hundred separate readings of your intent. Parallelism isn't free: Bun partitioned like a distributed system – separate worktrees, single-file commits, banned git commands.
When to use which
Reach for a control loop when you can tick all of these. If the first one fails, stop there.
- The end state is a machine-checkable assertion, not a direction.
- A sensor that is not the agent exists or can be written: a linter rule, a compiler, a spec validator, a test suite you trust.
- The work decomposes into small independent units, each reviewable on its own.
- Every step is reversible – a PR, not a deploy or a data migration.
- You have the review capacity to read what it produces, and someone named who owns each bot PR.
The workloads that fit are repetitive, well-defined, measurable and boring – exactly what humans do badly over months: eradicating a bad pattern, adopting a framework incrementally, keeping an API compliant with its OpenAPI spec, keeping a fork in sync with upstream, mirroring a project into another language, keeping integrations current, working down errors and alerts. It does not fit product design, greenfield architecture, anything irreversible, or "make it better".
| Solo · not business-critical | Team · production | |
|---|---|---|
| Ralph loop | genuinely good: side projects, prototypes, throwaway tools, learning | not enough: no sensor, no WIP limit, no ownership |
| Who carries the risk | you – and you decide the tolerance | someone else: the next developer, the customer |
| Disturbances | none, it's your repo | the normal operating state |
| Review | your call | mandatory – and the bottleneck |
| What you need | a sandbox and an iteration cap | sensor, ratchet, small PRs, feedback file, WIP limit, named owners |
A reality check on adoption, because the conference stage distorts it: what companies actually run today falls into two buckets – cron-triggered agents (nightly or weekly tech-debt clean-up, eval suites, suggestions) that end in a PR a developer reviews, and event-triggered agents (a ticket opens, an error arrives, customer feedback lands). Neither is new; we can just do far more useful things inside them now. If your company is on an IDE assistant plus the occasional CLI agent, you are not behind. The differences between tools are much smaller than the differences between processes: what matters is whether you have a sensor and a review, not which CLI you invoke.
Takeaways
- Name the loop. Agentic loop (always running) ≠ Ralph loop (a
whileuntil done) ≠ control loop (measured and regulated). - No sensor, no loop – only hope. Make it deterministic, fast, versioned, and not the model that writes the code.
- Ratchet first, generation second. "It must not get worse" is cheaper and more valuable than "make it go away". Adopt it even without AI.
- The controller can be
head -1. The best one works from production data. - Small PR, one unit per run, human on the loop.
- A review comment should become a rule, not a repair. That is how review cost amortises.
- WIP limit. Review capacity sets the throughput, not the token budget.
- Fix the process, not the code – which requires reading the code.
- Review adversarially, with separated context. The reviewer sees only the diff and assumes it is wrong.
Mistele's answer to the hype's three lines is the position I'd sign: build loops, read the code, preserve human ownership, maintain and improve code quality, solve hard problems in complex codebases. So the question isn't "should we use loops?" but "what can I measure?". If there is something to measure, a loop is not a risk but a tool: you have a sensor, a ratchet, small PRs, and if it goes sideways you see it tomorrow morning. If there is nothing to measure, you don't have a loop. You just started something.
I'm a realist about this, and a little critical – but it clearly can work. The Bun rewrite isn't marketing; it is a million lines and 128 fixed bugs. These processes just can't be left alone. A human still has to look in, reach in – and read.
Sources
- Kyle Mistele (HumanLayer), Loop Engineering from First Principles – AI Engineer World's Fair 2026 (video). The control-loop model, the eight steps and the Effect migration example.
- Geoffrey Huntley, Ralph Wiggum as a "software engineer" – the original Ralph loop, July 2025.
- Dex Horthy (HumanLayer), A Brief History of Ralph – the "independent context windows" reframing and the 6-hour refactor; the "dumb zone" is from his talk No Vibes Allowed.
- Jarred Sumner, Bun in Rust – the numbers, adversarial review and "fix the process".
- Matt Pocock, Getting started with Ralph – the capped, sandboxed Ralph script.
- MindStudio, What is a dark factory? – evaluation gaming and the other risks of fully autonomous codebases.
- Tools: ast-grep, react-doctor, and HumanLayer's design-control-loop skill.