Skip to content

Advanced Agent Patterns

🧠 Chapters 5 and 6 gave you a loop that calls tools until the model is done. That loop is the skeleton; this chapter adds the muscles - a step budget so it can never run away, extended thinking so it reasons before it acts, self-critique so it catches its own mistakes, structured output you can parse, error recovery, and a coordinator that hands work to helper agents. Each pattern is a small, independent addition to the loop you already know.

You already have the tool round-trip and the agent loop from Chapters 5 and 6, so here you only add patterns on top. As always the key comes from the environment - never hardcode it - and Bun auto-loads .env.

The autonomous loop

An autonomous agent is the loop with a leash. You accumulate messages, branch on stop_reason, and - the part beginners skip - cap the number of steps so a confused model can never spin forever. Track usage as you go, so you can see what each run costs.

ts
// bun run examples/07-advanced-patterns/autonomous-loop.ts

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';

const tools: Anthropic.Tool[] = [
  {
    name: 'word_length',
    description: 'Return the number of letters in a single word.',
    input_schema: {
      type: 'object',
      properties: { word: { type: 'string', description: 'One word' } },
      required: ['word'],
    },
  },
];

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'Use the tool to compare the lengths of "agent" and "orchestration", then say which is longer.' },
];

const maxSteps = 6;
let totalTokens = 0;

for (let step = 0; step < maxSteps; step++) {
  const response = await client.messages.create({ model, max_tokens: 1024, tools, messages });
  totalTokens += response.usage.input_tokens + response.usage.output_tokens;
  messages.push({ role: 'assistant', content: response.content });
  console.log(`step ${step + 1}: stop_reason=${response.stop_reason} total_tokens=${totalTokens}`);

  if (response.stop_reason !== 'tool_use') {
    const text = response.content.find((b) => b.type === 'text');
    console.log(text?.text ?? '');
    break;
  }

  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== 'tool_use') continue;
    const { word } = block.input as { word: string };
    results.push({ type: 'tool_result', tool_use_id: block.id, content: String(word.length) });
  }
  messages.push({ role: 'user', content: results });
}

Run it and watch the step count and running token total print each iteration:

sh
bun run examples/07-advanced-patterns/autonomous-loop.ts

The loop exits two ways: cleanly on end_turn, or defensively when maxSteps is hit - never trusting the model to stop on its own.

Going deeper: token guardrails

That running totalTokens is the hook for a hard budget. Read usage.input_tokens and usage.output_tokens after each step and break once a threshold is crossed - a runaway agent costs money one step at a time, and inside the loop is the only place to catch it.

Thinking and reflecting

Sometimes you want the model to reason before it answers. Extended thinking turns that reasoning into its own thinking content block you can read, separate from the final text - and those thinking tokens count toward usage.

ts
// bun run examples/07-advanced-patterns/extended-thinking.ts

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';

const response = await client.messages.create({
  model,
  max_tokens: 2048,
  thinking: { type: 'adaptive' },
  messages: [
    { role: 'user', content: 'A bat and a ball cost 1.10 in total. The bat costs 1.00 more than the ball. How much is the ball?' },
  ],
});

console.log('stop_reason:', response.stop_reason);

for (const block of response.content) {
  if (block.type === 'thinking') {
    console.log('\n[thinking]\n' + block.thinking);
  }
  if (block.type === 'text') {
    console.log('\n[answer]\n' + block.text);
  }
}

You enable it with thinking: { type: 'adaptive' }, then narrow on block.type === 'thinking' to read block.thinking.

Older models

adaptive is the current shape. On models 4.6 and earlier, use thinking: { type: 'enabled', budget_tokens: N } instead (at least 1024, and below max_tokens).

A cheaper kind of reasoning is to let the model grade its own work. After it writes a draft, you ask it to critique and revise - but the critique must go back as a plain user turn, never as a fabricated tool_result.

ts
// bun run examples/07-advanced-patterns/reflection.ts

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'Write a one-sentence tagline for a tool that turns shell commands into plain English.' },
];

const draft = await client.messages.create({ model, max_tokens: 512, messages });
const draftText = draft.content.find((b) => b.type === 'text');
console.log('draft:', draftText?.text ?? '');

messages.push({ role: 'assistant', content: draft.content });
messages.push({
  role: 'user',
  content: 'Critique your tagline for clarity and length, then write one improved version.',
});

const revised = await client.messages.create({ model, max_tokens: 512, messages });
const revisedText = revised.content.find((b) => b.type === 'text');
console.log('\nrevised:', revisedText?.text ?? '');

The draft is appended verbatim as the assistant turn, and the critique request is an ordinary user message - so the model treats it as feedback to act on, not as a tool's output.

Structured output and recovering from errors

When you need machine-readable output instead of prose, give the model one tool and force it with tool_choice - it must call the tool, so its input becomes your typed result.

ts
// bun run examples/07-advanced-patterns/structured-output.ts

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';

type Sentiment = { label: 'positive' | 'negative' | 'neutral'; confidence: number };

const emitResult: Anthropic.Tool = {
  name: 'emit_result',
  description: 'Return the sentiment classification as structured data.',
  input_schema: {
    type: 'object',
    properties: {
      label: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
      confidence: { type: 'number', description: 'A value from 0 to 1' },
    },
    required: ['label', 'confidence'],
  },
};

const response = await client.messages.create({
  model,
  max_tokens: 512,
  tools: [emitResult],
  tool_choice: { type: 'tool', name: 'emit_result' },
  messages: [
    { role: 'user', content: 'Classify the sentiment of: "This refactor saved me hours, fantastic work."' },
  ],
});

const block = response.content.find((b) => b.type === 'tool_use');
if (block && block.type === 'tool_use') {
  const result = block.input as Sentiment;
  console.log('label:', result.label);
  console.log('confidence:', result.confidence);
}

The enum and required fields pin the shape; you read block.input as your own type and skip parsing free text entirely.

Real agents also hit failures - a rate limit, a tool that throws. Retry transient API errors with backoff, and turn tool failures into tool_result blocks with is_error: true so the model can adapt instead of crashing.

ts
// bun run examples/07-advanced-patterns/error-recovery.ts

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';

const tools: Anthropic.Tool[] = [
  {
    name: 'read_file',
    description: 'Read a text file by name.',
    input_schema: {
      type: 'object',
      properties: { path: { type: 'string', description: 'File name' } },
      required: ['path'],
    },
  },
];

function readFile(path: string): string {
  if (path !== 'notes.txt') throw new Error(`no such file: ${path}`);
  return 'remember to water the plants';
}

async function createWithBackoff(messages: Anthropic.MessageParam[]): Promise<Anthropic.Message> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await client.messages.create({ model, max_tokens: 1024, tools, messages });
    } catch (error) {
      if (error instanceof Anthropic.RateLimitError && attempt < 2) {
        await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
        continue;
      }
      throw error;
    }
  }
  throw new Error('unreachable');
}

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'Read notes.txt and config.txt, then tell me what each says or whether it is missing.' },
];

while (true) {
  const response = await createWithBackoff(messages);
  messages.push({ role: 'assistant', content: response.content });

  if (response.stop_reason !== 'tool_use') {
    const text = response.content.find((b) => b.type === 'text');
    console.log(text?.text ?? '');
    break;
  }

  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== 'tool_use') continue;
    const { path } = block.input as { path: string };
    try {
      results.push({ type: 'tool_result', tool_use_id: block.id, content: readFile(path) });
    } catch (error) {
      const reason = error instanceof Error ? error.message : String(error);
      results.push({ type: 'tool_result', tool_use_id: block.id, content: reason, is_error: true });
    }
  }
  messages.push({ role: 'user', content: results });
}

Anthropic.RateLimitError gets exponential backoff; a thrown tool becomes an error result the model reads and works around - here it reports the missing file instead of stalling.

Orchestrating subagents

For bigger jobs, one agent becomes a coordinator: it holds the high-level tools, and each tool call is dispatched to a helper that runs its own client.messages.create with a narrow job and its own context.

ts
// bun run examples/07-advanced-patterns/orchestrator.ts

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic();
const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6';

async function summarize(text: string): Promise<string> {
  const response = await client.messages.create({
    model,
    max_tokens: 256,
    system: 'You summarize text in one short sentence.',
    messages: [{ role: 'user', content: text }],
  });
  const block = response.content.find((b) => b.type === 'text');
  return block?.text ?? '';
}

const tools: Anthropic.Tool[] = [
  {
    name: 'summarize_text',
    description: 'Summarize a block of text into one sentence.',
    input_schema: {
      type: 'object',
      properties: { text: { type: 'string', description: 'Text to summarize' } },
      required: ['text'],
    },
  },
];

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'Summarize this for me: "The agent loop calls the model, runs any tools it asks for, and repeats until the model is done."' },
];

while (true) {
  const response = await client.messages.create({ model, max_tokens: 1024, tools, messages });
  messages.push({ role: 'assistant', content: response.content });

  if (response.stop_reason !== 'tool_use') {
    const text = response.content.find((b) => b.type === 'text');
    console.log(text?.text ?? '');
    break;
  }

  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== 'tool_use') continue;
    const { text } = block.input as { text: string };
    results.push({ type: 'tool_result', tool_use_id: block.id, content: await summarize(text) });
  }
  messages.push({ role: 'user', content: results });
}

The coordinator never sees the subagent's internal turns - only its final result comes back as a tool_result, which keeps each context small and focused.

Going deeper: a planning tool

A common first move for a coordinator is a dedicated make_plan tool that returns an explicit task list. The loop then iterates those subtasks on following turns, dispatching each to a helper - so planning and doing become separate, inspectable steps.

What's next: Chapter 8 - Production and Deployment.