AI Development30

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

Building AI Agents with Tool Use: Claude, Functions & Autonomous Workflows (2026)

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.

  • 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 →