Skip to content
Back to blog

Engineering7 min read

Programmatic tool calling: stop sending every tool result through the model

Twenty tool calls means twenty model round trips and every raw result in your context window. Programmatic tool calling lets Claude write one script that calls your tools as async functions and hands back only the answer. Markus Kohler covers the setup, where it saves tokens, where it costs more, and how he applies the same pattern to agents on Blocks.ai.

Your agent can easily make 20 tool calls to answer one question, and every one of them goes through the model, one after the other. Your context window fills up fast. There's one more acronym worth learning here: PTC, programmatic tool calling. I'll cover how it works, how to turn it on, when it's worth it, and how I use the same idea to call other agents.

The round-trip problem

Start with normal tool calling. You ask Claude something and it decides it needs a tool. It stops and asks your app to make the call. Your app runs the tool and sends back the result. Claude reads the result and works out what to do next. That loop is one round trip.

Now multiply it by 20. Ask which of 20 engineers went over their travel budget, and you get 20 tool calls, which means 20 round trips with the model running again after each one. Every result lands in your context window. That's over 2,000 expense lines, just so it can tell you three people went over.

It gets worse the more MCP servers you connect. In Anthropic's example, five servers use about 55,000 tokens on tool definitions alone, before you've asked a single thing. Tool search already deals with that half: it loads a tool only when it's needed. PTC goes after the other half, the results.

How PTC works

With programmatic tool calling, Claude stops calling your tools one at a time. It writes a script that calls all of them. Inside that script your tools are ordinary async functions, so it can fire off all 20 lookups at once.

The script runs in Anthropic's code execution sandbox. When it calls one of your tools, the container pauses, your app runs the tool as usual, and the result goes back to the running script, not to Claude. The script adds everything up and prints the answer, and that output is all Claude sees.

So it's one round trip instead of 20, and Claude reads three lines instead of 2,000. Anthropic tried this on its agentic search benchmarks, and it used 24% fewer input tokens while scoring 11% better.

Setting it up on the Claude API

This part is for you if you're building an agent on the Claude API, because you write the tool definitions and that's where it all happens.

First, add the code execution tool to your tool list. That's the sandbox Claude's scripts run in. Then give each tool an allowed_callers setting:

  • ["direct"] (the default): Claude calls the tool the normal way.
  • ["code_execution_20260120"]: the tool is called from inside a script.
  • Both values: Claude decides each time.
json
{
  "tools": [
    { "type": "code_execution_20260120", "name": "code_execution" },
    {
      "name": "get_expenses",
      "description": "Returns one employee's expense lines for a date range as a JSON array of {date, category, amount_usd}. Usually 50 to 200 rows.",
      "input_schema": {
        "type": "object",
        "properties": {
          "employee_id": { "type": "string" },
          "from": { "type": "string", "description": "ISO date" },
          "to": { "type": "string", "description": "ISO date" }
        },
        "required": ["employee_id", "from", "to"]
      },
      "allowed_callers": ["code_execution_20260120"]
    },
    {
      "name": "issue_refund",
      "description": "Refunds one order. Returns {refund_id, status}.",
      "input_schema": {
        "type": "object",
        "properties": { "order_id": { "type": "string" } },
        "required": ["order_id"]
      },
      "allowed_callers": ["direct"]
    }
  ]
}

My rule of thumb is to split tools by what they do:

  • Tools that only fetch data, like pulling someone's expenses or querying a database, go to code. It's safe to run 20 of them at once, and they tend to return a lot.
  • Tools that change something, like sending an email or issuing a refund, stay on direct. You don't want a script firing off 20 refunds in a loop.
  • When I genuinely can't tell, like with a search tool, I allow both. Anthropic's docs recommend picking one per tool because it gives Claude clearer guidance, so I keep "both" for the real edge cases.

Tell Claude what comes back

The description is what Claude reads to work out what a tool is for, so say what it returns and roughly how much. "About 100 rows of JSON" is a strong hint that this is a job for a script. Because the script parses the result in code, describe the shape too: field names and types.

Since you also write the agent's system prompt, add one line there along these lines:

If you need the same tool on many items, write one script and print only the summary. If each step depends on the last result, call the tools directly.

Check what it actually did

Every tool_use block says who made the call, so your logs show when Claude went for a script:

json
{
  "type": "tool_use",
  "name": "get_expenses",
  "input": { "employee_id": "e_014", "from": "2026-10-01", "to": "2026-10-31" },
  "caller": { "type": "code_execution_20260120", "tool_id": "srvtoolu_abc123" }
}

A direct call carries "caller": { "type": "direct" } instead. The tool_id points at the code execution run that made the call. When you answer a programmatic call, send a user message that contains only tool_result blocks, with no text alongside them.

Is it always worth it?

Honestly, no.

  • Sequential reasoning: if each step needs Claude to look at the last result and think, a script can't save you those round trips.
  • Small jobs: if it's only two or three small calls, starting the sandbox can cost more than you save.

Anthropic's own numbers go both ways. On an agent with 75 tools, PTC cut billed input tokens by about 38% with no change in accuracy. On a benchmark with one or two calls per turn, the score stayed the same and it cost about 8% more.

So try it on your own traffic. Run a sample with and without allowed_callers and compare billed input tokens.

Three things to check before you ship

  • Some tools can't go in a script at all. Tools from the MCP connector stay direct no matter what you set, and tools with strict: true aren't supported with programmatic calling.
  • The setting is a hint, not a lock. Claude can still call a code-only tool directly, so your app has to handle a direct tool_use for any tool it defines. Don't treat allowed_callers as a security boundary.
  • The sandbox keeps data for up to 30 days. Programmatic tool calling isn't covered by zero data retention, so be careful what you send in there.

The same idea works anywhere you have a loop

PTC itself is a Claude API feature, but the pattern works anywhere you've got a loop. Here's where I use it.

I work on Blocks.ai, where you can hire other people's agents, and they behave a lot like tools. Claude Code can hire them through our MCP server, but then every result comes back into its context. Same problem as before.

Blocks.ai also has an SDK you can script, so I asked Claude Code to do basically what PTC does: write one script that sends 20 receipts to my document reader agent at once. For each receipt it sends a task, waits for it to finish, and saves whatever comes back. Then it adds up the totals.

The script it wrote looks roughly like this:

typescript
import { readdir, readFile } from 'node:fs/promises';
import { TaskClient } from '@blocks-network/sdk';

const LIMIT_USD = 75; // per-receipt limit

const client = await TaskClient.create({
  billingMode: 'free',
  apiKey: process.env.BLOCKS_API_KEY!,
});

const files = (await readdir('receipts')).filter((f) => f.endsWith('.pdf'));

// Send every receipt at once. The network queues whatever the agent can't take yet.
const results = await Promise.all(
  files.map(async (fileName) => {
    const session = await client.sendMessage({
      agentName: 'document-reader',
      requestParts: [{
        partId: 'input_file', // must match an input id in the agent's card
        file: await readFile(`receipts/${fileName}`),
        fileName,
        contentType: 'application/pdf',
      }],
    });
    const terminal = await session.waitForTerminal(300_000);
    const [ref] = session.listArtifacts();
    const parsed = ref
      ? JSON.parse(new TextDecoder().decode((await session.downloadArtifact(ref)).data))
      : null;
    await session.asyncClose();
    return { fileName, state: terminal.state, total: parsed?.total ?? 0 };
  }),
);

// Only this summary goes back to the model.
console.log(JSON.stringify({
  receipts: results.length,
  total: results.reduce((sum, r) => sum + r.total, 0),
  overLimit: results.filter((r) => r.total > LIMIT_USD).map((r) => r.fileName),
  failed: results.filter((r) => r.state !== 'completed').map((r) => r.fileName),
}));

client.destroy();

Here's how the run went:

  • All 20 tasks went out in the same second.
  • My agent only handles four at a time, so the other 16 sat in the queue. The last one waited two whole minutes before it even started.
  • All 20 were still done in about two and a half minutes.
  • Claude Code got back one short summary: the total, and the two receipts that went over the limit.

None of those 20 documents went through the model's context.

If you want Claude Code to work this way by default, put a line in your CLAUDE.md along these lines:

When a task needs the same call on many items, write a script, run it, and bring back only the summary.

The takeaway

If you take one thing from this, stop sending every result through the model. Let code run the loop and hand the model the answer.

If you've tried PTC, tell me on X whether your token bill went up or down.

Try it: Connect your first agent in minutes at blocks.ai/docs/quickstart.