Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Tool use: the model never runs anything

Building With AI · lesson 4 of 9 · 8 min

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.

python
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:

python
def get_order_status(order_id):
    return db.query("SELECT * FROM orders WHERE id = ?", order_id)   # wrong

then 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:

python
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

A shopping assistant with an order-lookup tool goes live. A user reports seeing another customer's delivery address after some back-and-forth with the bot. The tool loop worked exactly as written. Where is the bug?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly