Tool Use and Function Calling
A model can write a lovely sentence about where your order is. It can't know. Its training data has never heard of order A-1001. Left alone it will guess, as you now know, with confidence.
Tool use fixes this by letting the model ask your code for facts and actions. The model doesn't run anything. It writes a small structured request, your code decides whether to honour it, and the result goes back into the conversation.
You describe tools to the model (a name, a purpose, an argument schema). The model replies with a tool call instead of text. Your code validates the call, runs it and returns the result, and the model writes the final answer. The model proposes and your code disposes.
The loop
sequenceDiagram
participant U as User
participant A as Your app
participant M as Model
participant T as Tool
U->>A: Where is order A-1001?
A->>M: Question plus tool definitions
M-->>A: tool_call get_order_status(order_id)
A->>A: Validate name and arguments
A->>T: Run the tool
T-->>A: Result
A->>M: Result as a tool message
M-->>A: Final answer
A-->>U: Your order has shipped
Most providers support this natively with slightly different names (function calling, tool use). Some also offer structured-output modes that guarantee valid JSON against a schema. Those help, and you should still validate on your side.
Never run a call you haven't checked
The arguments come from a probabilistic model, possibly steered by untrusted text. Treat them as untrusted input: allow-list the tool names, check every argument, and return a clear error the model can recover from.
TOOLS = {
"get_order_status": {
"required": {"order_id": str},
"enum": {},
"fn": lambda order_id: {"order_id": order_id, "status": "shipped"},
},
"set_priority": {
"required": {"ticket_id": str, "level": str},
"enum": {"level": {"low", "normal", "high"} },
"fn": lambda ticket_id, level: {"ticket_id": ticket_id, "level": level},
},
}
def call_tool(name, args):
"""Run a model-requested tool only if the name and arguments pass validation."""
spec = TOOLS.get(name)
if spec is None:
return {"error": f"unknown tool '{name}'"} # allow-list: unknown names never run
if set(args) != set(spec["required"]):
return {"error": f"expected arguments {sorted(spec['required'])}"}
for key, typ in spec["required"].items():
if not isinstance(args[key], typ):
return {"error": f"'{key}' must be {typ.__name__}"}
for key, allowed in spec["enum"].items():
if args[key] not in allowed:
return {"error": f"'{key}' must be one of {sorted(allowed)}"}
return spec["fn"](**args)
print(call_tool("get_order_status", {"order_id": "A-1001"}))
print(call_tool("set_priority", {"ticket_id": "T-9", "level": "urgent!!"}))
print(call_tool("delete_database", {}))
An unknown tool never runs, a bad enum value is rejected with a message, and the model can read that message and try again. In production you would typically use a schema library for validation instead of hand-rolled checks, but the principle is identical.
The conversation loop
The orchestration is a short loop with a hard stop. This one uses a stand-in model so you can run it as it is.
import json
def fake_model(messages):
"""Stand-in for an LLM: asks for a tool first, then answers from the tool result."""
if messages[-1]["role"] == "tool":
result = json.loads(messages[-1]["content"])
return {"type": "answer", "text": f"Your order {result['order_id']} has {result['status']}."}
return {"type": "tool_call", "name": "get_order_status", "args": {"order_id": "A-1001"} }
def run_tool(name, args): # in real code: validate, then execute (see previous snippet)
return {"order_id": args["order_id"], "status": "shipped"}
def chat(user_text, max_steps=5):
messages = [{"role": "user", "content": user_text}]
for _ in range(max_steps): # a hard step limit stops runaway loops
reply = fake_model(messages)
if reply["type"] == "answer":
return reply["text"]
result = run_tool(reply["name"], reply["args"])
messages.append({"role": "tool", "content": json.dumps(result)})
return "Sorry, I couldn't finish that."
print(chat("Where is order A-1001?"))
The step limit matters. Without it, a confused model can bounce between tools forever, and you pay for every round trip.
Designing good tools
- Small and specific.
get_order_status(order_id)beatsrun_query(sql). - Descriptions are prompts. The model chooses tools from their names and descriptions, so write them as carefully as any prompt.
- Useful errors. Return what went wrong and what is allowed so the model can correct itself.
- Idempotent where possible. Retries happen. A tool that creates a duplicate order on every retry is a bug waiting for a busy day.
The solution architect's view
The moment a model can call tools, it has the permissions of whatever you wired it to. That is where most of the risk in an AI system lives.
Least privilege, per user
Run tools with the end user's permissions, not a powerful service account. Give each tool the narrowest access that gets the job done. Read-only tools and side-effecting tools should be clearly separated.
Gate the consequential actions
Anything that sends, deletes, pays or changes data should require confirmation or approval outside the model. A prompt instruction such as "never delete anything" is not a control. A missing permission is.
Assume injection
Tool results, retrieved documents and web pages can all contain text that tries to give the model orders. Keep untrusted content labelled as data, validate arguments independently of what the model "intended", and avoid combining private data, untrusted input and an outbound channel in one agent without strong controls.
Make it observable and bounded
Log every call with its arguments, result, caller and latency. Set timeouts, rate limits and a maximum number of steps. Alert on unusual patterns such as sudden spikes in a particular tool.
Standardise how tools are exposed
Protocols such as the Model Context Protocol (MCP) define a common way to describe and call tools across applications. They reduce glue code, and they don't change the security question: you still decide what each tool may do and for whom.
Questions for the design review
- What can each tool do, with whose permissions?
- Which actions need human approval, and where is that enforced?
- What validates arguments before execution?
- What happens when a tool fails, times out or is called twice?
- What is logged, and who can review it?
Key Takeaways
- The model proposes tool calls; your code validates and executes them.
- Treat arguments as untrusted input: allow-list names, check types and values.
- Bound the loop with step limits, timeouts and budgets.
- Small, specific, idempotent tools with clear errors work best.
- Enforce least privilege and approvals in code, never only in the prompt.
- Log every call so you can audit and debug what the system did.

Comments
Loading comments…