Engineering Reliable Coding Agent Loops: Control Flow, Verification, Retries, and Stop Conditions
How to supervise long-running coding work with explicit contracts, verification, retries, and stopping conditions.
Coding agents such as Claude Code and Codex already contain an internal execution loop that can inspect files, call tools, modify code, and respond to intermediate results. That native loop is useful for completing a single run, but it does not provide the full control needed for long-running or production-oriented coding workflows. A supervisory runtime must decide when another run should begin, what action the worker is allowed to perform, how repository changes are isolated, which evidence is required, when a failure should be retried or replanned, and what conditions justify stopping.
This article develops a practical architecture for building that outer control loop around coding agents. It introduces typed task contracts, durable plan ledgers, bounded worker handoffs, provider adapters, normalized event streams, independent Git diff collection, verification and completion gates, failure classification, no-progress detection, cross-run budgets, Git worktree isolation, trigger admission, cancellation handling, and terminal artifact retention.
The concepts are developed through one parser failure that persists across the full article. The controller reproduces the error, delegates a bounded patch attempt, rejects an implementation that preserves the same failure fingerprint, revises the plan, verifies the corrected patch, and records completion against the resulting repository state. Runnable Python examples demonstrate the supervisory components, while concrete Claude Code and Codex CLI integrations show how the provider-independent controller can invoke native coding-agent workers.
The result is a reference design for coding-agent systems in which model-driven execution remains flexible, while workflow transitions, permissions, budgets, verification, recovery, and termination remain deterministic and auditable.
Table of Contents:
1. The Two Loops: Native Agent Execution and Supervisory Control
The inner coding-agent loop
The outer supervisory loop
Model policy versus runtime control
Why native completion is insufficient
The parser failure used throughout the article
2. The Loop Contract: Guidance, Permissions, and Controller State
Defining the task contract
Separating guidance from enforcement
Native permissions and tool boundaries
Workflow state versus evidence state
Legal state transitions and terminal outcomes
3. Planning Across Runs and Selecting One Bounded Action
Persistent plans and plan revisions
The plan ledger
Selecting the next ready step
Constructing a bounded worker handoff
Revising a failed implementation hypothesis
4. Invoking Claude Code or Codex as a Controlled Worker
The provider-adapter boundary
Building bounded native CLI invocations
Running Claude Code in non-interactive mode
Running Codex through
codex execCapturing native event streams
Normalizing provider-specific results
Process timeouts, termination, and session resumption
5. Evidence, Verification, and Completion Gates
Collecting the Git diff independently
Enforcing file and scope policy
Running deterministic verification
Layered acceptance predicates
Separating worker completion from task completion
Binding evidence to the verified repository state
6. Failure Classification, Retry, Replanning, and No-Progress Detection
Classifying infrastructure, execution, and verification failures
Retrying transient failures
Replanning failed hypotheses
Comparing failure fingerprints
Detecting repeated actions and unchanged evidence
Escalating when progress stalls
7. Isolation, Budgets, Triggers, and Safe Stopping
Admitting a new iteration
Isolating mutating attempts with Git worktrees
Reserving and aggregating cross-run budgets
Normalizing manual, scheduled, and event triggers
Persisting cancellation intent
Handling local and external side effects
Routing terminal artifacts by outcome
8. Reference Implementation: From Parser Failure to Verified Patch
Defining the integrated runtime boundary
Reproducing the baseline parser failure
Executing the first bounded hypothesis
Classifying the unchanged failure
Replanning and applying the corrected patch
Passing verification and the completion gate
Replacing fixtures with native Claude Code and Codex adapters
Persisting the final run record
I’m hosting a 3-hour live workshop on Hands-On Loop Engineering with Claude.
We will build Claude loops with persistent state, verification, scheduling, and safer permissions, then apply the pattern to real coding, research, and automation workflows.
1. The Two Loops: Native Agent Execution and Supervisory Control
Claude Code and Codex already run an agent loop when they receive a coding task. They inspect relevant files, select tools, execute commands, interpret the output, and continue until the current run reaches a result. Anthropic describes its Agent SDK as exposing the same tools, agent loop, and context management used by Claude Code. Codex provides a comparable programmable surface through codex exec, which can run inside scripts and emit machine-readable events.
This blog adds a second loop around that native behavior. The supervisory loop decides when to start an agent run, what contract applies to it, whether the produced evidence represents progress, and whether another run is justified. It also owns cross-run limits and terminal outcomes, which remain stable when the underlying coding agent, model, or invocation method changes.

Claude Code includes several native controls that can participate in this design. /goal evaluates a condition after every turn using a separate evaluator, while Stop hooks can run deterministic scripts or model-evaluated checks. In non-interactive mode, — max-turns and — max-budget-usd bound an individual invocation.
Codex exposes related controls at a different layer. codex exec is intended for scripts, CI jobs, and scheduled workflows, supports explicit sandbox settings, and can emit a JSONL event stream containing thread, turn, item, and error events. Codex sandbox modes determine whether a run is read-only, workspace-writable, or unrestricted.
These native mechanisms remain inside one invocation or product session. The supervisory loop maintains the task-level record across invocations: the original acceptance criteria, earlier failures, approved scope, unresolved hypotheses, and the reason the overall task eventually stopped.
A. Start with a reproducible engineering failure
The running example is a small header parser that fails on an HTTP-style blank separator line. Every later section will advance this same task rather than replacing it with isolated examples.
The parser should accept:
Host: example.com
Accept: application/jsonand produce two headers. Its current implementation attempts to split every line around a colon, including the empty line.
This goes in the parser example used throughout the post.
def parse_headers(block: str) -> dict[str, str]:
headers: dict[str, str] = {}
for line in block.splitlines():
name, value = line.split(”:”, 1)
headers[name.lower()] = value.strip()
return headers
sample = “Host: example.com\n\nAccept: application/json”
try:
parse_headers(sample)
except Exception as exc:
print(”reproduction_status=failed”)
print(f”error_type={type(exc).__name__}”)
print(f”error={exc}”)Output:
reproduction_status=failed
error_type=ValueError
error=not enough values to unpack (expected 2, got 1)
The process itself exits normally because the exception is caught, while the parser operation has failed. A controller that records only subprocess exit status would miss this distinction. The outer loop therefore needs task-specific evidence in addition to process metadata, including the failed input, exception class, test identity, and any artifacts produced by the run.
This reproduction will become the first durable observation in the task state. Later attempts can compare their result against the same input and determine whether the failure disappeared, changed shape, or moved elsewhere. A repeated run that produces the same exception without modifying the hypothesis or environment has not generated new information.
Key concept: Native agent activity produces observations. The supervisory loop determines whether those observations represent progress against the task’s acceptance criteria.
B. Treat Claude Code and Codex as provider-specific workers
The controller needs a narrow interface for starting either coding agent. Claude Code print mode can return JSON and apply invocation-level turn and budget limits. Codex can run non-interactively with JSON events and an explicit sandbox.
The first provider adapter only builds the commands. Subprocess execution, event parsing, timeout handling, and session continuation belong in Section 4. Keeping the builder runnable allows us to verify the exact arguments that the later executor will receive.
This goes in the provider adapter.
from dataclasses import dataclass
from shlex import join
from typing import Literal
Provider = Literal[”claude”, “codex”]
@dataclass(frozen=True)
class InvocationPolicy:
max_turns: int = 6
max_budget_usd: float = 3.00
sandbox: str = “workspace-write”
def build_agent_command(
provider: Provider,
prompt: str,
policy: InvocationPolicy,
) -> list[str]:
if provider == “claude”:
return [
“claude”,
“-p”,
“--output-format”,
“json”,
“--max-turns”,
str(policy.max_turns),
“--max-budget-usd”,
f”{policy.max_budget_usd:.2f}”,
“--allowedTools”,
“Read”,
“Edit”,
“Bash”,
prompt,
]
if provider == “codex”:
return [
“codex”,
“exec”,
“--json”,
“--sandbox”,
policy.sandbox,
prompt,
]
raise ValueError(f”Unsupported provider: {provider}”)
policy = InvocationPolicy()
prompt = (
“Reproduce the parser bug. Do not claim completion. “
“Return the failing command and evidence.”
)
for provider in (”claude”, “codex”):
command = build_agent_command(provider, prompt, policy)
print(f”{provider}_command={join(command)}”)Output
claude_command=claude -p --output-format json --max-turns 6 --max-budget-usd 3.00 --allowedTools Read Edit Bash ‘Reproduce the parser bug. Do not claim completion. Return the failing command and evidence.’
codex_command=codex exec --json --sandbox workspace-write ‘Reproduce the parser bug. Do not claim completion. Return the failing command and evidence.’
The Claude invocation bounds the work performed inside one native run. Its turn and spending limits do not represent the full task budget because the outer controller may start another run after verification fails. The Codex invocation similarly defines the sandbox for one worker execution, while the supervisory contract may impose narrower path rules and a smaller permitted diff.
Provider adapters also create a compatibility cost. Claude and Codex expose different event schemas, session models, budget controls, and permission semantics. The controller needs a normalized result type so later logic operates on changed_files, evidence, usage, and stop_reason rather than provider-specific payloads.

C. Keep the completion decision outside the worker
A coding agent can propose that the task is complete, request more context, or report that it cannot continue. The controller evaluates that proposal against repository state and independent checks. A completion message becomes one input to the decision rather than the terminal event itself.
The example below simulates a Codex run that reports completion without changing files. The controller executes the parser acceptance check and rejects the claim because the original failure remains reproducible.
This goes in the supervisory controller.
from dataclasses import dataclass
from enum import Enum
class SupervisorDecision(str, Enum):
VERIFY = “verify”
REPLAN = “replan”
SUCCEED = “succeed”
ESCALATE = “escalate”
@dataclass(frozen=True)
class AgentRunResult:
provider: str
claimed_complete: bool
changed_files: tuple[str, ...]
summary: str
def parse_headers(block: str) -> dict[str, str]:
headers: dict[str, str] = {}
for line in block.splitlines():
name, value = line.split(”:”, 1)
headers[name.lower()] = value.strip()
return headers
def verify_parser_behavior() -> tuple[bool, str]:
sample = “Host: example.com\n\nAccept: application/json”
try:
parsed = parse_headers(sample)
except ValueError as exc:
return False, f”ValueError:{exc}”
expected = {
“host”: “example.com”,
“accept”: “application/json”,
}
return parsed == expected, f”parsed={parsed}”
def supervise(
result: AgentRunResult,
) -> tuple[SupervisorDecision, str]:
if result.changed_files:
return (
SupervisorDecision.VERIFY,
“repository changed; verification is required”,
)
passed, evidence = verify_parser_behavior()
if result.claimed_complete and not passed:
return (
SupervisorDecision.REPLAN,
f”completion claim rejected; {evidence}”,
)
if passed:
return SupervisorDecision.SUCCEED, evidence
return SupervisorDecision.REPLAN, evidence
result = AgentRunResult(
provider=”codex”,
claimed_complete=True,
changed_files=(),
summary=”The parser task appears complete.”,
)
decision, reason = supervise(result)
print(f”provider={result.provider}”)
print(f”agent_claimed_complete={result.claimed_complete}”)
print(f”supervisor_decision={decision.value}”)
print(f”reason={reason}”)Output:
provider=codex
agent_claimed_complete=True
supervisor_decision=replan
reason=completion claim rejected; ValueError:not enough values to unpack (expected 2, got 1)
The printed trace preserves the disagreement between the worker and the controller. That disagreement is operationally useful: it records that the agent believed the task was complete while the deterministic check still reproduced the failure. A later failure-analysis stage can classify this as unsupported completion rather than a tool failure or an implementation regression.
The same rule applies when native completion helpers are enabled. Claude Code’s /goal uses a separate evaluator after every turn, which provides stronger separation than self-evaluation inside the working turn. An external controller may still retain the final authority when completion depends on repository policies, cross-run budgets, or evidence stored outside the agent session.
Takeaway: Native goal evaluators can decide whether another native turn should begin. The supervisory controller decides whether the overall engineering task has reached an accepted terminal state.

The supervisory layer adds latency because evidence may be checked again after the native agent has already run tests. It also adds storage and integration code for normalized events, task state, and provider adapters. That duplication becomes useful when the same contract must work across Claude Code and Codex or when a task continues across multiple sessions, workers, or scheduled runs.
2. The Loop Contract: Guidance, Native Permissions, and Controller State
The parser task now has one failed observation and a replan decision. Before another coding-agent run begins, the controller needs a contract that defines the accepted result, authorized scope, required checks, and outer-loop limits. Part of that contract can be translated into native Claude Code or Codex configuration, while the remaining fields stay in the external controller.
The contract has three layers:
→ Durable project guidance
→ Native execution boundary
→ Supervisory task policyEach layer has a different enforcement model. Mixing them into one Markdown file makes it difficult to determine which rules are contextual instructions, which are enforced by the coding-agent runtime, and which are evaluated by the outer controller.
A. Separate guidance from enforcement
Claude Code loads CLAUDE.md files as persistent project instructions, while Codex reads AGENTS.md before beginning work. Both mechanisms are suitable for repository conventions, build commands, testing instructions, and directory-specific guidance. Claude’s documentation explicitly describes CLAUDE.md as context rather than enforced configuration, and also supports importing an existing AGENTS.md from CLAUDE.md.
Native execution controls provide the next layer. Claude Code permissions determine which tools, files, and domains can be accessed, while Codex sandbox and approval modes determine filesystem, network, and command boundaries.
The supervisory contract owns task-specific conditions that should remain stable across providers. These include acceptance predicates, total outer-loop iterations, permitted diff size, repeated-failure limits, and the mapping from failure conditions to terminal outcomes.
Key concept: CLAUDE.md and AGENTS.md provide durable context. Permissions, sandboxes, hooks, and the external controller provide enforcement.
B. Define one provider-independent contract
The contract below contains the fields needed by the external loop and renders the repository guidance shared with coding agents. The generated Markdown can be stored in AGENTS.md; a Claude project can import it from CLAUDE.md, avoiding duplicated instructions across tools.
The contract deliberately keeps outer-loop limits outside the rendered guidance. Telling an agent that it has four attempts does not prevent a wrapper from starting a fifth run. The controller must enforce that number before spawning another process.
This goes in the supervisory contract module.
from dataclasses import dataclass
@dataclass(frozen=True)
class AcceptanceCriterion:
name: str
command: str
@dataclass(frozen=True)
class LoopContract:
objective: str
writable_paths: tuple[str, ...]
protected_paths: tuple[str, ...]
criteria: tuple[AcceptanceCriterion, ...]
max_outer_iterations: int
max_changed_files: int
def validate_contract(contract: LoopContract) -> list[str]:
errors: list[str] = []
if not contract.objective.strip():
errors.append(”objective_empty”)
if not contract.writable_paths:
errors.append(”writable_paths_empty”)
if not contract.criteria:
errors.append(”criteria_empty”)
if contract.max_outer_iterations < 1:
errors.append(”invalid_iteration_limit”)
return errors
def render_shared_guidance(contract: LoopContract) -> str:
checks = “\n”.join(
f”- `{item.command}` ({item.name})”
for item in contract.criteria
)
writable = “\n”.join(
f”- `{path}`” for path in contract.writable_paths
)
protected = “\n”.join(
f”- `{path}`” for path in contract.protected_paths
)
return f”“”# Parser maintenance rules
## Objective
{contract.objective}
## Writable scope
{writable}
## Protected scope
{protected}
## Required checks
{checks}
“”“
contract = LoopContract(
objective=(
“Fix blank-line handling in parse_headers “
“and add a regression test.”
),
writable_paths=(
“src/parser/**”,
“tests/parser/**”,
),
protected_paths=(
“migrations/**”,
“infrastructure/**”,
),
criteria=(
AcceptanceCriterion(
“targeted_regression”,
“pytest tests/parser/test_headers.py -q”,
),
AcceptanceCriterion(
“static_analysis”,
“python -m compileall -q src”,
),
),
max_outer_iterations=4,
max_changed_files=4,
)
errors = validate_contract(contract)
guidance = render_shared_guidance(contract)
print(f”contract_valid={not errors}”)
print(f”outer_iteration_limit={contract.max_outer_iterations}”)
print(f”required_checks={len(contract.criteria)}”)
print(”guidance_preview=”)
print(”\n”.join(guidance.splitlines()[:8]))Output:
contract_valid=True
outer_iteration_limit=4
required_checks=2
guidance_preview=
# Parser maintenance rules
## Objective
Fix blank-line handling in parse_headers and add a regression test.
## Writable scope
- `src/parser/**`
- `tests/parser/**`The contract validator runs before either provider is invoked. A missing objective prevents useful planning, while missing acceptance criteria prevent evidence-based completion. Invalid outer limits are configuration errors and should move the run into a setup failure before model usage begins.
Rendering guidance from the same source reduces drift between the external contract and repository instructions. It does not guarantee that the agent will follow every instruction, so the controller still validates the resulting diff and verification evidence. The generated file should remain concise because both Claude and Codex load project guidance into their working context.
The contract introduces schema-management costs. Persisted tasks need a contract version, and resumed runs need compatibility checks when fields or criterion formats change. Repository guidance may also require provider-specific additions, such as Claude hooks or Codex rules, while retaining one shared task definition.
C. Make acceptance criteria executable
The contract’s objective remains prose because it needs to communicate intent. Completion depends on executable predicates. For the parser task, those predicates cover the original blank-line behavior, ordinary header parsing, and the existence of a targeted regression test.
The test-discovery check remains an in-memory stub in this section. Section 5 will replace it with repository inspection and captured test-run evidence.
This goes in the acceptance evaluator.
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class CheckResult:
name: str
passed: bool
evidence: str
def parse_headers(block: str) -> dict[str, str]:
headers: dict[str, str] = {}
for line in block.splitlines():
name, value = line.split(”:”, 1)
headers[name.lower()] = value.strip()
return headers
def check_blank_line() -> CheckResult:
sample = “Host: example.com\n\nAccept: application/json”
try:
parsed = parse_headers(sample)
except ValueError as exc:
return CheckResult(
“blank_line_is_ignored”,
False,
f”ValueError:{exc}”,
)
return CheckResult(
“blank_line_is_ignored”,
parsed.get(”accept”) == “application/json”,
f”parsed={parsed}”,
)
def check_standard_headers() -> CheckResult:
sample = “Host: example.com\nAccept: application/json”
parsed = parse_headers(sample)
expected = {
“host”: “example.com”,
“accept”: “application/json”,
}
return CheckResult(
“standard_headers_preserved”,
parsed == expected,
f”parsed={parsed}”,
)
def check_regression_test() -> CheckResult:
discovered_tests = {”test_standard_headers”}
required = “test_blank_line_is_ignored”
return CheckResult(
“regression_test_exists”,
required in discovered_tests,
f”required={required}”,
)
checks: tuple[Callable[[], CheckResult], ...] = (
check_blank_line,
check_standard_headers,
check_regression_test,
)
results = [check() for check in checks]
for result in results:
print(
f”{result.name}:”
f”passed={result.passed}:”
f”evidence={result.evidence}”
)
print(f”task_complete={all(item.passed for item in results)}”)Output:
blank_line_is_ignored:passed=False:evidence=ValueError:not enough values to unpack (expected 2, got 1)
standard_headers_preserved:passed=True:evidence=parsed={’host’: ‘example.com’, ‘accept’: ‘application/json’}
regression_test_exists:passed=False:evidence=required=test_blank_line_is_ignored
task_complete=FalseThe evaluator records partial progress. Existing header behavior still passes, while the target input and regression-test requirement fail. A later patch can therefore fix the blank-line case while the same evaluator checks whether ordinary parsing has regressed.
Each result carries compact evidence. A production implementation should replace long inline values with artifact identifiers, test node IDs, commands, exit codes, and hashes. The controller uses the boolean result for transitions, while operators and verifier agents use the evidence when auditing the decision.
Takeaway: Acceptance predicates preserve both success and failure. A passing check remains visible during an unsuccessful run and becomes a regression guard during later attempts.










