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:

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.

LOOPOne round

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:

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.

  1. The schema tells the model what you expect. Anthropic also offers an optional strict mode 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).
  2. Your function checks meaning: is this a real product, a sensible quantity, a date in the future? Our functions raise ValueError with a helpful message.
  3. The loop turns failures into a tool result marked "is_error": True instead 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}

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.

Quick check

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.

Key point

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.

Try this

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

  1. 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?
  2. 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?
  3. 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