Tool calling
On its own, a language model can only produce text. Tool calling (also called function calling) lets it produce a structured request — “call get_order_status with order_id A-1001” — which your code executes and feeds back. The model never runs anything itself; your code stays in control. This lesson covers the full cycle, a reusable tool registry, validation and error handling, and how to design tools a model uses well.
What tool calling is
A description you send with the request: the tool's name, a natural-language description of what it does and when to use it, and a JSON Schema for its parameters.
Instead of (or as well as) text, the model returns a structured request containing a call id, the tool name, and a JSON string of arguments. Your code runs the tool and sends the result back in a tool message with the same call id.
The cycle has four steps:
- You send messages plus tool definitions.
- The model replies with one or more tool calls (or a normal answer if no tool is needed).
- Your code validates and executes each call.
- You send the results back; the model uses them to answer — or calls more tools.
Defining a tool
tools = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the shipping status of a customer order. Use when the user asks where their order is.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order id, like 'A-1001'"},
},
"required": ["order_id"],
"additionalProperties": False,
},
},
}]
The description is a prompt: the model decides whether and how to call the tool almost entirely from the name, description, and parameter descriptions.
A tool registry built from type hints
Writing schemas by hand gets repetitive and drifts out of sync with the code. A decorator can build the schema from the function's signature and docstring.
import inspect
import json
from typing import get_type_hints
JSON_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean"}
TOOLS: dict[str, dict] = {}
def tool(fn):
"""Register fn as a tool and build its JSON Schema from type hints."""
hints = get_type_hints(fn)
params = inspect.signature(fn).parameters
properties = {name: {"type": JSON_TYPES[hints[name]]} for name in params}
required = [name for name, p in params.items() if p.default is inspect.Parameter.empty]
TOOLS[fn.__name__] = {
"fn": fn,
"schema": {
"type": "function",
"function": {
"name": fn.__name__,
"description": inspect.getdoc(fn),
"parameters": {"type": "object", "properties": properties, "required": required},
},
},
}
return fn
ORDERS = {"A-1001": "shipped", "A-1002": "processing"}
RATES = {"EUR": 0.92, "INR": 83.1}
@tool
def get_order_status(order_id: str) -> dict:
"""Look up the shipping status of a customer order by id, e.g. 'A-1001'."""
if order_id not in ORDERS:
raise KeyError(f"no order with id {order_id}")
return {"order_id": order_id, "status": ORDERS[order_id]}
@tool
def convert_currency(amount: float, to: str = "EUR") -> dict:
"""Convert an amount in USD to another currency (EUR or INR)."""
return {"amount": round(amount * RATES[to], 2), "currency": to}
print(json.dumps(TOOLS["convert_currency"]["schema"], indent=2))
Executing calls safely
Tool arguments come from the model, which means they ultimately come from user input. Treat them like any untrusted input: parse, validate, and catch errors. Return errors to the model as the tool result — it can often fix its own mistake on the next step.
def run_tool_call(name: str, arguments: str) -> str:
"""Execute one tool call and always return a JSON string for the model."""
if name not in TOOLS:
return json.dumps({"error": f"unknown tool '{name}'"})
try:
args = json.loads(arguments)
except json.JSONDecodeError:
return json.dumps({"error": "arguments were not valid JSON"})
try:
return json.dumps(TOOLS[name]["fn"](**args))
except TypeError:
return json.dumps({"error": f"wrong arguments for {name}; expected {list(inspect.signature(TOOLS[name]['fn']).parameters)}"})
except Exception as error:
return json.dumps({"error": f"{type(error).__name__}: {error}"})
print(run_tool_call("get_order_status", '{"order_id": "A-1001"}'))
print(run_tool_call("get_order_status", '{"id": "A-1001"}'))
print(run_tool_call("get_order_status", '{"order_id": "Z-9"}'))
print(run_tool_call("delete_everything", "{}"))
The full round trip
Here is the complete conversation with a simulated model that behaves like the real API: first it requests a tool, then it answers using the result.
def fake_model(messages: list[dict]) -> dict:
"""Returns an assistant message, possibly containing tool calls (same shape as the API)."""
last = messages[-1]
if last["role"] == "user":
return {"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "type": "function",
"function": {"name": "get_order_status", "arguments": '{"order_id": "A-1001"}'}},
]}
status = json.loads(last["content"])["status"]
return {"role": "assistant", "content": f"Good news - order A-1001 has {status}."}
messages = [{"role": "user", "content": "Where is my order A-1001?"}]
reply = fake_model(messages)
messages.append(reply)
for call in reply.get("tool_calls") or []:
result = run_tool_call(call["function"]["name"], call["function"]["arguments"])
messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
final = fake_model(messages)
messages.append(final)
for m in messages:
detail = m["content"] or f"tool_calls={[c['function']['name'] for c in m['tool_calls']]}"
print(f"{m['role']:>9}: {detail}")
The same cycle against the real API — note that the assistant message containing the tool calls must be appended before the tool results:
schemas = [t["schema"] for t in TOOLS.values()]
messages = [{"role": "user", "content": "Where is my order A-1001, and what is 50 USD in INR?"}]
response = client.chat.completions.create(model="gpt-4.1-mini", messages=messages, tools=schemas)
assistant = response.choices[0].message
messages.append(assistant) # keep the tool_calls in history
for call in assistant.tool_calls or []:
result = run_tool_call(call.function.name, call.function.arguments)
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
final = client.chat.completions.create(model="gpt-4.1-mini", messages=messages, tools=schemas)
print(final.choices[0].message.content)
The model may return several tool calls in one reply when they do not depend on each other — here, the order lookup and the currency conversion. Run them all (concurrently if they are slow) and send one tool message per call id before calling the model again.
Controlling tool use
tool_choice | Behaviour |
|---|---|
"auto" (default) | The model decides whether to call tools. |
"none" | The model must answer in text. |
"required" | The model must call at least one tool. |
{"type": "function", "function": {"name": "..."}} | The model must call this specific tool — useful for structured extraction. |
Adding "strict": true to a function definition (with additionalProperties: false and every property listed in required) makes the API guarantee the arguments match your schema.
Designing good tools
| Guideline | Why |
|---|---|
Clear verb–noun names: search_orders, send_email | The name is the model's first clue about purpose. |
| Descriptions that say when to use the tool, not just what it does | Prevents the model from picking a similar-looking tool. |
| Few parameters, with enums for fixed choices | Fewer ways to get arguments wrong. |
| Return small, relevant results | Huge tool outputs waste context and bury the answer. |
| Return helpful error messages | The model can correct a bad argument if the error says what was expected. |
| Keep the tool set small and distinct | Accuracy drops when many tools overlap. |
| Separate read tools from write tools | Lets you auto-approve reads and gate writes (Human in the loop). |
Never expose tools like run_sql(query) or run_shell(command) to a model that reads untrusted text. A prompt injection hidden in an email or web page could make it run anything. Expose narrow tools such as get_order_status(order_id) and enforce permissions in code.
- Add a
calculator(expression: str)tool that only accepts digits, spaces, and+-*/()., and test it with a malicious expression. - Extend
tool()to read parameter descriptions from the docstring. - Make
convert_currencyreturn a helpful error listing supported currencies whentois unknown.
Key takeaways
- The model only requests tool calls; your code executes them and stays in control.
- Tool names, descriptions, and parameter schemas are prompts — write them carefully.
- Validate arguments and return errors as tool results so the model can recover.
- Append the assistant's tool-call message, then one tool message per call id, before calling the model again.
- Prefer small, narrow, well-described tools, and never expose arbitrary code or SQL execution.