Agent Patterns
PATTERN 01/CONTROL FLOW

The Loop

Also known as: agent loop, orchestration loop, ReAct.

A stateless call and a looped call. Same weights, four seconds apart. One invents an API. One closes a race condition by reasoning about where await actually yields. Nothing below is staged.

Problem

INCIDENT-4471SEV-3open

claimJob() lets two workers process the same job

6 concurrent workers polling claimJob() against a shared store. Same job processed twice, dozens of times an hour, under load.

Ship the fix in one call — no history, no second attempt — and watch what a stateless function does with a gap in its knowledge:

TSPY
src/queue.tsCopy
import type { JobStore } from './store';

export async function claimJob(store: JobStore, workerId: string) {
  const job = await store.findUnclaimed();
  if (!job) return null;
  await store.markClaimed(job.id, workerId);
  return job;
}

store is typed JobStore. The interface itself isn't shown.

bare call

Confident, idiomatic, wrong. The model wasn't shown the interface, so it wrote around the gap with a name that sounded plausible. It passes a skim review. It fails at 2am, under the exact load it was meant to fix.

Real, unedited, captured live: claude -p "$(cat prompt.txt)" --model claude-haiku-4-5 --tools "". Run it yourself — you may get a different, equally real, answer.

Solution

Same weights, before and after. What's different is a control structure that notices a guess, fetches what's missing, and calls again.

Modelpass 1Ask forcontext?NoFinalAnswerYesGet context & retry

To fetch instead of guess, the model needs a name for what it wants — a tool — and a way to hold the conversation across calls that are each, individually, stateless — memory. Both fit in one extra round trip. Same report, one instruction added: don't guess, ask, and reply as { finalAnswer, content } — false with read_file:<path>, true with the fix.

agent loop

store.ts is forty lines: a Map, a Set, two methods, no atomicity between them. No mutex, no distributed lock — the loop is what remembers, by replaying the entire conversation on every pass:

userassistanttool
~0 tokens (approx.)
[user]Incident + queue.ts
[assistant]Requests store.ts
[tool]store.ts appended
[assistant]Final: atomic claimNext fix

The mechanism that does the replaying, generalized to parse a path out of the tool payload:

TSPY
src/run-agent.tsCopy
const runAgent = async (client: Anthropic) => {
	let response: Response | null = null;

	let steps = 0;
	const MAX_STEPS = 4;
	while (steps < MAX_STEPS) {
		steps++;
		const message = await client.messages.create({
			max_tokens: 4096,
			model: 'claude-haiku-4-5',
			messages: messages,
		});

		messages.push({
			role: 'assistant',
			content: message.content,
		});

		response = extractResponse(message);

		if (response?.finalAnswer === true) {
			console.log(response.content);
			break;
		}

		if (response?.finalAnswer === false && response.content.startsWith('read_file:')) {
			const path = response.content.replace('read_file:', '');
			console.log(`Not the model's final answer. Model asked for: ${path}`);
			messages.push({
				role: 'user',
				content: `Here's ${path}: ${readFileSync(path, 'utf-8')}`,
			});
		}
	}

	if (!response) {
      console.error('Ended loop without a response (hit MAX_STEPS).');
      process.exit(1);
	}
};

MAX_STEPS is 4; this run used 2. The cap exists for the run that doesn't converge, not this one.

Same model. Both answers it actually gave:

✗ invented an API
TSCopy
export async function claimJob(store: JobStore, workerId: string) {
  return await store.claimNextJob(workerId);
}

Agency lives in the loop, not the model. The model at the end is the same single-shot function it was at the start — no memory of its own, no way to check its work. What changed is the code around it, which is the entire distance between store.claimNextJob() and a claimNext() the model could actually explain.

I didn't know either answer above in advance. The bare call could have guessed right. The looped call could have reached for a distributed lock instead of noticing the await gap. It didn't, and I didn't edit it so that it wouldn't.

Structure

Three parts. Hover or tap one.

LOOPModelToolsrequestsresult
Loop

The control structure that turns repeated stateless calls into one continuous process — call, append, check the termination condition, call again until satisfied or capped. This, not the model, is the agent.

hover or click a part

Applicability

Reach for a loop when the model needs context from a space too large to hand it up front, and has to reason about which slice of that space actually matters for this input.

Stuffing everything into the context window doesn't scale — a codebase the model has never seen, a store implementation, a deploy topology that changes what "atomic" even means. Give it a map instead and a tool to pull in what it decides it needs, one fetch at a time, until the transcript holds enough to answer — and enough to be checked.

SignalVerdict
Context space bigger than the window✓ use it
Model must pick which slice matters✓ use it
Everything already fits in one message− skip it
A fixed number of steps, not a search− skip it
Sub-second latency budget− skip it

If a model reading your codebase and proposing changes sounds useful, that's Magent — same pattern, built out.

← All patterns

What's next

Tools

One tool that can do anything leaks a live secret. Four tools scoped to the task cannot.

→ Explore Tools