Tool and Function Calling
By the end of this lesson
Let a model invoke your functions, validating everything it returns.
The knowledge assistant can answer from documents, but "how much have I claimed this month?" is not in a document. It is in the expenses API. Tool calling is how a model reaches that answer without you writing a new feature for every question.
The name misleads people, so start here: the model does not execute anything. It cannot open a connection, run a query or call your code. What it returns is a request — a function name and a set of arguments, as data. Your code reads that request and decides whether to act on it. Every safety property in this lesson follows from that one fact.
The round trip, with the decision points marked:
You describe the available tools
Names, descriptions and argument schemas go with the request. They are sent on every call and billed as input tokens, so a long list of tools is a standing cost as well as a wider surface.
The model replies with a tool call instead of prose
You check the reply for tool calls before treating it as an answer. There may be none, one, or several, and the arguments arrive as a JSON string rather than as an object.
Your code validates the request
Three checks, all yours: is the name one you actually expose, do the arguments match the schema, and is the authenticated user permitted to see or do this. None of these can be delegated to the model.
Your code executes, or refuses
This is the step people imagine the model performing. It does not. A refusal here is a normal outcome, not an error, and the reason should be logged with the user's identity.
You send the result back, tagged with the call id
The result goes in as a tool message referencing the id from the request, then you call the model again. It now has the data it asked for, and the result is untrusted content in the context from this point on.
The model writes the answer, or asks again
It may request another tool call, which makes this a loop. Cap the number of rounds. Without a cap, a model that keeps asking for one more lookup runs until your timeout or your budget stops it.
// What you send: one narrow, read-only capability
{
"type": "function",
"function": {
"name": "get_expense_total",
"description": "Total expenses one employee claimed in one month. Read only.",
"parameters": {
"type": "object",
"properties": {
"employee_id": { "type": "string", "pattern": "^EMP[0-9]{5}$" },
"month": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}$" }
},
"required": ["employee_id", "month"],
"additionalProperties": false
}
}
}
// What comes back: a request, not an action
{
"id": "call_8f21c4",
"type": "function",
"function": {
"name": "get_expense_total",
"arguments": "{\"employee_id\": \"EMP04417\", \"month\": \"2025-02\"}"
}
}from pydantic import BaseModel, Field, ValidationError
class ExpenseTotalArgs(BaseModel):
employee_id: str = Field(pattern=r"^EMP[0-9]{5}$")
month: str = Field(pattern=r"^[0-9]{4}-[0-9]{2}$")
# Explicit allow-list. Read-only handlers only.
TOOLS = {
"get_expense_total": (ExpenseTotalArgs, expenses_api.month_total),
}
def run_tool_call(call, caller: User) -> dict:
if call.function.name not in TOOLS:
log.warning("unknown tool requested", extra={"name": call.function.name})
return {"error": "unknown tool"}
schema, handler = TOOLS[call.function.name]
try:
args = schema.model_validate_json(call.function.arguments)
except ValidationError as error:
return {"error": "invalid arguments", "detail": error.errors()}
if not caller.may_view_expenses_of(args.employee_id):
log.warning(
"tool call refused",
extra={"caller": caller.id, "target": args.employee_id},
)
return {"error": "not permitted for this user"}
return {"total_pence": handler(args.employee_id, args.month)}- The dispatch table is an allow-list, and looking the name up in a dictionary is the point. Resolving a model-supplied name dynamically — getattr on a module, or building a path from the string — is how a function you never intended to expose gets called.
- Arguments arrive as a JSON string, so parsing is a step that can fail on its own, before any type checking. The regex patterns reject an employee id of the wrong shape before it reaches the API.
- The permission check uses caller, the authenticated human who started the conversation. It deliberately ignores anything in the model's request about whose data this is. A model can be talked into asking for another employee's totals; authorisation is the thing that makes the answer no.
- Failures are returned to the model as data rather than raised. The model can then tell the user it could not look that up. An exception thrown here ends the conversation instead of degrading it.
- What is not here is equally deliberate: no write, no delete, no free-text query parameter. This tool answers one question about one employee for one month, and that narrowness is its main safety property.
How to design a tool that is safe to expose:
- One job, narrowly defined
- get_expense_total for one employee and one month can be reasoned about. run_query with a free-text SQL parameter cannot, because its capability is whatever the caller can express. Narrow beats flexible every time here.
- Read-only by default
- Start with retrieval and reporting. Add a write only when the feature genuinely requires it, and then treat it as a separate design problem rather than another entry in the table.
- Arguments that constrain themselves
- Enums, patterns and numeric bounds in the schema, enforced again in your validator. A parameter typed as a plain string accepts anything the model produces.
- Authorisation on the server, by user
- Check permissions inside the handler against the authenticated identity. Do not rely on the prompt saying which employee is asking, and do not accept an identity supplied in the tool arguments.
- Small results, and a log line either way
- Returning a 400-row table spends tokens and dilutes the context. Return the figure, or the top handful of rows. Log every call, every refusal and every argument set, because this is the audit trail when someone asks what the assistant did.
Summary
- The model returns a request to call a function; your code decides whether to execute it
- Validate in three stages: the tool name against an allow-list, the arguments against a schema, the action against the authenticated user's permissions
- Keep tools narrow and read-only by default, and scope the underlying credential to what the tool needs
- Never grant a model's requests more privilege than the least trusted content in its context
- Cap the tool-call rounds and the size of results, and log every call and refusal
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Which of these four would you expose?
A colleague proposes four tools for the assistant: get_policy_document(name), list_team_members(manager_id), approve_claim(claim_id), and send_email(to, subject, body).
Decide which you would expose as they stand, which you would change, and which you would refuse. Assume the assistant answers questions from documents that any employee can edit.
Show solution
get_policy_document is reasonable, with the name checked against a list of documents the caller may read. Without that check it is a way to request any file the service account can reach.
list_team_members is reasonable if manager_id is ignored in favour of the authenticated caller, or checked against what the caller may see. Accepting an arbitrary manager id makes the org chart readable by anyone who can phrase a question.
approve_claim should not be a tool. It moves money and it is hard to reverse. The defensible version has the model draft a recommendation and a human approve it, with your code performing the approval under that person's identity.
send_email is the one to refuse outright in this design. Combine an outbound channel with documents anyone can edit and you have given a capability to whoever last edited a document. If a notification is genuinely needed, send a fixed template to a fixed internal address from your own code, with no model-controlled recipient or body.
The thread running through all four: the question is never "would the model use this well?" It is "what happens when something in the context asks it to use this badly?"
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.