Tools Are What Make Agents Agents
Without tools, an LLM is a text transformer. It reads input, thinks, and produces output, all in text. It can’t actually do anything in the world.
Tools change this. A tool is simply a Python function that the LLM can ask your code to execute. The LLM describes what it wants to call and what arguments to use. Your code actually runs the function and returns the result. The LLM then uses that result to continue its reasoning.
This is the mechanism behind everything you’ve seen: “search the web, ” “run this code, ” “check this price, ” “send this email”, all tools.
How Tools Work: The Complete Loop
Understanding this loop precisely is essential. Let’s trace it step by step:
- You define tools as Python functions + JSON schema descriptions
- You send the tools to the LLM along with the user’s message
- The LLM decides whether to call a tool or respond directly
- If it calls a tool: the LLM returns a structured
tool_call(NOT the actual result, it can’t run code) - Your code reads the tool_call, finds the right function, runs it, gets the result
- You send the result back to the LLM as a
toolmessage - The LLM continues: reads the result and either calls another tool or produces the final response
This loop repeats until the LLM produces a response without a tool call. Each iteration is one tool use.
Defining Tools with JSON Schema
OpenAI’s function calling format uses JSON Schema to describe tools. Here’s the schema for a web search tool:
tools = [
{
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for current information on a topic. Use when you need facts, news, or data not in your training.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query. Be specific: '2024 Python AI frameworks comparison' is better than 'AI tools'."
},
"num_results": {
"type": "integer",
"description": "Number of results to return. Default 5, max 10.",
"default": 5
}
},
"required": ["query"]
}
}
}
]
The description fields are read by the LLM, it uses them to understand when to use the tool and what to put in each parameter. Invest time in writing clear descriptions; they’re the most important thing for tool call accuracy.
The Actual Python Functions
The schema is just the description. You also need the real Python functions that do the work:
import requests
def search_web(query: str, num_results: int = 5) -> list[dict]:
"""Real web search using the Serper API."""
response = requests.post(
'https://google.serper.dev/search',
headers={'X-API-KEY': 'your-serper-api-key'},
json={'q': query, 'num': num_results}
)
results = response.json().get('organic', [])
return [
{'title': r['title'], 'url': r['link'], 'snippet': r['snippet']}
for r in results[:num_results]
]
def get_current_time() -> str:
"""Return the current date and time."""
from datetime import datetime
return datetime.now().strftime('%Y-%m-%d %H:%M UTC')
Keep a registry that maps tool names to functions:
TOOL_REGISTRY = {
'search_web': search_web,
'get_current_time': get_current_time,
}
The Tool-Use Loop in Code
Now let’s build the complete loop:
from openai import OpenAI
import json
client = OpenAI()
def run_agent(user_message: str) -> str:
"""Run an agent that can use tools."""
messages = [
{'role': 'system', 'content': 'You are a helpful research assistant.'},
{'role': 'user', 'content': user_message},
]
# Keep looping until the LLM stops calling tools
while True:
response = client.chat.completions.create(
model='gpt-5.6-sol',
messages=messages,
tools=tools, # send the tool definitions
tool_choice='auto', # let the model decide when to use tools
)
msg = response.choices[0].message
messages.append(msg) # always add the assistant's message to history
Now handle the two cases: tool call or final response:
if msg.tool_calls:
# Model wants to use one or more tools
for tool_call in msg.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
print(f"→ Calling tool: {name}({args})")
# Execute the real function
func = TOOL_REGISTRY[name]
result = func(**args)
# Add the result back as a tool message
messages.append({
'role': 'tool',
'tool_call_id': tool_call.id,
'content': json.dumps(result),
})
else:
# No tool calls: this is the final response
return msg.content
Test it:
answer = run_agent("What is the current date and time, and what are the latest developments in AI agents?")
print(answer)
The agent will call get_current_time() and search_web(), incorporate both results, and produce a comprehensive answer.
Parallel Tool Calls
OpenAI’s API (and others) support parallel tool calls: the LLM can request multiple tools in a single response when they’re independent:
# The model might produce this single response:
# tool_calls = [
# ToolCall(name='search_web', args={'query': 'AI agents 2024'}),
# ToolCall(name='search_web', args={'query': 'LangGraph framework'}),
# ToolCall(name='get_current_time', args={}),
# ]
Your loop already handles this: for tool_call in msg.tool_calls iterates over all of them. To run them in parallel (important for performance):
import concurrent.futures
if msg.tool_calls:
def execute_tool(tc):
name = tc.function.name
args = json.loads(tc.function.arguments)
result = TOOL_REGISTRY[name](**args)
return tc.id, json.dumps(result)
with concurrent.futures.ThreadPoolExecutor() as executor:
results = list(executor.map(execute_tool, msg.tool_calls))
for tool_call_id, result in results:
messages.append({
'role': 'tool',
'tool_call_id': tool_call_id,
'content': result,
})
For 3 independent tool calls that each take 1 second, sequential execution takes 3 seconds; parallel execution takes ~1 second. At scale, this difference is enormous.
Exercise: Add a third tool to the agent:
read_webpage(url: str) -> strthat fetches the text content of a URL. Write the schema definition and the Python function (userequests.getandBeautifulSoupto extract text). Update the agent system prompt to mention this capability. Test by asking the agent to research a specific company and summarise their website.