All lessons · 44
Tools and Function Calling
How a model calls your code: tool schemas, the full function-calling round trip, a working weather bot, and the practices that keep agents reliable.
Start here: a tool call is text
If you have never done this before, start with one sentence, because everything else follows from it:
A “tool call” is the model writing text that asks you to run a function. Your code runs it, and pastes the result back — as more text.
Nothing exotic is happening. There is no plugin system, no code execution
inside the model, no secret channel to your database. The model writes
“I would like get_weather(city="Prague")” — the API hands you that request
in a neat field so you do not have to parse it out of a sentence — and then
it waits. It does nothing at all until you call it again with the answer.
Picture a colleague in a windowless room with a pad of request slips. They can
read, think and write, and that is all. You slip a message under the door;
they slide back a slip reading “please run get_weather(city='Prague') and
tell me what it says”. You go and run it. You write the result on a slip,
push it back under the door, and only then do they continue. Every agent
framework ever built is a machine for passing slips under that door.
Two consequences worth holding on to:
- The model cannot tell what really happened. A real temperature, a cached value, an error string you wrote by hand — it is all text to the model. That is why returning “Error: city not found” (instead of crashing) lets the model recover on the next turn.
- The model decides nothing about execution. It can ask for anything it has a name for. Whether that request actually runs, with whose credentials, and with what limits, is entirely your code’s decision — the security boundary.
The key misunderstanding, cleared up
The model never runs your functions. It cannot. It has no hands, no network access, no filesystem — it is a box that turns text into text. Everything that happens in the world happens in your code.
The picture I use: the model is a hotel concierge who cannot leave the desk. She can decide what needs doing and write it neatly on a request slip — “towels to room 412” — but she cannot carry them. Someone else reads the slip and does the work. If the slip is vague, the wrong thing happens; if the concierge has a hundred forms to choose from, she picks the wrong one more often. Your job is to give her a small number of crystal-clear forms.
This is the heart of every agent, and it is also the safety boundary: the model proposes, your code disposes.
When we say a model “calls a tool,” here is what actually happens:
- You describe the available tools in the request.
- The model outputs structured JSON: “I want to call
get_weatherwith these arguments.” - Your code sees that JSON, runs the real function, and sends the result back.
- The model reads the result and continues.
The model is a planner that can only emit text and JSON. Every real capability — search, database, filesystem, payments — lives in your code, behind a function the model may ask you to run. This is not a limitation to work around; it is the security boundary. The model can request actions, your code decides and executes them.
Describing a tool: the schema
A tool definition has three parts: a name, a description, and a JSON Schema for the arguments. A schema is just a form: it lists the fields the model must fill in, what type each one is, and which are required. The description is not decoration — it is the only thing the model uses to decide when and how to call the tool.
1import json2import requests3 4tools = [5 {6 #98c379">"type": "function",7 #98c379">"function": {8 #98c379">"name": "get_weather",9 #98c379">"description": (10 #98c379">"Get current weather for a city. Use for any question "11 #98c379">"about temperature, rain, wind or forecast."12 ),13 #98c379">"parameters": {14 #98c379">"type": "object",15 #98c379">"properties": {16 #98c379">"city": {17 #98c379">"type": "string",18 #98c379">"description": "City name, e.g. 'Prague'"19 },20 #98c379">"units": {21 #98c379">"type": "string",22 #98c379">"enum": ["celsius", "fahrenheit"],23 #98c379">"description": "Temperature units, default celsius"24 }25 },26 #98c379">"required": ["city"]27 }28 }29 }30]31 32def get_weather(city: str, units: str = #98c379">"celsius") -> str:33 # the REAL function - plain code, no magic34 r = requests.get(35 #98c379">"https://wttr.in/" + city,36 params={#98c379">"format": "j1"},37 timeout=10,38 )39 data = r.json()[#98c379">"current_condition"][0]40 temp = data[#98c379">"temp_C"] if units == "celsius" else data["temp_F"]41 return json.dumps({42 #98c379">"city": city,43 #98c379">"temp": temp,44 #98c379">"desc": data["weatherDesc"][0]["value"],45 })Note the enum on units — schemas let you constrain the model’s choices,
which removes whole categories of mistakes before they happen. enum simply
means “the answer must be one of these values”, required means “you may not
skip this one”.
Here is the schema part on its own, ready to paste into your own request:
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city. Use for any question about temperature, rain, wind or forecast. Returns temp and a short description.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'Prague'"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units, default celsius"
}
},
"required": ["city"]
}
}
}Three habits make schemas work: name the tool after the action
(get_weather, not weather), write the description as if briefing a new
hire, and keep the field list short enough that nothing is ambiguous.
The full round trip
Now watch the conversation itself, with token counts so you can see what each step costs:
The third message is the one people find surprising: it is an assistant
message whose content is a tool request, not prose. Nothing has happened in
the world yet — the model has only written the request slip. The fourth is the
tool result, appended to the conversation as a tool role message; that is
your code reporting back. Then the model is called again, now seeing the whole
history, and writes the human answer.
Here is what that reply literally looks like when it arrives, and what your code sends back on the next call — nothing but JSON in, text out:
1// What the model returned. Note: content is EMPTY and2// tool_calls carries the request. This is text - the model3// has not executed anything.4{5#98c379">"role": "assistant",6#98c379">"content": null,7#98c379">"tool_calls": [8 {9 #98c379">"id": "call_8Fk2",10 #98c379">"type": "function",11 #98c379">"function": {12 #98c379">"name": "get_weather",13 #98c379">"arguments": "{\"city\":\"Prague\",\"units\":\"celsius\"}"14 }15 }16]17}18 19// What YOUR code does next: run the real function, then20// append the result as a message with role "tool" and the21// matching id. The value is a STRING - text again.22{23#98c379">"role": "tool",24#98c379">"tool_call_id": "call_8Fk2",25#98c379">"content": "{\"city\": \"Prague\", \"temp\": \"14\", \"desc\": \"Light rain shower\"}"26}Say it out loud once: the model asks, your code acts, the model reads what came back. Every agent framework you will ever use is a wrapper around that sentence.
Here is the loop in code:
1from openai import OpenAI2import json3 4client = OpenAI()5messages = [6 {#98c379">"role": "system", "content": "You are a weather assistant."},7 {#98c379">"role": "user", "content": "Should I take an umbrella in Prague today?"},8]9 10while True:11 response = client.chat.completions.create(12 model=#98c379">"gpt-4o-mini",13 messages=messages,14 tools=tools,15 )16 msg = response.choices[0].message17 messages.append(msg)18 19 if not msg.tool_calls: # no tool requested -> final answer20 print(msg.content)21 break22 23 for call in msg.tool_calls: # execute every requested tool24 args = json.loads(call.function.arguments)25 result = get_weather(**args)26 messages.append({27 #98c379">"role": "tool",28 #98c379">"tool_call_id": call.id,29 #98c379">"content": result,30 })31 # loop: the model now sees the tool result and continuesThat while loop is an agent. Everything else is convenience.
Practices that keep it reliable
- Write descriptions for a new hire. “Get weather” is weak. Say what it is for (“use for any question about temperature, rain, wind”), what it returns, and when not to use it. The description is your prompt to the model about this tool.
- Keep the tool set small. Ten well-described tools beat forty fuzzy ones. With too many options, models pick the wrong tool or invent arguments more often.
- Return errors as strings, not exceptions. If the API call fails, feed “Error: city not found” back into the loop — the model will often correct itself. A crashed process cannot recover; the loop can.
- Constrain with the schema. Enums, required fields, and types turn “the model might write anything” into “the model must pick from these.”
- Log every call and result. When an agent misbehaves, the transcript of tool calls is your debugging data — without it you are guessing.
What does the model actually do when it "calls a tool"?
A tool keeps getting called with the wrong arguments. The first thing to improve is:
An API call inside a tool fails. What should your code do?
Where this leaves you
You can now close the loop: define tools, let the model request them, run
them, and feed results back. That while loop is the whole engine. The one
thing worth reading next is MCP — the open standard for
exposing tools so that any model, not just yours, can discover and use them.