You Can't Authorize Autonomy
Coding agents quit long jobs even when you authorize everything and say keep going. That leaves the one decision that matters - when to stop - inside the model that keeps quitting. Real autonomy isn't a permission you grant; it's a control loop you build around the model. A working manual.
In Opus 4.8 Would Rather Tell You It Failed, I landed on a phrase: calibrated autonomy - a model that can run longer, manage its own subagents, and tell you the truth. That's the model. This post is about the part the model can't give you: actually making it run to the end.
Every operator hits this. You give a coding agent a well-scoped but long job. You authorize everything - full permissions, skip the prompts, work autonomously until complete. It runs for an hour or two, does real work... then stops. Sometimes politely. Sometimes it asks for input it doesn't need. Sometimes it declares victory with the tests unrun. Sometimes it just invents an exit: “I've laid the groundwork,” “next steps would be,” “you may want to review.”
This is not a permission problem. It's a control-loop problem. And you can't fix it from inside the chat - telling the model “keep going” pleads with the exact faculty that keeps quitting.
Why it quits
Three failure modes, and none of them are solved by authorizing more:
- Context exhaustion. Long jobs accumulate state - files read, edits made, tests run, failures debugged. Eventually the session compacts. After compaction the agent keeps a summary, not the full working texture of the task. It loses constraints, loses sharpness, and quietly concludes “enough” has happened. This is what has come to be called context rot, and it's the real reason the ceiling sits around 1-2 hours.
- Training pressure toward stopping. These models are optimized to be cooperative assistants - keep the human in the loop, avoid damage, don't run forever. Great for chat, bad for batch work. “Let me check with you first” is often learned politeness cosplaying as judgment.
- Unreliable self-assessment. The model is not a build system. Ask it whether the job is done and it answers on vibes. It will say “done” when the real condition is “tests pass, the artifact exists, and the acceptance script returns zero.”
If the exit condition is “the agent feels finished,” you will get premature exits. Every time.
The move: externalize the control loop
You don't get autonomy by asking nicely, and you don't get it by saying “you're fully authorized.” You get it by taking the loop out of the model. The model becomes a worker. A harness owns the job: it re-invokes the worker with fresh context, checks objective progress, blocks stopping until a real done-condition is true, and carries cost limits, iteration caps, logs, and commits. The harness has to be the manager, the memory, the clock, the accountant, and the judge - because the model is none of those reliably.
Three layers do the work: an executable definition of done, a Stop hook that blocks quitting, and a loop that re-invokes on fresh context. Here's each, copy-pasteable.
Layer 1 - Make “done” executable
A natural-language goal is not a stop condition. “Refactor billing and make sure everything works” has no exit. Turn it into a script the shell can judge:
#!/usr/bin/env bash
# scripts/done_check.sh -- the ONLY arbiter of "finished"
set -euo pipefail
npm run lint
npm run typecheck
npm test
npx playwright test tests/invoice-preview.spec.ts
# no half-finished work left behind
if rg -n "TODO|FIXME|placeholder|stub" src tests; then
echo "unfinished marker found"; exit 1
fi
test -f dist/invoice-preview.html # the artifact actually exists
echo "DONE: all checks green"
The agent can lie. The shell is less imaginative. “All green” is not a slogan - it's the exit condition. (This is eval-driven development pointed at autonomy: if you can't write the check, you don't actually know what “done” means.)
Layer 2 - The external slap: a Stop hook
This is the piece most people miss, and it's the one that matters most: a way to slap the agent externally when it tries to quit. Claude Code fires a Stop hook the moment the agent finishes responding. If that hook exits with code 2, Claude reads whatever you wrote to stderr and is forced to keep working. You wire it in .claude/settings.json:
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "python .claude/hooks/stop_gate.py" } ] }
]
}
}The gate runs your done-check. If it fails, exit 2 with instructions; if it passes, exit 0 and let the agent rest. The one non-obvious detail: check stop_hook_active so you don't trap the agent in an infinite loop - when it's already in forced-continuation, let the next clean stop through.
#!/usr/bin/env python3
# .claude/hooks/stop_gate.py
import json, subprocess, sys
data = json.load(sys.stdin)
# Already forced to continue once -> don't loop forever. Let it stop this time.
if data.get("stop_hook_active"):
sys.exit(0)
done = subprocess.run(["./scripts/done_check.sh"]).returncode == 0
if done:
sys.exit(0) # genuinely finished -> allow stop
# exit 2 -> stderr is fed back to Claude as "keep working"
msg = '''You are NOT done. ./scripts/done_check.sh failed.
Do not ask me whether to continue. Continue.
1. Re-read AGENT_SPEC.md and AGENT_JOURNAL.md.
2. Run ./scripts/done_check.sh and read the first failure.
3. Fix the highest-priority failing condition.
4. Update AGENT_JOURNAL.md, then try to finish again.'''
print(msg, file=sys.stderr)
sys.exit(2)
That single move changes the relationship. The agent says “I'm done.” The harness says “no, the check failed - continue.” You've stopped negotiating with a chatbot and started supervising a worker. (Full contract: the Claude Code hooks reference.)
One caveat: the hook is a single honesty-check per stop, not a loop of its own - it catches the premature exit and sends the agent back once. Sustained, multi-pass persistence across a filling context window is the next layer's job.
Layer 3 - The loop that beats the context ceiling
The Stop hook keeps one session honest, but a session still dies when context fills. The fix is older and dumber than it looks: re-invoke the agent on a fresh context against a persistent spec, over and over, until the check passes. Geoffrey Huntley named it the Ralph loop - Ralph Wiggum as a software engineer - and the whole point is that it's not clever:
#!/usr/bin/env bash
# scripts/agent_loop.sh -- Ralph: re-invoke on fresh context until done
set -euo pipefail
MAX_ITERS="${MAX_ITERS:-20}"
for i in $(seq 1 "$MAX_ITERS"); do
echo "=== pass $i / $MAX_ITERS ==="
claude -p "Read AGENT_SPEC.md, then AGENT_JOURNAL.md. Continue the job from the
last recorded state. Make concrete progress this pass. Do NOT ask for permission
unless a real blocker (missing credentials, destructive ambiguity). Before you
stop, append what changed / commands run / current failures / next action to
AGENT_JOURNAL.md." --dangerously-skip-permissions
if ./scripts/done_check.sh; then echo "done"; exit 0; fi
git add -A && git commit -m "agent: pass $i" || true # checkpoint every pass
done
echo "max iterations reached"; exit 1
Each pass starts clean, so context rot never accumulates. The state that matters lives in files, not chat - a spec (the durable definition of the job) and a journal (what's done, what's failing, what's next). Those two files are the agent's real memory:
# AGENT_SPEC.md -- the durable prompt
## Objective
One specific paragraph. What "good" looks like.
## Non-goals
- Don't rewrite unrelated modules. Don't change public APIs. Don't delete data.
## Constraints
- Match existing style. Small, reviewable changes. Commit per pass.
## Definition of done
- [ ] ./scripts/done_check.sh exits 0
- [ ] required tests pass + artifact exists
- [ ] no unfinished placeholders
- [ ] AGENT_JOURNAL.md says DONE with evidence
# AGENT_JOURNAL.md -- survives compaction, updated every pass
## Current state
<one short paragraph>
## Last pass
changed: ...
commands run: ...
current failures: ...
next action: ...
## Real blockers only
(missing creds / destructive ambiguity / failing external dep)
Keep the journal short. A bloated journal is just context rot in a different file. Headless claude -p is what makes this scriptable - drop agent_loop.sh in cron or a CI job with a per-run timeout and you get logs, artifacts, and cost boundaries for free.
Where the native layer fits: Dynamic Workflows
Not everything should be a bash loop. For jobs that need breadth in one shot - a bug hunt across a whole service, a migration over hundreds of files, anything that wants multiple independent angles - Anthropic's Dynamic Workflows are the native answer. Claude writes an orchestration script that runs tens to hundreds of parallel subagents in one session, then has adversarial agents try to refute the findings before anything reaches you, iterating until the answers converge. It's built for runs that stretch into hours, and it checkpoints so it can resume within a session. Turn it up with ultracode (it sets effort to xhigh).
So pick the tool by the shape of the job. Dynamic Workflows = orchestration + verification inside a session (fan-out, refute, converge). The external loop = crossing the context ceiling and enforcing an objective stop across many sessions. They compose: a Ralph loop whose per-pass agent kicks off a dynamic workflow, gated by a Stop hook and a done-check. Orchestration lives outside the model; verification lives inside it; the loop owns the clock.
Wait - does this mean I stop using Claude Code?
Reasonable worry, and the answer is no. The harness isn't a different tool - it's Claude Code in a different operating mode. Headless claude -p is Claude Code. The Stop hook lives in Claude Code's own .claude/settings.json. Dynamic Workflows run inside it. The loop wraps Claude Code; it doesn't replace it. What changes isn't the engine - it's the control surface.
Interactive mode is a conversation: you type, it responds, you correct. Autonomous mode moves the interface to the job definition and the evidence trail - the spec, the done-check, the journal, the logs, git history. You're not chatting; you're setting up a worker, defining “finished,” and reading the receipts. The journal and git log become the dashboard - not a cute one, a real one: what it tried, what changed, where it's stuck. And when judgment is needed, you drop back into interactive Claude Code. That's the mature setup: autonomous by default for bounded work, interactive when the ambiguity exceeds the harness.
So it's modes, not a migration. Stay interactive for exploration, ambiguous architecture, debugging weird behavior, pairing on taste - anytime you don't yet know what “good” looks like. Go headless-plus-loop for well-defined batch work: migrations, test-backed refactors, doc sweeps, mechanical multi-file changes - anytime the bottleneck is babysitting, not judgment. Dynamic Workflows sit in the middle: still inside Claude Code, but fanning out across subagents.
Your role shifts with it - less typist, more precise. Vagueness gets expensive when you're not there to course-correct; the agent will happily burn two hours interpreting a sloppy spec. The terminal stays home base - you launch the loop, read logs, open diffs, and drop into interactive Claude Code there - but files, tests, git, and the journal are interface now too.
What about OpenHands, Devin, Cursor's background agents, Amp? Those genuinely are different interfaces - they package the loop, the workspace, task tracking, and review surfaces into a product. Upside: less plumbing, real dashboards, remote execution, team visibility. Downside: less control, hidden policy, lock-in, and a harness you can't see into. My take: if you already run Claude Code seriously, Claude-Code-as-subprocess plus your own harness gets you most of the real autonomy - uglier, but you can see the loop, change the done-check, and define what failure means. Reach for the products when their interface solves an actual problem - team visibility, remote execution, managed environments, browser-heavy tasks, nontechnical stakeholders - not because you think autonomy requires a new brand of chair.
Bottom line: you're not abandoning Claude Code. You're promoting it from chat partner to subprocess.
Bound it, or it bites
Autonomy without bounds is just a slow way to burn money and compound errors. Non-negotiables:
- Caps + timeouts.
MAX_ITERS,timeout 45m claude -p ..., a token/dollar budget the harness refuses to exceed. - Commit per pass. Every iteration is a git checkpoint, so a bad run is
git reset, not a disaster. - Sandbox the risky stuff. Containers for dependency-heavy jobs; separate
git worktreebranches if multiple agents touch the tree in parallel, merged deliberately. - Watch for thrashing. If the same check fails three passes running, the harness changes the order: “stop editing, produce a root-cause analysis and a minimal fix plan.” You want persistence, not a model headbutting the same wall.
The system, in one place
Here's the whole recommended setup - small enough to drop into any repo. Point an agent at this post and it can wire it for you:
AGENT_SPEC.md # the durable job definition + checkable DoD
AGENT_JOURNAL.md # short rolling memory, updated every pass
scripts/done_check.sh # the only arbiter of "finished" (exit 0 = done)
scripts/agent_loop.sh # Ralph: re-invoke headless claude -p until done_check passes
.claude/settings.json # registers the Stop hook
.claude/hooks/stop_gate.py # blocks stopping (exit 2) until done_check passes
The winning setup is persistent spec + journal + headless invocation + Stop hook + executable done-check + git checkpoint per pass + hard caps. The losing setup is one sentence: “please keep working autonomously until complete.” That sentence feels powerful. It isn't - it leaves every decision that matters (continue? stop? is this good enough? what does done mean?) inside the model that keeps getting them wrong.
Move those decisions outside. That's the entire trick. You don't get multi-hour autonomy by trusting the agent harder - you get it by giving it fewer subjective decisions. Calibrated autonomy was the model's half. This is yours.
Sources: Geoffrey Huntley on the Ralph loop, the Claude Code hooks reference (Stop hook + exit-code 2 + stop_hook_active), and Dynamic Workflows in Claude Code.
Opus 4.8 Would Rather Tell You It Failed — calibrated autonomy + Dynamic Workflows; this post is the practical sequel.
Eval-Driven Development — why “done” has to be a passing check, not a vibe.
The Architect's Protocol — spec-first orchestration of agents.
The Cockpit — running autonomous operations as infrastructure.