Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export default {
text: '5. Implementing Tools and Function Calling',
link: '/chapters/05-tools',
},
{
text: '6. Building Tool Chains and Complex Workflows',
link: '/chapters/06-tool-chains',
},
],
},
],
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Published with VitePress at https://yagop.github.io/coding-agents-tutorial/. Eac
3. [Handling User Requests: REPL and Telegram Bot](chapters/03-repl-telegram.md)
4. [Context and Conversation Management](chapters/04-context.md)
5. [Implementing Tools and Function Calling](chapters/05-tools.md)
6. [Building Tool Chains and Complex Workflows](chapters/06-tool-chains.md)

More chapters are tracked as issues and land here as they are written.

Expand Down
65 changes: 65 additions & 0 deletions chapters/06-tool-chains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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.

<<< @/examples/06-tool-chains/agent-loop.ts

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.

<<< @/examples/06-tool-chains/sequential-chain.ts

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.

<<< @/examples/06-tool-chains/parallel-tools.ts

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.

<<< @/examples/06-tool-chains/tool-choice.ts

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

::: warning 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.

<<< @/examples/06-tool-chains/agent-runner.ts

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.

<<< @/examples/06-tool-chains/runner-with-stream.ts

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

::: details 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.
45 changes: 45 additions & 0 deletions examples/06-tool-chains/agent-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 });
}
69 changes: 69 additions & 0 deletions examples/06-tool-chains/agent-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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);
44 changes: 44 additions & 0 deletions examples/06-tool-chains/parallel-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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 });
}
50 changes: 50 additions & 0 deletions examples/06-tool-chains/runner-with-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// 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');
62 changes: 62 additions & 0 deletions examples/06-tool-chains/sequential-chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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 });
}
38 changes: 38 additions & 0 deletions examples/06-tool-chains/tool-choice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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}`);
}