bedda.tech logobedda.tech
← Back to blog

Claude tool_use: Taming Multi-Step AI Workflows

Matthew J. Whitney
9 min read
artificial intelligencellmai integrationmachine learningbest practices

Here's the deal: the Claude tool_use API is one of the most powerful primitives in the current LLM landscape, and also one of the easiest ways to accidentally burn $200 in API credits in a single runaway loop at 2am. I've done both. This post is about the latter — what actually breaks in production when you chain 5+ tool calls with Claude, and exactly how we fixed it.

We've been running multi-step agentic workflows inside a few Bedda.tech projects — most heavily in KRAIN, our knowledge retrieval and AI navigation layer, and more recently in Crowdia. The patterns I'm describing here came out of real incidents: infinite loops, token budget explosions, and tool errors that Claude silently swallowed instead of surfacing.


The AI Integration Pattern That Looks Fine Until It Isn't

The basic loop everyone starts with looks approximately like this:

import anthropic

client = anthropic.Anthropic()

tools = [
    {
        "name": "search_knowledge_base",
        "description": "Search the internal knowledge base for relevant documents.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "limit": {"type": "integer", "default": 5}
            },
            "required": ["query"]
        }
    },
    {
        "name": "fetch_document",
        "description": "Fetch a specific document by ID.",
        "input_schema": {
            "type": "object",
            "properties": {
                "document_id": {"type": "string"}
            },
            "required": ["document_id"]
        }
    }
]

messages = [{"role": "user", "content": user_query}]

response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=4096,
    tools=tools,
    messages=messages
)

while response.stop_reason == "tool_use":
    tool_uses = [b for b in response.content if b.type == "tool_use"]
    tool_results = []

    for tool_use in tool_uses:
        result = dispatch_tool(tool_use.name, tool_use.input)
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": tool_use.id,
            "content": result
        })

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

    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=4096,
        tools=tools,
        messages=messages
    )

This is pulled directly from Anthropic's tool use documentation. It works. Until it doesn't.


What Most Guides Miss: The Three Production Failure Modes

1. The Infinite Loop That Costs Real Money

Claude will occasionally get into a state where it calls the same tool repeatedly with slightly different inputs, never reaching end_turn. This happened to us in KRAIN when the knowledge base search returned empty results — Claude kept rephrasing the query instead of giving up. Without a circuit breaker, that loop ran 47 iterations before we killed it manually.

The fix is embarrassingly simple but almost never mentioned in tutorials:

MAX_TOOL_ITERATIONS = 10
iteration_count = 0

while response.stop_reason == "tool_use":
    iteration_count += 1
    if iteration_count > MAX_TOOL_ITERATIONS:
        raise RuntimeError(
            f"Tool loop exceeded {MAX_TOOL_ITERATIONS} iterations. "
            f"Last tool calls: {[b.name for b in response.content if b.type == 'tool_use']}"
        )
    # ... rest of loop

Ten is a reasonable ceiling for most workflows. We use 15 for KRAIN's more complex research chains, but anything beyond that is almost certainly a bug, not a feature.

2. Token Budget Blowouts in Long Chains

Here's what happens with a 5-step tool chain on claude-opus-4-5: every round trip appends both the assistant's tool call content and your tool results to the message history. By iteration 5, you're potentially sending 15,000+ tokens of context just in message history, before Claude even starts generating a response.

On a workflow that runs 50 times a day, that compounds fast.

The approach we landed on in production is result truncation with a summary flag:

def truncate_tool_result(result: str, max_chars: int = 2000) -> str:
    if len(result) <= max_chars:
        return result
    truncated = result[:max_chars]
    return f"{truncated}\n\n[TRUNCATED: {len(result) - max_chars} additional characters omitted]"

for tool_use in tool_uses:
    raw_result = dispatch_tool(tool_use.name, tool_use.input)
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": tool_use.id,
        "content": truncate_tool_result(str(raw_result))
    })

You can also use Claude's extended thinking with token budgeting to get more predictable behavior on complex chains, but for most workflows, aggressive result truncation buys you 60-70% of the benefit at a fraction of the complexity.

3. Silent Tool Errors That Corrupt the Conversation

This one is subtle. If your dispatch_tool function raises an exception and you catch it generically and return an empty string, Claude will happily continue the conversation as if the tool succeeded. It will hallucinate what the result probably was. We saw this in Crowdia when a third-party API call timed out — Claude fabricated three "documents" that didn't exist.

The correct pattern is to return a structured error that Claude can reason about:

def dispatch_tool(name: str, inputs: dict) -> str:
    try:
        if name == "search_knowledge_base":
            return search_kb(inputs["query"], inputs.get("limit", 5))
        elif name == "fetch_document":
            return fetch_doc(inputs["document_id"])
        else:
            return f"ERROR: Unknown tool '{name}'"
    except TimeoutError:
        return "ERROR: Tool call timed out after 10 seconds. The external service may be unavailable."
    except Exception as e:
        return f"ERROR: Tool execution failed — {type(e).__name__}: {str(e)}"

Claude handles explicit error strings remarkably well. It will typically surface the error to the user or try an alternative approach rather than inventing results. Returning structured errors instead of raising exceptions is one of the highest-leverage changes we made to our production tool loops.


LLM Workflow Design: Structuring Tool Definitions That Actually Work

Bad tool definitions are the silent killer of multi-step AI workflows. Claude's tool selection is only as good as your descriptions.

A few rules we follow:

Be specific about failure cases in the description. Don't just say "searches the knowledge base." Say "searches the internal knowledge base and returns up to N document summaries. Returns an empty list if no results are found — do NOT retry with the same query."

Use strict schemas. Setting "additionalProperties": false in your input schema catches a surprising number of cases where Claude tries to pass parameters you didn't define:

{
    "name": "search_knowledge_base",
    "description": "Search the internal knowledge base. Returns empty list if no results found — do NOT retry the same query.",
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "The search query. Be specific and concise."
            },
            "limit": {
                "type": "integer",
                "description": "Max results to return. Default 5, max 20.",
                "default": 5,
                "minimum": 1,
                "maximum": 20
            }
        },
        "required": ["query"],
        "additionalProperties": false
    }
}

Fewer tools is almost always better. Every tool you add increases the chance Claude picks the wrong one or tries to chain them in unexpected ways. In KRAIN we went from 9 tools down to 5 by merging related operations, and reliability improved noticeably.


Machine Learning Best Practices: Testing Your Tool Chains

You can't test multi-step tool chains the same way you test regular functions. The non-determinism is real and it matters.

We run three types of tests against our tool chains:

Smoke tests — fixed inputs, assert that the chain terminates within N iterations and returns a non-empty result. These run in CI.

Adversarial inputs — deliberately broken tool responses (timeouts, empty results, malformed data) to verify the error handling paths work. These caught the Crowdia hallucination bug before it hit production.

Cost benchmarks — we log total input + output tokens for each workflow run and alert if a run exceeds 2x the p90 baseline. This is how we caught a prompt change that accidentally doubled our context size.

The community is clearly thinking about sandboxing and observability for AI agents — projects like AgentNest (which showed up on HN this week) are trying to solve the isolation problem for agentic workflows, which is a real gap. We've been running our tool dispatch in isolated Lambda functions for exactly this reason — if a tool call goes sideways, it can't take down the whole service.


The Full Production Loop

Here's the complete pattern we actually run in production, combining all of the above:

import anthropic
from typing import Any

client = anthropic.Anthropic()
MAX_ITERATIONS = 10

def run_tool_chain(user_query: str, tools: list, system_prompt: str) -> str:
    messages = [{"role": "user", "content": user_query}]
    iteration = 0

    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=4096,
        system=system_prompt,
        tools=tools,
        messages=messages
    )

    while response.stop_reason == "tool_use":
        iteration += 1
        if iteration > MAX_ITERATIONS:
            raise RuntimeError(
                f"Exceeded {MAX_ITERATIONS} tool iterations. "
                f"Possible loop on: {[b.name for b in response.content if b.type == 'tool_use']}"
            )

        tool_uses = [b for b in response.content if b.type == "tool_use"]
        tool_results = []

        for tool_use in tool_uses:
            raw_result = dispatch_tool(tool_use.name, tool_use.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": tool_use.id,
                "content": truncate_tool_result(str(raw_result), max_chars=2000)
            })

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

        response = client.messages.create(
            model="claude-opus-4-5",
            max_tokens=4096,
            system=system_prompt,
            tools=tools,
            messages=messages
        )

    text_blocks = [b.text for b in response.content if hasattr(b, "text")]
    return " ".join(text_blocks)

This is the pattern. Not revolutionary, but every piece is load-bearing.


My Concrete Recommendation

If you're building with the Claude tool_use API right now, do these things in this order:

  1. Add the iteration cap first. Before anything else. A runaway loop at scale will ruin your week.
  2. Return structured error strings from every tool. Never raise, never return empty. Give Claude something to reason about.
  3. Truncate tool results aggressively. 2,000 characters is usually enough. If it's not, your tool is doing too much.
  4. Reduce your tool count. If you have more than 6-7 tools, you're probably solving a tool design problem with quantity instead of quality.
  5. Log tokens per run and alert on outliers. You will not catch budget blowouts any other way.

The Claude tool_use API is genuinely excellent for building multi-step workflows — we wouldn't be using it across multiple production systems if it weren't. But it rewards careful design and punishes sloppy error handling in ways that other APIs don't. The agentic loop is only as reliable as its weakest tool definition.

Build the guardrails before you build the features. You'll thank yourself at 2am.


Authoritative references: Anthropic Tool Use Documentation · Claude Extended Thinking · Anthropic API Reference

Have Questions or Need Help?

Our team is ready to assist you with your project needs.

Contact Us