Building AI Agents with Tool Use: Claude, Functions & Autonomous Workflows (2026)
The complete guide to building production AI agents with tool use in 2026. Learn how Claude
LLM vs Agent: What's Actually Different?
An LLM call is a single exchange: you send a prompt, the model returns a response, done. This works perfectly for answering questions, generating content, and summarizing text — tasks where all the information needed is in the prompt.
An AI agent is something different: it's an LLM in a loop that can take actions in the world, observe the results, and decide what to do next — all without human intervention between steps. When you ask an agent "research this company and write a report," it doesn't just generate text from its training data. It searches the web, reads the results, identifies gaps, searches again, synthesizes everything, and then writes the report — all autonomously.
The key insight: tools transform an LLM from a knowledge base into an action-taking system . With the right tools, an agent can browse the web, query databases, execute code, send emails, create files, call APIs, and interact with virtually any system. The LLM becomes the reasoning engine that decides when and how to use those capabilities.
How Claude Tool Use Actually Works
Tool use (also called "function calling" in OpenAI's API) works through a structured conversation protocol. Claude doesn't directly execute code or call APIs — instead, it requests a tool call, your application executes it, and returns the result back to Claude. The model then decides whether to call another tool or produce a final answer.
The Three Message Types
Understanding how Claude's API represents tool interactions is essential. There are three distinct message roles in a tool-using conversation:
- User message: The original question or task. May also contain tool_result blocks — the responses from tool executions.
- Assistant message: Claude's response. May contain text blocks (natural language) and/or tool_use blocks (requests to execute a tool).
- Stop reason: When Claude responds with stop_reason: "tool_use" , it means the model is waiting for tool results before continuing. When it's "end_turn" , the task is complete.
Claude's tool use API is slightly different from OpenAI's "function calling." The key differences: Claude calls them "tools" not "functions", tool results are sent as tool_result blocks in user messages (not as a separate "tool" role), and Claude can request multiple tools in a single response. Both APIs achieve the same goal — the implementation differs.
Defining Tools: The Complete Toolkit
A tool definition tells Claude what the tool does, what parameters it takes, and which parameters are required. The quality of your tool descriptions directly determines how well Claude uses them. Write tool descriptions like you're writing for a new engineer on your team who needs to know when and how to use each tool.
The Essential Tool Library
Your First Agent: From Zero to Autonomous
Step 1 — Define Your Tools
import Anthropic from '@anthropic-ai/sdk'
// Tool definitions — what you give to Claude
export const TOOLS: Anthropic. Tool [] = [
{
name : 'web_search' ,
description : `Search the internet for current information.
Use this when the user asks about recent events, current prices,
live data, or anything that requires up-to-date information.
Returns a list of search results with titles, URLs, and snippets.` ,
input_schema : {
type : 'object' ,
properties : {
query : {
type : 'string' ,
description : 'The search query to look up'
},
max_results : {
type : 'number' ,
description : 'Maximum number of results (default: 5)'
}
},
required : [ 'query' ]
}
},
{
name : 'fetch_url' ,
description : `Fetch and read the full content of a specific URL.
Use this after web_search to read the full text of an article or page.
Do NOT use for URLs that require authentication.` ,
input_schema : {
type : 'object' ,
properties : {
url : {
type : 'string' ,
description : 'The full URL to fetch'
}
},
required : [ 'url' ]
}
},
{
name : 'query_database' ,
description : `Query the application database using SQL.
Only SELECT statements are allowed — no INSERT, UPDATE, or DELETE.
Use this to look up user data, product information, or analytics.
Available tables: users, orders, products, support_tickets` ,
input_schema : {
type : 'object' ,
properties : {
sql : {
type : 'string' ,
description : 'A read-only SELECT SQL query'
}
},
required : [ 'sql' ]
}
},
{
name : 'write_file' ,
description : `Write content to a file in the /workspace directory.
Use this to save reports, generated code, or processed data.
Files are accessible to the user after the task completes.` ,
input_schema : {
type : 'object' ,
properties : {
filename : { type : 'string' },
content : { type : 'string' }
},
required : [ 'filename' , 'content' ]
}
}
]Step 2 — Implement the Tool Executors
import { createClient } from '@/lib/supabase/server'
type ToolInput = Record < string , unknown >
export async function executeTool (
name: string ,
input: ToolInput
): Promise < string > {
switch (name) {
case 'web_search' :
return executeWebSearch (input.query as string , input.max_results as number )
case 'fetch_url' :
return executeFetchUrl (input.url as string )
case 'query_database' :
return executeQuery (input.sql as string )
case 'write_file' :
return executeWriteFile (
input.filename as string ,
input.content as string
)
default :
return JSON . stringify ({ error: `Unknown tool: ${name}` })
}
}
async function executeWebSearch (
query: string , maxResults = 5
): Promise < string > {
// Using Tavily API — best for agent search workflows
const response = await fetch ( 'https://api.tavily.com/search' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'Authorization' : `Bearer ${process.env.TAVILY_API_KEY}`
},
body: JSON . stringify ({ query, max_results: maxResults })
})
const data = await response. json ()
return JSON . stringify (data.results. map ((r: any ) => ({
title: r.title, url: r.url, snippet: r.content
})))
}
async function executeFetchUrl (url: string ): Promise < string > {
// Validate URL is safe to fetch
const parsed = new URL (url)
if (![ 'http:' , 'https:' ]. includes (parsed.protocol)) {
return JSON . stringify ({ error: 'Only HTTP/HTTPS URLs allowed' })
}
const res = await fetch (url, {
signal: AbortSignal. timeout ( 8000 ),
headers: { 'User-Agent' : 'CodebitbybitAgent/1.0' }
})
const html = await res. text ()
// Strip HTML tags — return plain text (truncated)
const text = html
. replace (/<[^>]+>/g, ' ' )
. replace (/\s+/g, ' ' )
. trim ()
. slice ( 0 , 8000 ) // Limit to prevent context overflow
return JSON . stringify ({ url, content: text, truncated: html.length > 8000 })
}
async function executeQuery (sql: string ): Promise < string > {
// SECURITY: Only allow SELECT statements
const normalized = sql. trim (). toUpperCase ()
if (!normalized. startsWith ( 'SELECT' )) {
return JSON . stringify ({ error: 'Only SELECT queries are allowed' })
}
const supabase = createClient ()
const { data, error } = await supabase. rpc ( 'execute_readonly_query' , { sql })
if (error) return JSON . stringify ({ error: error.message })
return JSON . stringify ({ rows: data, count: data.length })
}
async function executeWriteFile (
filename: string , content: string
): Promise < string > {
// Sanitize filename — prevent path traversal
const safe = filename. replace (/[^a-zA-Z0-9._-]/g, '_' )
const { writeFile } = await import ( 'fs/promises' )
await writeFile ( `/workspace/${safe}` , content, 'utf-8' )
return JSON . stringify ({
success: true ,
filename: safe,
bytes: new TextEncoder (). encode (content).length
})
}Step 3 — The Agent Loop
This is the heart of the agent: the loop that keeps calling Claude, executing tools, and feeding results back until the task is complete or a limit is reached.
import Anthropic from '@anthropic-ai/sdk'
import { TOOLS } from './tools'
import { executeTool } from './executors'
const anthropic = new Anthropic ()
interface AgentOptions {
task: string
systemPrompt?: string
maxSteps?: number // Safety limit (default: 10)
onStep?: (step: AgentStep ) => void // Stream steps to UI
}
interface AgentStep {
type: 'thinking' | 'tool_call' | 'tool_result' | 'answer'
toolName?: string
toolInput?: unknown
content: string
stepNumber: number
}
export async function runAgent (opts: AgentOptions ) {
const { task, systemPrompt, maxSteps = 10 , onStep } = opts
const messages: Anthropic. MessageParam [] = [
{ role: 'user' , content: task }
]
let stepCount = 0
let totalTokens = 0
while (stepCount < maxSteps) {
stepCount++
// Call Claude with tools available
const response = await anthropic.messages. create ({
model: 'claude-sonnet-4-20260514' ,
max_tokens: 4096 ,
tools: TOOLS,
system: systemPrompt ?? `You are a helpful AI assistant with access to tools.
Complete the user's task using the available tools.
Always verify information before including it in your final answer.
When the task is complete, provide a comprehensive final response.` ,
messages,
})
totalTokens += response.usage.input_tokens + response.usage.output_tokens
// Add assistant's response to conversation history
messages. push ({ role: 'assistant' , content: response.content })
// Emit thinking/text steps
const textContent = response.content
. filter (b => b.type === 'text' )
. map (b => (b as Anthropic. TextBlock ).text)
. join ( '\n' )
if (textContent) {
onStep?.({ type: 'thinking' , content: textContent, stepNumber: stepCount })
}
// If stop_reason is end_turn, we're done!
if (response.stop_reason === 'end_turn' ) {
onStep?.({ type: 'answer' , content: textContent, stepNumber: stepCount })
return { answer: textContent, steps: stepCount, tokens: totalTokens }
}
// Process all tool calls in this response
const toolUses = response.content. filter (b => b.type === 'tool_use' )
const toolResults: Anthropic. ToolResultBlockParam [] = []
for ( const toolUse of toolUses) {
const tu = toolUse as Anthropic. ToolUseBlock
onStep?.({
type: 'tool_call' ,
toolName: tu.name,
toolInput: tu.input,
content: `Calling ${tu.name}` ,
stepNumber: stepCount
})
// Execute the tool and get result
let result: string
try {
result = await executeTool (tu.name, tu.input as Record < string , unknown >)
} catch (err) {
result = JSON . stringify ({ error: `Tool execution failed: ${err}` })
}
onStep?.({
type: 'tool_result' ,
toolName: tu.name,
content: result,
stepNumber: stepCount
})
toolResults. push ({
type: 'tool_result' ,
tool_use_id: tu.id,
content: result
})
}
// Add all tool results as a single user message
messages. push ({ role: 'user' , content: toolResults })
}
// Safety limit reached
return {
answer: 'Task reached maximum steps. Partial results may be incomplete.' ,
steps: stepCount,
tokens: totalTokens,
}
}Always set a maximum step limit. Without it, a poorly designed task or an agent in a confused state can loop indefinitely — racking up API costs. 10 steps covers 95% of real-world agent tasks. For complex research or code generation tasks, 20–30 steps is reasonable. Anything requiring 50+ steps is a sign the task decomposition needs work, not that you should raise the limit.
Streaming Agent Steps to the UI in Real Time
Nobody wants to stare at a spinner while the agent works for 30 seconds. The best agent UIs stream each step as it happens: "Searching for X...", "Reading article at Y...", "Writing report to Z...". Server-Sent Events (SSE) are the perfect transport for this.
import { NextRequest } from 'next/server'
import { runAgent } from '@/lib/agent/runner'
import { createClient } from '@/lib/supabase/server'
export async function POST (req: NextRequest ) {
const supabase = createClient ()
const { data: { user } } = await supabase.auth. getUser ()
if (!user) return Response . json ({ error: 'Unauthorized' }, { status: 401 })
const { task } = await req. json ()
if (!task) return Response . json ({ error: 'Task required' }, { status: 400 })
// Server-Sent Events stream
const encoder = new TextEncoder ()
const stream = new ReadableStream ({
async start (controller) {
const send = (data: object ) => {
controller. enqueue (encoder. encode ( `data: ${JSON.stringify(data)}\n\n` ))
}
try {
send ({ type: 'start' , task })
const result = await runAgent ({
task,
maxSteps: 12 ,
onStep: (step) => send (step),
})
send ({ type: 'done' , ...result })
} catch (err) {
send ({ type: 'error' , message: `Agent failed: ${err}` })
} finally {
controller. close ()
}
}
})
return new Response (stream, {
headers: {
'Content-Type' : 'text/event-stream' ,
'Cache-Control' : 'no-cache' ,
'Connection' : 'keep-alive' ,
}
})
}A Complete Research Agent: From Task to Report
Let's trace through a real agent run to see exactly how the loop plays out. The task: "Research the top 3 AI coding tools in 2026, compare their pricing, and write a summary report."
The entire run took approximately 28 seconds and used ~12,000 tokens (~$0.06 with Claude Sonnet). It searched the web 3 times, fetched 2 full pages, and wrote a complete report — all autonomously. The user saw each step appear in real time via the SSE stream.
Error Handling: Building Agents That Don't Panic
Production agents fail in predictable ways. Network timeouts, rate limits, malformed tool outputs, and infinite loops are the four failure modes you need to handle before going live. Here's the complete defensive pattern.
interface RetryOptions {
maxRetries?: number
timeoutMs?: number
backoffMs?: number
}
export async function safeExecute < T >(
fn: () => Promise < T >,
opts: RetryOptions = {}
): Promise < T | { error: string }> {
const { maxRetries = 2 , timeoutMs = 10000 , backoffMs = 1000 } = opts
for ( let attempt = 0 ; attempt < = maxRetries; attempt++) {
try {
// Wrap with timeout
const result = await Promise . race ([
fn (),
new Promise <never>((_, reject) =>
setTimeout (() => reject( new Error ( 'Tool timeout' )), timeoutMs)
)
])
return result
} catch (err) {
const isRetryable =
err instanceof Error &&
(err.message. includes ( 'timeout' ) ||
err.message. includes ( 'rate limit' ) ||
err.message. includes ( '503' ))
if (!isRetryable || attempt === maxRetries) {
return { error: `Failed after ${attempt + 1} attempts: ${err}` }
}
// Exponential backoff before retry
await new Promise (r => setTimeout (r, backoffMs * ( 2 ** attempt)))
}
}
return { error: 'Unexpected error in safeExecute' }
}
// Usage in your tool executor:
const result = await safeExecute (
() => executeWebSearch (query),
{ maxRetries: 2 , timeoutMs: 8000 }
)
return 'error' in result
? JSON . stringify ({ error: result.error, suggestion: 'Try a different search query' })
: JSON . stringify (result)Agents can get stuck in loops — calling the same tool with the same input, getting the same result, and trying again. Detect this by tracking the last 3 tool calls. If the same tool is called with identical inputs twice in a row, inject a system message: "You've called [tool_name] with the same input multiple times. The tool cannot provide different results. Please try a different approach or admit you cannot complete this task." This breaks the loop gracefully.
The Production Agent Safety Checklist
- Tool permissions are least-privilege. Every tool should only do what the agent needs it to do — no more. Read-only database access for a research agent. No file deletion. No email sending without explicit confirmation.
- All destructive tools require confirmation. Sending email, posting to Slack, creating calendar events — any tool with real-world side effects should have a "human-in-the-loop" confirmation step before execution. Never let agents send emails autonomously in production.
- Hard token budget per task. Set a maximum token budget (e.g., 50,000 tokens) that terminates the agent if exceeded. This caps worst-case API costs and prevents infinite loops from being expensive.
- Every tool call is logged. Log tool name, input, output, execution time, and success/failure to a agent_logs table. This is essential for debugging and for auditing what the agent did.
- User attribution on every action. Every agent run should be tied to an authenticated user. If the agent writes a file or queries a database, log which user triggered it.
- Tool output size limits. Enforce maximum output sizes for every tool — a web scrape returning 500KB will overflow your context window. Truncate all tool outputs before returning them to Claude.
- Graceful degradation. When a tool fails, the agent should try an alternative approach — not crash. A well-designed system prompt teaches Claude to adapt when tools fail rather than giving up.
- Rate limiting per user. Prevent abuse by limiting agent runs per user per hour. An agent run can be 10–50× more expensive than a plain LLM call. Budget accordingly.
The Golden Rule of Agentic Systems
Give agents the minimum permissions needed to complete their tasks. An agent that can only read should never have write access. An agent that can only search should never have database access. The blast radius of a misconfigured or misbehaving agent is bounded by its tool permissions. Build the smallest permission set first and expand only when genuinely needed.
Multi-Agent Systems: When One Agent Isn't Enough
Some tasks are too complex for a single agent — either because they exceed the context window, benefit from specialization, or require parallel work streams. Multi-agent systems solve this by coordinating multiple Claude instances, each with a specific role.
The Orchestrator–Subagent Pattern
The most practical multi-agent pattern: an orchestrator agent that receives the task, decomposes it into subtasks, and delegates each subtask to a subagent with the right tools. The orchestrator's tools are other agents — not web searches or databases.
import { runAgent } from './runner'
// Specialist agent configurations
const SPECIALIST_AGENTS = {
researcher: {
systemPrompt: 'You are a research specialist. Find accurate, current information.' ,
tools: [ 'web_search' , 'fetch_url' ],
maxSteps: 8
},
analyst: {
systemPrompt: 'You are a data analyst. Analyze data and identify patterns.' ,
tools: [ 'query_database' , 'execute_code' ],
maxSteps: 6
},
writer: {
systemPrompt: 'You are a technical writer. Create clear, well-structured documents.' ,
tools: [ 'write_file' ],
maxSteps: 4
}
}
// Orchestrator tool: delegate to a specialist
export async function delegateToSpecialist (
specialist: keyof typeof SPECIALIST_AGENTS,
subtask: string
): Promise < string > {
const config = SPECIALIST_AGENTS[specialist]
const result = await runAgent ({
task: subtask,
systemPrompt: config.systemPrompt,
maxSteps: config.maxSteps,
})
return JSON . stringify ({
specialist,
subtask,
result: result.answer,
steps: result.steps,
tokens: result.tokens
})
}
// Parallel execution for independent subtasks
export async function runParallel (
tasks: Array <{ specialist: keyof typeof SPECIALIST_AGENTS; subtask: string }>
) {
return Promise . all (
tasks. map (({ specialist, subtask }) =>
delegateToSpecialist (specialist, subtask)
)
)
}Multi-agent systems are not for beginners. Get a single reliable agent working in production before you start orchestrating multiple ones. The debugging complexity multiplies with every agent you add.
You Have a Production Agent System
You now have every component: tool definitions with proper JSON schemas, tool executors with security guardrails and error handling, the agent loop with a step limit, an SSE streaming API route, and a multi-agent orchestration pattern. The full system is deployable today.
The most impactful next steps: add more domain-specific tools that match your application's actual data and capabilities, implement the human-in-the-loop confirmation flow for destructive tools, and add per-run cost tracking so you can monitor and optimize token usage over time.
Agents are not magic — they're loops with good tools and smart exit conditions. Once you internalize that, building increasingly capable autonomous systems becomes straightforward engineering.
What to Read Next
- RAG Explained: Build a Production AI Knowledge Base from Scratch →
- Claude vs GPT-4o vs Gemini for Coding: 200 Real Tasks →
- Vibe Coding 101: Build Full-Stack Apps with AI in 2026 →
- Prompt Engineering for Developers: 20 Templates That Actually Work →
