You will be able to
- Explain what function calling is and why the model never runs your code itself
- Describe a tool with a name, a clear description and a JSON Schema for its parameters
- Write the loop that sends tools, runs the requested calls and returns the results
- Validate tool arguments and limit the loop so mistakes cannot spread
- Explain what the Model Context Protocol adds on top of plain function calling
What function calling really does
With function calling you send the model a list of tools alongside the messages. Each tool is only a description: a name, a sentence saying what it does, and the shape of the arguments it expects. The model reads the conversation and decides whether answering needs one of those tools.
If it does, the reply contains no prose. Instead it holds one or more tool calls, each with the tool's name and a JSON string of arguments the model filled in from the conversation. Nothing has happened yet in the real world. The model has only written a request.
Your code then does the actual work. It reads the requested name, checks the arguments, runs the matching Python function and sends the result back as a message with the tool role. The model reads that result and either calls another tool or writes the final answer for the user.
This split is what makes tools safe enough to use. The model chooses and fills in, while your program decides what is allowed, performs the action and controls what the model sees afterwards.
Describing a tool well
The tool definition is a prompt, and the model reads it closely. A precise name and a one-sentence description that says when to use the tool matter as much as the code behind it. Vague descriptions lead to tools being called at the wrong time or not at all.
Parameters use JSON Schema, the same standard that validates JSON in many other systems. Give every property a type, mark the ones that must be present as required, and use enum, minimum or maximum when only certain values make sense. A short description on a property helps whenever its name alone could be read two ways.
Keep each tool small and single-purpose. One tool that adds a session and another that lists the plan are easier for the model to choose between than one tool with a mode argument that does five things. Every tool definition also takes up tokens in each request, so offer only the tools the current task needs.
- Name: a verb and an object, such as
add_sessionorsearch_notes - Description: what it does and when to use it, in one or two sentences
- Parameters: an object schema with typed, described properties
- Required: only the fields the function truly cannot work without
- Limits: ranges and allowed values written into the schema
The call, run, return loop
Every tool-using app repeats the same cycle. Send the messages and tools to the model. If the reply contains tool calls, append that reply to the history, run each call, and append one tool message per call with the matching tool_call_id. Then send everything again and repeat until the reply has no tool calls.
Order and pairing matter. The assistant message that asked for the tools must come before their results, and every call needs exactly one result with its ID. Most errors people meet in their first tool loop come from a missing assistant message or a result with the wrong ID.
A model can ask for several tools in one reply, for example adding two sessions at once. Handle them all before calling the model again. If a call fails, do not crash: return a short error as the tool result, and the model will often correct the arguments and try again on its own.
Always cap the loop. A fixed number of rounds, such as five, stops a confused model from calling tools forever and running up your bill. When the cap is reached, tell the user that the task could not be finished instead of failing silently.
Validation is your job
Schemas guide the model but do not bind it. Depending on the provider and settings, you can still get a missing field, a string where a number belongs, a value outside the range or arguments that are not valid JSON at all. Some providers offer a strict mode that enforces the schema more tightly, but your code should check anyway.
Validate inside the function, before any side effect. Check types, ranges and lengths, trim text to a sane size and reject unknown tool names. Return a clear error message the model can act on, such as saying that minutes must be a whole number from 5 to 120.
Treat tool arguments like any other user input, because indirectly that is what they are. If a tool writes to a database, sends a message or spends money, add a confirmation step for the user, and give the tool the narrowest permissions that still let it work. Lesson 14 returns to this from the security side.
MCP: one standard for tools
Writing tool definitions by hand works for a few functions inside one app. It becomes repetitive when many apps need the same tools, such as access to files, a calendar or a database. The Model Context Protocol, usually called MCP, is an open standard that solves this sharing problem.
An MCP server is a small program that publishes tools, each described with a name, a description and a JSON Schema for its input, and runs them when asked. An MCP client, built into an assistant app or your own code, connects to the server, lists its tools and offers them to the model. The loop you wrote in this lesson stays the same; MCP standardises where the tools come from.
The same rules apply to MCP tools as to your own. Install servers only from sources you trust, check which permissions they need, and remember that the text a tool returns goes straight into the model's context.
Example
A study-plan tool with the full loop
import json, osfrom openai import OpenAI client = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"])MODEL, plan = os.environ["LLM_MODEL"], [] def add_session(topic, minutes): # validate: never trust the model's arguments if not isinstance(topic, str) or not isinstance(minutes, int) or not 5 <= minutes <= 120: return {"error": "topic must be text and minutes a whole number from 5 to 120"} plan.append({"topic": topic[:80], "minutes": minutes}) return {"ok": True, "sessions_in_plan": len(plan)}TOOLS = [{"type": "function", "function": {"name": "add_session", "description": "Add one study session.", "parameters": {"type": "object", "required": ["topic", "minutes"], "properties": { "topic": {"type": "string"}, "minutes": {"type": "integer", "minimum": 5, "maximum": 120}}}}}]def run_tool(call): try: args = json.loads(call.function.arguments) return add_session(**args) if call.function.name == "add_session" else {"error": "unknown tool"} except (ValueError, TypeError): return {"error": "arguments must be a JSON object with topic and minutes"} messages = [{"role": "user", "content": "Plan 30 minutes of fractions and 20 minutes of French verbs."}]for _ in range(5): # hard stop, so a confused model cannot loop forever msg = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS).choices[0].message messages.append(msg.model_dump(exclude_none=True)) for call in msg.tool_calls or []: messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(run_tool(call))}) if not msg.tool_calls: breakprint(msg.content, plan, sep="\n")The model never runs add_session itself; it only asks for it, and run_tool decides whether to run it. Any bad argument comes back to the model as an error it can fix, and the loop stops after five rounds whatever happens.
Practice
Give Study Buddy a study plan
- Save the example as
tools.pyand run it; check that the printed plan holds two sessions with the right minutes. - Change the request to ask for 300 minutes of chemistry and confirm that the model receives the error and either corrects the value or explains the limit.
- Add a second tool,
list_sessions, with no parameters, that returns the current plan, and register it inTOOLSandrun_tool. - Ask what is already in the plan and confirm from a print statement in
run_toolthatlist_sessionswas called. - Save the plan to
plan.jsonafter each successful call so it survives a restart.
Done when: Your script adds valid sessions, refuses out-of-range ones with an error the model reacts to, answers a question about the plan by calling list_sessions, and leaves a plan.json file you can open.
Common mistakes
- Believing the model executed the function, when it only returned a request that your code must run.
- Forgetting to append the assistant's tool-call message before the tool results, which makes the next request fail.
- Passing the model's arguments straight into a function with side effects without checking types and ranges.
- Running the loop with no round limit, so a confused model can keep calling tools indefinitely.
Check yourself
Pick an answer to see whether it is right and why.
From the founder
Break your tool on purpose before you trust it: send wrong types, huge numbers and an unknown tool name, and watch what the model does with each error. A tool you have seen fail safely is one you can build on.