As we have seen, models are conceptually quite simple: a list of input messages goes in and the model predicts the next token repeatedly to form the output message. It can't reach the internet or your database or Slack. And it cannot reliably do arithmetic or (cringe example incoming) tell you the number of r's in 'strawberry' - remember from part 1 that it never sees the letters, only token IDs.
In that sense, the model is like a brain without a body. It can reason through a problem, describe the steps to take, even recite all of Shakespeare. But without a body, the model cannot perform any action. This is where tools come in, and making them is very much like normal software engineering.
Tools are just functions or methods in a programming language. How tool use works under the hood depends on the model, but let's walk through it conceptually. It starts with a catalog of tools that the model can use, often a json list. In the API you pass this catalog as a separate tools
parameter and the provider injects it for you:
[
{
"name": "multiply",
"description": "Multiplies two numbers and returns the result.",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "The first number."
},
"b": {
"type": "number",
"description": "The second number."
}
},
"required": ["a", "b"]
}
},
[..more tools..]
]
If the model decides that it wants to use a tool, its output message will be a structured message like we saw in article 2:
{
"name": "multiply",
"arguments": {
"a": 24,
"b": 57
}
}
Your job as an AI engineer is to take such outputs and pass them to (Python) functions that actually perform the action. Then you respond with a structured message:
Tool result (multiply):
{
"result": 1368
}
The tool result is now part of the model's context window and can, therefore, be used for further reasoning.
So how did the model learn to do this? The model was trained on large volumes of tool-use transcripts, so requesting a tool and reading back a result is a pattern it has seen many times. Your tool catalog is the variable part, the runtime configuration if you will. Let's now look at a real example.
Payment operations requires deterministic arithmetic, and models are not reliable calculators. Create app/tools.py
(and an empty app/__init__.py
so we can import our tools). We will create a tool that calculates refund costs.
from langchain_core.tools import tool
@tool
def calculate_refund_cost(
original_charge_eur: float,
refund_amount_eur: float,
processing_fee_pct: float,
processing_fee_fixed_eur: float,
refund_admin_fee_eur: float = 0.25,
) -> dict:
"""Calculate what refunding a payment actually costs the merchant.
Use this whenever the user gives you (a) the original charge amount
and (b) the payment method's fee structure (percentage plus fixed fee
per transaction). Do not guess the fee structure yourself if the user
hasn't provided it.
Processing fees paid on the original charge are NOT returned by the
processor when you refund, and most processors charge a small admin
fee per refund on top.
The returned total_cost_of_refund_eur represents the merchant's total
out-of-pocket cost after the refund is processed:
refunded amount + non-refundable original processing fee + refund admin fee.
Limitations:
- Assumes the original payment processing fee is fully retained by the
processor after a refund. Some processors, payment methods, or regions
may have different refund fee policies.
- Does not account for currency conversion costs, exchange-rate changes,
taxes, accounting impacts, subscription adjustments, or other
business-specific costs.
Args:
original_charge_eur: The amount of the original charge.
refund_amount_eur: The amount being refunded (full or partial).
processing_fee_pct: Percentage fee on the original charge,
e.g. 1.8 for 1.8%.
processing_fee_fixed_eur: Fixed fee on the original charge.
refund_admin_fee_eur: Processor's per-refund admin fee, default
€0.25.
Returns:
A dictionary containing:
- refund_amount_eur: The amount sent back to the customer.
- processing_fee_eur: The original payment processing
fee that the merchant still pays after issuing the refund.
- total_cost_of_refund_eur: The merchant's total out-of-pocket cost.
This includes the money returned to the customer and the processing fees.
"""
processing_fee = (
original_charge_eur * processing_fee_pct / 100
+ processing_fee_fixed_eur
)
total = refund_amount_eur + refund_admin_fee_eur + processing_fee
return {
"refund_amount_eur": round(refund_amount_eur, 2),
"processing_fee_eur": round(processing_fee, 2),
"total_cost_of_refund_eur": round(total, 2),
}
Docstrings have always been important guidance for programmers deciding which function to use, and they now fulfill the same role for models choosing between tools. And models actually read the docs, so docstrings are arguably more important than ever.
Three tips for writing tool docstrings:
Say when, not just what. "Use this whenever the user gives you (a)... and (b)..." - that is routing logic helping the model choose between tools. A docstring that only describes the computation leaves the when to chance.
Set boundaries. "Do not guess the fee structure yourself" might be the most crucial logic in the whole tool definition. Hallucinating the fee structure will impact the calculation heavily. Another example is to explicitly tell the model not to underestimate the costs even if the user keeps asking for a better answer. Assistants without such boundaries have made real headlines: a car dealership's chatbot was talked into "agreeing" to sell a Chevy Tahoe for one dollar, and Air Canada was held liable in court for a refund policy its chatbot invented. Boundaries matter.
Be frank about limitations. Every piece of logic has assumptions. For example, our simple refund tool assumes euros and does not take into account cross-border payments with multiple currencies. Making these limitations explicit is useful because it prevents the model from over-trusting the calculation. These limitations are often passed in the output to the user as well.
Let's try our new calculate_refund_cost
tool. Create 03_tool_manual.py
, which imports the tool we just wrote and binds it to the model using bind_tools
:
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from app.tools import calculate_refund_cost
import pprint
load_dotenv()
model = init_chat_model("anthropic:claude-sonnet-5")
model_with_tools = model.bind_tools(
[calculate_refund_cost]
)
question = ("Customer paid €480 on a European consumer card (1.8% + €0.25). "
"Full refund - what does it cost us?")
msg = model_with_tools.invoke(question)
pprint.pp(msg)
And you'll see something like (I truncated longer texts for your reading convenience):
$ python3 03_tool_manual.py
AIMessage(
content=[...],
tool_calls=[
{
"name": "calculate_refund_cost",
"args": {
"original_charge_eur": 480,
"refund_amount_eur": 480,
"processing_fee_pct": 1.8,
"processing_fee_fixed_eur": 0.25,
},
"id": "toolu_011GofEYU4Vz1...",
"type": "tool_call",
}
],
invalid_tool_calls=[],
usage_metadata={
"input_tokens": 1259,
"output_tokens": 224,
"total_tokens": 1483,
"input_token_details": {[...]},
},
)
The output is not an answer to your question. It's a tool request. Your application logic should check if tool_calls
is set, invoke the tools, and then pass the results back to the model to resume. Note that tool_calls
is a list: a model can request several tools in one turn, so always loop over it instead of grabbing the first entry.
The snippet below, 03_tool_manual_2.py
, does two rounds with the model. First, we pass it the question and the model decides to respond with a tool request. Then, we respond with the tool results and the model gives us the final answer.
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.messages import ToolMessage
from app.tools import calculate_refund_cost
load_dotenv()
model = init_chat_model("anthropic:claude-sonnet-5")
model_with_tools = model.bind_tools([calculate_refund_cost])
question = (
"Customer paid €480 on a European consumer card (1.8% + €0.25). "
"Full refund - what does it cost us?"
)
msg = model_with_tools.invoke(question)
print(msg)
tool_map = {
"calculate_refund_cost": calculate_refund_cost
}
tool_messages = []
for tool_call in msg.tool_calls:
tool = tool_map[tool_call["name"]]
result = tool.invoke(tool_call["args"])
tool_messages.append(
ToolMessage(content=str(result), tool_call_id=tool_call["id"])
)
next_msg = model_with_tools.invoke(
[{"role": "user", "content": question}, msg, *tool_messages]
)
print(next_msg.content)
Pro tip: if a tool raises, don't let the exception escape. Catch it and send the error text back as the ToolMessage
content. The model can then fix its arguments or explain the problem to the user, whereas an unhandled exception just kills the turn.
And the result:
$ python3 03_tool_manual_2.py
Here's the breakdown for a full refund on the €480 charge
(European consumer card, 1.8% + €0.25):
| Item | Amount |
|-------------------------------------------|--------:|
| Refunded to customer | €480.00 |
| Original processing fee (non-refundable) | €8.89 |
| Refund admin fee | €0.25 |
| **Total out-of-pocket cost** | **€489.14** |
So issuing the full refund actually costs you **€489.14**:
- €480.00 returned to the customer
- €8.89 original processing fee (not refunded)
- €0.25 refund processing/admin fee
Our calculate_refund_cost
tool is a simple example, but I hope you see how powerful this tool usage can be. You can make tools to search on the internet, to select data from your database, you could even let it send out emails or write to a queue. Anything you could do with normal Python code can now be integrated with the model. Tool usage is the integration point between these two worlds.
The next two articles dive into advanced tool categories: knowledge retrieval via RAG and API integration via MCP. You might have heard about these three-letter acronyms and we'll learn what they actually mean.