cd /news/ai-agents/how-ai-actually-calls-an-api-tool-ca… · home topics ai-agents article
[ARTICLE · art-131987] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

How AI Actually Calls an API? Tool Calling Explained from Scratch

A developer demonstrated how tool calling works in practice, showing that a foundation model does not execute code itself but instead emits a structured request that application code reads and runs. Using Amazon Bedrock's Converse API with a Claude model, the engineer built a get_weather tool backed by the Open-Meteo API, then extended the example to two tools and a fact-injection technique. "The model is the decision-maker. Your code is the hands," the developer wrote, with sample code published in a GitHub repository.

by read11 min views1 publishedSep 16, 2026

In the previous post, we taught a model to read our documents. It could search a pile of files and answer from them, which was very useful.

But I still couldn't ask it if it was going to rain, check a live price or even what today's date is.

Because as we discussed this earlier, a foundation model on its own is frozen in time. Its knowledge stops at its training cutoff and it's locked in a box. No window to the outside world.

This post is about that window, tool calling. We give the model one tool and watch it reach out for live data, add a second tool, then get into the two very different ways an app can hand a model a fact it doesn't have. One of those two is the reason one of the AI assistant you've used can tell you today's date.

All the code is in my GitHub repo, in the ep07-tool-calling folder. Three tiny scripts, one idea each: one tool, two tools, and the injection trick.

When I first heard "the model calls a tool," I pictured the model reaching out and running code by itself.

That is not what happens.

The model does not run anything because it really can't. It is still just reading a prompt and producing text.

What it produces is a structured request that says "I'd like to call this tool, with these inputs."

It just hands you a note, that your code reads and then runs the actual tool. It then hands the result back to the model for further actions, either to tell you the answer or call another tool.

The model is the decision-maker. Your code is the hands.

This is run every single time:

call get_weather, city is Toronto. I'm using Amazon Bedrock again, same as the whole series, calling a Claude Model through the Converse API. Converse has a spot built in for tools, called toolConfig.

response = bedrock.converse(
    modelId=MODEL,
    messages=messages,
    toolConfig={"tools": [WEATHER_TOOL]},
    inferenceConfig={"maxTokens": 2048},
    additionalModelRequestFields=THINKING,
)

Let's take the simplest tool to start: get the weather.

Describing a tool to the model is three parts: a name, a plain-English description, and an input schema for the arguments.

WEATHER_TOOL = {
    "toolSpec": {
        "name": "get_weather",
        "description": "Get the current weather for a single city.",
        "inputSchema": {
            "json": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "A plain city name, e.g. Toronto or Paris.",
                    }
                },
                "required": ["city"],
            }
        },
    }
}

This description and schema are the only things the model reads to decide when and how to use this tool.

Your tool description is a prompt, so treat it like one.

And separately, the real function that does the work:

import requests

WEATHER_CODES = {0: "clear sky", 2: "partly cloudy", 3: "overcast", 61: "light rain", 63: "moderate rain"}

def get_weather(city: str) -> dict:
    geo = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": city, "count": 1},
    ).json()["results"][0]
    now = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": geo["latitude"],
            "longitude": geo["longitude"],
            "current": "temperature_2m,weather_code,wind_speed_10m",
        },
    ).json()["current"]
    return {
        "city": geo["name"],
        "country": geo["country"],
        "temperature_c": now["temperature_2m"],
        "conditions": WEATHER_CODES.get(now["weather_code"], "unknown"),
        "wind_kph": now["wind_speed_10m"],
    }

This is normal code. No AI in it. It hits Open-Meteo, a free weather API with no key required.

Question: "Do I need an umbrella in Toronto today?"

I send that to the model along with the get_weather definition.

The model stops with a stopReason of tool_use and hands back a request:

{
  "toolUse": {
    "toolUseId": "tooluse_abc123",
    "name": "get_weather",
    "input": { "city": "Toronto" }
  }
}

I never told it which tool to use, and I never told it the argument. It read one question and worked out both. But nothing has run yet.

So my code runs get_weather("Toronto"), hits the API, and gets back the real conditions. Then I package that up and send it back to the model as a toolResult:

messages.append({
    "role": "user",
    "content": [{
        "toolResult": {
            "toolUseId": "tooluse_abc123",
            "content": [{"json": {
                "city": "Toronto",
                "country": "Canada",
                "temperature_c": 23.8,
                "conditions": "overcast",
                "wind_kph": 3.9,
            }}],
        }
    }],
})

With a single tool, the whole thing is a straight line. Send, get the request, run it, send the result back, get the answer. Top to bottom, no loop:

messages = [{"role": "user", "content": [{"text": QUESTION}]}]

response = bedrock.converse(
    modelId=MODEL,
    messages=messages,
    toolConfig={"tools": [WEATHER_TOOL]},
)
messages.append(response["output"]["message"])

tool_request = next(
    b["toolUse"] for b in response["output"]["message"]["content"] if "toolUse" in b
)
result = get_weather(tool_request["input"]["city"])
messages.append({
    "role": "user",
    "content": [{
        "toolResult": {
            "toolUseId": tool_request["toolUseId"],
            "content": [{"json": result}],
        }
    }],
})

final = bedrock.converse(modelId=MODEL, messages=messages, toolConfig={"tools": [WEATHER_TOOL]})

One tool, one round trip. I know exactly what's going to happen, so I can just write it out.

With real data in hand, the model writes the answer: "Based on the current weather in Toronto, you probably don't need an umbrella right now."

That answer did not exist anywhere in the model. It went from frozen to current in one tool call.

Now something that feels like it should be trivial.

Question: "What's today's date?"

No tool call comes back. The model just says, plainly, that it doesn't have access to the current date.

The only tool it has access to is weather, so nothing here can reach a date. It can't answer, and this is the part I love, it doesn't pretend to. It just tells me it doesn't know, which is a real shift from the hallucinations post.

If the problem is "there's no tool for the date," the fix is obvious, lets give it one.

DATETIME_TOOL = {
    "toolSpec": {
        "name": "get_current_datetime",
        "description": "Get the current date and time.",
        "inputSchema": {"json": {"type": "object", "properties": {}}},
    }
}

def get_current_datetime() -> dict:
    from datetime import datetime
    now = datetime.now()
    return {
        "date": now.strftime("%Y-%m-%d"),
        "day_of_week": now.strftime("%A"),
        "time": now.strftime("%H:%M"),
    }

No arguments, no AI, it just returns today's date and time. I add it to the list of tools the model is allowed to use. Now the model has two tools - weather and date.

Question: "Do I need an umbrella in Toronto? And what is today's date?"

Two requests come back, for two tools. get_weather with {"city": "Toronto"}, then get_current_datetime with {}. My code runs each one, hands both results back, and the model writes one answer using both.

One sentence, two different needs, right tool for each. It just routed it.

But notice the problem with my nice straight line from before. With one tool, I knew there'd be exactly one round trip. With two, I don't know which the model will pick, or how many, or whether it'll come back for more after seeing the first result. So the four steps go inside a loop. Keep going while the model keeps asking for tools, and stop when it writes the answer instead:

TOOLS = {
    "get_weather": get_weather,
    "get_current_datetime": get_current_datetime,
}

messages = [{"role": "user", "content": [{"text": QUESTION}]}]

while True:
    response = bedrock.converse(
        modelId=MODEL,
        messages=messages,
        toolConfig={"tools": [WEATHER_TOOL, DATETIME_TOOL]},
    )
    assistant_message = response["output"]["message"]
    messages.append(assistant_message)

    if response["stopReason"] != "tool_use":
        answer = "".join(b["text"] for b in assistant_message["content"] if "text" in b)
        break

    tool_results = []
    for block in assistant_message["content"]:
        if "toolUse" not in block:
            continue
        request = block["toolUse"]
        result = TOOLS[request["name"]](**request["input"])
        tool_results.append({
            "toolResult": {
                "toolUseId": request["toolUseId"],
                "content": [{"json": result}],
            }
        })
    messages.append({"role": "user", "content": tool_results})

That while loop is the whole difference. One tool was a straight line I could hardcode. More than one, and I hand the control to the model and let it drive until it's done.

This is so so so important to understand, because this is a seed of an agent!

This is the part that bugged me while I was learning. If a raw model doesn't know today's date, how does ChatGPT or Claude or any AI assistant know it? You ask what day it is and they answer instantly. Are they calling a date tool every time? Short answer, no.

Anthropic actually publishes the system prompt they use for Claude, in their release notes. They say Claude's web interface and mobile apps use a system prompt to provide up-to-date information, such as the current date, at the start of every conversation.

That's it. No tool runs. It's just text thats slipped into the instructions before your message ever gets there. The model was handed the date as context.

You can do the exact same thing in a script. Take away the date tool and paste today's date into the system prompt as plain text:

system_prompt = [{
    "text": f"Today's date is {datetime.now():%A, %d %B %Y}."
}]

Ask "what's today's date?" and it answers, correctly, with no tool call at all. Because you handed it the date.

So there are two ways to give a model a fact it doesn't have. A tool it calls and you run, or context you inject straight into the prompt. When do you use which?

And remember that schema, city and nothing else? That's why I can't ask this thing about next week. There's no date to pass in. If I wanted a forecast, that's a different tool.

So we've got two tools working. Weather and date. Great. But real systems don't have just two tools. They have dozens - check the calendar, search the CRM, query the database, send the email and/or read the file.

And with what we just built, every one of those is something I hand-wire myself - write the schema, write the function, register it, keep the description in sync when the tool changes.

For two tools, that's fine. Fifty tools, across five apps, all changing over time? That's a maintenance nightmare. And everyone building AI apps was writing the same glue code, over and over, for the same tools.

This is the problem MCP solves. MCP stands for Model Context Protocol. It's an open standard, started by Anthropic and now used across the industry, for how AI apps and tools talk to each other.

The clean way to think about it: MCP is like USB-C for AI tools. Before USB-C, every device had its own cable and connector. It was a chaos of cables. USB-C is one standard plug. MCP is that, but for connecting models to tools and data.

The tool lives behind an MCP server, and that server describes itself: here are the tools I offer, here's what each does, here are the inputs I need. Your app is the MCP client. It just asks "what have you got?" and the server tells it. The tools get discovered at runtime.

So if someone builds an MCP server for GitHub, or your database, or Slack, you don't write the integration. You point your app at the server and the tools show up.

We're not building one today, that's a whole topic on its own. The mental model is enough for now: tool calling is how one model uses a tool, and MCP is how any model discovers and uses tools.

If you're just getting started: Tool calling is how AI stops being a closed box. Give it tools and it can pull live information and take action instead of just talking. The one thing to hold onto: the model is the brain, your code is the hands.

If you're more on the builder side: The model picks the tool and fills in the arguments, and the only thing it reads to make that call is your description and schema. So write them like prompts, and be specific about what the tool does and doesn't do. Then: static facts get injected, live facts get a tool. And once you're past a couple of tools, stop hardcoding and look at MCP.

Today the model called one tool, or two, once each, then answered. But what happens when a question needs several tools, in the right order? Check my calendar, then check the weather for that day, then draft the email. The model has to plan, act, look at the result, and decide the next step. Over and over in a loop, until it's done.

Well, that loop is actually called an agent. And next post, we build one with Strands Agents SDK.

Ride along.

This post is part of the "Learning AI Out Loud" series, a cloud architect learning AI from first principles.

── more in #ai-agents 4 stories · sorted by recency
── more on @amazon bedrock 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-ai-actually-call…] indexed:0 read:11min 2026-09-16 ·