Loop detection for Anthropic Computer Use

Anthropic’s Computer Use beta gives Claude a computer tool whose actions are screenshot, left_click, right_click, middle_click, double_click, mouse_move, type, key, scroll, and cursor_position. Every screenshot action returns a base64 PNG that the SDK threads back into the next messages.create request as an image content block, the model takes that image as input on the next turn, and at Anthropic’s tokens ≈ (width × height) / 750 vision rate a 1280×800 screenshot lands ~1,366 tokens, a 1920×1080 screenshot lands ~2,765 tokens, and a 2560×1440 screenshot lands ~4,915 tokens — on every turn until the model stops needing the prior history. Fifty screenshots at 1920×1080 is ~138K tokens of vision input that re-bills on every following turn; on claude-sonnet-4-6 at $3 per million input tokens that’s about $0.41 of input bill per turn from turn fifty onward, on claude-opus-4-7 at $15 per million it’s ~$2.07 per turn. The Anthropic Computer Use beta’s knobs — display_width_px, display_height_px, disable_parallel_tool_use, the per-turn max_tokens on output, the four versioned tool types computer_20241022 / computer_20250124 / computer_20250429 — shape what the agent sees and does, not what it costs over time. There is no max_screenshots knob, no per-run dollar cap, no onBudgetExceeded hook, and no onLoopDetected hook. response.usage.input_tokens tells you what the turn billed after it returned; stop_reason: "tool_use" means the model wants another tool call which the SDK will dutifully execute and re-bill. None of this looks at cumulative dollars spent so far in this run and none of it stops the next turn before it fires. This page is the runtime loop detector and budget guard we ship and how it slots around an Anthropic Computer Use call in eight lines.

Where the dollars actually accumulate inside an Anthropic Computer Use run

What the Anthropic Computer Use knobs give you and what they don’t

The Anthropic Computer Use beta’s knobs are correct in shape and wrong in unit for runtime cost control. display_width_px and display_height_px shape per-screenshot tokens (lower resolution = cheaper per shot) but don’t bound how many shots accumulate; a session that takes one hundred 1024×768 screenshots still pays ~104K tokens of input on every turn from screenshot one hundred onward. disable_parallel_tool_use on the computer-use-2025-04-29 beta header (or whichever beta header the chosen model needs) prevents the model from emitting two computer tool calls in one turn — useful for sequential UI flows where parallel actions race against the same screen state, irrelevant to whether the next turn fires when you’ve already crossed your dollar cap. The per-turn max_tokens on the request bounds what one assistant turn can output; it doesn’t bound the next turn or the next or the next. stop_reason on the response is your only signal — "tool_use" means “the model wants another action, please run it and call messages.create again”, "end_turn" means “done, stop”, "max_tokens" means “the assistant ran out of output budget mid-thought, please call again with a continuation”. None of those stop the host loop on a dollar threshold. response.usage.input_tokens tells you what this turn billed after it returned; response.usage.cache_creation_input_tokens and response.usage.cache_read_input_tokens tell you what tier; nothing in the usage field is a cumulative ledger across turns of the same run. The model parameter (claude-sonnet-4-6, claude-opus-4-7, claude-haiku-4-5) shifts the per-token rate — Haiku is roughly 4–5x cheaper than Sonnet, Opus is roughly 5x more expensive than Sonnet — but doesn’t change the cost shape; a 200-screenshot run on Haiku is still a 200-screenshot run, just at a quarter the bill. The Anthropic console’s monthly soft cap is org-wide; one runaway Computer Use script that loops on a CAPTCHA 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 click in your loop is the one that finally crossed the cap.

What a runtime loop detector for Computer Use actually has to do

Wrapping an Anthropic Computer Use turn with runguard

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

const client = new Anthropic();
const RATE_IN = 3e-6, RATE_CACHE = 0.3e-6, RATE_OUT = 15e-6;  // sonnet 4.6
const tools = [{ type: "computer_20250429", name: "computer", display_width_px: 1280, display_height_px: 800 }];

async function _step(messages) {
  const resp = await client.messages.create({
    model: "claude-sonnet-4-6", max_tokens: 2048, tools, messages,
    betas: ["computer-use-2025-04-29"],
  });
  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 tu = resp.content.find(b => b.type === "tool_use");
  const action = tu?.input?.action ?? "end_turn";
  const arg = JSON.stringify(tu?.input?.coordinate ?? tu?.input?.text ?? tu?.input?.key ?? "").slice(0, 64);
  return { resp, usd, sig: `anthropic-cu:sonnet-4-6:${action}:${arg}` };
}

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 (!done) await guardedStep(messages);
} 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; the Claude Agent SDK wrap is here.

How the breaker behaves around messages.create

Tuning for the Anthropic Computer Use cost shape

A typical Computer Use turn at message-history index ten on claude-sonnet-4-6 with five accumulated 1280×800 screenshots (~6,830 tokens of vision), a 1K-token system prompt, a 1K-token user message, and a 200-token tool-use response lands around $0.03 of bill. By turn thirty with thirty accumulated screenshots that same shape lands around $0.13 because the vision tokens triple. By turn fifty with fifty accumulated screenshots it’s ~$0.21. On claude-opus-4-7 multiply by five; on claude-haiku-4-5 divide by ~four. The default maxUsd: 5 on the budget tracker corresponds to roughly 50–100 typical-shape turns on Sonnet, 10–20 on Opus, 200–400 on Haiku — a normal task 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 UI-driving chat agent, a one-off browser-control flow, a customer-facing automation), 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 Computer Use overnight (a regression-test harness driving a real desktop app, a nightly screenshot-diffing pass on production URLs, a knowledge-base re-screening that drives a browser through hundreds of pages), drop windowMs entirely — you want a hard cumulative cap on the whole job. For high-stakes Opus runs (production data-entry agents driving SaaS dashboards, paid-content moderation that drives a CMS, large-context legal-document UI-reading), 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 misclick loop usually trips the loop guard first (same model plus same action plus same coordinate hashes to the same signature on each retry), but a slow-burn screenshot-accumulation drift on a session that legitimately takes new actions every turn trips the budget instead — both stop the run, both leave a typed error, both are cheap to retry. Drop your screenshot resolution if you can: 1280×800 is half the per-shot tokens of 1920×1080 and most desktop UIs render fine at the lower resolution; you save 50% of vision rebill on every turn.

The misclick, retype, and CAPTCHA shapes on the same wrap

The first loop our SDK caught was ours — same primitive, different surface

It wasn’t a Computer Use 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 Computer Use turn loop when the model keeps proposing the same left_click against the same on-screen rendering three turns in a row, when a CAPTCHA gate keeps surfacing the same screenshot because the model can’t pass it, or when a retype loop fires the same text into a window that lost focus three turns in a row.

What this is not

The minimum Anthropic Computer Use integration

One npm i @runguard/sdk (or pip install runguard for the Python SDK), one guard() wrap around a thin _step that calls client.messages.create and returns {resp, usd, sig}, and one onTrip that pages the channel you actually read. Eight lines of wrap, no Anthropic subclass to register, no SDK monkey-patch, no proxy gateway in front of every Computer Use call. The breaker trips on the dollar cap or the third repeat of any action signature, halts the run, and leaves a structured event and a typed error behind for the post-mortem — long before your host’s iteration cap 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, with the Computer Use beta’s computer_20250429 tool type (and earlier computer_20241022 / computer_20250124 versions), with the streaming messages.stream variant, with the Bedrock-routed and Vertex-routed clients, and with whatever wrapper sits above (the Claude Agent SDK’s ClaudeAgent.send(), an internal tool-use harness, a third-party framework); pick whichever level your code lives at and the breaker reads the same usage in the end.