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.
- A safe calculator that parses expressions with Python's
astmodule instead of dangerouseval(). - A time-zone tool using
zoneinfoand 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 returnsis_error: trueresults instead of crashing. - A manual agentic loop that handles
tool_use,end_turn,max_tokensandrefusalstop 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,
**kwargsand 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 WindowsStep 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 astimport 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 - a Claude assistant with calculator, clock and currency tools.
Run with: python assistant.py"""import astimport jsonimport operatorfrom datetime import datetimefrom 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
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_toolandclient.beta.messages.tool_runner) and compare how much code disappears. - Add a
search_notestool that callsretrieve()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.