LLM tool calling is what turns a chatbot into something that does real work: the model reads a request, decides it needs one of your functions, and hands that decision back as JSON for your code to run. Frameworks like LangGraph and CrewAI wrap this in nice classes, but underneath they all run one small loop. Here we build that loop by hand, about sixty lines of plain Python.
“An agent is a model calling tools in a loop. Everything else is a convenience on top of that.”
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 21 minutes
- LLM APIs and function calling (this post goes one level deeper)
- Python functions and dictionaries
- The loop here runs offline. To point it at a real model: pip install anthropic and set ANTHROPIC_API_KEY
- Model IDs change fast. The one below was current at the time of writing; check docs.claude.com before you ship.
Here is the mental model. Picture a head chef who never touches a pan. She reads the order, calls out “table four wants the paneer, check if we have fresh coriander,” and a runner goes to look. The runner comes back, says “yes, plenty,” and only then does the chef finish plating. The chef is the brain, the runner is your code. The model never runs your function. It looks at the question, picks a tool, fills in the arguments, and waits for you to hand the answer back. Then it either asks for another tool or writes the final reply.
That back and forth is the whole trick, and it is exactly four moves repeated until the model is happy: ask the model, check if it wants a tool, run the tool, feed the result back. This is the single most interview-relevant piece of GenAI (Generative AI) engineering right now, because it is provider-agnostic and framework-proof. Learn it raw and you can read any agent codebase.
Table of Contents
What LLM Tool Calling Actually Is
The diagram below is the entire post in one picture. You send the model a message plus a list of tools it may use. The model replies, and you check one field on that reply: its stop reason. If the stop reason says the model wants a tool, you run the tool it named, append the result, and ask again. If the stop reason says the model is done, you take its text and return it. That is a state machine with a loop in the middle, and a guard so it cannot spin forever.
Notice what the model does not do: it never runs code, never touches your database, never sees your Application Programming Interface (API) keys. It only ever produces text and structured requests. Every real action happens in your code, which means you decide what is allowed to run. That boundary is the reason this pattern is safe to build on, and it is the first thing a good interviewer will ask you to explain.
The Two Tools: Calculator and Weather Lookup
A tool is just a normal Python function. We will give the model two: a calculator that evaluates a simple arithmetic string safely, and a weather lookup backed by a small dictionary so it runs offline with no network. The calculator deliberately avoids Python’s eval, because the model can put anything in that string and you never want to hand raw text to eval. We walk an abstract syntax tree instead and only allow numbers and five operators.
📄 tools.py: two plain functions the model is allowed to request
import ast, operator
# Aditi writes two plain functions. The model never runs these;
# it only asks your code to run them.
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
def _eval(node):
if isinstance(node, ast.Constant): # a number like 42
return node.value
if isinstance(node, ast.BinOp): # a + b, a * b, ...
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp): # -a
return _OPS[type(node.op)](_eval(node.operand))
raise ValueError("only numbers and + - * / ** are allowed")
def calculator(expression: str) -> dict:
"""Evaluate a simple arithmetic expression safely (no eval)."""
return {"expression": expression, "result": _eval(ast.parse(expression, mode="eval").body)}
WEATHER_DB = {"Pune": {"temp_c": 31, "sky": "sunny"},
"Mumbai": {"temp_c": 29, "sky": "humid"},
"Delhi": {"temp_c": 27, "sky": "hazy"}}
def get_weather(city: str) -> dict:
"""Look up today's weather for a city from a local file."""
if city not in WEATHER_DB:
return {"error": f"no weather on file for {city!r}"}
return {"city": city, **WEATHER_DB[city]}
if __name__ == "__main__":
print(calculator("18 * 6 + 4"))
print(calculator("2 ** 10"))
print(get_weather("Pune"))
print(get_weather("Tokyo"))
try:
calculator("__import__('os').system('rm -rf /')")
except Exception as e:
print(f"blocked: {type(e).__name__}: {e}")
▶ Output
{'expression': '18 * 6 + 4', 'result': 112}
{'expression': '2 ** 10', 'result': 1024}
{'city': 'Pune', 'temp_c': 31, 'sky': 'sunny'}
{'error': "no weather on file for 'Tokyo'"}
blocked: ValueError: only numbers and + - * / ** are allowed
What happened here: Both functions return a dictionary, which matters because we will hand that back to the model as JSON. The weather lookup returns an error key for an unknown city instead of crashing, and the calculator refuses anything that is not plain arithmetic. That last line is the important one: a hostile expression trying to import os is rejected with a clear ValueError, because our tree walker simply has no rule for function calls. Safe tools are tools that fail loudly on input they do not understand.
Tool Schemas: The Description Is a Prompt
The model cannot see your Python. It only sees a schema you write for each tool: a name, a description, and the shape of the arguments as JSON Schema. Treat the description as a mini prompt, because that is exactly what it is. The model reads it to decide when to reach for the tool and how to fill in the fields. A vague description like “weather tool” makes the model guess; a description that says when to call it makes the model reliable.
📄 schemas.py: what the model sees instead of your code
TOOLS = [
{"name": "get_weather",
"description": "Get today's weather for an Indian city. Call this whenever "
"the user asks about temperature or sky conditions.",
"input_schema": {"type": "object",
"properties": {"city": {"type": "string", "description": "City name, e.g. Pune"}},
"required": ["city"]}},
{"name": "calculator",
"description": "Evaluate one arithmetic expression with + - * / **. "
"Call this for any math instead of doing it in your head.",
"input_schema": {"type": "object",
"properties": {"expression": {"type": "string", "description": "e.g. 144 / 12"}},
"required": ["expression"]}},
]
What happened here: Each tool names its function, describes when to use it, and lists its arguments with types and a required list. The line “call this for any math instead of doing it in your head” is not decoration. Language models are famously shaky at arithmetic, so that sentence pushes the model to hand the sum to a real calculator rather than guess. Writing good tool descriptions is a real skill, and it is closer to prompt engineering than to coding.
The Raw Loop in About Sixty Lines
Now the heart of it. The run_agent function below is the real loop you would ship. The only thing standing in for a live model is ScriptedModel, a tiny stand-in that returns pre-written responses so the whole thing runs offline with no API key and no bill. Swap ScriptedClient for anthropic.Anthropic() and the loop body does not change one line, because the response shape is the same: a list of content blocks and a stop_reason.
📄 agent.py: the loop, plus an offline stand-in for the model
import json
from dataclasses import dataclass
MODEL = "claude-opus-4-8" # current at the time of writing; keep it in one place
# --- block shapes that match the anthropic SDK response ---
@dataclass
class TextBlock:
text: str
type: str = "text"
@dataclass
class ToolUseBlock:
id: str
name: str
input: dict
type: str = "tool_use"
@dataclass
class Response:
content: list
stop_reason: str
# --- a scripted stand-in so the loop runs offline ---
class ScriptedModel:
"""Returns pre-written responses in order. Swap for anthropic.Anthropic()
and run_agent below does not change one line."""
def __init__(self, script):
self._script, self._i = list(script), 0
def create(self, **kwargs):
r = self._script[min(self._i, len(self._script) - 1)]
self._i += 1
return r
class ScriptedClient:
def __init__(self, script):
self.messages = ScriptedModel(script)
# --- the raw agent loop: this is the whole idea ---
def run_agent(client, user_message, tools, tool_fns, max_iterations=8):
messages = [{"role": "user", "content": user_message}]
for step in range(1, max_iterations + 1):
response = client.messages.create(
model=MODEL, max_tokens=1024, tools=tools, messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use": # model is done
return "".join(b.text for b in response.content if b.type == "text"), step
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f" [round {step}] model calls {block.name}({block.input})")
try:
output = tool_fns[block.name](**block.input) # YOUR code runs it
is_error = "error" in output
except Exception as e:
output = {"error": f"{type(e).__name__}: {e}"}
is_error = True
print(f" -> {output}")
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": json.dumps(output), "is_error": is_error})
messages.append({"role": "user", "content": results}) # hand results back
raise RuntimeError(f"gave up after {max_iterations} rounds with no final answer")
What happened here: Read the loop body top to bottom, because it is the interview answer in code form. We call the model. We always append the assistant reply verbatim, tool-call blocks and all, so the conversation stays consistent. We check the stop reason: not tool_use means done, so we return the text. Otherwise we run every tool the model asked for, wrap each result in a tool_result block tagged with the matching tool_use_id, and send them all back in one user message. Then we loop. The for step in range is the guard: it caps how many round trips can happen, which we will lean on shortly.
Watch One Question Go Around the Loop
Let us give it a question that needs both tools in sequence: check the weather, then do a sum. The script below is what a real model would send back over three turns, written out so you can watch the round trips without an API key. A user named Anvay asks one thing; the model works through it step by step.
📄 run_sequential.py: one question, two tools, three model calls
from agent import TextBlock, ToolUseBlock, Response, ScriptedClient, run_agent
from tools import calculator, get_weather
from schemas import TOOLS
TOOL_FNS = {"calculator": calculator, "get_weather": get_weather}
# What the real model would send back, written out so the loop runs offline.
script = [
Response([ToolUseBlock("t1", "get_weather", {"city": "Pune"})], "tool_use"),
Response([ToolUseBlock("t2", "calculator", {"expression": "144 / 12"})], "tool_use"),
Response([TextBlock("Pune is 31C and sunny today, so yes, it is above 30. "
"And 144 / 12 = 12.")], "end_turn"),
]
user = "I'm in Pune. Tell me if it's above 30 today, and what is 144 divided by 12?"
print(f"User: {user}\n")
answer, rounds = run_agent(ScriptedClient(script), user, TOOLS, TOOL_FNS)
print(f"\nFinal answer (after {rounds} model calls):\n{answer}")
▶ Output
User: I'm in Pune. Tell me if it's above 30 today, and what is 144 divided by 12?
[round 1] model calls get_weather({'city': 'Pune'})
-> {'city': 'Pune', 'temp_c': 31, 'sky': 'sunny'}
[round 2] model calls calculator({'expression': '144 / 12'})
-> {'expression': '144 / 12', 'result': 12.0}
Final answer (after 3 model calls):
Pune is 31C and sunny today, so yes, it is above 30. And 144 / 12 = 12.
What happened here: Three trips to the model, two of them just to run a tool. Round one, the model asks for the weather and reads back 31C. Round two, armed with that, it asks the calculator for the division. Round three, it has everything it needs, so the stop reason flips away from tool_use and it writes the answer. Your loop did not decide any of that ordering; the model did. You only ran the tools and passed results back. That is the difference between an agent and a single API call: the model drives, across as many round trips as the task needs.
When Tools Fail, and the Runaway Guard
Two things go wrong in production, and both have clean answers. First, a tool errors: the model asks for something impossible, like dividing by zero. You do not crash the loop. You catch the exception, turn it into a normal tool_result with is_error set, and hand it back. The model reads the error like any other result and tries again. Second, the model gets stuck in a loop and keeps asking for tools forever. That is the runaway agent that quietly bills five hundred dollars overnight. The max-iteration guard is your seatbelt.
📄 run_failure.py: a tool error, and a runaway stopped by the guard
from agent import TextBlock, ToolUseBlock, Response, ScriptedClient, run_agent
from tools import calculator, get_weather
from schemas import TOOLS
TOOL_FNS = {"calculator": calculator, "get_weather": get_weather}
print("=== Case 1: a tool errors, the loop keeps going ===")
script = [
Response([ToolUseBlock("t1", "calculator", {"expression": "10 / 0"})], "tool_use"),
Response([ToolUseBlock("t2", "calculator", {"expression": "10 / 2"})], "tool_use"),
Response([TextBlock("Dividing by zero is undefined, so I used 10 / 2 = 5 instead.")], "end_turn"),
]
answer, _ = run_agent(ScriptedClient(script), "What is 10 divided by 0?", TOOLS, TOOL_FNS)
print(f"Final answer: {answer}\n")
print("=== Case 2: a runaway model, stopped by the max-iteration guard ===")
# A broken model that never stops asking for tools. Without the guard this bills
# forever. With it, we stop after 4 rounds.
loop_forever = [Response([ToolUseBlock("x", "get_weather", {"city": "Pune"})], "tool_use")]
try:
run_agent(ScriptedClient(loop_forever), "hi", TOOLS, TOOL_FNS, max_iterations=4)
except RuntimeError as e:
print(f"guard tripped: {e}")
▶ Output
=== Case 1: a tool errors, the loop keeps going ===
[round 1] model calls calculator({'expression': '10 / 0'})
-> {'error': 'ZeroDivisionError: division by zero'}
[round 2] model calls calculator({'expression': '10 / 2'})
-> {'expression': '10 / 2', 'result': 5.0}
Final answer: Dividing by zero is undefined, so I used 10 / 2 = 5 instead.
=== Case 2: a runaway model, stopped by the max-iteration guard ===
[round 1] model calls get_weather({'city': 'Pune'})
-> {'city': 'Pune', 'temp_c': 31, 'sky': 'sunny'}
[round 2] model calls get_weather({'city': 'Pune'})
-> {'city': 'Pune', 'temp_c': 31, 'sky': 'sunny'}
[round 3] model calls get_weather({'city': 'Pune'})
-> {'city': 'Pune', 'temp_c': 31, 'sky': 'sunny'}
[round 4] model calls get_weather({'city': 'Pune'})
-> {'city': 'Pune', 'temp_c': 31, 'sky': 'sunny'}
guard tripped: gave up after 4 rounds with no final answer
What happened here: In case one, the zero division raised a ZeroDivisionError, our loop caught it, and the model saw {'error': 'ZeroDivisionError: division by zero'} as its tool result. Instead of falling over, the model recovered and used a valid sum, then explained itself. Returning errors as results, not as crashes, is what lets an agent self-correct. In case two, a broken model asked for the same tool over and over. The guard stopped it dead at four rounds and raised. In a real system you would pair that guard with a running cost check so a stuck loop can never run up a surprise bill, which we cover in the LLM cost optimization guide.
Parallel Tool Calls
Sometimes a request needs two independent things at once, like a sum and a weather check that do not depend on each other. A capable model will ask for both tools in a single turn, as two tool_use blocks in one reply. Our loop already handles this: the inner for block in response.content runs every tool the turn asked for and collects all the results into one user message. You do not send them back one at a time; you batch them.
📄 run_parallel.py: two tools in a single turn
from agent import TextBlock, ToolUseBlock, Response, ScriptedClient, run_agent
from tools import calculator, get_weather
from schemas import TOOLS
TOOL_FNS = {"calculator": calculator, "get_weather": get_weather}
# One turn, TWO tool calls. Your code runs both and returns both results
# in a single user message before asking again.
script = [
Response([ToolUseBlock("t1", "calculator", {"expression": "25 * 4"}),
ToolUseBlock("t2", "get_weather", {"city": "Mumbai"})], "tool_use"),
Response([TextBlock("25 * 4 = 100, and Mumbai is 29C and humid right now.")], "end_turn"),
]
user = "What is 25 times 4, and how is the weather in Mumbai?"
print(f"User: {user}\n")
answer, rounds = run_agent(ScriptedClient(script), user, TOOLS, TOOL_FNS)
print(f"\nFinal answer (after {rounds} model calls):\n{answer}")
▶ Output
User: What is 25 times 4, and how is the weather in Mumbai?
[round 1] model calls calculator({'expression': '25 * 4'})
-> {'expression': '25 * 4', 'result': 100}
[round 1] model calls get_weather({'city': 'Mumbai'})
-> {'city': 'Mumbai', 'temp_c': 29, 'sky': 'humid'}
Final answer (after 2 model calls):
25 * 4 = 100, and Mumbai is 29C and humid right now.
What happened here: Both tools ran in round one, and the whole task finished in just two model calls instead of three, because the model bundled the requests. The one rule that trips people up: when a turn has several tool calls, you must return all of their results in a single user message, each tagged with its own tool_use_id. Split them across messages and you break the conversation. Notice too that each tool call is itself a piece of structured output, the same typed-JSON idea from the function calling guide; tool calling is structured output with a loop wrapped around it.
The Same Loop on Any Provider
The loop is the durable part. Providers change their field names, but the four moves never do. To run the exact loop above against a real model, you change one line: build a real client. Anthropic’s Messages API already matches the shape our loop expects, so there is nothing else to touch.
📄 real_client.py: point the same loop at a live model
import anthropic
from agent import run_agent # unchanged
from tools import calculator, get_weather
from schemas import TOOLS
TOOL_FNS = {"calculator": calculator, "get_weather": get_weather}
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
answer, rounds = run_agent(client, "Weather in Delhi, and what is 12 * 12?", TOOLS, TOOL_FNS)
print(answer)
On OpenAI’s Responses API the moves are identical, only the names move around. Tool calls come back as output items of type function_call with a .name and JSON .arguments, and you feed results back as function_call_output items instead of tool_result blocks. Do not reach for the old Assistants API for this; the plain Responses loop is simpler and is where OpenAI is heading. Whichever provider you pick, resist the urge to start with a framework. LangGraph, CrewAI, and the Claude Agent SDK are conveniences built on the loop you just wrote, and you will debug them far faster having seen the raw version first.
Common Mistakes
- No max-iteration guard: a model that keeps asking for tools will loop until your bill or your patience runs out. Always cap the rounds and stop hard when you hit the cap.
- Dropping the assistant turn: you must append the model’s tool-call reply to the history before you send results back. Skip it and the next call has no memory of what was asked.
- Splitting parallel results: when one turn asks for several tools, return every result in one user message, each with its matching tool_use_id. One message per result breaks the conversation.
- Running tool arguments blindly: the model can put anything in the arguments, so treat them as untrusted input. Validate before you touch a database, send money, or run a shell command.
- Letting a tool crash the loop: catch exceptions and return them as error results so the model can recover, instead of taking the whole request down.
Best Practices
- Write descriptions that say when to call: the tool description is a prompt. Tell the model the trigger (“call this for any math”), not just what the tool does.
- Return dictionaries, not prose: keep tool outputs structured so you can serialize them cleanly and the model can read them without parsing sentences.
- Keep the model ID in one place: IDs and prices change every few months. Pin it in one constant so an upgrade is a one-line change.
- Gate anything with side effects: read-only tools can run freely, but wrap deletes, payments, and emails behind a human approval step.
- Log every round: print or record each tool call and result. When an agent misbehaves, the transcript is the first thing you will want to read.
Conclusion
You just built the beating heart of every AI agent: a loop that asks a model, checks whether it wants a tool, runs the tool, and feeds the result back until the model is done. You gave it two real tools, handled a tool error, batched parallel calls, and stopped a runaway with a guard, all in about sixty lines with zero frameworks. When you open LangGraph or the Claude Agent SDK next, you will recognize this loop under the abstractions, and that recognition is what separates people who can debug agents from people who can only wire them together.
For the full path from Python basics to building AI systems, head back to the Python + AI/ML tutorial series home and pick your next stop.
Frequently Asked Questions
What is LLM tool calling in simple terms?
LLM tool calling is when a language model, instead of answering directly, tells your code to run one of your functions with specific arguments. The model never runs the function itself. It returns a structured request as JSON, your code runs the real function, and you pass the result back so the model can finish its answer. Repeated in a loop, that is what makes an AI agent.
Do I need a framework like LangGraph to build an agent?
No. The core of every agent framework is the same small loop shown in this post: call the model, run any tool it requests, feed the result back, repeat until done. Frameworks add state management, retries, and multi-agent features on top, which are genuinely useful at scale, but you should write the raw loop once first so you understand what they are automating.
How do I stop an agent from looping forever?
Add a maximum-iteration guard. Count how many times you have called the model in one request and raise or return once you pass the limit, even if the model still wants a tool. Pair that with a running cost check so a stuck loop can never run up a surprise bill. Both are a few lines and both are non-negotiable in production.
Is tool calling the same as structured output?
They are close cousins. Structured output shapes the model’s final answer into typed JSON you can validate. Tool calling uses that same typed-JSON mechanism to name a function and its arguments, then wraps a loop around it so your code can act and the model can react. A simple way to say it: structured output shapes an answer, tool calling triggers an action.
Is it safe to let a model call my functions?
It is safe only if you treat the arguments as untrusted input. The model decides what to call, but your code runs it, so validate and sanitize every argument before doing anything with side effects. Never wire a model directly to functions that delete data, move money, or change production state without a human approval step in between.
Interview Questions on LLM Tool Calling
The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.
Q: Walk me through the tool-calling loop. What are the exact steps?
Send the model the conversation plus a list of tool schemas. Read the reply and check its stop reason. If the model requested a tool, run each requested tool in your own code, wrap each result in a tool_result tagged with the matching tool_use_id, append them all in one user message, and call the model again. If the stop reason says the model is done, take its text as the answer. Repeat until done, with a max-iteration guard so it cannot loop forever. The model decides which tools to use and in what order; your code only executes and reports back.
Q: A user asks one thing that needs two tools, but the model only calls the first and then makes up the second answer. What do you check?
First confirm you sent every tool result back before calling the model again: append the assistant tool-call message, then one tool_result per tool_use_id, then call again. A missing result forces the model to guess, which is exactly the hallucination you see. Then check that the tool descriptions are distinct enough for the model to tell them apart, and that you are not forcing a single tool choice. If the model does not support several tools in one turn, just loop: keep calling and running tools until the reply no longer asks for one.
Q: How do you handle a tool that throws an exception mid-loop?
Catch it and turn it into a normal tool result with an error flag, then hand it back to the model like any other result. The model reads the error and usually corrects course on the next round, for example by fixing its arguments. The mistake is letting the exception bubble up and kill the whole request; a well-built agent treats a failed tool as information, not as a fatal crash.
Q: Why does the tool description matter so much, and where does it live?
The model never sees your code, only the schema: a name, a description, and the argument shape. The description is effectively a prompt that tells the model when to reach for the tool and how to fill the fields. A vague description makes the model call the wrong tool or skip it; a description that states the trigger condition makes it reliable. Writing tool descriptions is closer to prompt engineering than to coding.
Q: How do agent frameworks like LangGraph relate to this loop?
They are conveniences built on top of exactly this loop. Underneath, they still call the model, run requested tools, feed results back, and repeat. What they add is graph-based control flow, persistent state, retries, and multi-agent coordination, which save real work at scale. But because they all sit on the same core, understanding the raw loop lets you reason about and debug any of them, and lets you drop down to plain code when a framework fights you.
Reference: the complete, always-current details live in Hugging Face documentation.
Related Posts
Previous: GenAI: LLM APIs (OpenAI, Anthropic, Structured Output & Function Calling)
Next: Python Chatbot Project: Multi-Provider Chat App with Costs
Series Home: Python + AI/ML Tutorial Series

No comment