The model never runs anything. It returns JSON naming a function, and your code decides what happens next.
Highlights
- Function calling never lets the model run anything on your machine. It returns JSON naming a function and its arguments, and your code decides whether to honour that.
- The whole thing is five steps with a gap in the middle. You send tools, the model asks for one, your code runs it, you send the result back, the model writes the answer.
- Your description decides whether a function ever gets picked. The model reads descriptions, not code, so saying when to use a tool beats saying what it does.
- Strict mode turns argument generation from best effort into a guarantee, so arguments match your schema exactly and a whole class of parsing bugs disappears.
- Valid is not the same as correct. Strict mode fixes the shape of the data and says nothing about its meaning, so a well typed path can still point nowhere.
- Anything that deletes, restarts, or writes needs a confirmation gate and narrow credentials, because leading models still score in the mid seventies on multi turn tool benchmarks.
- Function calling is the layer agent frameworks and MCP are built on. An agent is this same loop with more tools, memory, and planning bolted on.
A language model cannot check your disk usage. It cannot query your database, restart a service, or read a ticket from your tracker. Ask it how much space is left on /var and it will either refuse or invent a number that looks convincing and is completely wrong. Function calling is the mechanism that closes that gap. By the end of this tutorial you will have a working loop that takes a plain English question, picks the right Python function, runs it, and answers using real output from your own system.
What function calling actually is
Function calling does not let the model execute anything. That single sentence clears up most of the confusion beginners have with this feature.
What comes back is a structured request. It says, in effect, run the function named get_disk_usage with path set to /var. Your app receives that request. Your app decides whether to honour it. Your app runs the function and sends the result back. The model never touches your runtime, your credentials, or your files. It only ever produces JSON.
That gap is the whole security model. Everything the model asks for goes through your code first. So you control what exists, what is allowed, and what gets refused.
Did you know
The feature has two names and they mean the same thing. Providers called it function calling first, then moved to tool calling as it grew to cover things that are not strictly functions. The API parameter is now tools and the response field is tool_calls. Older tutorials and newer docs use different words for one mechanism, which trips people up when they search for help. Either term is fine.
The five step flow
Every function calling implementation, from a twenty line script to a production agent, follows the same cycle documented in the OpenAI function calling guide.
Picture a conversation with a gap in the middle. The model speaks. It goes quiet while your code does the real work. Then it speaks again once it has the answer. Two API calls, one function run, and three new messages each round.
The message list is what beginners get wrong most. Steps 4 and 5 only work if the history holds three things in order: the user message, the assistant message with the tool call, and the tool result. Drop any one and the second call fails, or the model answers from nothing.
Choosing the right environment
You need two things: somewhere to run Python, and model access for the code to call.
A browser based Python playground covers the first. It ships Python pre installed, so there is nothing to configure on your own machine and nothing left behind when you close the tab. Your own laptop works just as well if you already have Python 3.9 or later.
One KodeKey covers the second. That single key reaches Claude, GPT, Gemini, DeepSeek and others through one OpenAI compatible endpoint, so a reader with a KodeKloud account needs no separate provider account, no billing setup, and nothing to approve.
Generate your key before you start. It is an account credential rather than a session token, and you create it on a settings page rather than inside a playground, so generating it does not consume your one active playground slot. Head to your KodeKey keyspace and copy what it gives you.
Install the one package this tutorial needs:
pip install openaiExport the key so it never touches your source:
export KODEKEY_API_KEY="your key here"Then create the client.
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["KODEKEY_API_KEY"],
base_url="https://api.ai.kodekloud.com/v1",
)
MODEL = "claude-haiku-4-5"That is the whole setup. The base_url is what makes it work with the standard openai package, since KodeKey speaks the same chat format. Everything else in this tutorial is ordinary Python.
The MODEL line is worth noticing. It is a plain string, so swapping models is a one word change rather than a new account. That matters later, when you want to check whether a tool description works on more than one model.
Watch out
Function calling costs two API calls per answer, not one. Your code calls the model, runs the function, then calls again so the model can write the reply. That doubles your usage against any quota. On the KodeKey free tier, 25 requests a month is about twelve questions once you account for the pairing. Plenty for working through this tutorial, and worth knowing before you loop over a test set. The reusable loop later in this post can spend four or six calls on a single question, since the model may call a tool, read the result, and reach for another.
Get your key and try the models
Worth knowing before you send anything real through it. KodeKey is built for learning and prototyping rather than production traffic, and your prompts are neither stored nor used for training. The model list is worth a look too, since which models you can reach decides what the comparison later in this post will show you.
Get the Python foundations in place
This tutorial leans on dictionaries, JSON, and function arguments more than anything else. If any of that feels shaky, the Python Basics course covers data types, control flow, data structures, and file operations, and the Python Playground gives you somewhere to run the examples with no local setup.
Step 1: write the Python function
Start with an ordinary function. Nothing about it is special, and that is the point.
import shutil
def get_disk_usage(path):
total, used, free = shutil.disk_usage(path)
gb = 1024 ** 3
return {
"path": path,
"total_gb": round(total / gb, 2),
"used_gb": round(used / gb, 2),
"free_gb": round(free / gb, 2),
"percent_used": round(used / total * 100, 1),
}Return a dictionary, not a formatted string. The model reads it as text either way. But a dictionary turns into clean JSON and keeps the field names visible, and that noticeably improves how well the model reads the result.
Test it on its own first. If get_disk_usage("/") throws, the problem is your function, not your prompt.
Step 2: describe it as a JSON Schema
The model cannot see your source code. It only sees the description you write. That schema is the entire interface.
TOOLS = [
{
"type": "function",
"function": {
"name": "get_disk_usage",
"description": (
"Return total, used and free disk space in gigabytes for a "
"filesystem path on the current host. Use this whenever the "
"user asks about disk space, storage capacity, or whether a "
"volume is close to full."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": (
"Absolute filesystem path to inspect, for example "
"/var or /home. Defaults to / if the user does "
"not name a specific path."
),
}
},
"required": ["path"],
"additionalProperties": False,
},
},
}
]Note that name must match your Python function name exactly, because you will use it as a dictionary key shortly. The parameters block is plain JSON Schema. Every type, enum, and nested object you already know works here.
Why the description does more work than the code
Your function can be perfect and still never get called. The model picks tools by reading descriptions.
So write the description to answer two questions. What does this return? When should it be used? The second matters more than people expect. A description reading "gets disk usage" says what the function does and nothing about when to reach for it. Add "use this whenever the user asks about disk space, storage capacity, or whether a volume is close to full" and the model now has trigger conditions. Selection gets noticeably better.
Parameter descriptions deserve the same care. Tell the model that path defaults to / when nobody names one, and you head off a whole class of failure. Otherwise it invents a path, or asks a question you did not want.
Quick tip
Test your function on its own before the model ever sees it. Call get_disk_usage("/") in a plain Python shell first. If it throws, the problem is your function, not your prompt. Debugging that through two API calls and a JSON schema wastes an afternoon, and it is a mistake almost everyone makes once.
Step 3: send the tools and read the tool call
Now make the first API call.
messages = [
{"role": "user", "content": "Is /var running out of space?"}
]
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
message = response.choices[0].message
print(message.content)
print(message.tool_calls)The interesting bit is that message.content is usually None. When the model decides to call a tool, it stops writing prose and emits the call instead. There is no text to print yet.
What you get in message.tool_calls looks like this:
[
ChatCompletionMessageToolCall(
id="call_a1b2c3",
function=Function(
arguments='{"path": "/var"}',
name="get_disk_usage",
),
type="function",
)
]Three fields matter. The id links the result back to this request, and you must return it unchanged. The name tells you which function to run. The arguments field is a JSON string rather than a dictionary, so parse it first.
Notice what just happened. The model pulled /var out of a sentence that never mentioned a function, a parameter, or a path. That extraction is the real value here.
Step 4: run the function and return the result
Two things go into the message list here: the assistant message containing the tool call, and one tool message per call.
import json
AVAILABLE_FUNCTIONS = {
"get_disk_usage": get_disk_usage,
}
messages.append(message)
for call in message.tool_calls:
function = AVAILABLE_FUNCTIONS[call.function.name]
arguments = json.loads(call.function.arguments)
result = function(**arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})The AVAILABLE_FUNCTIONS dictionary is your allowlist. Only what you register here can ever run. So never dispatch with eval or globals() on a name the model handed you.
Watch out
Appending message before the tool results is not optional, and the error you get when you forget is unhelpful. The API rejects a tool message unless it directly follows an assistant message with a matching tool_call_id. Also loop over message.tool_calls rather than reading index zero, because a model can ask for several tools at once and code that assumes one silently drops the rest.
Step 5: let the model write the answer
Send the updated message list back.
final = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
print(final.choices[0].message.content)You get something along the lines of: "The /var filesystem is using 41.2 GB of 98.4 GB, which is 41.9 percent. There is 57.2 GB free, so it is not close to running out of space."
Every number there came from shutil.disk_usage on the machine that ran the script. The model did the reading. Your code supplied the facts. Nothing was invented.
Wrapping it in a reusable loop
Real requests often need more than one round. The model calls a tool, reads the result, and decides it needs another. This loop handles that.
def run(user_message, max_rounds=5):
messages = [{"role": "user", "content": user_message}]
for _ in range(max_rounds):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content
for call in message.tool_calls:
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(dispatch(call)),
})
raise RuntimeError("Tool loop exceeded the round limit")The max_rounds ceiling is a safety device, not a formality. A model that misreads a tool result can call the same function repeatedly, and without a limit you are looking at an unbounded spend on API calls.
Dispatch deserves its own function so failures become data instead of exceptions.
def dispatch(call):
name = call.function.name
if name not in AVAILABLE_FUNCTIONS:
return {"error": f"No tool named {name} is available"}
try:
arguments = json.loads(call.function.arguments)
except json.JSONDecodeError:
return {"error": "Arguments were not valid JSON"}
try:
return AVAILABLE_FUNCTIONS[name](**arguments)
except Exception as exc:
return {"error": f"{type(exc).__name__}: {exc}"}Returning the error to the model rather than raising it is a deliberate choice. Models recover well from a message saying the path does not exist, because they will usually apologise, correct the argument, and try again. A raised exception just kills your process.
Adding a second tool
Scaling to more tools costs one schema entry and one dictionary entry.
import subprocess
def list_recent_errors(service, minutes=15):
output = subprocess.run(
["journalctl", "-u", service, "--since", f"{minutes} minutes ago", "--no-pager"],
capture_output=True, text=True, timeout=15,
).stdout
errors = [line for line in output.splitlines() if "ERROR" in line]
return {
"service": service,
"window_minutes": minutes,
"error_count": len(errors),
"samples": errors[:5],
}
AVAILABLE_FUNCTIONS["list_recent_errors"] = list_recent_errorsAdd the matching schema to TOOLS and the model will now choose between them. Ask "why is nginx throwing errors and is the disk full" and a capable model will call both, often in the same response.
Two practical limits are worth knowing early. Every tool definition consumes context tokens on every request, so twenty verbose schemas add real cost per call. Selection accuracy also degrades as the tool count rises, particularly when descriptions overlap, so keep the list tight and make each description distinct.
Turning on strict mode
By default, argument generation is best effort. The model usually matches your schema and occasionally does not, which means a missing required field or a string where you expected an integer.
Strict mode removes that uncertainty. Setting strict to true constrains generation so the arguments are guaranteed to match your schema, and the OpenAI documentation recommends enabling it by default.
{
"type": "function",
"function": {
"name": "get_disk_usage",
"strict": True,
"description": "Return disk usage for a filesystem path.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
"additionalProperties": False,
},
},
}Two schema requirements come with it. Every object needs additionalProperties set to false, and every field in properties must appear in required. To make a field optional under those rules, keep it in required and allow null as a type, as in {"type": ["string", "null"]}.
Support varies by provider and model, so confirm it works on yours before depending on it. Where it is unavailable, validate the parsed arguments yourself with Pydantic or jsonschema before execution.
Controlling when the model calls a tool
The tool_choice parameter decides how much discretion the model gets.
Forcing one tool turns this into a structured extraction trick. If you always want the same shape back, force the call and read the arguments as your output. You never even run the function.
In the wild
Forcing one tool is how a lot of production extraction gets done, and it is worth knowing the shape. You define a tool whose arguments are the fields you want, force it, then read the arguments and never run anything. The catch is that forcing removes the model's option to decline. Send text that has none of your fields in it and you still get a tool call, filled with whatever the model could infer. So pair a forced call with a nullable field or a confidence value, and check that before you trust the result.
Try the same tool on three models
Here is something worth doing while everything is still fresh, and it takes about five minutes.
Your MODEL line is a string. Change it, run the same question, and watch what happens.
for model in ("claude-haiku-4-5", "gpt-5.5", "gemini-3.5-flash"):
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Is /var running out of space?"}],
tools=TOOLS,
)
call = response.choices[0].message.tool_calls
print(model, "->", call[0].function.arguments if call else "no tool call")Three things tend to show up.
Some models call the tool and some answer in prose instead, which tells you your description is doing less work than you thought. Argument extraction varies, so one model finds /var in the sentence and another defaults to /. And the wording of the final answer differs a lot even when the tool call is identical, because that step is ordinary generation.
None of that is a bug. It is the reason to write descriptions around trigger conditions rather than hoping. And it costs you three requests to find out.
Quick tip
Keep this loop somewhere. When you change a tool description, run it across two or three models before deciding the change worked. A description tuned against one model can quietly overfit to it, and you will not notice until you swap. Because the model is a string here rather than an account, that check costs a few seconds instead of a procurement conversation, which is the whole reason it is worth doing at all.
Five failures you will hit in production
Arguments that are valid but wrong. Strict mode guarantees the shape, never the meaning. A model can pass a perfectly typed path that does not exist on your host, so validate values, not just types, before acting on them.
Tools that never get selected. When a function is ignored, the description is almost always the reason. Rewrite it around trigger conditions and test the same prompt across several phrasings before blaming the model. Then test it on a second model, which is one word in your MODEL string. If a tool fires on Claude and not on GPT, the description is carrying assumptions rather than instructions.
Silent loss of parallel calls. Code written against a single tool call keeps working right up until the model requests two, then quietly drops one. Always iterate.
Runaway loops. A model that cannot interpret a tool result will often retry it. Cap the rounds and log every call so you can see where it stalled.
Destructive actions with no gate. This is the one that hurts. A tool that deletes, restarts, or writes should never execute on the model's say so alone. Split tools into read only and mutating, run mutating tools behind an explicit confirmation step, and give the credentials behind them the narrowest permissions that still work. The Berkeley Function Calling Leaderboard, which grades models on stateful multi turn tool use, still shows overall scores in the mid seventies rather than the high nineties, so treat every call as a suggestion that needs checking.
Take the loop further
Once this cycle makes sense, agents are the same loop with more tools, memory across turns, and some planning on top. The AI Agents Fundamentals course covers tokens, embeddings, RAG, vector databases, orchestration, and MCP through an end to end project.
How function calling compares
Function calling is the layer everything else sits on. Agent frameworks and MCP both wrap this same cycle. Learn it directly and those tools stop being black boxes when they misbehave.
Try this on your own function
Half an hour, and you already have everything you need.
Pick a function you have written that returns something factual. A disk check, a database count, a status lookup. Anything that takes simple arguments and gives back a dictionary.
Write the schema for it, and spend most of that time on the description. Say what it returns, then say when to use it. Those two sentences decide whether the model ever picks it.
Run the five step flow end to end. Ask a question that never mentions your function by name and watch the model work out that it applies.
Then add a second tool with a deliberately similar description and see what happens. Selection gets ambiguous fast, and watching it happen teaches you more about descriptions than any guidance can. Finish by running the same question across two models, which is one word in your MODEL string. If the two disagree, your description is the thing to fix.
Conclusion
Function calling turns a model from something that writes text into something that makes decisions your code acts on. The mechanism is small. Describe your functions as JSON Schema. Read the tool call. Run it yourself. Hand the result back.
Three things separate a demo from something users can touch. Descriptions decide whether a tool gets picked, so write them around when to use it. Errors should go back to the model as data, so it can correct itself rather than crashing your process. And mutating tools need a gate and narrow credentials, because schema validation tells you the arguments are well formed and nothing about whether they are safe.
So take one function you already have. Wrap it in a schema. Run the five steps end to end. Then add a second tool and watch what happens when two descriptions overlap. That is where the choices in this post stop being theory.
Ready to build on top of this loop?
Function calling is the foundation, and the next steps are ordinary engineering. Grab a key from KodeKey and every example here runs as written. The Python Basics course covers the dictionary and JSON handling this relies on. The AI Agents Fundamentals course takes the same loop and adds tools, memory, and MCP. And the AI Learning Path sequences it alongside retrieval and evaluation. Start with one function you already have.
FAQs
Q1: What do I need to know before learning function calling?
Working Python, comfort with dictionaries and JSON, and one successful API call to a model behind you. That is the list. JSON Schema helps and is not required. The schemas here cover most of what you will write, and the syntax is easy to pick up as you go. You need no machine learning at all. This is an API job, not a modelling one. If your Python feels rusty, the Python Basics course covers the dictionary and JSON handling this relies on, and the Python Playground gives you somewhere to run the examples without setting anything up locally.
Q2: Does function calling mean the model runs code on my machine?
No. This is the most common misunderstanding about the feature. The model returns a JSON object naming a function and its arguments, and nothing more. Your app gets that object, decides whether to act on it, and calls the function itself using code you wrote. The model has no runtime. It has no way into your systems. It cannot run anything you did not register. That is why the allowlist matters so much. If a function is not in your dictionary, no amount of model output can make it run. So the security question is never what the model might do. It is what you chose to register.
Q3: How is function calling different from the Model Context Protocol?
Function calling is the mechanism. MCP is a standard built on top of it. With function calling you define tools inside your app. So every app that needs the same tool defines it again. MCP moves those definitions into a server that any client can connect to, so one definition serves many apps. The trade is setup. MCP means running a server and handling its transport. Function calling needs a dictionary and a schema. Learn this one first. MCP wraps this exact cycle, and it is much easier to debug once you can picture what sits underneath.
Q4: Why does the model ignore my function even though the schema is valid?
Nine times out of ten the description is too thin. The model picks tools by reading descriptions, not by inspecting your code. So "gets user data" gives it nothing to work with. Rewrite it to say what the function returns and when to use it. Name the kinds of questions it answers. Check for overlap too. Two tools with similar descriptions make the choice a coin flip, and the model may pick either or neither. Then test the same intent across several wordings. A tool that fires on one and not another usually has a description that is too narrow.
Q5: How do I stop the model from calling something destructive?
Build it so that it cannot. Split your tools into two groups, read only and mutating. Never register a mutating tool beside the read only ones without a gate. For anything that deletes, restarts, or writes, have the tool say what it would do rather than doing it. Then make a human confirm. Give those credentials the least access that still works, so a wrong call fails rather than succeeds. And log every call with its arguments and its result. The first sign of a bad description is usually a call you did not expect, sitting in the log.
Q6: Where does function calling fit into building AI agents?
It is the foundation the whole agent pattern rests on. An agent is this loop with more tools, memory across turns, and some planning on top. Learn the raw cycle and frameworks stop being mysterious. Once you can write, register, and debug tools by hand, a framework is a convenience rather than a black box. You can also tell whether a failure is in your tool or in the framework, which is hard to work out any other way. For a path from this loop to full agents, the AI Agents Fundamentals course covers the steps, and the AI Learning Path sets it beside the retrieval and evaluation topics agents depend on.
Discussion