All lessons · 44
Path 3 · AI Engineer Roadmap·Lesson 21 of 33

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.

LevelIntermediateTime~15 min

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:

  1. You describe the available tools in the request.
  2. The model outputs structured JSON: “I want to call get_weather with these arguments.”
  3. Your code sees that JSON, runs the real function, and sends the result back.
  4. 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.

weather_bot.py - tool schema
1import json
2import requests
3 
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 magic
34 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:

A ready-to-use tool schema — copy and adapt
{
"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:

function calling, step by step
press the button below to send the first message
sent to the modelnothing yet — every message is sent in full

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:

the raw reply, and your next request
1// What the model returned. Note: content is EMPTY and
2// tool_calls carries the request. This is text - the model
3// 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, then
20// append the result as a message with role "tool" and the
21// 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:

the agent loop, minimal
1from openai import OpenAI
2import json
3 
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].message
17 messages.append(msg)
18 
19 if not msg.tool_calls: # no tool requested -> final answer
20 print(msg.content)
21 break
22 
23 for call in msg.tool_calls: # execute every requested tool
24 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 continues

That 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.
Check yourself

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.