Ask a language model "Where is Maria's order?" and it has no way to know. It has never seen your orders. A model can only produce text.

So how do AI assistants check a calendar, search the web or look up an order? The answer is called tool calling, or function calling, and it's simpler than it sounds. It's a loop.

This guide builds that loop in plain Python with no framework. It includes safety checks and a stand-in model, so you can run and test the whole thing without an API key.

What tool calling actually is

The model never runs any code. It only asks.

Here's the whole idea in five steps:

  1. You describe the tools. Each one gets a name, a description of what it does, and the arguments it takes.
  2. The model reads the question. If it needs a tool, it doesn't answer yet. It replies with a request, such as "please call get_order with order number A-1042".
  3. Your program runs the function. The model has no access to your computer. Your code does the work.
  4. You send the result back. The model reads it.
  5. The model answers, or asks for another tool. Repeat until it has a final answer.

That's it. Everything else is detail.

This has an important consequence. Because your program does the running, your program decides what's allowed. The model proposes, and your code disposes.

A model that can use tools in a loop like this is what most people mean by an AI agent. If the words assistant, chatbot and agent tend to blur together, I explain how they differ in AI agent vs assistant vs chatbot.

What you need

  • A recent version of Python 3
  • Nothing to install for the first part. The stand-in model runs entirely on your computer
  • Later, if you want a real model, an API key from a provider

Step 1: Define the tools

Our example is a small shop's support assistant. Create tools.py:

ORDERS = { "A-1042": {"customer": "Maria Lopez", "status": "shipped", "eta": "2026-10-16", "items": ["3 blue notebooks", "2 desk lamps"]}, "A-1043": {"customer": "Maria Lopez", "status": "processing", "eta": None, "items": ["1 pen set"]}, "B-2001": {"customer": "Sam Okafor", "status": "delivered", "eta": None, "items": ["1 canvas bag"]}, } REFUNDED = [] def get_order(order_number): order = ORDERS.get(order_number.strip().upper()) if not order: raise ValueError(f"No order called {order_number!r}. Order numbers look like A-1042") return order def find_orders(customer_name): name = customer_name.strip().lower() return [number for number, order in ORDERS.items() if order["customer"].lower() == name] def refund_order(order_number): get_order(order_number) # makes sure the order exists  REFUNDED.append(order_number.strip().upper()) return f"Refunded {order_number}" # What the model sees: a name, when to use the tool, and the arguments it takes TOOLS = [ {"name": "get_order", "description": "Look up one order by its order number. Returns the status, delivery date and items.", "input_schema": {"type": "object", "properties": {"order_number": {"type": "string", "description": "For example A-1042"}}, "required": ["order_number"]}}, {"name": "find_orders", "description": "List the order numbers for a customer, given their full name. Use this when you don't have an order number.", "input_schema": {"type": "object", "properties": {"customer_name": {"type": "string", "description": "For example Maria Lopez"}}, "required": ["customer_name"]}}, {"name": "refund_order", "description": "Refund an order. Only use this when the customer has clearly asked for a refund.", "input_schema": {"type": "object", "properties": {"order_number": {"type": "string", "description": "For example A-1042"}}, "required": ["order_number"]}}, ] FUNCTIONS = {"get_order": get_order, "find_orders": find_orders, "refund_order": refund_order} 

The top half is ordinary Python: a pretend database and three functions. The bottom half is what the model gets to see.

Look at the descriptions. They're the model's only guide to when a tool is the right one, so they say things like "use this when you don't have an order number". That's a hint about which tool to pick first.

The input_schema describes the arguments, using JSON Schema, a standard way to describe the shape of JSON data.

Notice that refund_order is different from the other two. Looking something up changes nothing. A refund moves money. We'll treat it with extra care.

Step 2: Write the loop

This is the heart of it. Create agent.py:

import json import logging log = logging.getLogger("agent") MAX_STEPS = 6 # a safety net, so a confused model can't loop forever  def run_tools(calls, functions, needs_approval, approve): """Run every tool the model asked for, and return the tool_result blocks.""" results = [] for call in calls: name, args = call["name"], call["input"] try: if name not in functions: raise ValueError(f"There is no tool called {name!r}") if name in needs_approval and not approve(name, args): raise PermissionError("A person declined this action. Don't try again; explain that you can't do it") content, is_error = json.dumps(functions[name](**args)), False except Exception as error: # tell the model what went wrong, so it can adjust  content, is_error = f"Error: {error}", True log.info("%s(%s) -> %s", name, args, content[:100]) result = {"type": "tool_result", "tool_use_id": call["id"], "content": content} if is_error: result["is_error"] = True results.append(result) return results def run_agent(question, call_model, tools, functions, needs_approval=(), approve=lambda name, args: False): """Let the model use tools until it has an answer. call_model(messages, tools) returns {"stop_reason": ..., "content": [blocks]}. """ messages = [{"role": "user", "content": question}] for _ in range(MAX_STEPS): reply = call_model(messages, tools) messages.append({"role": "assistant", "content": reply["content"]}) calls = [block for block in reply["content"] if block["type"] == "tool_use"] if reply["stop_reason"] != "tool_use" or not calls: return " ".join(block["text"] for block in reply["content"] if block["type"] == "text") messages.append({"role": "user", "content": run_tools(calls, functions, needs_approval, approve)}) raise RuntimeError(f"The model was still asking for tools after {MAX_STEPS} steps, so I stopped") 

Let's walk through run_agent(), because it's the five steps from earlier:

  1. It starts a conversation with the question.
  2. It asks the model for a reply.
  3. If the reply says it wants tools (stop_reason is tool_use), it runs them with run_tools().
  4. It adds the results to the conversation and asks again.
  5. When the model stops asking for tools, whatever text it wrote is the answer.

The messages list is the model's whole memory of what's happened so far. Each loop adds to it, so the model sees every earlier request and result. I wrote about why that's only short-term memory in agent memory: short-term vs long-term.

The format of the messages follows the rules of Anthropic's Claude API, which has a few strict requirements:

  • The results go in a user message that comes straight after the model's request.
  • There's one result for each request, all in the same message.
  • The tool results come first in that message.

The loop follows these by construction, because it always answers every request in one message.

Now the safety features, which make up half of the code:

  • Unknown tools are refused. The model can only call functions in your functions list.
  • Errors become messages. If a tool fails, or the model sends the wrong arguments, the loop sends back Error: ... marked with is_error. The model can then fix its mistake or explain the problem. It doesn't crash your program.
  • Risky tools need a person. Any tool named in needs_approval only runs if the approve() function says yes. The default is no.
  • The loop has a limit. After MAX_STEPS, it stops with an error.
  • Only complete requests run. If a reply was cut off before the model finished, it isn't treated as a request.

Step 3: Try it without a real model

To test the loop, we need something that plays the part of the model. This stand-in follows a script of simple rules. It isn't an AI. It's a few if statements that behave like one for this one question. Create fake_model.py:

import json from itertools import count ids = count(1) def tool_use(name, **args): return {"type": "tool_use", "id": f"toolu_{next(ids):02d}", "name": name, "input": args} def fake_model(messages, tools): """A stand-in for a real model. It reads the conversation and decides what to do next.""" last = messages[-1] if isinstance(last["content"], str): # a new question from the user  if "maria lopez" in last["content"].lower(): return {"stop_reason": "tool_use", "content": [ {"type": "text", "text": "Let me look that up."}, tool_use("find_orders", customer_name="Maria Lopez")]} return {"stop_reason": "end_turn", "content": [{"type": "text", "text": "Which customer do you mean?"}]} names = {b["id"]: b["name"] for b in messages[-2]["content"] if b["type"] == "tool_use"} # which tool made each result  results = {b["tool_use_id"]: b for b in last["content"]} if all(names[i] == "find_orders" for i in results): # we have order numbers, so look up each one  numbers = json.loads(next(iter(results.values()))["content"]) return {"stop_reason": "tool_use", "content": [tool_use("get_order", order_number=n) for n in numbers]} orders = [json.loads(r["content"]) for r in results.values() if not r.get("is_error")] # we have the details, so answer  lines = [f"{o['status']} ({', '.join(o['items'])})" + (f", arriving {o['eta']}" if o["eta"] else "") for o in orders] return {"stop_reason": "end_turn", "content": [{"type": "text", "text": f"Maria Lopez has {len(lines)} orders: " + "; ".join(lines) + "."}]} 

Then create demo.py to run it:

import logging from agent import run_agent from fake_model import fake_model from tools import FUNCTIONS, TOOLS logging.basicConfig(level=logging.INFO, format="%(message)s") print(run_agent("Where are Maria Lopez's orders?", fake_model, TOOLS, FUNCTIONS, needs_approval={"refund_order"})) 

Run it with python3 demo.py:

find_orders({'customer_name': 'Maria Lopez'}) -> ["A-1042", "A-1043"] get_order({'order_number': 'A-1042'}) -> {"customer": "Maria Lopez", "status": "shipped", "eta": "2026-10-16", "items": ["3 blue notebooks", get_order({'order_number': 'A-1043'}) -> {"customer": "Maria Lopez", "status": "processing", "eta": null, "items": ["1 pen set"]} Maria Lopez has 2 orders: shipped (3 blue notebooks, 2 desk lamps), arriving 2026-10-16; processing (1 pen set). 

Watch what happened. The model didn't know Maria's order numbers, so it first asked for find_orders. Then, with the two numbers in hand, it asked for both orders at once. Finally it wrote the answer.

That's an agent loop: three trips to the model, three tool calls, one answer. The lines at the top come from log.info in the code, which gives you a record of every call the model made.

Step 4: Connect a real model

The loop doesn't care what's behind call_model(). It needs a function that takes the messages and the tools, and returns the reply in a simple shape. Here's one for Claude, using Anthropic's Python library (pip install anthropic):

import anthropic client = anthropic.Anthropic() # reads the key from the ANTHROPIC_API_KEY environment variable SYSTEM = "You answer questions about customer orders. Use the tools to look things up. If the tools can't answer, say so." def call_model(messages, tools): response = client.messages.create(model="claude-sonnet-5-5", max_tokens=1024, system=SYSTEM, tools=tools, messages=messages) content = [] for block in response.content: if block.type == "text": content.append({"type": "text", "text": block.text}) elif block.type == "tool_use": content.append({"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}) return {"stop_reason": response.stop_reason, "content": content} 

Then run the same loop with it:

print(run_agent("Where are Maria Lopez's orders?", call_model, TOOLS, FUNCTIONS, needs_approval={"refund_order"})) 

A few notes, based on Anthropic's documentation:

  • stop_reason is how the model signals what it wants. tool_use means "run my tools", and end_turn means "I'm done".
  • Each request has an id. Your result must carry the same ID, so the model knows which result answers which request.
  • Parallel calls are on by default. The model may ask for several tools in one turn, as it did in our demo. Return one result for each, all together.
  • Model names change. Check Anthropic's documentation for the current ones, and choose the model that suits your task and budget.
  • Other providers work in...