Without tools, an LLM is just a very eloquent parrot. It can generate text, but it can't check the weather, send an email, query a database, or do anything in the real world. Tool use (also called function calling) is what transforms a language model into an agent.
This guide covers how tool use works under the hood, how to design great tool schemas, handle errors gracefully, orchestrate multi-tool workflows, and keep everything secure.
The concept is simple: you tell the LLM what tools are available, and it decides when to use them. The LLM doesn't actually execute anything — it outputs a structured request that your code executes.
# The tool use loop
while not done:
# 1. Send message + available tools to LLM
response = llm.call(messages, tools=tool_definitions)
# 2. Check if LLM wants to use a tool
if response.has_tool_call:
tool_name = response.tool_call.name
tool_args = response.tool_call.arguments
# 3. YOUR CODE executes the tool
result = execute_tool(tool_name, tool_args)
# 4. Send result back to LLM
messages.append({"role": "tool", "content": result})
else:
# LLM is done, return final response
return response.text
Tools are defined as JSON schemas that describe what the tool does, what parameters it accepts, and what it returns.
tools = [
{
"type": "function",
"function": {
"name": "search_products",
"description": "Search the product catalog by keyword, category, or price range. Returns up to 10 matching products.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keywords (e.g., 'wireless headphones')"
},
"category": {
"type": "string",
"enum": ["electronics", "clothing", "home", "sports"],
"description": "Product category to filter by"
},
"max_price": {
"type": "number",
"description": "Maximum price in USD"
},
"in_stock_only": {
"type": "boolean",
"description": "Only return products currently in stock",
"default": true
}
},
"required": ["query"]
}
}
}
]
tools = [
{
"name": "search_products",
"description": "Search the product catalog. Returns up to 10 matching products with name, price, and availability.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search keywords"
},
"category": {
"type": "string",
"enum": ["electronics", "clothing", "home", "sports"]
},
"max_price": {"type": "number"},
"in_stock_only": {"type": "boolean", "default": true}
},
"required": ["query"]
}
}
]
search_products not sp. The LLM reads the name to decide when to use the tool.number, boolean, enum) instead of freeform strings where possible.| Category | Examples | Complexity |
|---|---|---|
| Read-only | Search, lookup, get status, fetch data | Low (safe) |
| Write | Create record, send email, post message | Medium (reversible) |
| Destructive | Delete, overwrite, cancel subscription | High (irreversible) |
| Computational | Calculate, convert, analyze data | Low (deterministic) |
| External API | Weather, stock prices, map directions | Medium (latency, rate limits) |
| Code execution | Run Python, SQL query, shell command | High (security critical) |
Tools fail. APIs return errors, databases time out, inputs are invalid. How you report errors to the LLM determines whether it recovers gracefully or spirals.
def execute_tool(name, args):
try:
if name == "search_products":
results = product_db.search(**args)
return json.dumps({"status": "success", "results": results})
elif name == "send_email":
send_email(**args)
return json.dumps({"status": "success", "message": "Email sent"})
except ValidationError as e:
# Tell the LLM what was wrong so it can fix the input
return json.dumps({
"status": "error",
"error": f"Invalid input: {e}",
"hint": "Check parameter types and required fields"
})
except RateLimitError:
return json.dumps({
"status": "error",
"error": "Rate limited. Try again in 30 seconds.",
"retry_after": 30
})
except Exception as e:
# Generic error — still give the LLM useful info
return json.dumps({
"status": "error",
"error": str(e),
"hint": "This tool is temporarily unavailable"
})
Real-world agents often need multiple tools in sequence. The LLM naturally chains tool calls when it understands the available tools.
# Example: "Book a meeting with John next Tuesday"
# The agent will:
# Step 1: Look up John's contact info
→ tool_call: search_contacts(query="John")
← result: {"name": "John Smith", "email": "[email protected]"}
# Step 2: Check John's calendar
→ tool_call: check_availability(email="[email protected]", date="2026-04-01")
← result: {"available_slots": ["10:00", "14:00", "16:00"]}
# Step 3: Check your calendar
→ tool_call: check_availability(email="[email protected]", date="2026-04-01")
← result: {"available_slots": ["09:00", "10:00", "11:00", "14:00"]}
# Step 4: Find overlapping slot and book
→ tool_call: create_meeting(
title="Meeting with John Smith",
attendees=["[email protected]", "[email protected]"],
date="2026-04-01",
time="10:00",
duration=30
)
← result: {"status": "booked", "meeting_id": "mtg_123"}
Modern APIs (OpenAI, Anthropic) support parallel tool calls — the LLM can request multiple tools at once when there are no dependencies:
# LLM response with parallel tool calls:
{
"tool_calls": [
{"name": "get_weather", "args": {"city": "Paris"}},
{"name": "get_weather", "args": {"city": "London"}},
{"name": "get_exchange_rate", "args": {"from": "EUR", "to": "GBP"}}
]
}
# Execute all three in parallel
import asyncio
async def execute_parallel(tool_calls):
tasks = [execute_tool(tc["name"], tc["args"]) for tc in tool_calls]
return await asyncio.gather(*tasks)
# BAD: Direct execution of LLM output
def execute_sql(query):
return db.execute(query) # SQL injection!
# GOOD: Parameterized queries with validation
def search_orders(customer_id, status=None):
if not isinstance(customer_id, int):
raise ValidationError("customer_id must be integer")
query = "SELECT * FROM orders WHERE customer_id = %s"
params = [customer_id]
if status in ["pending", "shipped", "delivered"]:
query += " AND status = %s"
params.append(status)
return db.execute(query, params)
TOOL_PERMISSIONS = {
"search_products": "read", # Always allowed
"get_order_status": "read", # Always allowed
"send_email": "write", # Requires confirmation
"delete_account": "destructive", # Requires human approval
"run_code": "dangerous", # Sandboxed only
}
def execute_with_permissions(tool_name, args, user_approved=False):
level = TOOL_PERMISSIONS.get(tool_name, "dangerous")
if level == "read":
return execute_tool(tool_name, args)
elif level == "write" and user_approved:
return execute_tool(tool_name, args)
elif level == "destructive":
return {"status": "pending_approval",
"message": f"Action '{tool_name}' requires human approval"}
else:
return {"status": "blocked",
"message": f"Tool '{tool_name}' not allowed"}
# Prevent runaway agents from making 1000 API calls
class ToolRateLimiter:
def __init__(self, max_calls_per_minute=20, max_total=100):
self.calls = []
self.max_per_minute = max_calls_per_minute
self.max_total = max_total
def check(self):
now = time.time()
self.calls = [t for t in self.calls if now - t < 60]
if len(self.calls) >= self.max_per_minute:
raise RateLimitError("Too many tool calls per minute")
if len(self.calls) >= self.max_total:
raise RateLimitError("Maximum tool calls exceeded")
self.calls.append(now)
| Feature | OpenAI | Anthropic | DeepSeek |
|---|---|---|---|
| Function calling | Yes (all models) | Yes (all Claude models) | Yes (V3+) |
| Parallel tool calls | Yes | Yes | Yes |
| Streaming tool calls | Yes | Yes | Yes |
| Tool choice control | auto/required/none/specific | auto/any/tool | auto/required |
| Max tools per request | 128 | No hard limit | 64 |
| Schema format | JSON Schema | JSON Schema | JSON Schema |
MCP (Model Context Protocol) is Anthropic's open standard for connecting AI agents to external tools and data sources. Instead of building custom integrations for each tool, MCP provides a universal interface.
# MCP server exposes tools via a standard protocol
# Your agent connects to MCP servers to discover and use tools
# Example: connecting to a GitHub MCP server
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "ghp_..."}
}
}
}
# The agent automatically discovers available tools:
# - github_create_issue
# - github_search_repos
# - github_create_pull_request
# ...and uses them like any other tool
MCP is especially powerful because:
Our AI Agent Playbook includes tool schema templates, permission frameworks, and MCP integration guides.
Get the Playbook — $29Tool use patterns, MCP updates, and agent frameworks. 3x/week, no spam.
Subscribe to AI Agents Weekly