Function Calling and Tools
Function calling (also called tool use) lets a model ask your program to run a function, such as checking a price, and then use the result in its answer.
The Idea in Four Steps
- Your app sends the question plus a list of tools the model may use.
- The model replies with a tool call: "please run
get_pricewith{"item": "pen"}". - Your code runs the function and sends the result back.
- The model writes the final answer using the result.
The model never runs code itself. It only asks. Your program decides what really happens. This is the most important safety fact about tools.
The Tools and Function Calling chapter of the AI Agents Tutorial explains each step with the message shapes. This chapter adds what you need for a robust app: several tool calls at once, bad arguments, errors, and the full loop.
Describing Tools
Each tool has a name, a description and its parameters in JSON Schema. The description is how the model decides when to use it, so write it like a note to a new employee:
Example
TOOL_SPECS = [
{"type": "function", "function": {
"name": "get_price",
"description": "Get the price in rupees of one item sold in the shop, e.g. 'pen' or 'notebook'.",
"parameters": {
"type": "object",
"properties": {"item": {"type": "string", "description": "Item name in lowercase"}},
"required": ["item"],
},
}},
{"type": "function", "function": {
"name": "get_delivery_fee",
"description": "Get the delivery fee in rupees for an area of Karachi, e.g. 'Clifton'.",
"parameters": {
"type": "object",
"properties": {"area": {"type": "string"}},
"required": ["area"],
},
}},
]
print([t["function"]["name"] for t in TOOL_SPECS])['get_price', 'get_delivery_fee']
Running Tool Calls Safely
The model can ask for several tools in one reply, can send arguments that do not fit, and can even ask for a tool that does not exist. Your runner must never crash. It should return an error message instead, so the model can explain or try again:
Example
import json
def get_price(item):
prices = {"notebook": 150, "pen": 40}
if item not in prices:
raise ValueError(f"unknown item {item}")
return {"item": item, "price_rs": prices[item]}
def get_delivery_fee(area):
return {"area": area, "fee_rs": 0 if area.lower() == "orangi" else 200}
TOOLS = {"get_price": get_price, "get_delivery_fee": get_delivery_fee}
def run_tool_call(call_id, name, arguments):
try:
if name not in TOOLS:
raise ValueError(f"no tool named {name}")
args = json.loads(arguments)
result = TOOLS[name](**args)
except Exception as e: # bad JSON, wrong args, tool error
result = {"error": str(e)}
return {"role": "tool", "tool_call_id": call_id, "content": json.dumps(result)}
# A model may ask for several tools in ONE reply
tool_calls = [
("c1", "get_price", '{"item": "pen"}'),
("c2", "get_delivery_fee", '{"area": "Clifton"}'),
("c3", "get_price", '{"item": "laptop"}'),
("c4", "get_weather", '{"city": "Karachi"}'),
]
for call in tool_calls:
print(run_tool_call(*call)){'role': 'tool', 'tool_call_id': 'c1', 'content': '{"item": "pen", "price_rs": 40}'}
{'role': 'tool', 'tool_call_id': 'c2', 'content': '{"area": "Clifton", "fee_rs": 200}'}
{'role': 'tool', 'tool_call_id': 'c3', 'content': '{"error": "unknown item laptop"}'}
{'role': 'tool', 'tool_call_id': 'c4', 'content': '{"error": "no tool named get_weather"}'}Note the tool_call_id. Every result must carry the id of the call it answers, so the model knows which result belongs to which request.
The Full Loop with a Real API
Put the two pieces above in one file and add this loop. It keeps going while the model asks for tools, and stops when the model writes a normal answer. The range(5) limit stops a confused model from looping forever:
Example
import os
from openai import OpenAI
client = OpenAI(base_url=os.environ["AI_BASE_URL"], api_key=os.environ["AI_API_KEY"])
messages = [
{"role": "system", "content": "You are the assistant of a stationery shop in Karachi. "
"Use the tools for prices and delivery. Never guess a price."},
{"role": "user", "content": "How much is a pen, and what is delivery to Clifton?"},
]
for step in range(5):
response = client.chat.completions.create(
model=os.environ["AI_MODEL"], messages=messages, tools=TOOL_SPECS,
)
msg = response.choices[0].message
if not msg.tool_calls: # a normal answer: we are done
print(msg.content)
break
messages.append(msg.model_dump(exclude_none=True)) # keep the model's request
for call in msg.tool_calls:
print("Running", call.function.name, call.function.arguments)
messages.append(run_tool_call(call.id, call.function.name, call.function.arguments))A typical run prints two "Running" lines (one for each tool) and then an answer like "A pen costs Rs 40 and delivery to Clifton is Rs 200." The exact words depend on the model.
Useful Options
| Option | What it does |
|---|---|
tool_choice="auto" | The model decides whether to use a tool (the default) |
tool_choice="none" | The model must not use tools this time |
tool_choice="required" | The model must call at least one tool |
A specific function in tool_choice | Force one tool, useful for extraction tasks |
Support for these options can differ between providers, so check the documentation.
Rules for Safe Tools
- Give read-only tools first (check price, check order). Add tools that change things later.
- For anything that spends money, sends messages or deletes data, ask a human to approve first. See Agent Risks and Human Approval.
- Check the arguments in your code. Never trust them just because the model sent them.
- Keep tool results short. Every result goes back into the context and costs tokens.
When the loop runs many steps by itself and decides its own next action, you have built an agent. The AI Agents Tutorial continues from here.
Exercise
Exercise
When a model returns a tool call, who actually runs the function?
