Part of Python AI Tutorial Series

Build AI Apps with Python: Currency Converter with Function Calling | Episode 7

Celest KimCelest Kim

Video: Build AI Apps with Python: Currency Converter with Function Calling | Episode 7 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Student code: github.com/GoCelesteAI/build-ai-apps-python/tree/main/episode07 Claude stops being a thing that talks. It becomes a thing that acts.

For six episodes Claude has been an output-only model. You send words, you get words back. That's the whole API surface you've used. Even structured output (Episode 5) is still text in, text out — it's just that the text happens to be parseable JSON.

This episode is a step-change. We're going to teach Claude to call a Python function we wrote. The model will read a question — "How much is 100 USD in Singapore dollars?" — recognise that it can't answer with general knowledge alone, decide to call our convert_currency function with the right arguments, and then use the result we return to write a final natural-language answer.

This is the loop every AI agent in the world is built on. Once you've understood it once, you've understood agent design. Everything in Episodes 8–22 is variations on this loop.

What "tool use" actually is

The Claude API has a parameter called tools. When you pass a list of tools alongside messages, you're telling Claude: "In addition to writing text, you may also choose to call one of these functions."

Each tool is described as a JSON object — a name, a one-sentence description of what it does, and an input_schema describing its arguments. Claude reads these descriptions and uses them to decide:

  1. Should I answer this directly from what I know?
  2. Or do I need to call a tool?
  3. If a tool — which tool, with what arguments?

The model then responds with either plain text (no tool needed) or a tool_use block — a structured request that includes the chosen tool's name and arguments. Crucially: Claude does not execute anything. The API doesn't run your function. It tells you what it wants to call. You run the function, you take the result, you send the result back as a follow-up message, and Claude uses that result to compose its final answer.

So the "tool use" loop is:

  1. You send the user's question + the tool list.
  2. Claude responds with stop_reason="tool_use" and a tool-use block specifying name + arguments.
  3. You call the named function with those arguments.
  4. You send a follow-up message containing a tool_result block with the function's output.
  5. Claude uses the result to write the final answer.

That's the dance. Five steps, two API calls, one function execution. Memorise the shape — you'll write it dozens of times in the rest of the series.

What we're building

A currency converter. Mock exchange rates as a dict, a convert_currency(amount, from, to) function, a JSON schema describing it, and a single user question that requires it: "How much is 100 USD in Singapore dollars?"

By the end you'll have a script that prints both the tool call Claude wanted to make and Claude's final natural-language answer derived from the function result.

The script

import os
import json
from dotenv import load_dotenv
from anthropic import Anthropic

load_dotenv()

client = Anthropic()

RATES = {
    "USD": 1.0, "SGD": 1.34, "EUR": 0.92,
    "GBP": 0.79, "JPY": 149.50, "MYR": 4.47,
}

def convert_currency(amount, from_currency, to_currency):
    from_rate = RATES.get(from_currency.upper())
    to_rate = RATES.get(to_currency.upper())
    if not from_rate or not to_rate:
        return {"error": f"Unknown currency: {from_currency} or {to_currency}"}
    usd = amount / from_rate
    result = round(usd * to_rate, 2)
    return {"amount": result, "from": from_currency, "to": to_currency}

tools = [
    {
        "name": "convert_currency",
        "description": "Convert an amount from one currency to another. Supported: USD, SGD, EUR, GBP, JPY, MYR.",
        "input_schema": {
            "type": "object",
            "properties": {
                "amount": {"type": "number", "description": "The amount to convert"},
                "from_currency": {"type": "string", "description": "Source currency code"},
                "to_currency": {"type": "string", "description": "Target currency code"},
            },
            "required": ["amount", "from_currency", "to_currency"],
        },
    }
]

message = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "How much is 100 USD in Singapore dollars?"}
    ],
)

if message.stop_reason == "tool_use":
    tool_block = next(b for b in message.content if b.type == "tool_use")
    result = convert_currency(**tool_block.input)

    final = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        tools=tools,
        messages=[
            {"role": "user", "content": "How much is 100 USD in Singapore dollars?"},
            {"role": "assistant", "content": message.content},
            {
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": tool_block.id,
                        "content": json.dumps(result),
                    }
                ],
            },
        ],
    )

    print(final.content[0].text)

That's the entire pattern. Let's walk the new pieces.

The tool definition

tools = [
    {
        "name": "convert_currency",
        "description": "Convert an amount from one currency to another. Supported: USD, SGD, EUR, GBP, JPY, MYR.",
        "input_schema": {
            "type": "object",
            "properties": {
                "amount": {"type": "number", "description": "The amount to convert"},
                "from_currency": {"type": "string", "description": "Source currency code"},
                "to_currency": {"type": "string", "description": "Target currency code"},
            },
            "required": ["amount", "from_currency", "to_currency"],
        },
    }
]

Three top-level fields: name, description, input_schema.

The name is what Claude refers to the tool by. Use a clear, snake-case Python-function-style name — that way you can map directly from tool_block.name to the actual function.

The description is the most important field, full stop. Claude decides which tool to call by reading these descriptions. A vague description ("Currency stuff") leads to wrong tool selection. A specific one ("Convert an amount from one currency to another. Supported: USD, SGD, EUR...") gives the model the information it needs to decide. The list of supported currencies isn't trivia — it tells Claude when it can use the tool versus when it should refuse.

The input_schema is JSON Schema. The model reads this to know what arguments the tool takes, what types they are, what they mean, and which ones are required. Each property has a type and a description. The descriptions are not optional — they're how Claude figures out, e.g., that from_currency should hold the source currency code, not the target.

The first call

message = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "How much is 100 USD in Singapore dollars?"}
    ],
)

The only addition is tools=tools. The model now has a list of available functions in mind.

When the response comes back, look at message.stop_reason. Three common values:

  • "end_turn" — Claude finished writing a normal text response. No tool call.
  • "tool_use" — Claude wants to call a tool.
  • "max_tokens" — the response hit the token cap.

For our script, we expect "tool_use". If we got "end_turn", Claude tried to answer from general knowledge — which would be wrong, because it can't know the live exchange rate. The tools parameter and a good description push it toward calling the tool.

Extracting the tool block

tool_block = next(b for b in message.content if b.type == "tool_use")
result = convert_currency(**tool_block.input)

When stop_reason == "tool_use", message.content is a list of content blocks. There may be a text block (Claude sometimes writes a sentence like "Let me convert that for you." before the tool call) followed by a tool_use block. We use next() with a generator expression to grab the first tool-use block.

Each tool_use block has three useful fields:

  • tool_block.id — a unique ID for this specific tool call. We need it later when we return the result.
  • tool_block.name — the tool's name, e.g. "convert_currency".
  • tool_block.input — a dict containing the arguments Claude chose, e.g. {"amount": 100, "from_currency": "USD", "to_currency": "SGD"}.

The clever line is convert_currency(**tool_block.input). We unpack the dict as keyword arguments. Because we named our function parameters to match the schema's property names, the kwargs land in the right slots automatically. This is why naming consistency matters: if the schema has from_currency and the function expects source, the unpacking breaks. Keep them in sync.

Returning the result

final = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "How much is 100 USD in Singapore dollars?"},
        {"role": "assistant", "content": message.content},
        {
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": tool_block.id,
                    "content": json.dumps(result),
                }
            ],
        },
    ],
)

The second call is more interesting. Look at the message list:

  1. The original user question.
  2. The assistant's previous response — message.content, the whole content list including the tool-use block.
  3. A new user message whose content is a list with a single tool_result block.

The third message is the new shape. It's a user turn (because results come from "outside" the model, the same way user input does), but its content isn't a string — it's a list with a tool_result block carrying:

  • tool_use_id — references the specific tool call. This is how Claude pairs the result with the request, especially when there are multiple parallel tool calls.
  • content — the actual result. We json.dumps(result) because the field expects a string. Claude can read JSON inside this string just fine.

The tools=tools repeat is required: the API insists you pass tools on every call where they might be relevant.

When this second call returns, final.content[0].text contains Claude's natural-language answer that uses the function result. Something like:

100 USD is approximately 134 Singapore dollars.

Two API calls, one function execution, one clean answer.

Why this pattern is the foundation

Once you can tell Claude about a function and have it decide whether and how to call it, you've left the "AI as a writing tool" world for good. The model is now agentic — it has agency over which actions to take to satisfy a request.

Everything you need for an AI agent is here in miniature. Episode 8 generalises to multiple tools (Claude routes among them). Episode 9 makes the tools touch the file system. Episode 10 turns it into a loop, so Claude can call multiple tools in sequence. Episode 11 hardens the tools against errors. By the end of Phase 2, you have a recognisable AI agent — and the only thing structurally new from this episode is the agentic loop, which is just do this five-step dance until Claude stops calling tools.

Common mistakes

Skipping the tool_use_id. Without it, Claude can't pair your result with its request. The API will reject the message. Always include it.

Returning Python objects instead of strings. tool_result.content expects a string. Run json.dumps() over your dict.

Vague descriptions. "Does conversions" will make Claude wrong on tool choice. Spend words on the description; it's free at runtime and pays back in correctness.

Hardcoding the question into both API calls. Easy to mistype on the second call and confuse Claude. In real code, store the question in a variable.

Forgetting tools= on the second call. The SDK will complain. Pass tools every time.

What's next

Next episode: multiple tools. Same pattern, but Claude now has three functions to choose from — currency, weather, and time — and has to pick the right one for each question. The dispatch step (tool_dispatch[tool_block.name](...)) becomes the central piece.

Recap

What we did today. Defined a Python function (convert_currency). Described it as a JSON schema in the tools list. Sent a question alongside the tool list. Detected stop_reason="tool_use" and extracted the tool-use block from the response. Executed the function with the arguments Claude chose. Sent a follow-up message with the result wrapped in a tool_result block. Got a natural-language final answer.

You haven't built an AI agent. But you've built every load-bearing piece of one. The rest of Phase 2 is making this loop more general, more robust, and more powerful.

Next episode: multiple tools. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.