Skip to content

Building Tool Chains and Complex Workflows

🔁 In Chapter 5 you ran a single tool round-trip: one call, one result, one answer. Real work rarely fits in one exchange - the model looks something up, reads the result, decides it needs one more thing, and only then writes its reply. This chapter turns that single round-trip into an engine: a loop that keeps calling client.messages.create until the model is finally done. Get this loop right and everything else - sequential chains, parallel calls, a reusable runner - is just a variation on it.

You already have tools, tool_use/tool_result, and content-block narrowing from Chapter 5, so here you only add the loop around them. As always the key comes from the environment - never hardcode it - and Bun auto-loads .env.

The agent loop

The engine is smaller than you expect: call the model, then branch on stop_reason. If it is end_turn, the model is done - print the text and stop. If it is tool_use, run the requested tools, feed the results back, and call again. The one rule a beginner cannot guess from the types: append the model's entire response.content as the assistant turn before you add any tool_result, so every tool_use block is answered exactly once.

ts
// bun run examples/06-tool-chains/agent-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: 'get_weather',
    description: 'Get the current weather for a city. Call this when asked about weather.',
    input_schema: {
      type: 'object',
      properties: { city: { type: 'string', description: 'City name, e.g. Paris' } },
      required: ['city'],
    },
  },
];

function runTool(block: Anthropic.ToolUseBlock): string {
  const { city } = block.input as { city: string };
  return `It is 18C and clear in ${city}.`;
}

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'What is the weather in Paris, and is it warmer than Oslo?' },
];

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;
    results.push({ type: 'tool_result', tool_use_id: block.id, content: runTool(block) });
  }
  messages.push({ role: 'user', content: results });
}

Run it and watch the loop turn over more than once - the model asks about Paris and Oslo, reads both, then answers:

sh
bun run examples/06-tool-chains/agent-loop.ts

The loop exits on stop_reason !== 'tool_use'; end_turn is the everyday case, and you will meet max_tokens and pause_turn when a later chapter needs them.

Chaining and parallel calls

Two shapes of multi-tool work fall out of the same loop. A sequential chain is when one tool's output is the next tool's input - look up an id, then fetch something for that id. You do not orchestrate this yourself: the model calls the first tool, reads the result on the next turn, and only then calls the second.

ts
// bun run examples/06-tool-chains/sequential-chain.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: 'find_user',
    description: 'Look up a user id by name. Call this first when you only have a name.',
    input_schema: {
      type: 'object',
      properties: { name: { type: 'string', description: 'Full name' } },
      required: ['name'],
    },
  },
  {
    name: 'get_balance',
    description: 'Get the account balance for a user id. Needs the id from find_user.',
    input_schema: {
      type: 'object',
      properties: { userId: { type: 'string', description: 'User id like u_42' } },
      required: ['userId'],
    },
  },
];

function runTool(block: Anthropic.ToolUseBlock): string {
  if (block.name === 'find_user') {
    const { name } = block.input as { name: string };
    return name.includes('Ada') ? 'u_42' : 'u_unknown';
  }
  const { userId } = block.input as { userId: string };
  return userId === 'u_42' ? '1200.50 USD' : 'no such account';
}

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: "What is Ada Lovelace's account balance?" },
];

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

  for (const block of response.content) {
    if (block.type === 'tool_use') console.log(`-> ${block.name}(${JSON.stringify(block.input)})`);
  }

  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;
    results.push({ type: 'tool_result', tool_use_id: block.id, content: runTool(block) });
  }
  messages.push({ role: 'user', content: results });
}

The model serializes the dependent calls on its own - find_user returns u_42, and only the following turn calls get_balance with it.

When calls are independent, the model often asks for them all in one turn - several tool_use blocks at once. Filter them with b.type === 'tool_use' and resolve them together with Promise.all, returning one tool_result per block.

ts
// bun run examples/06-tool-chains/parallel-tools.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: 'get_weather',
    description: 'Get the current weather for one city. Call once per city.',
    input_schema: {
      type: 'object',
      properties: { city: { type: 'string', description: 'City name' } },
      required: ['city'],
    },
  },
];

async function runTool(block: Anthropic.ToolUseBlock): Promise<Anthropic.ToolResultBlockParam> {
  const { city } = block.input as { city: string };
  await new Promise((resolve) => setTimeout(resolve, 100));
  return { type: 'tool_result', tool_use_id: block.id, content: `20C in ${city}` };
}

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'Compare the weather in Paris, Tokyo, and Cairo right now.' },
];

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 calls = response.content.filter((b) => b.type === 'tool_use');
  console.log(`running ${calls.length} tool call(s) for this turn`);
  const results = await Promise.all(calls.map(runTool));
  messages.push({ role: 'user', content: results });
}

Independent results all ride back in a single user turn, so the model gets the whole batch at once. The rule of thumb: return what you can in parallel, and let the model serialize whatever genuinely depends on an earlier answer.

Steering the loop with tool_choice

tool_choice decides whether - and which - tool the model must call, and it touches your loop in one sharp way. With auto the model picks; with none it may not call a tool at all; with any or { type: 'tool', name } it is forced to call one.

ts
// bun run examples/06-tool-chains/tool-choice.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: 'get_time',
    description: 'Get the current time in a city.',
    input_schema: {
      type: 'object',
      properties: { city: { type: 'string', description: 'City name' } },
      required: ['city'],
    },
  },
];

const prompt = 'Just say hello - do not look anything up.';
const choices: { label: string; tool_choice: Anthropic.ToolChoice }[] = [
  { label: 'auto', tool_choice: { type: 'auto' } },
  { label: 'any', tool_choice: { type: 'any' } },
  { label: 'tool', tool_choice: { type: 'tool', name: 'get_time' } },
  { label: 'none', tool_choice: { type: 'none' } },
];

for (const { label, tool_choice } of choices) {
  const response = await client.messages.create({
    model,
    max_tokens: 256,
    tools,
    tool_choice,
    messages: [{ role: 'user', content: prompt }],
  });
  const calledTool = response.content.some((b) => b.type === 'tool_use');
  console.log(`${label.padEnd(5)} stop_reason=${response.stop_reason} called_tool=${calledTool}`);
}

The gotcha worth a warning: a forced tool call comes back as stop_reason: 'tool_use', never end_turn on that turn.

Forcing and termination

Because { type: 'any' } and { type: 'tool', name } guarantee a tool_use turn, the model can never say "I'm done" while a tool is forced. Keep the loop's exit condition on end_turn, and only force on the turns where you actually want a call - force every iteration and the loop runs forever.

A reusable runner

Everything so far has been one-off scripts. Lift the loop into a function and it becomes a runner you can drop in front of any input: pass the messages, the Anthropic.Tool[], and a Map from tool name to handler, plus a maxIterations cap so a misbehaving model can never spin forever, and is_error: true whenever a handler is missing or throws.

ts
// bun run examples/06-tool-chains/agent-runner.ts

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

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

type Handler = (input: Anthropic.ToolUseBlock['input']) => Promise<string> | string;

async function runAgent(
  messages: Anthropic.MessageParam[],
  tools: Anthropic.Tool[],
  handlers: Map<string, Handler>,
  maxIterations = 10,
): Promise<string> {
  for (let i = 0; i < maxIterations; i++) {
    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');
      return text?.text ?? '';
    }

    const calls = response.content.filter((b) => b.type === 'tool_use');
    const results = await Promise.all(
      calls.map(async (block): Promise<Anthropic.ToolResultBlockParam> => {
        const handler = handlers.get(block.name);
        if (!handler) {
          return { type: 'tool_result', tool_use_id: block.id, content: `unknown tool ${block.name}`, is_error: true };
        }
        try {
          return { type: 'tool_result', tool_use_id: block.id, content: await handler(block.input) };
        } catch (error) {
          const reason = error instanceof Error ? error.message : String(error);
          return { type: 'tool_result', tool_use_id: block.id, content: reason, is_error: true };
        }
      }),
    );
    messages.push({ role: 'user', content: results });
  }
  return `stopped after ${maxIterations} iterations`;
}

const tools: Anthropic.Tool[] = [
  {
    name: 'add',
    description: 'Add two numbers and return the sum.',
    input_schema: {
      type: 'object',
      properties: { a: { type: 'number', description: 'First addend' }, b: { type: 'number', description: 'Second addend' } },
      required: ['a', 'b'],
    },
  },
];

const handlers = new Map<string, Handler>([
  ['add', (input) => {
    const { a, b } = input as { a: number; b: number };
    return String(a + b);
  }],
]);

const answer = await runAgent(
  [{ role: 'user', content: 'What is 19 + 23, and then add 100 to that result?' }],
  tools,
  handlers,
);
console.log(answer);

The handler Map is the only thing that grows per app; the loop, the cap, and the error handling stay put. This is the same runner you would wire to the Telegram bot from Chapter 3.

Because the body is just the loop, you can swap create for stream plus finalMessage() to surface the model's text as it arrives - the tool round-trips happen quietly in between.

ts
// bun run examples/06-tool-chains/runner-with-stream.ts

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

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

type Handler = (input: Anthropic.ToolUseBlock['input']) => string;

const tools: Anthropic.Tool[] = [
  {
    name: 'roll_die',
    description: 'Roll a die with the given number of sides and return the result.',
    input_schema: {
      type: 'object',
      properties: { sides: { type: 'number', description: 'Number of sides' } },
      required: ['sides'],
    },
  },
];

const handlers = new Map<string, Handler>([
  ['roll_die', (input) => {
    const { sides } = input as { sides: number };
    return String(1 + Math.floor(Math.random() * sides));
  }],
]);

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'Roll a 20-sided die, then tell me whether it beat a 10.' },
];

for (let i = 0; i < 10; i++) {
  const stream = client.messages.stream({ model, max_tokens: 1024, tools, messages });
  stream.on('text', (delta) => process.stdout.write(delta));
  const response = await stream.finalMessage();
  messages.push({ role: 'assistant', content: response.content });

  if (response.stop_reason !== 'tool_use') break;

  const results: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== 'tool_use') continue;
    const handler = handlers.get(block.name);
    const content = handler ? handler(block.input) : `unknown tool ${block.name}`;
    results.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: !handler });
  }
  messages.push({ role: 'user', content: results });
}
process.stdout.write('\n');

finalMessage() hands back the same assembled Message - stop_reason, content, and all - so the loop logic is unchanged; only the output went live.

Going deeper: betaZodTool and toolRunner

Once you have written the loop by hand, the SDK's client.beta.messages.toolRunner is a thin wrapper over exactly this shape - it runs the create / tool / result cycle for you. Pair it with betaZodTool (from @anthropic-ai/sdk/helpers/beta/zod) to define a tool from a Zod schema and receive a typed input instead of casting. Reach for it once the hand-written loop feels obvious; until then, the loop above is the version whose behavior you can actually see.

What's next: Chapter 7 - Advanced Agent Patterns.