Tool Use and Agents
A language model cannot look anything up or press any button by itself. Here you give it tools — Python functions you write and control — and build the loop that lets it use them, safely and within limits.
A customer messages your shop: “Ada Milo 1kg? I want two, and one minyak masak. How much altogether?” A language model can write a friendly reply in Manglish, but it has no idea what is on your shelves or what your prices are. Ask it anyway and it will cheerfully invent a number. The fix is not a cleverer prompt. It is to let the model say “I need to check the stock” and have your program do the checking. That small handshake — the model asks, your code acts, the model reads the result — is called tool use, and it is the engine inside every AI agent.
What a tool is
In the Practitioner track you met agents from the outside: assistants that browse, click and fill in forms for you. From the inside, an agent is much plainer. It is a large language model, a list of functions it is allowed to ask for, and a loop of ordinary code. This page builds that loop by hand, so nothing about agents will seem magical again.
A tool, as far as the model is concerned, is just a description with three parts:
- A name — a short identifier such as
check_stock. - A description — plain English saying what the tool does and when to use it. The model reads this to decide whether to call the tool, so it matters more than you would think.
- An input schema — a JSON Schema that lists the inputs the tool expects, their types and which are required.
The model never sees your Python code and never runs it. When it decides a tool would help, it replies with a structured request — “please call check_stock with {"product": "milo 1kg"}” — and stops. Your program reads that request, decides whether to honour it, runs the function and sends back the result. Every bit of real-world action happens in code you wrote.
Consensus This is the same mechanism across providers. OpenAI and Google call it function calling; Anthropic’s documentation says “tool use (also called function calling)”; open-weight models served through tools such as Ollama support the same pattern. The field names differ slightly; the request → call → result → answer shape is identical.
The tool-use loop
Because the model is stateless (as you saw in Calling LLM APIs), every turn of the conversation, including tool requests and results, has to be sent back in the message list. One round of the loop looks like this — tap each step.
Two details trip up nearly everyone the first time. First, keep all of the model’s reply when you append it to the history — the text, any thinking blocks and the tool requests — not just the text. Second, if the model asks for two tools at once, send both results back together in one user message. Both rules appear in the code below.
Build a kedai assistant
Our example is a small shop assistant with two tools: one looks up a product’s price and stock, the other works out a bill including SST. The rule of tool building is: write and test the plain Python functions first, with no AI anywhere. If the functions are wrong, no model can save you.
# kedai_tools.py - two plain Python functions the model will be allowed to call
STOCK = { # made-up shop data: price in RM, units on the shelf
"milo 1kg": {"price": 21.90, "units": 14},
"beras 5kg": {"price": 26.50, "units": 0},
"minyak masak 2kg": {"price": 13.80, "units": 32},
"teh tarik 3-in-1": {"price": 9.20, "units": 7},
}
def check_stock(product):
key = str(product).strip().lower()
if key not in STOCK:
raise ValueError(f"unknown product '{product}'. Known: {', '.join(STOCK)}")
item = STOCK[key]
return {"product": key, "price_rm": item["price"], "units_in_stock": item["units"]}
def total_with_sst(items, sst_rate=0.08):
if not isinstance(items, list) or not items:
raise ValueError("items must be a non-empty list")
subtotal_sen = 0 # work in sen to avoid float surprises
for line in items:
qty = line.get("quantity")
if not isinstance(qty, int) or not 1 <= qty <= 100:
raise ValueError(f"quantity must be a whole number from 1 to 100, got {qty!r}")
price = check_stock(line.get("product"))["price_rm"]
subtotal_sen += round(price * 100) * qty
sst_sen = round(subtotal_sen * sst_rate)
return {"subtotal_rm": subtotal_sen / 100, "sst_rm": sst_sen / 100,
"total_rm": (subtotal_sen + sst_sen) / 100}
Nothing here is AI. check_stock tidies the product name (“Milo 1kg ” becomes “milo 1kg”) and refuses anything not in the dictionary instead of guessing. total_with_sst checks every quantity is a whole number in a sensible range, looks up each price through check_stock (so the two tools cannot disagree), and does the arithmetic in sen — whole numbers — because adding floating-point ringgit values directly can produce totals like 57.599999. The 8% rate is a default argument, used here because it is Malaysia’s standard service-tax rate As of 2026; real SST depends on the goods or service (goods carry sales tax at other rates, and many basic foods are exempt), so treat this as a teaching example and check the Royal Malaysian Customs Department before using anything like this for actual invoices.
Now test the functions, including the ways they should fail:
# test_tools.py
from kedai_tools import check_stock, total_with_sst
print(check_stock("Milo 1kg"))
print(check_stock("beras 5kg"))
print(total_with_sst([{"product": "milo 1kg", "quantity": 2},
{"product": "minyak masak 2kg", "quantity": 1}]))
for bad in ([{"product": "kopi", "quantity": 1}],
[{"product": "milo 1kg", "quantity": -3}]):
try:
total_with_sst(bad)
except ValueError as e:
print("ValueError:", e)
{'product': 'milo 1kg', 'price_rm': 21.9, 'units_in_stock': 14}
{'product': 'beras 5kg', 'price_rm': 26.5, 'units_in_stock': 0}
{'subtotal_rm': 57.6, 'sst_rm': 4.61, 'total_rm': 62.21}
ValueError: unknown product 'kopi'. Known: milo 1kg, beras 5kg, minyak masak 2kg, teh tarik 3-in-1
ValueError: quantity must be a whole number from 1 to 100, got -3
Check the bill by hand: 2 × 21.90 + 13.80 = 57.60; 8% of that is 4.608, rounded to 4.61; total 62.21. Notice the error messages are written for a reader. That reader will be the model, and a clear message (“Known: milo 1kg, …”) lets it recover by itself.
Describing the tools to the model
Next, the definitions the model will see, and a dispatcher that runs only the tools we chose to expose:
# kedai_schema.py - describe the tools to the model, and run them safely
import json
from kedai_tools import check_stock, total_with_sst
TOOLS = [
{"name": "check_stock",
"description": "Look up one product in the kedai: its price in RM and how many "
"units are on the shelf. Use before quoting a price or promising stock.",
"input_schema": {"type": "object",
"properties": {"product": {"type": "string",
"description": "Product name, e.g. 'milo 1kg'"}},
"required": ["product"]}},
{"name": "total_with_sst",
"description": "Work out the subtotal, SST and total in RM for a list of products "
"and quantities. Use whenever the customer asks how much to pay.",
"input_schema": {"type": "object",
"properties": {"items": {"type": "array",
"items": {"type": "object",
"properties": {"product": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1}},
"required": ["product", "quantity"]}}},
"required": ["items"]}},
]
FUNCTIONS = {"check_stock": check_stock, "total_with_sst": total_with_sst}
def run_tool(name, tool_input):
if name not in FUNCTIONS: # only tools we chose to expose
raise ValueError(f"no tool called {name}")
result = FUNCTIONS[name](**tool_input) # may raise ValueError / TypeError
return json.dumps(result) # tool results are sent back as text
# test_schema.py
from kedai_schema import TOOLS, run_tool
print([t["name"] for t in TOOLS])
print(run_tool("check_stock", {"product": "teh tarik 3-in-1"}))
for name, args in [("check_stock", {"item": "milo 1kg"}), ("delete_stock", {})]:
try:
run_tool(name, args)
except (ValueError, TypeError) as e:
print(type(e).__name__ + ":", e)
['check_stock', 'total_with_sst']
{"product": "teh tarik 3-in-1", "price_rm": 9.2, "units_in_stock": 7}
TypeError: check_stock() got an unexpected keyword argument 'item'
ValueError: no tool called delete_stock
The schema is a promise about shape, not a guarantee. A model usually sends inputs that match it, but your code must still cope when it does not — a wrong field name (item instead of product) or a tool that does not exist. Here both are caught as ordinary Python exceptions. The dictionary FUNCTIONS is also a security boundary: the model can only ever reach the two functions listed in it.
The loop itself
Now the AI part. This follows the request → tool_use → run → tool_result loop exactly, with one addition: a hard limit on how many rounds it may take.
# kedai_agent.py - the tool-use loop
import anthropic
from kedai_schema import TOOLS, run_tool
MODEL = "claude-opus-5-5" # check the provider's docs for current model names
MAX_TURNS = 6 # hard stop, so a confused loop cannot run up a bill
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
system = ("You are the assistant for a small Malaysian kedai. Use the tools for "
"every price, stock level and total; never guess numbers.")
messages = [{"role": "user", "content":
"Ada Milo 1kg? I want 2 of those and 1 minyak masak 2kg. How much altogether?"}]
for turn in range(MAX_TURNS):
response = client.messages.create(model=MODEL, max_tokens=16000, system=system,
tools=TOOLS, messages=messages)
if response.stop_reason != "tool_use":
break
messages.append({"role": "assistant", "content": response.content}) # keep ALL blocks
results = []
for block in response.content:
if block.type == "tool_use":
print(f"[tool] {block.name}({block.input})")
try:
out = run_tool(block.name, block.input)
results.append({"type": "tool_result", "tool_use_id": block.id, "content": out})
except (ValueError, TypeError) as e:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": f"Error: {e}", "is_error": True})
messages.append({"role": "user", "content": results}) # all results in ONE message
else:
raise SystemExit("Stopped: too many tool turns. Check the log above.")
print("".join(b.text for b in response.content if b.type == "text"))
Line by line, the parts that are new:
tools=TOOLSsends the definitions with every request; the model needs them each time because it remembers nothing between calls.stop_reason != "tool_use"is the exit: the model has finished (end_turn), run out of room (max_tokens) or declined (refusal).block.idpairs each result with the request it answers, throughtool_use_id. Get this wrong and the API rejects the conversation.- The
for … elseis a Python feature many people never meet: theelseruns only if the loop finished without abreak— that is, if we used up all six turns. That is our runaway-loop alarm. - The
printof each tool call is a simple audit trail. In a real app it goes to a log (see Shipping an AI App).
Without a valid key this script cannot reach the model. Run with a placeholder key, the last line of the error is:
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'Invalid bearer token'}, 'request_id': 'req_…'}
(Request id shortened.) An authentication error is the result we want here: it means the SDK accepted the shape of the call and only the key was refused. With no key set at all, the SDK stops even earlier with a message that it “could not resolve authentication method”. With your own key in place, a run looks something like this:
Example output — your text will differ
[tool] check_stock({'product': 'milo 1kg'})
[tool] check_stock({'product': 'minyak masak 2kg'})
[tool] total_with_sst({'items': [{'product': 'milo 1kg', 'quantity': 2}, {'product': 'minyak masak 2kg', 'quantity': 1}]})
Yes, we have 14 tins of Milo 1kg. 2 x Milo 1kg plus 1 x minyak masak 2kg comes to RM57.60, SST RM4.61, total RM62.21.
That transcript was produced by replaying scripted model replies through the real loop above, so the plumbing is tested; a live model may call the tools in a different order, call total_with_sst straight away, or word its reply differently. What it should not do is state a price that did not come from a tool.
Validating inputs and returning errors
Treat every tool input as you would treat text typed into a public web form: possibly wrong, occasionally hostile. Three layers of defence work well together.
- The schema tells the model what you expect. Anthropic also offers an optional
strictmode that makes tool calls match the schema exactly As of 2026; useful, but it does not check meaning (a quantity of 99 is valid JSON and may still be a typo). - Your function checks meaning: is this a real product, a sensible quantity, a date in the future? Our functions raise
ValueErrorwith a helpful message. - The loop turns failures into a tool result marked
"is_error": Trueinstead of crashing. The model reads the error and usually corrects itself — asking the customer to clarify, or trying the product name it was offered.
And if it does not correct itself? We tested that too, by replacing the API with a stand-in that asks for check_stock("kopi") every single time. The loop sent back this result on each round:
[{'type': 'tool_result', 'tool_use_id': 't1', 'content': "Error: unknown product 'kopi'. Known: milo 1kg, beras 5kg, minyak masak 2kg, teh tarik 3-in-1", 'is_error': True}]
and after six identical rounds it stopped with:
SystemExit: Stopped: too many tool turns. Check the log above.
Testing an agent’s plumbing with a fake model like this is a habit worth stealing: it costs nothing, runs in milliseconds and lets you try the nasty cases on purpose.
A shorter way: the SDK’s tool runner
Writing the loop by hand teaches you what happens. Once you understand it, the Anthropic Python SDK offers a helper that writes the loop for you As of 2026. It is marked beta, which means the details may still change. You decorate a typed, documented function with @beta_tool; the SDK builds the JSON Schema from the type hints and docstring, runs the tool when asked, and sends exceptions back as error results.
# kedai_runner.py - the same assistant with the SDK's beta tool runner
import anthropic
from anthropic import beta_tool
import kedai_tools
MODEL = "claude-opus-5-5" # check the provider's docs for current model names
@beta_tool
def check_stock(product: str) -> str:
"""Look up one product's price in RM and units in stock.
Args:
product: Product name, e.g. 'milo 1kg'.
"""
return str(kedai_tools.check_stock(product))
client = anthropic.Anthropic()
runner = client.beta.messages.tool_runner(
model=MODEL, max_tokens=16000, max_iterations=6, tools=[check_stock],
messages=[{"role": "user", "content": "Is there any beras 5kg left?"}],
)
for message in runner: # one assistant message per turn of the loop
for block in message.content:
if block.type == "text":
print(block.text)
Run with a placeholder key it fails with the same AuthenticationError: Error code: 401 as before, confirming the call shape. You can also see the schema the decorator generated from the function, by printing check_stock.to_dict():
{"name": "check_stock", "description": "Look up one product's price in RM and units in stock.", "input_schema": {"additionalProperties": false, "properties": {"product": {"description": "Product name, e.g. 'milo 1kg'.", "title": "Product", "type": "string"}}, "required": ["product"], "type": "object"}}
Compare that with the dictionary you wrote by hand: same idea, less typing. Note max_iterations=6 — the runner has no limit unless you set one. Use the runner when your tools are simple; keep the manual loop when you need to ask a human before a tool runs, log each step your own way, or stop early on business rules.
For comparison, here is how the same tool definition and result look with OpenAI’s Responses API, which calls the feature function calling:
# tool definition
{"name": "check_stock", "description": "...", "input_schema": {...JSON Schema...}}
# the model's request: a content block
block.type == "tool_use"; block.name; block.input (a dict); block.id
# your reply, inside a user message
{"type": "tool_result", "tool_use_id": block.id, "content": out}
# kedai_openai.py - the same loop with OpenAI's Responses API ("function calling")
import json
from openai import OpenAI
from kedai_schema import TOOLS, run_tool
MODEL = "gpt-6-astra" # check the provider's docs for current model names
client = OpenAI() # reads OPENAI_API_KEY
tools = [{"type": "function", "name": t["name"], "description": t["description"],
"parameters": t["input_schema"]} for t in TOOLS]
input_list = [{"role": "user", "content": "How much for 2 Milo 1kg?"}]
for turn in range(6):
response = client.responses.create(model=MODEL, tools=tools, input=input_list)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
break
input_list += response.output
for call in calls:
try:
out = run_tool(call.name, json.loads(call.arguments))
except (ValueError, TypeError) as e:
out = f"Error: {e}"
input_list.append({"type": "function_call_output", "call_id": call.call_id, "output": out})
print(response.output_text)
Run with a placeholder key this fails with openai.AuthenticationError: Error code: 401. Differences: the schema goes under parameters, arguments arrive as a JSON string you must parse, and results pair up by call_id.
Keeping agents safe
The moment a tool can change something — send a message, update a record, move money — your loop has become an agent with real consequences. Four habits keep it in check. Tap each one.
Consensus The third habit deserves emphasis. Prompt injection heads the OWASP list of risks for language-model applications, and there is no known complete defence. The practical stance is to assume it will sometimes succeed and to design so that a hijacked agent cannot do much harm: least privilege, confirmations, and a human in the loop for anything that matters.
Who actually runs check_stock when the model asks for it?
The model only requests the call. In the manual loop your code decides whether and how to run it; the tool runner automates that step but it is still your code running on your machine.
The model asked for two tools in one reply. How should the results go back?
All results for one assistant turn belong in a single user message, paired with their requests by id.
A tool fails because the product does not exist. What is the best response?
A clear error lets the model recover — ask the customer, or try a valid name — while your turn limit protects against endless retries.
Your agent reads customer emails and can also issue refunds. What is the biggest risk?
That is prompt injection. Separate the reading agent from the refund tool, or require human confirmation for every refund.
MCP: tools you can plug in anywhere
So far the tools live inside your script. If you want the same stock lookup available in a desktop assistant, a coding tool and your own app, you would have to rewrite the definitions for each. The Model Context Protocol (MCP) is an open standard that solves this: you wrap your tools once as an MCP server, and any application that speaks MCP can discover and call them. Anthropic introduced it in November 2024; OpenAI, Google, Microsoft and many developer tools have since added support, and in December 2025 Anthropic donated it to the Agentic AI Foundation, a fund under the Linux Foundation As of 2026. Under the hood it is the same loop you just wrote — a name, a description and a JSON Schema per tool — with a standard way to list and call them. The same safety habits apply, with one addition: an MCP server written by someone else is code you are trusting, so install only ones from sources you would trust with the same access.
An agent is a loop you control: the model proposes, your code disposes. Everything that keeps an agent safe — which tools exist, what they may touch, when a human must agree, how many turns it gets — lives in your code, not in the prompt.
Add a third tool, reserve_item(product, quantity), that reduces units in STOCK. Because it changes data, make your loop print what it is about to do and wait for input("Proceed? y/n ") before running it. Test it first with plain Python (including reserving more than is in stock), then with a fake model that asks for it, and only then with a real key.
// Pause and reflect
- Think of a task at work that needs one lookup and one calculation. What would the two tools be called, and what would their descriptions say about when to use them?
- Which tool in your idea could cause harm if called with the wrong inputs, and where in the loop would you put a human check?
- If a colleague asked “why not just put the price list in the prompt?”, how would you explain when tools are better and when a prompt is enough?
Your notes are saved privately in this browser.
Key terms on this page
Sources & further reading
- Anthropic, Tool use with Claude (Claude API documentation, 2026) — definitions, the loop, handling tool calls and the tool runner.
- OpenAI, Function calling (OpenAI API documentation, 2026).
- JSON Schema: Getting started step by step (json-schema.org).
- Model Context Protocol — specification and guides (2024–2026).
- Anthropic, Donating the Model Context Protocol and establishing the Agentic AI Foundation (December 2025).
- OWASP GenAI Security Project, LLM01: Prompt Injection (2025).
- Anthropic, Building effective agents (December 2024) — when a simple loop beats a complex framework.