Tool calling is the single mechanism that turns a text generator into something that can act. Every agent framework, every MCP server, every AI coding assistant is built on it. This lesson implements it by hand, with no framework, so that everything later in the track is recognizable rather than magical.
One correction to a common misconception before we start: the model never runs your code. It cannot. It has no execution environment and no network access. What it does is emit a structured request that says “please run the function named get_weather with these arguments.” Your code decides whether to honor that, runs it, and hands back the result. You remain in control of every side effect — a fact that matters enormously when we get to security in Lesson 12.
Setup
pip install anthropic
Set your API key as an environment variable rather than hardcoding it:
export ANTHROPIC_API_KEY="sk-ant-..."
Step 1: Describe a tool
A tool definition is three things: a name, a description, and a JSON Schema for its inputs.
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a location. Call this whenever the user asks about current conditions, temperature, or whether they need an umbrella.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and country, e.g. 'Kolkata, India'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit. Defaults to celsius.",
},
},
"required": ["location"],
},
}
]
The description is not documentation — it is the prompt. It is the only information the model has when deciding whether this tool is relevant. Vague descriptions produce tools that never get called, or get called at absurd times.
Write descriptions that state when to call the tool, not just what it does. Compare “Gets weather data” with the description above. The second one tells the model about umbrellas, which is exactly the kind of oblique user request that should trigger a weather lookup. That specificity is worth real accuracy.
The same applies to each parameter. "location" with the description "City and country, e.g. 'Kolkata, India'" gets you well-formed inputs. A bare {"type": "string"} gets you “here”, “my place”, and “Kolkata” inconsistently.
Step 2: Make the call
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
tools=tools,
messages=[{"role": "user", "content": "Do I need an umbrella in Kolkata today?"}],
)
print(response.stop_reason)
print(response.content)
The stop_reason is the field to branch on. Here it will be "tool_use", meaning the model wants something run before it can answer. The values you will actually handle are:
end_turn— the model finished; the answer is in the content.tool_use— it wants one or more tools executed.max_tokens— you capped it too low and the output is truncated.refusal— the model declined. Check this before readingcontent, because content may be empty andcontent[0].textwill raise.
The response content is a list of blocks, not a string. With tool use it typically contains a text block (the model narrating) followed by a tool_use block:
[
TextBlock(text="Let me check the weather there.", type="text"),
ToolUseBlock(
id="toolu_01A9...",
name="get_weather",
input={"location": "Kolkata, India", "unit": "celsius"},
type="tool_use",
),
]
Note the id. You must echo it back with the result so the model knows which request the result answers. This matters when several tools run at once.
Step 3: Execute and return the result
Dispatch on the tool name, run the real function, and append two messages to the conversation: the assistant’s turn (unmodified) and a user turn containing the results.
def get_weather(location: str, unit: str = "celsius") -> str:
# Your real implementation goes here — an API call, a DB query, whatever.
return f"22°{'C' if unit == 'celsius' else 'F'}, light rain, 80% humidity"
TOOL_IMPLS = {"get_weather": get_weather}
messages = [{"role": "user", "content": "Do I need an umbrella in Kolkata today?"}]
response = client.messages.create(
model="claude-opus-5", max_tokens=16000, tools=tools, messages=messages
)
# Append the assistant's full content — including the tool_use blocks — unchanged.
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
output = TOOL_IMPLS[block.name](**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Error: {e}",
"is_error": True,
})
messages.append({"role": "user", "content": tool_results})
Three details worth internalizing, because each one breaks things subtly when you get it wrong:
Append response.content, not the text. If you extract the text string and append that, you have discarded the tool_use blocks, and the tool results you send next will refer to requests the model has no record of making. This is the most common bug in hand-written loops.
Tool results go in a user message. They are not an assistant turn, even though they are not something a human typed. That is just the protocol.
Return errors as results, not exceptions. Set is_error: true and put the message in content. The model will read it and adapt — trying different arguments, or telling the user what went wrong. If you raise instead, you have thrown away the model’s ability to recover.
Step 4: Close the loop
One tool call is rarely enough. The model may need the result of the first call to decide on the second. So wrap it in a loop that runs until the model stops asking:
def run_agent(user_input: str, max_turns: int = 10) -> str:
messages = [{"role": "user", "content": user_input}]
for _ in range(max_turns):
response = client.messages.create(
model="claude-opus-5", max_tokens=16000, tools=tools, messages=messages
)
if response.stop_reason == "end_turn":
return next(b.text for b in response.content if b.type == "text")
if response.stop_reason != "tool_use":
raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")
messages.append({"role": "assistant", "content": response.content})
results = []
for block in response.content:
if block.type == "tool_use":
try:
output = TOOL_IMPLS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
except Exception as e:
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Error: {e}",
"is_error": True,
})
messages.append({"role": "user", "content": results})
raise RuntimeError("Hit max_turns without completing")
That is a complete agent. Forty lines, no framework. Everything else in this track is refinement.
Keep the max_turns guard. Models occasionally get into loops — calling the same tool with the same arguments repeatedly — and without a ceiling that is an unbounded bill.
Parallel tool calls
One assistant message can contain several tool_use blocks. The loop above already handles this correctly, but there is a rule that is easy to violate: return all results in a single user message.
If you send them as separate messages, the API will usually accept it, but you are teaching the model that parallel calls do not work well — and it will quietly stop making them. That costs you real latency, because three independent lookups that could have gone out at once become three sequential round trips.
Since the calls are independent, run them concurrently in your own code too — a thread pool or asyncio.gather — rather than in the sequential for loop above.
Letting the SDK drive
Once the mechanism is clear, you do not have to write the loop yourself. The Anthropic SDK ships a tool runner that handles it:
from anthropic import beta_tool
@beta_tool
def get_weather(location: str, unit: str = "celsius") -> str:
"""Get the current weather for a location.
Args:
location: City and country, e.g. 'Kolkata, India'
unit: Temperature unit, either "celsius" or "fahrenheit".
"""
return "22°C, light rain, 80% humidity"
runner = client.beta.messages.tool_runner(
model="claude-opus-5",
max_tokens=16000,
tools=[get_weather],
messages=[{"role": "user", "content": "Do I need an umbrella in Kolkata?"}],
)
for message in runner:
print(message)
The schema is generated from the type hints and docstring, and the loop runs to completion on its own. This is the right default for most work — it is not a black box, and it still gives you hooks to inspect or block a call before it executes.
Write the manual loop once, to understand it. Then use the runner.
Where this breaks down
You now have working tool calling. The problem is that it does not travel.
Those tool definitions live inside your application. If a colleague wants the same weather capability in their app, they rewrite it. If you want your internal API available in Claude Desktop, in your IDE, and in a custom app, you implement it three times against three different interfaces. Multiply by every integration your organization needs and you have a genuine mess — the same N integrations rebuilt for M applications.
That is exactly the problem MCP exists to solve, and it is where we go next.
Next: Lesson 3: What Is MCP (Model Context Protocol)?
New lessons in this track publish weekly. Subscribe to get each one when it lands.