DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Claude API with tool use: build agents that call real functions

The most important thing to understand about Claude's tool use: Claude doesn't call your functions. You call your functions, when Claude tells you to.

This distinction matters for how you design tool-using agents. Let me show you how it actually works, then build something real.

The execution model

Here's the basic loop:

1. You send messages + tool definitions to Claude
2. Claude responds with either:
   a. A text response (done)
   b. A tool_use block (Claude wants data)
3. If tool_use: you execute the function, send the result back
4. Claude incorporates the result and continues
5. Repeat until Claude returns a final text response
Enter fullscreen mode Exit fullscreen mode

Claude never has network access. It never calls your API. It just returns structured JSON telling you what it would like you to run and with what arguments.

import anthropic

client = anthropic.Anthropic()

# Define what tools Claude can "use"
tools = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a location",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "City and state, e.g. 'Denver, CO'"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "Temperature unit"
                }
            },
            "required": ["location"]
        }
    }
]

# Initial message
messages = [{"role": "user", "content": "What's the weather in Denver?"}]

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

print(response.stop_reason)  # "tool_use"
print(response.content)
# [TextBlock(text="I'll check the weather for you."), 
#  ToolUseBlock(id='toolu_01...', name='get_weather', input={'location': 'Denver, CO'})]
Enter fullscreen mode Exit fullscreen mode

Claude returned a tool_use block. Now you execute the actual function:

# Execute the function Claude asked for
def get_weather(location: str, unit: str = "fahrenheit") -> dict:
    # Your real weather API call here
    return {
        "location": location,
        "temperature": 72,
        "unit": unit,
        "condition": "Partly cloudy",
        "humidity": 45,
    }

# Find tool use blocks in the response
tool_calls = [block for block in response.content if block.type == "tool_use"]

# Execute each tool call
tool_results = []
for tool_call in tool_calls:
    if tool_call.name == "get_weather":
        result = get_weather(**tool_call.input)
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": tool_call.id,
            "content": str(result),
        })

# Send results back to Claude
messages = [
    {"role": "user", "content": "What's the weather in Denver?"},
    {"role": "assistant", "content": response.content},
    {"role": "user", "content": tool_results},
]

final_response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    messages=messages,
)

print(final_response.content[0].text)
# "The weather in Denver, CO is currently 72°F and partly cloudy, with 45% humidity."
Enter fullscreen mode Exit fullscreen mode

Building a real agent: multi-step research

The power shows up when you give Claude multiple tools and let it decide how to chain them. Here's a research agent that can search, read pages, and summarize findings:

import anthropic
import requests
from bs4 import BeautifulSoup

client = anthropic.Anthropic()

tools = [
    {
        "name": "search_web",
        "description": "Search the web for information on a topic. Returns a list of results with titles and snippets.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
                "num_results": {"type": "integer", "description": "Number of results (max 5)", "default": 3}
            },
            "required": ["query"]
        }
    },
    {
        "name": "read_page",
        "description": "Fetch and read the content of a web page. Use after searching to get full article content.",
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "URL to fetch"},
                "max_chars": {"type": "integer", "description": "Max characters to return", "default": 5000}
            },
            "required": ["url"]
        }
    },
    {
        "name": "save_note",
        "description": "Save an important finding or note for the final report.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string"},
                "content": {"type": "string"},
                "source": {"type": "string", "description": "URL or source of this finding"}
            },
            "required": ["title", "content"]
        }
    }
]


def execute_tool(name: str, inputs: dict) -> str:
    if name == "search_web":
        # Replace with your search API (Serper, Brave, SerpAPI, etc.)
        results = your_search_api(inputs["query"], inputs.get("num_results", 3))
        return str(results)

    elif name == "read_page":
        try:
            resp = requests.get(inputs["url"], timeout=10, headers={"User-Agent": "Mozilla/5.0"})
            soup = BeautifulSoup(resp.text, "html.parser")
            # Remove scripts, styles, navigation
            for tag in soup(["script", "style", "nav", "header", "footer"]):
                tag.decompose()
            text = soup.get_text(separator="\n", strip=True)
            max_chars = inputs.get("max_chars", 5000)
            return text[:max_chars]
        except Exception as e:
            return f"Error fetching page: {e}"

    elif name == "save_note":
        # Save to your notes system
        notes.append(inputs)
        return f"Note saved: {inputs['title']}"

    return "Unknown tool"


def run_agent(task: str, max_iterations: int = 10) -> str:
    """Run the research agent until it produces a final answer."""
    messages = [{"role": "user", "content": task}]
    notes = []

    for i in range(max_iterations):
        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=4096,
            tools=tools,
            messages=messages,
        )

        # Add assistant response to history
        messages.append({"role": "assistant", "content": response.content})

        # Done — Claude has a final answer
        if response.stop_reason == "end_turn":
            text_blocks = [b for b in response.content if b.type == "text"]
            return text_blocks[-1].text if text_blocks else ""

        # Execute tool calls
        tool_calls = [b for b in response.content if b.type == "tool_use"]
        tool_results = []

        for tool_call in tool_calls:
            print(f"  → Calling {tool_call.name}({tool_call.input})")
            result = execute_tool(tool_call.name, tool_call.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": tool_call.id,
                "content": result,
            })

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

    return "Max iterations reached"


# Use it
result = run_agent(
    "Research the top 3 MCP servers for developer productivity in 2026. "
    "Find real examples, check their GitHub stars, and summarize what makes each valuable."
)
print(result)
Enter fullscreen mode Exit fullscreen mode

Claude will:

  1. Search for MCP servers
  2. Read the top result pages
  3. Save notes on key findings
  4. Search for more specific information as needed
  5. Return a synthesized report

You didn't write any of that logic. Claude decided the research strategy.

Parallel tool calls

Claude 3.5+ can make multiple tool calls in a single response when they're independent. This is critical for performance:

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "Compare the weather in Denver, NYC, and LA"}],
)

# Response may contain 3 tool_use blocks simultaneously
tool_calls = [b for b in response.content if b.type == "tool_use"]
print(len(tool_calls))  # 3 — Claude called get_weather 3 times in parallel

# Execute them all, return all results
tool_results = []
for tool_call in tool_calls:
    result = get_weather(**tool_call.input)
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": tool_call.id,
        "content": str(result),
    })
Enter fullscreen mode Exit fullscreen mode

In your executor, you can run these in parallel with asyncio or ThreadPoolExecutor since they're independent.

Forcing tool use

Sometimes you want Claude to always use a specific tool, not just when it decides to:

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "get_weather"},  # Force this tool
    messages=messages,
)
Enter fullscreen mode Exit fullscreen mode

Or force any tool use (Claude must use at least one tool):

tool_choice={"type": "any"}
Enter fullscreen mode Exit fullscreen mode

Or let Claude decide (default):

tool_choice={"type": "auto"}
Enter fullscreen mode Exit fullscreen mode

Error handling in tool results

You can return errors to Claude and it will adapt:

try:
    result = execute_tool(tool_call.name, tool_call.input)
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": tool_call.id,
        "content": result,
    })
except Exception as e:
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": tool_call.id,
        "content": f"Error: {str(e)}",
        "is_error": True,  # Tell Claude this was an error
    })
Enter fullscreen mode Exit fullscreen mode

Claude will see the error and decide whether to retry with different arguments, use a different tool, or acknowledge the failure in its response.

Token efficiency tips

Tool calls consume tokens. Here's how to keep costs down:

1. Tighten your tool schemas — Only include the parameters Claude actually needs. Extra fields in descriptions cost tokens.

2. Return structured data, not prose{"temp": 72, "unit": "F"} is cheaper than "The temperature is currently 72 degrees Fahrenheit".

3. Cap tool output size — Add a max_chars parameter to any tool that reads external content.

4. Use claude-haiku-4-5 for simple tool routing — If the tool call logic is simple (just picking which API to call), Haiku handles it well at a fraction of the cost.

5. Pre-filter tool results — If your search returns 10 results but Claude only needs metadata, strip the full content before returning.


What to build with this

The agent pattern above handles 80% of practical use cases. The tools that make agents most powerful in practice:

  • Code execution — let Claude run Python snippets to calculate things
  • Database queries — read-only SQL access against your data
  • File system access — read and write files within a sandboxed directory
  • API integrations — Stripe, Notion, GitHub, Slack — anything with an API
  • Web scraping — fetch and parse any public page

The MCP protocol standardizes this pattern so tools are reusable across different Claude integrations. You define a tool once as an MCP server and it works in Claude Desktop, Claude Code, and your own apps.

The Whoff Agents MCP servers are pre-built examples of this — Crypto Data MCP, Security Scanner, Workflow Automator — each exposing a set of tools that Claude can use to do real work with real data.


The Anthropic API docs have a complete tool use reference at docs.anthropic.com. The examples in this post are simplified — production code should add retry logic, token budget tracking, and rate limit handling.

Top comments (0)