The loop, honestly described
"Function calling" is a bad name. The model does not call your function. It cannot reach your database, your filesystem or the internet. What it does is emit a structured request — a tool name and some arguments — and then stop.
Your code decides whether to run it, runs it, and sends the result back as another message. Then the model continues. That is the entire mechanism.
tools = [{
"name": "get_order_status",
"description": (
"Look up the delivery status of a single order. "
"Use when the customer asks where their order is. "
"Do not use for refunds or cancellations."
),
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order id like ORD-48213"}},
"required": ["order_id"],
},
}]
messages = [{"role": "user", "content": "where is ORD-48213"}]
for _ in range(6): # a budget, not a while True
r = client.messages.create(model=MODEL, max_tokens=1024, tools=tools, messages=messages)
messages.append({"role": "assistant", "content": r.content})
if r.stop_reason != "tool_use":
break # the model is done, r has the final text
results = []
for block in r.content:
if block.type != "tool_use":
continue
try:
out = run_tool(block.name, block.input, session=session)
except PermissionError:
out = {"error": "not_authorised", "hint": "Ask the customer to sign in."}
except Exception as e:
out = {"error": "tool_failed", "detail": str(e)[:200]}
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": json.dumps(out)})
messages.append({"role": "user", "content": results})Note what happens on failure. The error goes back to the model as a tool result, not up as an exception. A model that is told "not_authorised" can say something useful to the customer. A model that never hears back cannot.
Tool descriptions are prompt text
The description field is not documentation for you. It is the only thing the model reads when deciding whether this tool applies. Write it like an instruction to a new colleague: what it does, when to use it, when not to.
The most common bug in tool use is not a broken tool. It is two tools whose descriptions overlap, so the model picks the wrong one about a third of the time. If search_orders and get_order_status both say "find order information", you have built a coin flip. Either sharpen the boundary in the descriptions or merge them into one tool with a parameter.
Keep the set small. Five well-separated tools work far better than twenty with fuzzy edges.
Authorise against the session, never the arguments
This is the part that becomes a security incident.
The arguments in a tool call are text the model produced, largely from text the user wrote. They are untrusted input, exactly like a query string. If your handler does this:
def get_order_status(order_id):
return db.query("SELECT * FROM orders WHERE id = ?", order_id) # wrongthen anyone who can get the model to emit a different order id can read a stranger's order. And getting a model to emit a different string is not hard.
The fix is not in the prompt. It is in the handler:
def get_order_status(order_id, *, session):
order = db.get_order(order_id)
if order is None or order.customer_id != session.customer_id:
raise PermissionError
return order.public_view()The session comes from your auth layer and the model cannot influence it. Every privileged tool gets this shape. Write the rule down for your team: a tool call is a request from an untrusted source; the handler decides.
Make tools boring
A few habits that save you later:
- Prefer read tools. Every write tool is a way for a confused model to do something you cannot undo.
- Make writes idempotent, keyed on something stable, so a retried call does not double-charge anyone.
- Return small results. A tool that dumps 40KB of JSON pushes everything else out of context and costs money on every subsequent turn.
- Log every tool call with its arguments and result. When someone asks "why did it do that", this log is the only answer you will have.
Before you move on