LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
AI Tools & Libraries GuideAdvanced~2 hours

Tool-Calling Assistant (Agent Loop)

Give Claude calculator, time-zone and currency tools, and write the agentic loop that runs tools and feeds results back.

Tool UseAgent LoopJSON SchemaSafe Evaluation

Overview

Language models are great at understanding requests but can make mistakes with exact arithmetic, and they cannot know the current time or look anything up on their own. Tool use solves this: you describe functions the model may call, the model replies with a request to call one, your code runs it, and you send the result back so the model can continue.

In this project you write that loop by hand — the same loop that powers coding agents and assistants. Claude gets three tools: an exact calculator, a world clock, and a currency converter. You will see Claude chain several tool calls to answer one question, call tools in parallel, and recover when a tool reports an error.

What You'll Build
  • A safe calculator that parses expressions with Python's ast module instead of dangerous eval().
  • A time-zone tool using zoneinfo and a currency converter with sample exchange rates.
  • Tool definitions with JSON Schema and strict: true, so tool inputs always match the schema.
  • A run_tool() dispatcher that returns is_error: true results instead of crashing.
  • A manual agentic loop that handles tool_use, end_turn, max_tokens and refusal stop reasons, with a turn limit.

Prerequisites

  • The Streaming Terminal Chatbot project (client setup and error handling).
  • An Anthropic API key in ANTHROPIC_API_KEY — lesson 3.
  • Model Context Protocol and tool-use standards — lesson 21.
  • Python dictionaries, **kwargs and exceptions.

Project Structure

Everything is in assistant.py, organised top to bottom in the order data flows: plain Python tool functions (no AI involved, easy to unit test), the TOOLS list describing them to the model, a HANDLERS map from tool names to functions, run_tool() which executes one tool call safely, and run_agent() which drives the conversation until Claude produces a final answer.

pip install anthropic tzdata # tzdata provides time-zone data on Windows

Step 1: Implement the Tools Safely

Never pass model-generated text to eval() — it can run any Python code. Instead, ast.parse turns the expression into a syntax tree, and a small recursive function evaluates only numbers and arithmetic operators, rejecting everything else. A cap on exponents stops expressions like 9**9**9 from freezing your computer.

import ast
import operator
_OPERATORS = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos,
}
def calculate(expression: str) -> str:
def evaluate(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPERATORS:
left, right = evaluate(node.left), evaluate(node.right)
if isinstance(node.op, ast.Pow) and abs(right) > 100:
raise ValueError("exponent too large")
return _OPERATORS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPERATORS:
return _OPERATORS[type(node.op)](evaluate(node.operand))
raise ValueError("only numbers, parentheses and + - * / % ** are allowed")
result = evaluate(ast.parse(expression, mode="eval").body)
return str(round(result, 10)) if isinstance(result, float) else str(result)

Step 2: Describe the Tools with JSON Schema

The model decides when to use a tool based on its name and description, so descriptions should say what the tool does and when to prefer it. input_schema is JSON Schema. With "strict": True, plus "additionalProperties": False and a required list, the API guarantees that the tool input Claude sends matches the schema exactly.

TOOLS = [
{
"name": "calculate",
"description": "Evaluate an arithmetic expression exactly. Always use this for maths "
"instead of calculating yourself. Supports numbers, parentheses and + - * / % **.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "For example: (1250 * 12) * 1.18"}
},
"required": ["expression"],
"additionalProperties": False,
},
},
# get_time and convert_currency are defined the same way - see Complete Code
]

Step 3: Run Tools & Report Errors

Each tool_use block in Claude's response has an id, a name and an input dictionary. Your reply must contain a tool_result with the same tool_use_id. If the tool fails, do not crash and do not skip it — return the error message with "is_error": True, so Claude can explain the problem or retry with corrected input.

def run_tool(block) -> dict:
try:
output = HANDLERS[block.name](**block.input)
is_error = False
except (KeyError, TypeError, ValueError, ArithmeticError, SyntaxError) as err:
output = f"Error: {err}"
is_error = True
print(f" -> {block.name}({json.dumps(block.input)}) = {output}")
return {"type": "tool_result", "tool_use_id": block.id, "content": output, "is_error": is_error}

Step 4: The Agentic Loop

The loop keeps calling the API until Claude stops asking for tools. Three details matter. First, append the assistant's full response.content to the history before sending tool results. Second, when Claude requests several tools in one response, run them all and return every result in a single user message — splitting them across messages discourages Claude from calling tools in parallel. Third, check other stop reasons (max_tokens, refusal) and cap the number of rounds so a confused model cannot loop forever.

def run_agent(question: str, max_rounds: int = 10) -> str:
messages = [{"role": "user", "content": question}]
for _ in range(max_rounds):
response = client.messages.create(
model=MODEL, max_tokens=16000, system=SYSTEM_PROMPT, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "tool_use":
tool_calls = [b for b in response.content if b.type == "tool_use"]
messages.append({"role": "user", "content": [run_tool(b) for b in tool_calls]})
continue
if response.stop_reason == "refusal":
return "[Claude declined this request.]"
if response.stop_reason == "max_tokens":
return "[The answer was cut off - try a narrower question.]"
return "".join(b.text for b in response.content if b.type == "text")
return "[Stopped after too many tool rounds.]"

Complete Code

assistant.py
"""assistant.py - a Claude assistant with calculator, clock and currency tools.
Run with: python assistant.py
"""
import ast
import json
import operator
from datetime import datetime
from zoneinfo import ZoneInfo
import anthropic
MODEL = "claude-opus-5-5"
SYSTEM_PROMPT = (
"You are a helpful assistant. Use the calculate tool for every arithmetic step "
"instead of doing maths yourself. If a tool returns an error, explain it or retry "
"with corrected input. Keep final answers short."
)
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
# ---------------- Tool implementations (plain Python, easy to test) ----------------
_OPERATORS = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos,
}
def calculate(expression: str) -> str:
def evaluate(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPERATORS:
left, right = evaluate(node.left), evaluate(node.right)
if isinstance(node.op, ast.Pow) and abs(right) > 100:
raise ValueError("exponent too large")
return _OPERATORS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPERATORS:
return _OPERATORS[type(node.op)](evaluate(node.operand))
raise ValueError("only numbers, parentheses and + - * / % ** are allowed")
result = evaluate(ast.parse(expression, mode="eval").body)
return str(round(result, 10)) if isinstance(result, float) else str(result)
def get_time(timezone: str) -> str:
now = datetime.now(ZoneInfo(timezone)) # raises an error for unknown zone names
return now.strftime("%A %d %B %Y, %H:%M (%Z, UTC%z)")
# Sample rates for learning purposes - NOT live market data.
RATES_IN_INR = {"INR": 1.0, "USD": 88.0, "EUR": 102.0, "GBP": 118.0, "AED": 24.0, "JPY": 0.59}
def convert_currency(amount: float, from_currency: str, to_currency: str) -> str:
source, target = from_currency.upper(), to_currency.upper()
for code in (source, target):
if code not in RATES_IN_INR:
raise ValueError(f"unsupported currency {code}; supported: {', '.join(RATES_IN_INR)}")
converted = amount * RATES_IN_INR[source] / RATES_IN_INR[target]
return f"{amount:,.2f} {source} = {converted:,.2f} {target} (sample rates, not live)"
HANDLERS = {"calculate": calculate, "get_time": get_time, "convert_currency": convert_currency}
# ---------------- Tool definitions sent to the model ----------------
TOOLS = [
{
"name": "calculate",
"description": "Evaluate an arithmetic expression exactly. Always use this for maths "
"instead of calculating yourself. Supports numbers, parentheses and + - * / % **.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "For example: (1250 * 12) * 1.18"}
},
"required": ["expression"],
"additionalProperties": False,
},
},
{
"name": "get_time",
"description": "Get the current local date and time in a time zone.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "IANA time zone name, e.g. Asia/Kolkata, Europe/London, America/New_York",
}
},
"required": ["timezone"],
"additionalProperties": False,
},
},
{
"name": "convert_currency",
"description": "Convert an amount between currencies using fixed sample rates. "
"Supported: INR, USD, EUR, GBP, AED, JPY.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"amount": {"type": "number"},
"from_currency": {"type": "string", "description": "3-letter code, e.g. USD"},
"to_currency": {"type": "string", "description": "3-letter code, e.g. INR"},
},
"required": ["amount", "from_currency", "to_currency"],
"additionalProperties": False,
},
},
]
# ---------------- The agentic loop ----------------
def run_tool(block) -> dict:
"""Run one tool_use block and package the result (or error) as a tool_result."""
try:
output = HANDLERS[block.name](**block.input)
is_error = False
except (KeyError, TypeError, ValueError, ArithmeticError, SyntaxError) as err:
output = f"Error: {err}"
is_error = True
print(f" -> {block.name}({json.dumps(block.input)}) = {output}")
return {"type": "tool_result", "tool_use_id": block.id, "content": output, "is_error": is_error}
def run_agent(question: str, max_rounds: int = 10) -> str:
messages = [{"role": "user", "content": question}]
for _ in range(max_rounds):
response = client.messages.create(
model=MODEL, max_tokens=16000, system=SYSTEM_PROMPT, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "tool_use":
tool_calls = [b for b in response.content if b.type == "tool_use"]
# Return every result in ONE user message, matched by tool_use_id.
messages.append({"role": "user", "content": [run_tool(b) for b in tool_calls]})
continue
if response.stop_reason == "refusal":
return "[Claude declined this request.]"
if response.stop_reason == "max_tokens":
return "[The answer was cut off - try a narrower question.]"
return "".join(b.text for b in response.content if b.type == "text")
return "[Stopped after too many tool rounds.]"
def main() -> None:
print("Ask me anything that needs maths, the time somewhere, or a currency conversion.")
while True:
try:
question = input("\nYou: ").strip()
except (EOFError, KeyboardInterrupt):
break
if not question:
break
try:
print(f"Claude: {run_agent(question)}")
except anthropic.APIConnectionError:
print("[Network problem - check your connection and try again.]")
except anthropic.RateLimitError:
print("[Rate limited - wait a moment and try again.]")
except anthropic.APIStatusError as err:
print(f"[API error {err.status_code}: {err.message}]")
print("Goodbye!")
if __name__ == "__main__":
main()

Sample Run

python assistant.py (example session - the tool lines are real tool output; Claude's wording and tool choices will vary)

Click Run to see what this code prints.

In the first question Claude chained two tools — a conversion, then a calculation on its result. In the second it asked for both time zones in a single response (parallel tool use), and your loop returned both results together. In the third, the unknown currency produced an is_error result, and Claude explained the problem instead of inventing a rate.

Extend This Project

  • Replace the manual loop with the SDK's beta tool runner (@beta_tool and client.beta.messages.tool_runner) and compare how much code disappears.
  • Add a search_notes tool that calls retrieve() from the RAG project, turning the assistant into an agent that looks things up when needed.
  • Ask for confirmation in the terminal before running a tool marked as "sensitive", returning a "user declined" result if the answer is no.
  • Log every round (stop reason, tools called, token usage) to a file to understand how the agent behaves.
  • Swap the sample rates for a real exchange-rate API, handling timeouts as tool errors.

Summary

You implemented tool use from first principles: safe tool functions, strict JSON-schema descriptions, a dispatcher that reports failures as is_error results, and an agentic loop that appends full assistant turns, returns parallel results in one message, handles every stop reason and caps its own rounds. This loop is the foundation of every AI agent — larger agents simply have more tools and better context management.