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 @@ -64,6 +64,10 @@ export default {
text: '4. Context and Conversation Management',
link: '/chapters/04-context',
},
{
text: '5. Implementing Tools and Function Calling',
link: '/chapters/05-tools',
},
],
},
],
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Published with VitePress at https://yagop.github.io/coding-agents-tutorial/. Eac
2. [Streaming Responses and Message Types](chapters/02-streaming.md)
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)

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

Expand Down
69 changes: 69 additions & 0 deletions chapters/05-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Implementing Tools and Function Calling

🔧 Everything you have built so far lives entirely inside the conversation: words in, words out. Tools are how your agent reaches past that wall and touches the real world - reading a file, calling an API, running a query. Here is the part worth getting straight before any code: the model never runs your code. It only *emits* a structured request to call a tool, and then *reads* a structured result you hand back. You own every step in between. Master this one round-trip - declare a tool, catch the request, run it, return the result - and every multi-tool workflow in later chapters is just this loop again.

You already have `new Anthropic()`, the env vars, content-block narrowing, and the `messages` array from Chapters 1-2 (Chapter 4 helps too), so here you only add the tool layer. As always the key comes from the environment - never hardcode it - and Bun auto-loads `.env`, so there is nothing to import.

## Declaring a tool and catching the call

A tool is a small contract you give the model: a `name`, a `description` of when to use it, and an `input_schema` - JSON Schema with `type`, `properties`, `required`, and `enum` for fixed value sets. You declare it with the SDK's own `Anthropic.Tool` type, not a hand-rolled interface, and pass it in the `tools` array. When the model decides to call it, it returns a `tool_use` content block carrying an `id`, the `name`, and an `input`.

<<< @/examples/05-tools/define-tool.ts

Run it to watch a `get_weather` call come back:

```sh
bun run examples/05-tools/define-tool.ts
```

Notice that you find the call by narrowing `response.content` to the block whose `type === 'tool_use'` - an `Anthropic.ToolUseBlock` - exactly the way you narrowed `text` blocks before.

::: warning The one gotcha worth tattooing on your wrist
`block.input` arrives **already parsed** as an object from the SDK. Do not `JSON.parse` it, and never raw-string-match the serialized JSON to pull out an argument - read `block.input.location` like any plain object property.
:::

## Closing the loop with tool_result

Catching the call is half the round-trip; now you close it. The flow is a fixed five-step dance, and it is worth holding the whole shape in your head before you read the code:

```text
create(tools) -> stop_reason 'tool_use' -> run the tool locally
-> create again with a tool_result -> final text answer
```

When `stop_reason === 'tool_use'`, you append the assistant's content **verbatim** as an assistant turn, run the tool yourself, then send a new `user` turn whose `content` is a `tool_result` block - an `Anthropic.ToolResultBlockParam` that echoes the `tool_use_id` exactly. That echo is the rule that binds request to answer: every `tool_use` block in a turn needs a matching `tool_result`, or the next `create` call rejects the array.

<<< @/examples/05-tools/single-tool-loop.ts

The second `create` carries the assistant turn and your `tool_result` back, so the model reads the weather you fetched and writes a final sentence - and this time `stop_reason` comes back `end_turn` because the model is done. (You will meet the other `stop_reason` values as the chapters need them.)

## Dispatching and recovering from errors

Real agents carry more than one tool, so a friendlier pattern is a map from `block.name` to a typed handler. You read `block.input` as the parsed object it already is, call the handler that matches the name, and build each `tool_result` from what the handler returns.

<<< @/examples/05-tools/dispatch-handlers.ts

Each handler owns one tool, and the dispatch map is the only thing that grows as you add more - the loop around it never changes. The handlers return the `tool_result` content the model reads next.

Handlers meet messy input, so they must not throw. When a tool's parsed arguments are wrong, return a `tool_result` with `is_error: true` and an informative message instead of crashing - the model reads the message and can correct itself or retry on the next turn.

<<< @/examples/05-tools/tool-errors.ts

The error result rides back in the exact same `tool_result` shape as a success; only `is_error: true` and the explaining message differ. A thrown exception kills your process and tells the model nothing - this hands it a chance to recover.

## Steering which tool the model picks

You will spend more time on descriptions than on schemas, and it pays off: the model chooses a tool almost entirely from your `description`. Lead with "call this when..." phrasing, give every property its own description, and use `enum` to pin a field to a fixed set so the model cannot invent a value. When you need a firmer hand, `tool_choice` overrides the model's own judgment.

| `tool_choice` | Effect |
| --- | --- |
| `{ type: 'auto' }` | Model decides whether to call a tool (the default). |
| `{ type: 'any' }` | Model must call some tool, its pick. |
| `{ type: 'tool', name }` | Model must call this exact tool. |
| `{ type: 'none' }` | Model may not call any tool. |

<<< @/examples/05-tools/tool-choice.ts

Each mode is a single line of config, and because this prompt plainly needs the weather tool, every mode here calls it - the modes only pull apart on a prompt the model could answer in plain text, where `auto` stays text while `any` and `{ type: 'tool' }` force the call. Adding `disable_parallel_tool_use: true` caps the model at one tool call per turn - the simplest way to keep this chapter's single round-trip single while you find your footing.

What's next: Chapter 6 - Building Tool Chains and Complex Workflows.
45 changes: 45 additions & 0 deletions examples/05-tools/define-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// bun run examples/05-tools/define-tool.ts

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

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

// name, "call this when..." description, and a JSON Schema input_schema are the whole contract.
const getWeather: Anthropic.Tool = {
name: 'get_weather',
description: 'Call this when the user asks about current weather in a specific place.',
input_schema: {
type: 'object',
properties: {
location: { type: 'string', description: 'City and region, e.g. "Paris, France".' },
unit: { type: 'string', enum: ['celsius', 'fahrenheit'], description: 'Temperature unit.' },
},
required: ['location'],
},
};

// The SDK types block.input loosely, so cast it to the shape this tool produces.
type WeatherInput = { location: string; unit?: 'celsius' | 'fahrenheit' };

const message = await client.messages.create({
model,
max_tokens: 512,
tools: [getWeather],
messages: [{ role: 'user', content: 'What is the weather like in Tokyo right now?' }],
});

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

// Narrow on type to pick the tool_use block out of the content array.
const toolUse = message.content.find(
(block): block is Anthropic.ToolUseBlock => block.type === 'tool_use',
);

if (toolUse) {
// block.input arrives already parsed from the SDK - cast it, never JSON.parse it.
const input = toolUse.input as WeatherInput;
console.log('id:', toolUse.id);
console.log('name:', toolUse.name);
console.log('input:', input);
}
78 changes: 78 additions & 0 deletions examples/05-tools/dispatch-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// bun run examples/05-tools/dispatch-handlers.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: 'Call this when the user asks about current weather in a city.',
input_schema: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, e.g. Madrid.' } },
required: ['city'],
},
},
{
name: 'add',
description: 'Call this to add two numbers together.',
input_schema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First addend.' },
b: { type: 'number', description: 'Second addend.' },
},
required: ['a', 'b'],
},
},
];

// One typed shape per tool input. The SDK types block.input loosely, so each
// handler casts it to the matching shape before reading fields.
type WeatherInput = { city: string };
type AddInput = { a: number; b: number };

// A handler takes the already-parsed block.input object and returns tool_result content.
type Handler = (input: Anthropic.ToolUseBlock['input']) => string;

// One entry per tool name; this map is the only thing that grows as you add tools.
const handlers = new Map<string, Handler>([
['get_weather', (input) => `It is 21C and sunny in ${(input as WeatherInput).city}.`],
['add', (input) => {
const { a, b } = input as AddInput;
return String(a + b);
}],
]);

const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: 'What is the weather in Madrid, and what is 19 plus 23?' },
];

const message = await client.messages.create({ model, max_tokens: 512, tools, messages });

if (message.stop_reason === 'tool_use') {
messages.push({ role: 'assistant', content: message.content });

const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of message.content) {
if (block.type !== 'tool_use') continue;
const handler = handlers.get(block.name);
// Every tool_use needs a matching tool_result, so report a missing handler as an error.
if (!handler) {
const content = `No handler registered for tool ${block.name}.`;
results.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: true });
continue;
}
// block.input is already a parsed object from the SDK - never JSON.parse it.
const answer = handler(block.input);
results.push({ type: 'tool_result', tool_use_id: block.id, content: answer });
}

messages.push({ role: 'user', content: results });
const final = await client.messages.create({ model, max_tokens: 512, tools, messages });

const text = final.content.find((block) => block.type === 'text');
console.log('claude:', text?.text ?? '');
}
49 changes: 49 additions & 0 deletions examples/05-tools/single-tool-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// bun run examples/05-tools/single-tool-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: 'Call this when the user asks about current weather in a city.',
input_schema: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, e.g. Madrid' } },
required: ['city'],
},
},
];

type WeatherInput = { city: string };

function getWeather(input: WeatherInput): string {
return `It is 21C and sunny in ${input.city}.`;
}

const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: 'What is the weather in Madrid right now?' },
];

const first = await client.messages.create({ model, max_tokens: 512, tools, messages });

if (first.stop_reason === 'tool_use') {
// Append the assistant turn verbatim - the model needs to see its own tool_use block.
messages.push({ role: 'assistant', content: first.content });

const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of first.content) {
if (block.type !== 'tool_use') continue;
// block.input is already a parsed object from the SDK - never string-match the raw JSON.
const answer = getWeather(block.input as WeatherInput);
results.push({ type: 'tool_result', tool_use_id: block.id, content: answer });
}

messages.push({ role: 'user', content: results });

const second = await client.messages.create({ model, max_tokens: 512, tools, messages });
const text = second.content.find((b) => b.type === 'text');
console.log(text?.text ?? '');
}
44 changes: 44 additions & 0 deletions examples/05-tools/tool-choice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// bun run examples/05-tools/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_weather',
description: 'Call this when the user asks about weather in a city.',
input_schema: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, e.g. Madrid.' } },
required: ['city'],
},
},
];

const prompt = 'What is the weather in Madrid?';

// Same prompt, four ways of steering the model toward (or away from) the tool.
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_weather' } },
{ label: 'auto + no parallel', tool_choice: { type: 'auto', disable_parallel_tool_use: true } },
];

for (const { label, tool_choice } of choices) {
const message = await client.messages.create({
model,
max_tokens: 256,
tools,
tool_choice,
messages: [{ role: 'user', content: prompt }],
});

const used = message.content.find(
(block): block is Anthropic.ToolUseBlock => block.type === 'tool_use',
);
const chose = used ? used.name : 'none (answered in text)';
console.log(`${label.padEnd(18)} stop_reason=${message.stop_reason} chose=${chose}`);
}
68 changes: 68 additions & 0 deletions examples/05-tools/tool-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// bun run examples/05-tools/tool-errors.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: 'divide',
description: 'Divide one number by another. Call this for any division.',
input_schema: {
type: 'object',
properties: {
numerator: { type: 'number', description: 'The number being divided.' },
denominator: { type: 'number', description: 'The number to divide by.' },
},
required: ['numerator', 'denominator'],
},
},
];

type DivideInput = { numerator: number; denominator: number };

// block.input arrives as a parsed object from the SDK - read it, never re-parse the raw JSON.
function divide(block: Anthropic.ToolUseBlock): Anthropic.ToolResultBlockParam {
const { numerator, denominator } = block.input as DivideInput;

// On bad input, return is_error: true with a message the model can act on - do not throw.
if (denominator === 0) {
return {
type: 'tool_result',
tool_use_id: block.id,
is_error: true,
content: 'denominator was 0; division is undefined. Ask the user for a non-zero divisor.',
};
}

// A normal success result echoes the same tool_use_id and omits is_error.
return {
type: 'tool_result',
tool_use_id: block.id,
content: String(numerator / denominator),
};
}

const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: 'Use the divide tool: first compute 7 / 0, then 9 / 3.' },
];
const message = await client.messages.create({ model, max_tokens: 512, tools, messages });

if (message.stop_reason === 'tool_use') {
messages.push({ role: 'assistant', content: message.content });

const results = message.content
.filter((block) => block.type === 'tool_use')
.map(divide);

for (const result of results) {
console.log(`${result.is_error ? 'error' : 'ok'}: ${result.content}`);
}

messages.push({ role: 'user', content: results });
const answer = await client.messages.create({ model, max_tokens: 512, tools, messages });

const text = answer.content.find((block) => block.type === 'text');
console.log('claude:', text?.text ?? '');
}