JSON vs Programmatic Tool Calling with Claude
When building an application with the Claude API, connecting tools is the first step toward a functional agent. The model can call functions, query databases, send messages. But how you wire those tools drastically changes performance, cost, and code complexity. In 2026, the Claude API offers three distinct approaches. This article compares them with code, numbers, and practical experience from using all three. The problem: round trips Traditional tool calling works in a loop: the model requests a tool, your server executes it, sends back the result, the model reasons over the result, requests another tool, and so on. Each iteration is a full round trip with the model. The problem becomes concrete as the number of tools grows. Checking expenses for 20 employees? 20 round trips. Every intermediate result piles into the context. According to Anthropic's measurements, tool definitions alone can consume over 134,000 tokens before any conversation in a multi-server setup. Approach 1: JSON schema + manual loop This is the original approach. You define each tool with a JSON schema, send the request to the API, parse the tool_use response, execute the tool, and send back the result. client = anthropic.Anthropic() tools = [{ "name": "query_database", "description": "Execute a SQL query. Returns rows as JSON.", "input_schema": { "type": "object", "properties": { "sql": {"type": "string", "description": "SQL query"} }, "required": ["sql"] } }] messages = [{"role": "user", "content": "What's the revenue by region?"}] while True: response = client.messages.create( model="claude-sonnet-5", max_tokens=4096, tools=tools, messages=messages, ) if response.stop_reason == "end_turn": break for block in response.content: if block.type == "tool_use": result = execute_sql(block.input["sql"]) messages.append({"role": "assistant", "content": response.content}) messages.append({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result) }] }) The while True loop is the critical part. You handle the request/response cycle, parse tool_use blocks, accumulate messages, and manage error cases yourself. Pros: full control over every step. You can log, filter results, inject business logic between calls. Cons: lots of boilerplate. Every tool result passes through the model, even if it's just an intermediate value. With 10 tool calls, that's 10 full inference passes. Approach 2: Tool Runner SDK The Tool Runner is a helper in the official Anthropic SDKs (Python, TypeScript, Go, Java, C#, PHP, Ruby). It automates the agentic loop: tool definition, execution, conversation state management, and type validation. In Python, the @beta_tool decorator generates the JSON schema from type hints and the docstring: from anthropic import Anthropic, beta_tool client = Anthropic() @beta_tool def query_database(sql: str) -> str: """Execute a SQL query on the sales database. Args: sql: SQL query to execute """ rows = db.execute(sql) return json.dumps(rows) runner = client.beta.messages.tool_runner( model="claude-sonnet-5", max_tokens=4096, tools=[query_database], messages=[{"role": "user", "content": "What's the revenue by region?"}], ) for message in runner: print(message) In TypeScript, two options: betaZodTool with Zod validation (recommended), or betaTool with a plain JSON schema: const client = new Anthropic(); const queryDatabase = betaZodTool({ name: "query_database", description: "Execute a SQL query on the sales database", inputSchema: z.object({ sql: z.string().describe("SQL query to execute"), }), run: async (input) => { const rows = await db.execute(input.sql); return JSON.stringify(rows); }, }); const result = await client.beta.messages.toolRunner({ model: "claude-sonnet-5", max_tokens: 4096, tools: [queryDatabase], messages: [{ role: "user", content: "What's the revenue by region?" }], }); The Tool Runner handles the loop automatically: when Claude requests a tool, the runner executes it and sends back the result. No more while True, no manual tool_use block parsing. Pros: less code, type safety with Zod or Python type hints, built-in error handling. Cons: same execution model as the manual approach under the hood. Each tool call is still a round trip with the model. The gain is in DX, not performance. Approach 3: programmatic tool calling Programmatic tool calling changes the execution model. Instead of requesting one tool at a time through the API, Claude writes Python code that calls your tools directly inside a code execution container. Intermediate results stay in the container and only enter the model's context when Claude explicitly sends them. To enable this approach, you need two things: include the code_execution tool in the request, and add allowed_callers to the tools Claude can call from code: client = anthropic.Anthropic() response = client.messages.create( model="claude-sonnet-5", max_tokens=4096, messages=[{ "role": "user", "content": "Compare revenue across the West, East, and Central regions" }], tools=[ {"type": "code_execution_20260120", "name": "code_execution"}, { "name": "query_database", "description": "Execute a SQL query. Returns rows as JSON.", "input_schema": { "type": "object", "properties": { "sql": {"type": "string", "description": "SQL query"} }, "required": ["sql"] }, "allowed_callers": ["code_execution_20260120"] } ], ) Claude then generates a Python script that calls query_database in a loop or in parallel via asyncio.gather, filters the results, and only sends back the summary: results = {} for region in ["West", "East", "Central"]: rows = json.loads(await query_database({ "sql": f"SELECT SUM(revenue) as total FROM sales WHERE region = '{region}'" })) results[region] = rows[0]["total"] best = max(results, key=results.get) print(f"Highest revenue region: {best} ({results[best]} USD)") print(f"Details: {json.dumps(results)}") Instead of 3 model round trips, a single inference produced the code, and all 3 tool calls execute inside the container. The model only receives the final print() output. Tools are exposed as async Python functions. The allowed_callers field controls who can call each tool: Value Behavior ["direct"] Standard API call (default) ["code_execution_20260120"] Call only from code execution ["direct", "code_execution_20260120"] Both modes Anthropic recommends choosing a single mode per tool to avoid ambiguity. Pros: massive token and latency reduction. BrowseComp and DeepSearchQA benchmarks show +11% performance and -24% input tokens. On complex research tasks, average usage drops from 43,588 to 27,297 tokens, a 37% reduction. Cons: requires the code_execution tool (beta). Containers have a limited lifetime (~5 minutes idle). Not available on Amazon Bedrock or Google Cloud. The field is not a security boundary: allowed_callers guides Claude but does not strictly block direct calls. Comparison Criterion JSON + loop Tool Runner SDK Programmatic Inferences per N tools N N 1 + returns Intermediate tokens In context In context In container Code complexity High Low Medium Per-step control Full Limited Via generated code Latency N x inference N x inference 1 inference + exec Type safety Manual Zod / type hints N/A (generated code) Availability GA, all clouds Beta, all clouds Beta, direct API When to use what JSON + manual loop when you need fine-grained control between each tool call. For example: a workflow with human validation between steps, detailed logging, or conditional business logic. It's also the only option if you target Bedrock or Google Cloud without the Tool Runner. Tool Runner SDK for most use cases. The code is cleaner, type validation is automatic, and the behavior is identical to the manual loop under the hood. This is the approach I use by default in my TypeScript and Python projects. Programmatic tool calling when the number of tool calls per request is high or intermediate results are large. The typical example: aggregating data from 20 sources, filtering, and only returning the summary. It's also the most efficient approach for research tasks where Claude needs to explore and sort before concluding. You can combine approaches. A tool marked ["direct"] will be called through the standard loop, while another marked ["code_execution_20260120"] will be orchestrated by the code. In the same project. In practice In CodeRift, I use the Tool Runner for review agents: each agent has 2-3 tools (read a file, search symbols, post a comment) and the number of calls is predictable. The Tool Runner eliminates boilerplate without sacrificing visibility. For data exploration tasks in my IronFlow workflows, programmatic tool calling would be the right choice: an agent scanning 50 GitLab projects, filtering open MRs, and aggregating stats. The heavy lifting happens in the container, and only the summary enters the context. The manual JSON approach stays for cases where the Tool Runner isn't available (unsupported SDK, or integration with a third-party framework that manages its own loop). The choice isn't permanent. Start with the Tool Runner, migrate to programmatic when the token bill justifies it.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to