all posts

What is tool calling for AI agents?

Ajay Kumar··9 min read

You have called a chat endpoint. You send messages, you get text back. Then someone tells you their agent files tickets and runs the test suite, and the obvious question is: how? The model produces tokens. Tokens do not run a test suite.

Tool calling is the answer, and the mechanics are less magical than the vocabulary around them. Here is what goes over the wire, why the descriptions matter more than the model, and why the hard engineering is on the security side.

What actually crosses the wire

Alongside your messages you send a list of tool definitions. Each one is three things: a name, a description in prose, and a JSON Schema for the parameters. No code is uploaded. The API never sees your function — it sees a contract.

tools = [
    {
        "name": "get_order_status",
        "description": (
            "Look up the current status of a customer order by its order ID. "
            "Use this whenever the user asks where an order is, whether it "
            "shipped, or when it will arrive. Do NOT use it to change an "
            "order or to look a customer up by email. Returns JSON with "
            "status, carrier, tracking_number and estimated_delivery (ISO "
            "8601 date), or an error string if the order does not exist."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "Order ID like ORD-4821. Case sensitive.",
                },
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
    }
]

When the model decides a tool is the right move, it produces a structured block instead of prose. In the Anthropic Messages API that block has type tool_use and carries an id, the tool name, and an input object shaped like your schema; stop_reason comes back as tool_use rather than end_turn. Other providers name the fields differently.

Here is the part people skip past. The model does not execute anything. It emits a request. Your process reads that block, dispatches to your function, and gets a value. Every side effect — the row updated, the refund issued, the shell command run — is your code's doing. The entire security surface of an agent lives in your executor, not in the model.

You then send the whole conversation back, with the result attached as a tool_result block matched to the tool_use by id, in a user-role message. The model reads it and either answers or asks for another tool. That is the entire mechanism.

The schema and the description are the prompt

The description and the schema are the only things the model knows about your tool. It has never seen the implementation or the ticket explaining why the parameter is called since_ts. If tool choice is bad, look there first: it is the cause far more often than the model is. The classic failure is two tools called search_docs and search_web, both described as "search for information". The model picks by coin flip and you conclude it is bad at tool use. You gave it two identical contracts. Write descriptions like onboarding notes for someone competent who has never seen your system.

  • When to reach for it instead of a sibling tool.
  • Units and formats: seconds or milliseconds, UTC or local, inclusive or exclusive.
  • What it returns on success, and how it fails.

In the schema, enums beat free-form strings and additionalProperties false stops the model inventing fields. If you have forty tools, the problem is surface area, not wording.

The loop, and the four places it goes wrong

An agent is that round trip in a while loop. Nothing more. Here it is with no framework — worth writing once by hand even if you never ship it, because every framework wraps exactly this.

import json
import anthropic

client = anthropic.Anthropic()
messages = [{"role": "user", "content": "Where is order ORD-4821?"}]

for _ in range(12):  # hard cap: never loop forever
    resp = client.messages.create(
        model="claude-opus-5",
        max_tokens=16000,
        tools=tools,
        messages=messages,
    )
    if resp.stop_reason != "tool_use":
        break

    messages.append({"role": "assistant", "content": resp.content})

    results = []
    for block in resp.content:
        if block.type != "tool_use":
            continue
        try:
            out = dispatch(block.name, block.input)   # your code, your risk
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(out)[:20_000],  # cap the payload
            })
        except Exception as exc:
            # Return the failure AS A RESULT. Do not raise out of the loop.
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": f"error: {exc}",
                "is_error": True,
            })

    # All results from one assistant turn go back in ONE user message.
    messages.append({"role": "user", "content": results})

print(next(b.text for b in resp.content if b.type == "text"))

Four things break in production, all visible in that snippet. First, loops that do not terminate: a model retrying a tool that keeps failing will burn your budget, so cap the iterations and log it when you hit the cap. Second, tool errors. An exception escaping the loop kills the conversation; a tool_result with is_error set lets the model read the failure and try something sensible.

Third, parallel calls. One assistant turn can contain several tool_use blocks. Run them concurrently if you like, but return every result in a single user message — splitting them quietly teaches the model to stop calling tools in parallel. Fourth, token growth. Tool results are conversation history, so a 40,000-row CSV dumped into one result is re-sent and re-billed every turn after. Truncate, or write the output to a file and return the path.

Log every tool call with its inputs, its duration, and whether it errored, before you build anything else. Agent debugging without a tool-call log is guesswork, because the interesting failures are all decisions the model made three turns ago.

MCP, in one honest paragraph

The Model Context Protocol is a standard interface for exposing tools, so one server works across many clients instead of every application writing its own adapter for every integration. A server declares its tools; a client discovers them and hands them to a model; the client still runs the loop above. That is genuinely useful, and it is why an integration written once shows up in several agent products. It is not a different execution model. Something still executes the call, and the security questions do not change — they move. The process executing might now be a server you did not write.

The security part, which is the real reason this matters

Sooner or later someone adds a run_code or run_bash tool, because it is the most useful tool you can give a model — one tool replaces fifty. It is also the most dangerous, because its schema is "a string" and its capability is "whatever the machine can do".

Prompt injection makes this concrete. Your agent reads things: a web page, a PR description, a support email, the output of a previous tool. That text lands in the same context window that decides which tool to call next, and there is no privileged channel marking some tokens as data and others as instructions. An issue comment telling the agent to run a helpful setup script from a URL is indistinguishable from you asking for it.

The defence is not a better system prompt. Prompt-level mitigations reduce the rate; they do not change what happens when one gets through. The defence is the boundary the tool runs behind, chosen so a successful injection is contained rather than catastrophic.

  • Isolation: the code runs somewhere that is not your agent's process, ideally not sharing a kernel with it.
  • No ambient credentials: no instance metadata endpoint, no ~/.aws, no environment full of API keys. Pass one scoped token, or nothing.
  • Scoped egress: an allowlist of hosts the task needs, not the open internet by default.
  • Resource and wall-clock limits, so a runaway loop costs a bounded amount.
  • Disposability: one environment per session, destroyed afterwards.
A tool that reads untrusted text and a tool that has real credentials should not sit in the same agent without a human in the middle. The dangerous combination is not any single tool — it is read-the-internet plus act-on-production in one loop.

Giving an agent a code execution tool means giving it a machine

That framing decides the design: what kind of machine, and how cheaply can you throw it away? If a fresh environment takes thirty seconds to start, you will reuse one across users, and reuse is where state leaks.

That is the part PandaStack builds. A sandbox is a Firecracker microVM with its own kernel and KVM hardware isolation — not a container sharing yours, not a V8 isolate. Snapshot-restore puts create at 179ms p50 and 203ms p99, so a sandbox per session is a default rather than an optimisation you defer. Because it is a real VM you get root, apt, several processes at once, and arbitrary ports. Fork and snapshot are copy-on-write: branch a prepared environment for a speculative step, or reset after a bad run.

# pip install pandastack anthropic
from pandastack import Sandbox

_sessions = {}

def _ctx(session_id: str):
    """One sandbox per conversation, not one per process."""
    if session_id not in _sessions:
        sb = Sandbox.create(template="code-interpreter", ttl_seconds=1800)
        _sessions[session_id] = (sb, sb.create_code_context())
    return _sessions[session_id][1]

RUN_CODE = {
    "name": "run_code",
    "description": (
        "Run Python in an isolated Linux sandbox and return stdout, stderr "
        "and any error. Variables and imports persist between calls in the "
        "same conversation, so build work up step by step. Use print(...) "
        "for anything you want to see. The sandbox has no access to "
        "production systems or credentials."
    ),
    "input_schema": {
        "type": "object",
        "properties": {"code": {"type": "string"}},
        "required": ["code"],
        "additionalProperties": False,
    },
}

def run_code(session_id: str, code: str) -> dict:
    ex = _ctx(session_id).run_code(code)
    return {
        "stdout": ex.stdout[-8000:],
        "stderr": ex.stderr[-2000:],
        "error": ex.error,
    }

def close(session_id: str):
    sb, _ = _sessions.pop(session_id, (None, None))
    if sb:
        sb.delete()   # the boundary only helps if you actually destroy it

Firecracker is not the only reasonable shape. Hardened container runtimes are lighter if a shared kernel is acceptable, and WebAssembly fits pure computation with no filesystem or network. We picked microVMs because agents want a whole Linux userland behind a hardware boundary.

What to build first

  1. Write the loop by hand once, with two narrow tools and no framework.
  2. Spend real effort on the descriptions. They are cheaper to fix than a model swap.
  3. Cap the iterations, return errors as results, log every call with its inputs and duration.
  4. Treat every tool result as untrusted input, especially anything that touched the internet.
  5. Put code execution behind a real boundary before the first demo. Retrofitting isolation is a rewrite.
The model asks. Your code answers. Everything that can go wrong lives on the answering side, which is also the only side you control.

Frequently asked questions

What is the difference between tool calling and function calling?

Nothing meaningful. They are two names for the same mechanism, and the term you see depends on which provider's documentation you are reading. OpenAI popularised "function calling" and later moved to "tools"; Anthropic has used "tool use" throughout. In every case you send JSON Schema definitions with the request, the model returns a structured call rather than prose, your code executes it, and you return the result as a message. If you learn one provider's shape, the others are a field-renaming exercise, not a new concept.

Does the model actually run my code?

No, and this is the single most important thing to understand. The model emits a structured request naming a tool and the arguments it wants. Your application reads that request, decides whether to honour it, and calls the function itself. Nothing happens unless your process makes it happen. The practical consequence is that every security control lives in your executor: validation, authorisation, rate limiting, isolation. There is an exception worth knowing — some providers offer server-side tools like web search or a hosted code interpreter that run on their infrastructure — but for tools you define, execution is entirely yours.

Why does the model pick the wrong tool?

Usually because the descriptions do not distinguish them. The model sees only the name, the description, and the parameter schema, so two tools described as "search for information" are effectively identical and the choice becomes arbitrary. Fix the contract before you change the model: say what each tool is for, say explicitly when to prefer a sibling tool, state units and formats, and describe what comes back. Constrain parameters with enums rather than free-form strings. If you have dozens of tools, reduce the surface area by grouping related operations, because choice quality degrades as the list grows.

What is MCP and do I need it?

MCP is a protocol for exposing tools over a standard interface so one server works with many clients, instead of every application writing bespoke adapters for every integration. It is a distribution and interoperability story. It does not change the execution model: a client still runs the same request, execute, return-result loop described above, and something still runs the actual code. You do not need it to build an agent — plain tool definitions are fine, and simpler. You want it when tools should be reusable across products, or when you are consuming integrations other people published.

Is prompt injection solvable with a better system prompt?

No. Instructions reduce how often an injection succeeds, but they cannot change what happens when one does, because the model has no reliable way to distinguish instructions you wrote from instructions embedded in text it was asked to read. Both arrive as tokens in the same context. Treat prompt-level defences as one layer and put the real one underneath: run tools behind a boundary where a successful injection is contained. That means isolation, no ambient credentials, an egress allowlist, hard resource and time limits, and a fresh environment per session that gets destroyed afterwards.

How do I keep tool results from blowing up my token bill?

Remember that every tool result stays in the conversation and is re-sent on each subsequent turn, so one large payload is charged repeatedly. Cap the size of what you return — truncating to a few thousand characters with a note that it was truncated is usually better than the full dump. For genuinely large outputs, write them to a file inside the sandbox and return the path plus a summary, letting the model ask for specific slices. Also cap loop iterations, since runaway retries multiply the whole history rather than adding to it linearly.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.