LLM App Development #6: Connecting External Functions with Tool Calling
Through Part 5, Claude returned text or structured data as its “answer.” But Claude on its own does not know today’s weather and cannot look into our database. It can only answer from what it was trained on. In this post we cover tool calling, which lets Claude call functions we define and connect to the outside world.
What tool calling is #
The flow goes like this. We tell Claude the list of tools (functions) it can use, along with a description of each. While answering, if Claude decides it needs a tool, instead of running the function itself it requests “call the get_weather tool with city=Seoul.” We receive that request, our code actually runs the function, and we return the result to Claude. Claude then produces a final answer from that result.
The key is that Claude does not run the function itself. Claude only expresses the intent “I want to call this tool with these arguments”; execution is up to us.
Defining and calling a tool #
A tool is defined with a name, a description, and an input schema. The input schema uses the same JSON schema format we saw in Part 5.
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
},
"required": ["city"],
},
}
]
def get_weather(city: str) -> str:
# In practice, call a weather API. Here we return a sample value.
return f"The current weather in {city} is sunny, 22 degrees."
messages = [{"role": "user", "content": "What's the weather in Seoul?"}]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=tools,
messages=messages,
)
print(response.stop_reason) # tool_useThe description is not just a description. Claude reads it to decide when to use this tool. So it is important to write clearly not only what the tool does but also when to use it.
Since we asked about the weather, Claude does not answer directly but tries to call the tool. At this point stop_reason comes back as tool_use, and the response contains a tool_use block.
Returning the result and finishing #
The tool_use block contains the tool name (name), arguments (input), and an identifier (id). We run the function with those arguments and return the result as a tool_result. We must match tool_use_id to indicate which call the result is for.
while response.stop_reason == "tool_use":
# First add the assistant response containing the tool call to the history
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
if block.name == "get_weather":
result = get_weather(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
# Tool results go back in the user role
messages.append({"role": "user", "content": tool_results})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=tools,
messages=messages,
)
print(next(b.text for b in response.content if b.type == "text"))There is a reason it is wrapped in a while loop. Claude may not stop after using a tool once. It might check the weather and then call another tool. So while stop_reason is tool_use, we keep looping until Claude stops calling tools and finishes naturally (end_turn).
One easy thing to miss is that you must put the assistant response containing the tool call back into the history (messages.append). Only then does Claude remember which tool it called and connect it to the result.
Simplifying with the tool runner #
Instead of writing this loop by hand every time, the SDK’s tool runner handles calling, executing, and looping automatically. Define a function with the @beta_tool decorator and the input schema is generated from the function signature.
from anthropic import beta_tool
@beta_tool
def get_weather(city: str) -> str:
"""Get the current weather for a city.
Args:
city: City name.
"""
return f"The current weather in {city} is sunny, 22 degrees."
runner = client.beta.messages.tool_runner(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=[get_weather],
messages=[{"role": "user", "content": "What's the weather in Seoul?"}],
)
for message in runner:
print(message)Where people commonly trip up #
- Not matching
tool_use_id— Thetool_use_idon atool_resultmust exactly equal theidof thetool_useblock it answers. If you call several tools, match each one. - Not putting the assistant response in the history — If you do not put the response containing the tool call back into
messages, on the next call Claude cannot connect its call to the result and you get an error. - A poor description — If the
descriptionis vague, Claude calls the tool at the wrong time or not at all. Write clearly what the tool does and when to use it.
Wrapping up #
In this post we covered tool calling, which connects Claude to external functions.
- When we define tools, Claude expresses its intent to call one as a
tool_use, and we do the execution. - We return the execution result as a
tool_resultmatched to thetool_use_id, and loop whilestop_reasonistool_use. - The tool runner can handle this loop automatically.
Now Claude can use external functions. In the next post, “LLM App Development #7: Embeddings and Vector Search,” we will change direction and cover embeddings, which turn our documents into a form Claude can search. It is the preparation step that leads into RAG in the following part.