Build AI Apps with Python: Multiple Tools — Claude Learns to Choose | Episode 8
Video: Build AI Apps with Python: Multiple Tools — Claude Learns to Choose | Episode 8 by Taught by Celeste AI - AI Coding Coach
Student code: github.com/GoCelesteAI/build-ai-apps-python/tree/main/episode08 Three functions. One Claude. The model becomes the router.
In Episode 7 we taught Claude to call one function. The interesting bit wasn't the call itself — it was the dance: question in, tool decision out, function executed, result back, final answer. With one tool, "decision" was a foregone conclusion. The model only had one option.
The moment you give it more than one option, something new happens. The model has to choose. It reads the question, scans the available tools, and routes to the right one based on intent. "What's the weather in Tokyo?" → get_weather. "What time is it in London?" → get_time. "How much is 50 EUR in yen?" → convert_currency. No if/else ladder in your Python. The model is the router.
This is the part of tool use that feels closest to magic, and it's the part that makes AI agents actually scale. You don't write a parser. You write functions, describe them well, and let Claude figure out which one fits each request.
What we're building
Three mock tools — currency conversion, weather lookup, time-zone lookup — wired into the same tool-use loop from Episode 7. We'll send three questions, one for each tool, and confirm Claude routes correctly without any hints from us.
The structural change from Episode 7 is small but powerful: a dispatch dictionary that maps tool names to function objects. Once Claude tells you which tool to call, you do tool_dispatch[tool_block.name](**tool_block.input). Add a tool? Add it to the dict and the schema list. The loop doesn't change.
The script (the new bits)
The full file is in the student repo. The interesting parts:
def convert_currency(amount, from_currency, to_currency): ...
def get_weather(city): ...
def get_time(city): ...
tool_dispatch = {
"convert_currency": convert_currency,
"get_weather": get_weather,
"get_time": get_time,
}
tools = [
{"name": "convert_currency", "description": "Convert an amount from one currency to another.", "input_schema": {...}},
{"name": "get_weather", "description": "Get current weather for a city.", "input_schema": {...}},
{"name": "get_time", "description": "Get current time in a city.", "input_schema": {...}},
]
def ask(question):
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=tools,
system="Respond in plain text. No markdown formatting.",
messages=[{"role": "user", "content": question}],
)
if message.stop_reason == "tool_use":
tool_block = next(b for b in message.content if b.type == "tool_use")
fn = tool_dispatch[tool_block.name]
result = fn(**tool_block.input)
final = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=tools,
system="Respond in plain text. No markdown formatting.",
messages=[
{"role": "user", "content": question},
{"role": "assistant", "content": message.content},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": tool_block.id, "content": json.dumps(result)}]},
],
)
print(f" A: {final.content[0].text}\n")
ask("How much is 50 EUR in Japanese yen?")
ask("What is the weather like in Singapore?")
ask("What time is it in Tokyo?")
Three questions, three different tools chosen, three correct answers. We didn't tell Claude which tool to use. It chose.
Dispatch via dictionary
tool_dispatch = {
"convert_currency": convert_currency,
"get_weather": get_weather,
"get_time": get_time,
}
This dict is the bridge between Claude's world and Python's world. Claude returns a tool_use block whose .name is the string "convert_currency". We need to call the actual function object. The dict is the lookup.
fn = tool_dispatch[tool_block.name]
result = fn(**tool_block.input)
That's the entire dispatcher. Two lines. No if name == "convert_currency": ... elif name == "get_weather": ... ladder. Adding a fourth tool is one new function, one new entry in this dict, one new entry in the schema list. The ask() function never changes.
This is also why naming matters. The schema's name field, the dict's key, and the Python function's identifier should all be the same. Once they drift, debugging gets painful.
Why descriptions are the whole game
With one tool, the description was nice-to-have. With multiple tools, descriptions are the thing the model uses to route. Read these three back-to-back the way Claude does:
- "Convert an amount from one currency to another."
- "Get current weather for a city."
- "Get current time in a city."
Each is short, concrete, and points at exactly one kind of question. There's no overlap, no ambiguity. "Weather" and "time" both take a city, but the descriptions disambiguate the intent.
Now imagine the descriptions all said "Look up information about a place." All three would match a question like "What's it like in Tokyo right now?" and the model would have to guess. Sometimes it would pick weather, sometimes time, sometimes weather and refuse the others. The fix is in the schema, not in any parsing code.
When you write your own multi-tool apps, the rule is simple: a good description is one that Claude could use to write the tool's man page. Verb, object, scope, preconditions if any. "Cancels a subscription. Requires subscription_id from a billed account. Cannot cancel free trials — use end_trial for that." That's a description Claude can route on.
A subtle thing about system="No markdown"
system="Respond in plain text. No markdown formatting.",
Claude defaults to writing in markdown — bold for emphasis, bullet lists, headings. That looks great in a chat UI. It looks ugly in a terminal where the asterisks render as literal asterisks.
This little system prompt fixes that. It's a perfect example of the system-prompt pattern from Episode 2 in service of a tool-use script. As you build agents, you'll often write small system prompts like this one — one or two lines — to handle the surface details that don't deserve their own infrastructure.
Running it
:!python %. Three questions go through. Output (truncated):
Q: How much is 50 EUR in Japanese yen?
Tool: convert_currency({'amount': 50, 'from_currency': 'EUR', 'to_currency': 'JPY'})
Result: {'amount': 8125.0, 'from': 'EUR', 'to': 'JPY'}
A: 50 EUR is approximately 8,125 Japanese yen.
Q: What is the weather like in Singapore?
Tool: get_weather({'city': 'Singapore'})
Result: {'city': 'Singapore', 'temp': 31, 'condition': 'Partly cloudy', 'humidity': 78}
A: The weather in Singapore is partly cloudy at 31°C with 78% humidity.
Q: What time is it in Tokyo?
Tool: get_time({'city': 'Tokyo'})
Result: {'city': 'Tokyo', 'time': 'JST (UTC+9) — 4:00 PM'}
A: It's 4:00 PM in Tokyo (JST, UTC+9).
Three different tools chosen, each correctly. The dispatch is purely semantic — Claude picked based on what the question meant.
What this enables
This pattern scales to dozens of tools. A real app might wire up:
- Database read/write functions
- Email sending
- Calendar lookups
- Web search
- File system operations
- Internal APIs (CRM, analytics, billing)
Each is a function with a schema. Each goes into the dispatch dict and the tools list. Claude becomes a natural-language interface to all of them. "Email Mark to confirm Friday's meeting" → routes to send_email with the right body. "How many sales did we close in March?" → routes to query_database.
This is the architecture of a real AI assistant. The model isn't trying to do everything; it's choosing among capabilities you provide.
Common mistakes
Overlapping descriptions. Two tools that "could plausibly" serve the same question will see Claude pick inconsistently. Make scopes distinct.
Underspecifying schemas. If get_weather takes city but the schema doesn't say "city name as a string," Claude might pass {"latitude": ..., "longitude": ...}. The schema is also documentation; write it like docs.
Forgetting the dispatch dict. Newcomers do if name == "x": x_function(...) elif .... That works for two tools and breaks at five. Use a dict.
Hidden state in tools. A tool that depends on globals or mutable state will surprise you. Tools should be pure-ish: arguments in, dict out.
Letting tools throw uncaught exceptions. A blown tool kills the whole script. We'll handle that explicitly in Episode 11.
What's next
Next episode: file system tools. Same pattern, but the tools touch real disk — read, write, list. Claude becomes a file-system assistant that can take a prompt like "Create hello.txt with a greeting" and actually do it.
Recap
What we did today. Generalised the Episode 7 loop to multiple tools. Defined three functions, three schemas, and one dispatch dict. Wrapped the whole tool-use dance in a reusable ask() function. Sent three different questions and watched Claude route each to the right tool — no parsing code on our side. Established the truth that descriptions, not code, determine routing.
You haven't built a multi-tool agent. You've built the pattern every multi-tool agent uses. From here, growing the toolset is one new function and one new entry in two collections.
Next episode: file system tools. See you in the next one.