# Tool Calling vs. Code Execution for AI Agents: Choosing the Right Action Primitive

> Source: <https://machinelearningmastery.com/tool-calling-vs-code-execution-for-ai-agents-choosing-the-right-action-primitive/>
> Published: 2026-09-25 12:00:37+00:00

In this article, you will learn what tool calling and code execution are as agent action primitives, how they differ mechanically, and when to choose one over the other.

Topics we will cover include:

- How tool calling works under the hood, and why it remains the right choice for single, time-sensitive lookups.
- How code execution via Programmatic Tool Calling differs from standard tool calling, and what measurable benefits it offers for fan-out and aggregation tasks.
- A practical decision framework for choosing between the two primitives based on call count, data sensitivity, latency, infrastructure, and auditability needs.

Picture an agent asking one simple-sounding question: which of twenty employees went over their Q3 travel budget. To answer it, the agent needs each person’s expense line items, every flight, hotel, and meal receipt, compared against a budget limit tied to their level. Built the obvious way, with the model calling a tool for each person’s expenses one at a time, that’s twenty separate tool calls, each returning fifty to a hundred line items, and every single one of those items has to pass through the model’s context just so it can be added up. That’s over **2,000 line items and more than 50KB of raw data the model never actually needed to read — it needed a sum**.

That’s the real cost hiding behind a design decision most agent tutorials skip past entirely: how does an agent actually take action in the world. There are two real answers, **tool calling** and **code execution**, and which one you reach for isn’t a style preference — it’s an architectural choice with measurable consequences for cost, latency, and accuracy. This article breaks down both action primitives for AI agents in detail, builds a real, runnable example of each using the same underlying tool, and closes with an honest, numbers-backed framework for choosing between them. If you haven’t built a basic tool-calling agent yet, check out this article, [Easy Agentic Tool Calling with Gemma 4](https://www.kdnuggets.com/easy-agentic-tool-calling-with-gemma-4) — it is the natural place to start before this one.

## What Is an Action Primitive, and Why Does the Choice Matter?

An action primitive is the fundamental mechanism by which a language model turns a decision into a real effect in the world — a database write, an API call, a file read. Every agent framework, whatever else it does, is built on top of one of these primitives at its core.

**Tool calling** is the primitive most people learn first: the model produces one structured request at a time, a host application executes it, and the result comes back into the conversation before the model decides what to do next. **Code execution** is the newer alternative: instead of requesting one action and waiting, the model writes an actual program — in Python or TypeScript — that performs several actions in sequence or in parallel, and only the program’s final output returns to the model.

Neither one is a wrapper around the other, and neither has quietly replaced the other. They’re genuinely different mechanisms with different failure modes, different infrastructure requirements, and different cost profiles, and the rest of this article is about understanding both well enough to pick correctly.

## Tool Calling

It’s worth understanding what’s actually happening underneath a tool call, because the mechanics explain both its strengths and its real limitations. According to [Cloudflare’s detailed breakdown](https://blog.cloudflare.com/code-mode/) of the process, a model generating a tool call doesn’t produce ordinary text. It’s been specifically trained to output a pair of special tokens — one signaling “the following is a tool call” and another marking its end — with a JSON payload describing the tool name and arguments sitting between them. The application running the model watches for those tokens, pauses generation the moment it sees the closing one, parses the JSON against a schema you defined, actually executes the call, and feeds the result back into the conversation as though it were the next thing the user said.

That’s a clean, auditable, one-step-at-a-time loop, and it’s exactly why tool calling became the default. Every action is a discrete, loggable event. Every result is something the model directly sees and can reason about in natural language before deciding what happens next.

## Code Execution

Code execution takes a different starting position entirely: instead of asking the model to describe an action in a constrained JSON format, you let it write actual code that performs the action, running in a sandboxed environment separate from the model itself. [Anthropic’s original code-execution-with-MCP pattern](https://www.anthropic.com/engineering/code-execution-with-mcp) frames this precisely as presenting your tools as a code API rather than a set of directly callable functions, so the model can write a script that imports exactly the tools it needs and calls them the way it would call any other function.

The mechanism that makes this genuinely different — not just a relabeled tool call — is what Anthropic now calls [Programmatic Tool Calling](https://www.anthropic.com/engineering/advanced-tool-use), released alongside two companion features in November 2025. Rather than each tool result flowing back through the model one at a time, you mark specific tools as callable from code by adding an **allowed_callers** field to their definition, and add a **code_execution** tool to the request. When the model wants to act, it writes a full script — loops, conditionals, error handling, and all — that calls those tools directly inside a sandboxed execution environment. Each individual tool call the script makes still executes exactly the way it would in ordinary tool calling; you still receive a request and return a result, but that result is intercepted and processed by the running script rather than being pushed into the model’s context. Only when the script finishes does its final output — and nothing else — return to the model.

That’s the entire difference in one sentence: tool calling puts every intermediate result in front of the model; code execution lets the model decide, through the code it writes, exactly what makes it back.

## Tool Calling for a Single, Time-Sensitive Lookup

Theory is easier to trust once it’s running against a real API, so both examples in this article use the same tool — a **get_weather** function backed by [Open-Meteo](https://open-meteo.com/), a free weather API that needs no API key at all, only an Anthropic API key to run the agent itself.

Start with the case tool calling is obviously right for: a single question that needs one lookup and a natural-language answer — “*what’s the weather like in London right now*.”

``` python
import json
import requests
from anthropic import Anthropic

client = Anthropic()  # reads ANTHROPIC_API_KEY from the environment

def get_weather(city: str) -> dict:
    """Look up a city's coordinates, then fetch its current temperature
    and this week's daily highs from Open-Meteo's free, keyless API."""
    geo = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": city, "count": 1},
    ).json()
    if not geo.get("results"):
        return {"error": f"Could not find a location named '{city}'"}
    lat = geo["results"][0]["latitude"]
    lon = geo["results"][0]["longitude"]

    forecast = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": lat,
            "longitude": lon,
            "current": "temperature_2m",
            "daily": "temperature_2m_max",
            "timezone": "auto",
        },
    ).json()

    return {
        "city": city,
        "current_temp_c": forecast["current"]["temperature_2m"],
        "week_high_temps_c": forecast["daily"]["temperature_2m_max"],
        "unit": "celsius",
    }

weather_tool = {
    "name": "get_weather",
    "description": (
        "Get the current temperature and this week's daily high "
        "temperatures for a city. Returns JSON with city, "
        "current_temp_c, week_high_temps_c (7 daily highs), and unit."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name, e.g. 'Lagos'"}
        },
        "required": ["city"],
    },
}

messages = [{"role": "user", "content": "What's the weather like in London right now?"}]

response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    tools=[weather_tool],
    messages=messages,
)

# Keep resolving tool calls until Claude produces a final text answer
while response.stop_reason == "tool_use":
    messages.append({"role": "assistant", "content": response.content})
    tool_results = []

    for block in response.content:
        if block.type == "tool_use" and block.name == "get_weather":
            result = get_weather(**block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(result),
            })

    messages.append({"role": "user", "content": tool_results})
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        tools=[weather_tool],
        messages=messages,
    )

for block in response.content:
    if block.type == "text":
        print(block.text)

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586

import jsonimport requestsfrom anthropic import Anthropic client = Anthropic()  # reads ANTHROPIC_API_KEY from the environment def get_weather(city: str) -> dict:    """Look up a city's coordinates, then fetch its current temperature    and this week's daily highs from Open-Meteo's free, keyless API."""    geo = requests.get(        "https://geocoding-api.open-meteo.com/v1/search",        params={"name": city, "count": 1},    ).json()    if not geo.get("results"):        return {"error": f"Could not find a location named '{city}'"}    lat = geo["results"][0]["latitude"]    lon = geo["results"][0]["longitude"]     forecast = requests.get(        "https://api.open-meteo.com/v1/forecast",        params={            "latitude": lat,            "longitude": lon,            "current": "temperature_2m",            "daily": "temperature_2m_max",            "timezone": "auto",        },    ).json()     return {        "city": city,        "current_temp_c": forecast["current"]["temperature_2m"],        "week_high_temps_c": forecast["daily"]["temperature_2m_max"],        "unit": "celsius",    } weather_tool = {    "name": "get_weather",    "description": (        "Get the current temperature and this week's daily high "        "temperatures for a city. Returns JSON with city, "        "current_temp_c, week_high_temps_c (7 daily highs), and unit."    ),    "input_schema": {        "type": "object",        "properties": {            "city": {"type": "string", "description": "City name, e.g. 'Lagos'"}        },        "required": ["city"],    },} messages = [{"role": "user", "content": "What's the weather like in London right now?"}] response = client.messages.create(    model="claude-sonnet-5",    max_tokens=1024,    tools=[weather_tool],    messages=messages,) # Keep resolving tool calls until Claude produces a final text answerwhile response.stop_reason == "tool_use":    messages.append({"role": "assistant", "content": response.content})    tool_results = []     for block in response.content:        if block.type == "tool_use" and block.name == "get_weather":            result = get_weather(**block.input)            tool_results.append({                "type": "tool_result",                "tool_use_id": block.id,                "content": json.dumps(result),            })     messages.append({"role": "user", "content": tool_results})    response = client.messages.create(        model="claude-sonnet-5",        max_tokens=1024,        tools=[weather_tool],        messages=messages,    ) for block in response.content:    if block.type == "text":        print(block.text)
```

Walking through what matters here: **get_weather** itself is ordinary Python — nothing agent-specific about it — it geocodes a city name and pulls both the current temperature and the week’s daily highs in one request. The **weather_tool** dictionary is the schema Claude actually sees, and the description matters more than it looks — a vague description is one of the most common causes of a model calling a tool with the wrong arguments. The **while response.stop_reason == “tool_use”** loop is the real mechanical heart of standard tool calling: every time Claude requests the tool, your code has to actually run it, wrap the result as a **tool_result** block, append it to the conversation, and call the API again — and this repeats for as many tool calls as the task needs. For a single lookup like this one, that’s one pass through the loop and done, which is exactly why tool calling fits this case well: one call, one result, and a result small and relevant enough that Claude genuinely benefits from seeing it directly before writing a natural-language answer.

## Code Execution for Fan-Out and Aggregation

Now change the question, using the exact same **get_weather** function — completely unchanged: “*given these fifteen cities, which one will have the coldest high temperature this week, and what’s the average weekly high across all of them?*”

Run that through the tool-calling loop above and you’d get fifteen separate tool calls, fifteen full JSON payloads of daily temperatures pushed into Claude’s context, and Claude would then have to manually compare and average them in natural language — slow, token-expensive, and exactly the kind of arithmetic a model is more error-prone at than a for-loop is. This is precisely the case Programmatic Tool Calling was built for.

``` python
import json
from anthropic import Anthropic

client = Anthropic()

# Same get_weather function from the previous example, unchanged

weather_tool = {
    "name": "get_weather",
    "description": (
        "Get the current temperature and this week's daily high "
        "temperatures for a city. Returns JSON with city, "
        "current_temp_c, week_high_temps_c (7 daily highs), and unit."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name, e.g. 'Lagos'"}
        },
        "required": ["city"],
    },
    # This is the one line that changes the primitive: it opts the tool
    # into being called from inside generated code, not only directly
    # by the model
    "allowed_callers": ["code_execution_20250825"],
}

code_execution_tool = {"type": "code_execution_20250825", "name": "code_execution"}

cities = [
    "Lagos", "Nairobi", "Cairo", "Accra", "Kigali",
    "Casablanca", "Addis Ababa", "Dakar", "Tunis", "Kampala",
    "Harare", "Lusaka", "Maputo", "Windhoek", "Gaborone",
]

messages = [{
    "role": "user",
    "content": (
        f"Given these cities: {', '.join(cities)}, which one will have "
        "the coldest high temperature this week, and what's the average "
        "weekly high across all of them? Use the get_weather tool."
    ),
}]

response = client.beta.messages.create(
    betas=["advanced-tool-use-2025-11-20"],
    model="claude-sonnet-5",
    max_tokens=2048,
    tools=[code_execution_tool, weather_tool],
    messages=messages,
)

# The loop looks similar to standard tool calling, but now some
# tool_use blocks carry a "caller" field, meaning the request came
# from inside Claude's generated script rather than from Claude directly
while response.stop_reason == "tool_use":
    messages.append({"role": "assistant", "content": response.content})
    tool_results = []

    for block in response.content:
        if block.type == "tool_use" and block.name == "get_weather":
            result = get_weather(**block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(result),
            })

    if tool_results:
        messages.append({"role": "user", "content": tool_results})

    response = client.beta.messages.create(
        betas=["advanced-tool-use-2025-11-20"],
        model="claude-sonnet-5",
        max_tokens=2048,
        tools=[code_execution_tool, weather_tool],
        messages=messages,
    )

for block in response.content:
    if block.type == "text":
        print(block.text)

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182

import jsonfrom anthropic import Anthropic client = Anthropic() # Same get_weather function from the previous example, unchanged weather_tool = {    "name": "get_weather",    "description": (        "Get the current temperature and this week's daily high "        "temperatures for a city. Returns JSON with city, "        "current_temp_c, week_high_temps_c (7 daily highs), and unit."    ),    "input_schema": {        "type": "object",        "properties": {            "city": {"type": "string", "description": "City name, e.g. 'Lagos'"}        },        "required": ["city"],    },    # This is the one line that changes the primitive: it opts the tool    # into being called from inside generated code, not only directly    # by the model    "allowed_callers": ["code_execution_20250825"],} code_execution_tool = {"type": "code_execution_20250825", "name": "code_execution"} cities = [    "Lagos", "Nairobi", "Cairo", "Accra", "Kigali",    "Casablanca", "Addis Ababa", "Dakar", "Tunis", "Kampala",    "Harare", "Lusaka", "Maputo", "Windhoek", "Gaborone",] messages = [{    "role": "user",    "content": (        f"Given these cities: {', '.join(cities)}, which one will have "        "the coldest high temperature this week, and what's the average "        "weekly high across all of them? Use the get_weather tool."    ),}] response = client.beta.messages.create(    betas=["advanced-tool-use-2025-11-20"],    model="claude-sonnet-5",    max_tokens=2048,    tools=[code_execution_tool, weather_tool],    messages=messages,) # The loop looks similar to standard tool calling, but now some# tool_use blocks carry a "caller" field, meaning the request came# from inside Claude's generated script rather than from Claude directlywhile response.stop_reason == "tool_use":    messages.append({"role": "assistant", "content": response.content})    tool_results = []     for block in response.content:        if block.type == "tool_use" and block.name == "get_weather":            result = get_weather(**block.input)            tool_results.append({                "type": "tool_result",                "tool_use_id": block.id,                "content": json.dumps(result),            })     if tool_results:        messages.append({"role": "user", "content": tool_results})     response = client.beta.messages.create(        betas=["advanced-tool-use-2025-11-20"],        model="claude-sonnet-5",        max_tokens=2048,        tools=[code_execution_tool, weather_tool],        messages=messages,    ) for block in response.content:    if block.type == "text":        print(block.text)
```

The single most important line in this whole script is **“allowed_callers”: [“code_execution_20250825”]**. Without it, the tool behaves exactly as it did in the previous example — callable only directly by the model. With it added, Claude gains the option to write a script that calls **get_weather** fifteen times itself, likely in parallel using **asyncio.gather**, sum and sort the results, and print only the final answer — the coldest city and the average — to standard output. Your Python code doesn’t change how it responds to individual tool calls at all; that part of the loop looks nearly identical to the tool-calling example. What changes is invisible from your side of the API: fourteen of those fifteen weather lookups, and every intermediate comparison between them, never touch Claude’s context.

Claude only ever sees the two numbers it actually asked for. Since this uses a beta feature, it is worth double-checking the exact beta header string and block-handling details against [Anthropic’s current documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling) before relying on it in production, as beta APIs are the part of any platform most likely to shift.

## Why Code Execution Wins at Scale

The weather example makes the mechanism visible, but it’s worth backing this up with real, published figures rather than intuition alone. Anthropic’s original code-execution-with-MCP pattern took a real Google Drive-to-Salesforce workflow from 150,000 tokens down to 2,000 — a [98.7% reduction](https://www.anthropic.com/engineering/code-execution-with-mcp) — simply by keeping a full meeting transcript inside the execution environment instead of routing it through the model twice.

Programmatic Tool Calling’s own internal benchmarking, [reported directly by Anthropic](https://www.anthropic.com/engineering/advanced-tool-use), found average token usage on complex research tasks dropped from 43,588 to 27,297 — a 37% reduction — while accuracy on the GAIA benchmark actually improved, rising from 46.5% to 51.2%, and internal knowledge retrieval accuracy rose from 25.6% to 28.5%. That last detail matters more than the token savings alone: this isn’t purely a cost optimization. Offloading orchestration logic to actual code rather than asking a model to track it through natural language measurably reduces the kind of errors that come from a model losing track of a dozen intermediate values it’s trying to compare in its head.

The academic result underneath all of this predates Anthropic’s own tooling. The original [CodeAct paper](https://arxiv.org/abs/2402.01030) from Wang and colleagues in 2024 found that agents taking action through executable code, rather than JSON-formatted tool calls, succeeded up to 20% more often on complex, multi-step tasks. Code execution isn’t a recent product feature bolted onto an existing idea — it’s a research-backed pattern that the major labs have spent the past two years turning into production infrastructure.

## Where Tool Calling Still Wins

The numbers above can make code execution look like an unconditional upgrade, and it isn’t one. There’s a real, honest case for sticking with plain tool calling in a meaningful set of situations.

Single-call tasks are the clearest case. The Lagos weather example earlier in this article gains nothing from a sandbox — one call, one small result, and the overhead of spinning up a code execution environment adds latency without adding any real benefit. Tasks where the model genuinely needs to reason over an intermediate result in natural language are the second case: if the actual point of a step is for the model to notice something subtle in a document or a dataset and respond to it conversationally, filtering that data away in a sandbox defeats the purpose. Simpler infrastructure is a real, practical factor too — a team without an existing secure sandboxing setup takes on real operational cost standing one up, and that cost needs to be weighed against the savings, not assumed away. And auditability matters more than it gets credit for: a tool call is one clean, loggable event with a name and a set of arguments, while reasoning about exactly what a generated script did internally — especially after the fact, during an incident — is a genuinely harder debugging problem.

## Decision Framework: Choosing the Right Primitive

Pulling everything above into one practical reference:

| **Factor** | **Favors tool calling** | **Favors code execution** | 
|---|---|---|
| Number of calls needed | One, or a small, fixed few | Several, especially with fan-out or aggregation | 
| What happens to results | The model needs to read and reason over them directly | They just need to be filtered, summed, or compared | 
| Data sensitivity | Low — nothing problematic about the model seeing it | High — PII or large payloads better kept out of context | 
| Latency tolerance | Tight — sandbox startup isn’t worth paying for | Workflow already involves multiple round-trips anyway | 
| Team infrastructure | No existing sandboxing setup | Sandbox or code-execution tooling already in place | 
| Auditability needs | Every discrete action must be individually logged | Aggregate outcome matters more than each internal step | 

## The Hybrid Reality: Most Production Agents Use Both

It’s worth closing this out by pushing back gently on the framing of the article’s own title. In practice, this isn’t a permanent, once-and-for-all architectural decision — it’s a per-task judgment call, and Anthropic’s own guidance treats it exactly that way. Their [advanced tool use release](https://www.anthropic.com/engineering/advanced-tool-use) shipped Programmatic Tool Calling alongside two companion features specifically meant to be layered together as needed: a Tool Search Tool for discovering the right tool out of a large library without loading every definition upfront, and Tool Use Examples for teaching a model the conventions a schema alone can’t express. Their own recommendation is to start with whichever bottleneck is actually limiting a given agent — context bloat from too many tool definitions, large intermediate results polluting context, or parameter errors — and add the matching feature, rather than reaching for every capability on day one.

A single well-built agent, in practice, tends to use plain tool calling for its simple, single-shot lookups and switch to code execution the moment a task calls for fan-out, aggregation, or handling data too large or sensitive to put in front of the model directly. The actual skill worth building isn’t picking a primitive once — it’s recognizing, task by task, which one the work in front of you actually needs.

## Conclusion

An action primitive is infrastructure, not a preference, and the two examples built in this article prove it with the same fifteen lines of tool definition underneath both. Get it right and an agent handles a fan-out task across fifteen cities — or two thousand expense line items — in one clean pass. Get it wrong — reach for tool calling on a task that needs code execution — and nothing crashes. The agent still answers. It just does it slower, more expensively, and with a context window quietly filled with data nobody actually needed to read.
