How to give Claude tools, handle tool calls in a loop, and build reliable agentic workflows.
March 31, 2026

A language model that can only generate text has a ceiling. It can explain how to check the weather but it can't check the weather. Tool use breaks through that ceiling — Claude can now call functions you define, use the results, and continue reasoning based on real data.
This guide covers how tool use works in the Anthropic SDK, how to build a reliable tool-call loop, and the patterns that make agentic workflows production-ready.
You define tools as JSON schema descriptions of functions. Claude decides when to call them, constructs the arguments, and you execute the actual function. Claude then uses the result to continue its response.
The basic flow:
tools array describing available functionstool_use content block with the function name and argumentsimport Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
// Step 1: Define tools
const tools: Anthropic.Tool[] = [
{
name: 'get_post_count',
description: 'Returns the number of published posts for a given tenant',
input_schema: {
type: 'object' as const,
properties: {
tenantSlug: {
type: 'string',
description: 'The tenant slug (e.g., "arunabh-blog")',
},
},
required: ['tenantSlug'],
},
},
]
// Step 2: Send initial message
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
tools,
messages: [
{ role: 'user', content: 'How many posts does arunabh-blog have?' },
],
})
A single round-trip isn't always enough. Claude might need to call multiple tools in sequence, or call the same tool with different arguments. Build a loop:
async function runAgentLoop(
initialMessage: string,
tools: Anthropic.Tool[],
toolHandlers: Record<string, (input: unknown) => Promise<unknown>>
): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: initialMessage },
]
while (true) {
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
tools,
messages,
})
// If Claude is done, return its final text response
if (response.stop_reason === 'end_turn') {
const textBlock = response.content.find(b => b.type === 'text')
return textBlock?.type === 'text' ? textBlock.text : ''
}
// If Claude wants to use tools, execute them
if (response.stop_reason === 'tool_use') {
// Add Claude's response (including tool_use blocks) to messages
messages.push({ role: 'assistant', content: response.content })
// Execute each tool call and collect results
const toolResults: Anthropic.ToolResultBlockParam[] = []
for (const block of response.content) {
if (block.type !== 'tool_use') continue
const handler = toolHandlers[block.name]
if (!handler) {
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: `Error: unknown tool "${block.name}"`,
is_error: true,
})
continue
}
try {
const result = await handler(block.input)
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result),
})
} catch (err) {
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: `Error: ${(err as Error).message}`,
is_error: true,
})
}
}
// Add tool results and continue the loop
messages.push({ role: 'user', content: toolResults })
}
}
}
Here's a practical set of tools for a blog management agent:
const blogTools: Anthropic.Tool[] = [
{
name: 'list_posts',
description: 'List published posts for a tenant, optionally filtered by category',
input_schema: {
type: 'object' as const,
properties: {
tenantSlug: { type: 'string' },
categorySlug: { type: 'string', description: 'Optional category filter' },
limit: { type: 'number', default: 10 },
},
required: ['tenantSlug'],
},
},
{
name: 'get_post',
description: 'Get the full content of a single post by slug',
input_schema: {
type: 'object' as const,
properties: {
slug: { type: 'string' },
},
required: ['slug'],
},
},
{
name: 'create_draft',
description: 'Create a draft post (status: draft, not published)',
input_schema: {
type: 'object' as const,
properties: {
title: { type: 'string' },
slug: { type: 'string' },
description: { type: 'string' },
mdxContent: { type: 'string' },
tenantSlug: { type: 'string' },
categorySlug: { type: 'string' },
},
required: ['title', 'slug', 'description', 'mdxContent', 'tenantSlug'],
},
},
]
const blogHandlers = {
list_posts: async (input: unknown) => {
const { tenantSlug, categorySlug, limit } = input as {
tenantSlug: string
categorySlug?: string
limit?: number
}
// ... fetch from Payload API
},
get_post: async (input: unknown) => {
const { slug } = input as { slug: string }
// ... fetch from Payload API
},
create_draft: async (input: unknown) => {
const params = input as { title: string; slug: string; /* ... */ }
// ... create via Payload REST API
},
}
The description field is the most important part of a tool definition. Claude reads it to decide when to call the tool and how to use it. Be specific:
// ❌ Vague
{ name: 'search', description: 'Search for things' }
// ✅ Specific
{
name: 'search_posts',
description: 'Full-text search across all published posts. Use when the user asks about specific topics, keywords, or wants to find related content. Returns post titles, slugs, and descriptions.',
}
The description should answer: when should Claude use this, what does it return, and what are its limitations.
Add a maximum iteration count to the agent loop:
async function runAgentLoop(
initialMessage: string,
tools: Anthropic.Tool[],
handlers: Record<string, (input: unknown) => Promise<unknown>>,
maxIterations = 10
): Promise<string> {
let iterations = 0
// ... same loop as before, but:
while (iterations < maxIterations) {
iterations++
// ... rest of loop
}
throw new Error(`Agent loop exceeded ${maxIterations} iterations`)
}
Ten iterations is usually more than enough for practical tasks. If your agent needs more, it's likely stuck in a reasoning loop — add better tool descriptions or break the task into smaller steps.
tool_result blocks — Claude needs the full contextis_error: true rather than throwing — let Claude decide how to recover