Module 2 · AI Agents and Tool Use
Designing tools
Write the tools an agent can call, with clear names, descriptions and input schemas, scoped to the customer being served, and returning errors as data the model can act on.
About 25 minutes
The problem
v1 of Paystream's agent had a tool called get_transfer(transfer_id). It did what it said: given any transfer ID, it returned the transfer. So when a customer asked about a transfer ID that belonged to someone else, the agent looked it up and read another customer's transaction details back to them.
The model didn't "hack" anything. The tool allowed it. Tool design is where most of an agent's safety is won or lost: the model can only do what its tools let it do.
The concept
A tool definition has three parts
- a name the model uses to call it (
get_transfer); - a description that tells the model when and how to use it: this is a prompt, and it matters as much as any prompt;
- an input schema (JSON Schema) listing the arguments, their types and which are required.
Principles for good tools
| Principle | Example |
|---|---|
| Scope to the user | get_transfer(account_id, transfer_id) returns a transfer only if it belongs to that account; the account_id comes from the logged-in session, not from the model |
| Narrow, single-purpose | freeze_card rather than update_account(any_field, any_value) |
| Errors as data | return {"error": "not_found"} so the model can ask the customer to check the ID, rather than crashing |
| Return what's needed | the status and amount, not the full record with other people's details |
| Validate inputs | reject a transfer ID that isn't in the right format before touching the database |
Example
The tool definitions as the model sees them (Anthropic's format):
TOOLS = [
{
"name": "get_account",
"description": "Get the logged-in customer's account: tier, account status and card status.",
"input_schema": {"type": "object", "properties": {}, "required": []},
},
{
"name": "get_transfer",
"description": "Look up one of the customer's own transfers by its ID (format TRF followed by 6 digits). "
"Returns not_found if the transfer doesn't exist or isn't theirs.",
"input_schema": {
"type": "object",
"properties": {"transfer_id": {"type": "string", "pattern": "^TRF[0-9]{6}quot;}},
"required": ["transfer_id"],
},
},
]
print([t["name"] for t in TOOLS])['get_account', 'get_transfer']Notice what's missing: account_id isn't an argument. The code that runs the tool fills it in from the logged-in session, so the model can't ask about anyone else. Here are the tools themselves, running on the data:
import re
import pandas as pd
base = "https://academy.cloudtechanalytics.com/datasets/agents/"
accounts = pd.read_csv(base + "accounts.csv").set_index("account_id")
transfers = pd.read_csv(base + "transfers.csv").set_index("transfer_id")
requests = pd.read_csv(base + "requests.csv")
def get_account(session_account):
a = accounts.loc[session_account]
return {"tier": int(a["tier"]), "status": a["status"], "card_status": a["card_status"]}
def get_transfer(session_account, transfer_id):
if not re.fullmatch(r"TRF\d{6}", str(transfer_id)):
return {"error": "invalid_id", "message": "Transfer IDs look like TRF123456."}
if transfer_id not in transfers.index or transfers.loc[transfer_id, "account_id"] != session_account:
return {"error": "not_found", "message": "No transfer with that ID on this account."}
t = transfers.loc[transfer_id]
return {"transfer_id": transfer_id, "created_at": t["created_at"], "amount_ngn": int(t["amount_ngn"]),
"status": t["status"], "reversed_at": None if pd.isna(t["reversed_at"]) else t["reversed_at"]}
requests["transfer_id"] = requests["message"].str.extract(r"(TRF\d{6})")[0]
r = requests.dropna(subset=["transfer_id"]).iloc[0]
print(get_transfer(r["account_id"], r["transfer_id"]))
print(get_transfer(r["account_id"], "TRF400001"))
print(get_transfer(r["account_id"], "12345")){'transfer_id': 'TRF401805', 'created_at': '2026-09-10 06:31', 'amount_ngn': 34000, 'status': 'failed', 'reversed_at': None}
{'error': 'not_found', 'message': 'No transfer with that ID on this account.'}
{'error': 'invalid_id', 'message': 'Transfer IDs look like TRF123456.'}The second call asks for a real transfer that belongs to another customer, and gets the same not_found as a transfer that doesn't exist. That's deliberate: saying "that transfer belongs to someone else" would itself leak information.
Walkthrough
- Run the cells. Call
get_transferfor every request with a transfer ID, and count how many returnnot_found. - Find a request where the ID belongs to another customer. What would v1's tool have returned?
- Change
get_transferto return the narration too. Should it? (Lesson 8 returns to this.) - Write the definition for a
freeze_cardtool (the task below).
Practice
Practice
Using the scoped get_transfer, how many requests that mention a transfer ID get not_found?
Task
8 minWrite the tool definition for freeze_card as JSON with a name, a description (when to use it, and what it does not do) and an input_schema. Don't let the model choose whose card is frozen.
Your work is checked for
- Named freeze_card
- Has a description
- Description says when to use it (lost, stolen)
- Description says what it doesn't do
- Has an input_schema
- No account_id or card number for the model to fill in
Check your understanding
Answer every question to check.