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.
// 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);
}Run it to watch a get_weather call come back:
bun run examples/05-tools/define-tool.tsNotice 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.
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:
create() with tools
│
▼
stop_reason: 'tool_use'
│
▼
run the tool locally
│
▼
create() with the tool_result
│
▼
final text answerWhen 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.
// 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 ?? '');
}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.
// 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 ?? '');
}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.
// 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 ?? '');
}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. |
// 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}`);
}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.