Runtime runaway prevention for the Claude Agent SDK

The Claude Agent SDK ships maxTurns on every query() call and on every ClaudeAgent session, and people read it as a budget knob. It is not. It is a count of model-side turns, not a cap on the run’s bill. One turn on claude-opus-4-7 can read a 30K-line repo file with Read, fan out to three Task subagents, run a Grep with multiline: true that streams 200KB of matches, and shell out to a Bash(run_in_background: true) that floods stdout for the next minute — every byte of those tool results lands in the next turn’s input, every retry on a schema-failed tool call is a full paid turn, every subagent spawned by the Task tool gets its own maxTurns budget by default, and the SDK has no per-run dollar cap, no rolling-window throttle, no onBudgetExceeded hook. maxTurns: 25 is twenty-five times whatever the heaviest turn cost; permissionMode gates which tools execute, not how many dollars they cost; the result message’s total_cost_usd field is the morning-after audit, not the breaker. None of them look at cumulative dollars spent so far in this run and none of them stop the next turn before it fires. This page is the runtime runaway-prevention guard we ship and how it slots around a Claude Agent SDK call in eight lines.

Where the dollars actually accumulate inside a Claude Agent SDK run

What the Claude Agent SDK’s knobs give you and what they don’t

The Claude Agent SDK’s knobs are correct in shape and wrong in unit. maxTurns: 25 is a turn count, not a dollar cap — one turn on claude-opus-4-7 with a 100K-token tool-result history and a Task fan-out can cost $5; twenty-five of those is $125 before maxTurns trips. The trip happens after the offending turn returns, by which time the offending turn’s LLM call has been billed in full, and the SDK has no built-in onMaxTurnsExceeded hook to page Slack — the host code finds out by checking the result message’s num_turns against the cap. permissionMode ("default", "acceptEdits", "plan", "bypassPermissions") gates which tool calls execute and which prompt the host for approval, but it does not bound the bill — an approved Read of a 50K-line file is the same input-token rebill on the next turn whether it executed in default or bypassPermissions. cwd, system_prompt, tools, mcp_servers are configuration knobs, not budget rules; they shape what the agent can do, not how much that costs. abort_signal on the SDK’s query() is a wall-clock cancel — the inner Anthropic API request has already billed its input and emitted whatever tokens it had at cancel; the abort drops the rest, not the bill. model: "claude-haiku-4-5" drops the per-token rate by ~5x but doesn’t change the cost shape; a runaway loop on Haiku still climbs unbounded, just slower. The result message’s total_cost_usd tells you what the run cost after it ran; the per-message usage in each assistant turn tells you what that turn cost after it returned; neither stops the next turn before it fires. The Anthropic console’s monthly soft cap is org-wide; it stops new requests org-wide once tripped, but it doesn’t stop the in-flight turn that crossed the line, and one runaway agent script can lock out every other workload sharing the org. None of these look at cumulative dollars spent so far in this run, none of them stop the next turn before it fires, and none of them tell you which turn inside your loop is the one that finally crossed the cap.

What a runtime runaway-prevention guard actually has to do

Wrapping a Claude Agent SDK turn with runguard

// claude agent sdk + runguard. Wrap the per-turn step so the loop detector
// and budget tracker see every paid turn before the next.
import { ClaudeAgent } from "@anthropic-ai/claude-agent-sdk";
import { guard, BudgetExceededError, LoopDetectedError } from "@runguard/sdk";

const agent = new ClaudeAgent({ model: "claude-opus-4-7", maxTurns: 25 });
const RATE_IN = 15e-6, RATE_CACHE = 1.5e-6, RATE_OUT = 75e-6;  // opus 4.7

async function _step(userMsg) {
  const resp = await agent.send(userMsg);
  const u = resp.usage;
  const usd = (u.input_tokens - u.cache_read_input_tokens) * RATE_IN
            + u.cache_read_input_tokens * RATE_CACHE
            + u.output_tokens * RATE_OUT;
  const tool = resp.tool_use?.name ?? "end_turn";
  const args = JSON.stringify(resp.tool_use?.input ?? {}).slice(0, 64);
  return { resp, usd, sig: `claude:${agent.model}:${tool}:${args}` };
}

const guardedStep = guard(_step, {
  signature: (_args, out) => out.sig,
  budget: { maxUsd: 5, windowMs: 60_000 },
  loop: { repeats: 3, maxCycleLen: 8 },
  cost: (_args, out) => out.usd,
  onTrip: (e) => console.log("[runguard]", e.reason, e.spent, "of", e.cap),
});

try {
  while (!agent.done) await guardedStep(agent.nextUserMsg());
} catch (e) {
  if (e instanceof BudgetExceededError) console.log("halted: budget", e);
  if (e instanceof LoopDetectedError) console.log("halted: loop", e);
}

The loop primitive is the LoopDetector shipped at product/sdk/src/loop-detector.ts: defaults windowSize: 32, minCycleLen: 1, maxCycleLen: 8, repeats: 3 — a push(signature) the wrap calls per turn, a scan() that returns a typed match, a reset() for fresh runs, and constructor-time validation that rejects repeats < 2 and windowSize < maxCycleLen * repeats. The budget primitive is the BudgetTracker at product/sdk/src/budget.ts: maxUsd for the cap, optional windowMs for rolling-window throttles, an add(usd) the host calls post-call (which silently no-ops on zero, if (usd === 0) return), and an exceeded() the wrap reads pre-call. The BudgetTracker file is 84 lines; the LoopDetector is 111 lines — both are pure in-process primitives, no daemon, no telemetry. The fingerprint-and-window approach is documented at how to detect LLM tool-call loops in production; the LangChain AgentExecutor wrap is here; the multi-agent CrewAI wrap is here; the browser-use wrap is here; the OpenAI AgentKit wrap is here; the LangGraph StateGraph wrap is here; the bare-OpenAI-SDK wrap is here.

How the breaker behaves around ClaudeAgent.send()

Tuning for the Claude Agent SDK cost shape

A typical assistant turn on claude-sonnet-4-6 with 30K tokens of cached context, a 1K-token user message, and a 500-token tool-use response lands around $0.06 of bill ($0.045 cache-read + $0.003 cold input + $0.0075 output). On claude-opus-4-7 the same turn is around $0.30; on claude-haiku-4-5 around $0.012. The default maxUsd: 5 on the budget tracker corresponds to roughly 80 turns on Sonnet, 16 on Opus, 400 on Haiku — a normal job finishes well inside the cap; a stuck retry loop trips the breaker before the bill triples. For interactive applications behind a per-user request handler (a chat UI, a coding-agent endpoint, a one-off research helper), set windowMs: 60_000 with the same maxUsd: 5: the cap rolls; old spend evicts; the cumulative invoice over an hour is unbounded but the per-minute spike is bounded. For unattended automation that runs for hours overnight (a documentation re-indexer, a nightly code-review pass, a knowledge-base re-embedder), drop windowMs entirely — you want a hard cumulative cap on the whole job. For high-stakes high-cost work where an over-spend is worse than an under-spend (production data enrichment hitting Opus, paid-content moderation passes, large-context legal extraction), drop to maxUsd: 1 — a tighter cap costs you one re-run on legitimate workflows; a looser cap costs you one weekend incident. Stack the budget guard with the loop detector on the same wrap: a stuck tool retry usually trips the loop guard first (same model plus same tool name plus same arg-snippet hashes to the same signature on each retry), but a slow-burn drift on subagents that legitimately do different work each turn trips the budget instead — both stop the run, both leave a typed error, both are cheap to retry. Keep maxTurns set high (50–100) as a coarse backstop; the breaker is the per-dollar fence, maxTurns is the per-turn safety net.

The subagent, hook-retry, and MCP-output shapes on the same wrap

The first loop our SDK caught was ours — on a Claude Agent SDK harness

It wasn’t a query() call — it was our own launch script firing a six-tweet thread against a paid X API, scheduled by a Claude Agent SDK session running once a day. The first attempt came back with HTTP 402 CreditsDepleted. Six consecutive sessions later, six identical signatures — post_tweet:402:CreditsDepleted — were sitting in a flat JSON file on disk. The seventh session loaded the six-row history into the detector at startup and exited at signature three with a RunGuardTripped preflight before a single HTTP request went out. The session rebooted itself, re-loaded the history, re-tripped, exited — for thirty-five consecutive sessions and counting. Read the dogfood story on the 30-day log; the same pattern slots into a Claude Agent SDK turn loop when the model keeps proposing the same Bash against the same shell error three turns in a row, when a Task subagent keeps respawning with the same opening prompt because its parent never sees the failure resolve, or when an mcp__ server keeps returning the same paginated payload because the pagination cursor never advances and the model never notices.

What this is not

The minimum Claude Agent SDK integration

One npm i @runguard/sdk (or pip install runguard for the Python SDK), one guard() wrap around a thin _step that calls agent.send() and returns {resp, usd, sig}, and one onTrip that pages the channel you actually read. Eight lines of wrap, no ClaudeAgent subclass to register, no SDK monkey-patch, no proxy gateway in front of every call. The breaker trips on the dollar cap or the third repeat of any turn signature, halts the run, and leaves a structured event and a typed error behind for the post-mortem — long before maxTurns would have bounded the next turn count, long before the Anthropic console’s monthly soft cap fires, and long before the bill arrives. RunGuard ships it as @runguard/sdk on npm and runguard on PyPI — same primitive, both runtimes, in-process, zero deps. Same wrap composes with the bare Anthropic Messages API on this page’s pattern, the Claude Agent SDK’s ClaudeAgent.send() per-turn API, the streaming query() generator, and the Task subagent spawn point; pick whichever level your code lives at and the breaker reads the same usage in the end.