Topic 16.10
Tool Calling and an Agent Loop from Scratch
In one line
Tool calling lets a model ask your code to run a function: you describe tools with JSON schemas, the model replies with a tool name and arguments, your code validates and runs it, and the result goes back. An agent is that exchange in a loop with a step limit, an allow-list and human approval for risky actions.
Think of it like this
A manager with an assistant. The manager cannot open the filing cabinet, but can say "please fetch the Pune contract". The assistant fetches it and reports back. For a big task, the manager asks several things in turn. For something risky, like paying an invoice, the assistant must ask the owner first. The model is the manager, your code is the assistant, and the owner is the human approval.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Tool
- A function your code offers to the model, with a name, description and parameter schema.
- Tool call
- The model's request to run a named tool with given arguments.
- Agent
- A program that lets a model repeatedly choose tools and see results until a task is done.
- Step limit
- The maximum number of loop turns, so the agent cannot run forever.
- Allow-list
- A list of exactly what is permitted. Anything else is refused.
- Side effect
- A change in the outside world: sending a message, writing a file, spending money.
- Human approval
- A person confirming a risky action before it runs.
Step by step
01Describing tools
The first example registers two tools and prints the JSON description the model would receive. The description is plain data, which is why the same Python function can be described to any provider. Good descriptions matter: the model chooses tools by reading them.
02One exchange
The exchange has four moves: you send messages and tools, the model returns a tool request, you run the tool, you send the result back.
03The loop, with validation and a limit
The second example runs a scripted fake model through run_agent. We script it because a real model's choices change from run to run, and we want to show one wrong call, one fixed call and one runaway loop on purpose. (The line elif problems := ... uses the walrus operator from Topic 2.9: it assigns and tests in one step.) First the model uses a wrong argument name. The validator returns "missing 'city'; unknown argument 'town'" as the tool result. The next scripted action fixes it, then the model answers.
A second run uses a model that repeats the same call forever. The loop stops at step 3 and returns "stopped: step limit reached". Without that limit this program would never end.
04Real SDK shape: OpenAI-compatible
Not runnable here (needs a key). Note that the arguments arrive as a JSON string.
message = response.choices[0].message
if message.tool_calls:
call = message.tool_calls[0]
name = call.function.name
args = json.loads(call.function.arguments) # a JSON string
result = run_tool(name, args) # your validation + dispatch
messages.append(message) # keep the assistant turn
messages.append({"role": "tool", "tool_call_id": call.id, "content": str(result)})05Real SDK shape: Anthropic Messages
Tool requests are blocks in response.content, and results go back as tool_result blocks in a user message.
results = []
for block in response.content:
if block.type == "tool_use":
output = run_tool(block.name, block.input) # input is already a dict
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": results})06Allow-list, approval and untrusted results
The third example is only the dispatch step (the code that takes the model's request and decides what to run). Unknown tools are refused. Risky tools ask a (simulated) human. Results are wrapped in a tag and stripped of angle brackets, so tool output stays data. In a real system the approval is a button in a UI, and the arguments shown to the human must be the exact ones that will run.
07Design rules that keep agents safe
Prefer fewer, clearer tools. Make tools narrow ("get_order_status(order_id)") instead of broad ("run_sql(query)"). Run with the permissions of the current user only. Cap steps, tokens and time. Record every call and result for later review. And decide what happens when the model is wrong, because it sometimes will be.
Try it yourself
- 1
Raise the limit
In the second example run the stuck model with
max_steps=5. How many tool calls happen? What does it cost in a real system? - 2
Add a risky tool
In the third example add "delete_file" to both sets, call it, and answer "no" at the approval. Check that nothing runs.
Code & diagrams
Expected output
tools sent to the model: ['get_weather', 'add']
{
"type": "function",
"function": {
"name": "add",
"description": "Add two numbers.",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "number"
},
"b": {
"type": "number"
}
},
"required": [
"a",
"b"
]
}
}
}The model here is a script. Change the scripted actions to see how the loop reacts.
Expected output
step 1: get_weather({'town': 'Pune'}) -> error: missing 'city'; unknown argument 'town'
step 2: get_weather({'city': 'Pune'}) -> 31C, clear
step 3: final answer
It is 31C and clear in Pune.
step 1: get_weather({'city': 'Oslo'}) -> unknown city
step 2: get_weather({'city': 'Oslo'}) -> unknown city
step 3: get_weather({'city': 'Oslo'}) -> unknown city
stopped: step limit reachedExpected output
<tool_result>get_weather ran with {'city': 'Pune'}</tool_result>
refused: tool not on the allow-list
approval asked for send_email{'to': 'a@example.com'}: no
refused: a human did not approve
approval asked for send_email{'to': 'a@example.com'}: yes
<tool_result>send_email ran with {'to': 'a@example.com'}</tool_result>Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Pass the model's arguments straight to the function
Call a tool with an argument name it does not accept.
def get_weather(city):
return "31C"
args = {"town": "Pune"} # the model used the wrong argument name
print(get_weather(**args))Myth vs fact
Myth
The model runs the tools.
Fact
It only asks. Your code decides whether to run each call, with which checks.
Myth
Agents are a different technology from chat.
Fact
An agent is a chat loop plus tool results. The new risks come from what the tools can do.
Myth
A good system prompt makes tool use safe.
Fact
Safety comes from code: allow-lists, validation, least privilege and approvals. Prompts help, but are not a barrier.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Tool results become part of the next prompt, so a long result costs tokens every later turn. Truncate or summarise large outputs and keep the message list within budget (Topic 16.4).
- ▸
Models can request several tool calls in one turn. Run independent ones concurrently with asyncio (Topic 12.7), and return results matched by call id, not by position.
- ▸
Idempotency keys make retries of side-effecting tools safe. If a retry of "send_email" could send twice, either require approval each time or pass a unique key so the second call is ignored.
- ▸
Real arguments can be broken before they reach your validator. In the OpenAI-compatible shape
function.argumentsis a JSON string the model wrote, sojson.loadscan raiseJSONDecodeError(a truncated reply, for instance). Catch it and return an error result instead of crashing. Also drive the loop from the response: stop when there is no tool request (tool_callsempty, orstop_reason != "tool_use"in the Anthropic shape). If the Anthropic reply holds severaltool_useblocks, the next user message must carry atool_resultfor every one of them, and atool_resultcan setis_error: trueto tell the model the call failed. - ▸
Hand-written validators have gaps. In Python
isinstance(True, int)isTrue, so a boolean passes a "number" check, and nested objects, enums and ranges need more code. In production, validate arguments with a schema library (Pydantic models per tool, orjsonschema) built from the same definition you send to the model.
Remember this
- 1
A tool is an ordinary Python function plus a description the model can read: a name, a sentence about what it does, and a JSON Schema for its parameters (a standard way to describe which fields are allowed and what type each has; Topic 16.6). You send the descriptions with the request. In the OpenAI-compatible shape:
tools=[{"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}]. In the Anthropic shape:tools=[{"name": ..., "description": ..., "input_schema": {...}}]. - 2
The model never runs anything. It returns a request: in the OpenAI-compatible shape,
message.tool_callswithfunction.nameandfunction.arguments, which is a JSON string you must parse. In the Anthropic shape, atool_useblock withname,input(already a dict) and anid. Your code decides whether and how to run it. - 3
Your code then sends the result back so the model can continue: an OpenAI-style message with role
tool(and the call's id), or an Anthropictool_resultblock with thetool_use_idinside a user message. The model reads it and either asks for another tool or writes the final answer. - 4
The agent loop. An agent is a program that runs this exchange in a loop and lets the model decide what to do next. The loop is: call the model, if it asks for a tool, run it, append the result, call again, until the model gives a final answer or you hit a step limit. The limit is not optional: a model can loop forever, repeating the same call, and each turn costs tokens. When the limit is hit, stop and return a clear failure.
- 5
Validate every call. The model chooses the arguments, so they are untrusted input (Topic 16.6). Check that the tool exists (an allow-list: only your registered tools), that required arguments are present, types are right and values are in range. If validation fails, do not crash: return the error text as the tool result so the model can correct itself, as the example shows.
- 6
Risk control. Separate read-only tools from tools with side effects (sending email, deleting files, spending money). Run read-only tools freely, require human approval for risky ones, give each tool the least privilege it needs, and treat tool results as untrusted data (Topic 16.7): a web page the tool fetched may contain text meant to steer the model. Log every tool call. A full Hectal course on RAG and agents is planned to go deeper.
Explain it without notes
Describe the sequence of messages in one tool-calling exchange.
Why does an agent loop need a step limit and argument validation?
How would you make a tool that deletes data safe to expose to a model?
Your agent sometimes loops, calling the same tool again and again. How do you prevent it, detect it and debug it?
Practice
Write validate_args(parameters, args) that returns a list of problems for missing, unknown and wrongly typed arguments.
Write run_agent(model, tools, max_steps) with a step limit that returns the final answer or "gave up".
Write safe_dispatch(name, args, tools, risky, approve) with an allow-list and an approval gate.
Trade-offs
- ↔
Autonomy versus control. More autonomy saves human time and raises risk. Start with read-only tools, add approvals for side effects, loosen only with evidence from evals.
- ↔
Few broad tools versus many narrow tools. Many tools make selection harder and use more prompt tokens, but each is easier to validate and secure.
Done when you can
Done when you can describe a tool with a name, description and JSON Schema.
Done when you can explain the tool request and result messages in both API shapes.
Done when you can write an agent loop with a step limit.
Done when you validate arguments and return errors as results.
Done when you can explain allow-lists, approval and untrusted tool output.
Done when you know a full Hectal course on RAG and agents is planned.