Run Python Tools
From Tool Call to Python Function
Lesson 85 read and validated Claude's tool calls. The next step is to run them: map each call to the right Python function, give it its arguments, execute it safely, and turn its return value into a result that can be sent back to Claude (Lesson 87).
tool_use block ──► look up tool ──► validate input ──► run function ──► convert result to text
{id, name, input} (registry) (Pydantic) (your code) (ToolOutcome)
This lesson builds a small, reusable tool framework, tools_lib.py, that Lessons 87–90 also use.
Design Decisions
1. One Registry Holds Everything
Each tool combines four things: its name, its description, an input model (which generates the schema and validates input), and the function that does the work. Keeping them in one object means they cannot drift apart (Lessons 82 and 83).
2. Functions Receive Validated, Typed Arguments
Tool functions receive a Pydantic model instance, not a raw dictionary. By the time the function runs, the input has been checked and defaults have been applied, so the function itself can focus on its work.
3. Application Context Comes from Your Code, Not from Claude
Some information must never come from the model: who the current user is, their permissions, a database connection. These are passed to every tool in a context object created by your application. For example, get_enrollments reads the current user's ID from the context, so Claude cannot ask for another user's enrollments, whether by mistake or because of a prompt injection (Lesson 40).
4. Every Call Produces an Outcome
Whatever happens, whether success, invalid input, an expected failure, or an unexpected exception, running a tool produces a ToolOutcome with text content and an is_error flag. Every call needs a result (Lesson 87), and errors are information Claude can act on (Lesson 90).
5. Results Are Short Text
Claude reads tool results as text. Return concise, high-signal content (Lesson 82): strings as they are, and structured data (dictionaries and lists) as compact JSON. Very large results are truncated, so a single tool cannot flood the context window.
Practical Example: tools_lib.py
"""tools_lib.py: a small tool framework used in Lessons 86–90."""
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from pydantic import BaseModel, ValidationError
logger = logging.getLogger("tools")
MAX_RESULT_CHARS = 4_000
class ToolFailure(Exception):
"""An expected failure whose message is safe to show to Claude."""
@dataclass(frozen=True)
class ToolContext:
"""Information supplied by the application, never by the model."""
user_id: str
@dataclass(frozen=True)
class Tool:
name: str
description: str
input_model: type[BaseModel]
function: Callable[[BaseModel, ToolContext], object]
def definition(self) -> dict:
return {
"name": self.name,
"description": self.description,
"input_schema": strip_titles(self.input_model.model_json_schema()),
"strict": True,
}
@dataclass
class ToolOutcome:
content: str
is_error: bool
def strip_titles(schema):
"""Remove Pydantic's 'title' annotations, but keep fields named 'title' (Lesson 83)."""
if isinstance(schema, list):
return [strip_titles(item) for item in schema]
if not isinstance(schema, dict):
return schema
cleaned = {}
for key, value in schema.items():
if key == "title" and isinstance(value, str):
continue
if key in ("properties", "$defs"):
cleaned[key] = {name: strip_titles(sub) for name, sub in value.items()}
else:
cleaned[key] = strip_titles(value)
return cleaned
def to_text(result: object) -> str:
"""Convert a tool's return value into compact text, truncating very long results."""
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, default=str)
if len(text) > MAX_RESULT_CHARS:
original_length = len(text)
text = text[:MAX_RESULT_CHARS] + f"\n[truncated: the full result was {original_length} characters]"
return text
def run_tool(registry: dict[str, Tool], name: str, raw_input: dict, ctx: ToolContext) -> ToolOutcome:
tool = registry.get(name)
if tool is None:
return ToolOutcome(f"Unknown tool {name!r}. Available tools: {', '.join(sorted(registry))}.", True)
try:
args = tool.input_model.model_validate(raw_input or {})
except ValidationError as error:
problems = "; ".join(
f"{'.'.join(str(part) for part in e['loc']) or 'input'}: {e['msg']}" for e in error.errors()
)
return ToolOutcome(f"Invalid input for {name}: {problems}", True)
try:
return ToolOutcome(to_text(tool.function(args, ctx)), False)
except ToolFailure as error:
return ToolOutcome(str(error), True)
except Exception:
logger.exception("tool %s failed unexpectedly", name)
return ToolOutcome(f"{name} failed unexpectedly. Try again later, or answer without this tool.", True)
demo_tools.py: Three Example Tools
"""demo_tools.py: example tools built with tools_lib (simulated data, no external services)."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from tools_lib import Tool, ToolContext, ToolFailure
WEATHER = {"chennai": "31°C, humid, light rain expected at 5 PM",
"bengaluru": "24°C, partly cloudy, no rain expected"}
COURSES = [
{"slug": "django-101", "title": "Django for Beginners", "level": "beginner", "weeks": 6, "topic": "django"},
{"slug": "django-apis", "title": "Advanced Django APIs", "level": "advanced", "weeks": 4, "topic": "django"},
{"slug": "sql-basics", "title": "SQL Basics", "level": "beginner", "weeks": 3, "topic": "sql"},
]
ENROLLMENTS = {"meena": [{"course": "django-101", "progress": 65}, {"course": "sql-basics", "progress": 100}]}
class WeatherInput(BaseModel):
model_config = ConfigDict(extra="forbid")
city: str = Field(description="City name, e.g. Chennai")
class CourseSearchInput(BaseModel):
model_config = ConfigDict(extra="forbid")
topic: str = Field(description="Subject to search for, e.g. 'Django'")
level: Literal["beginner", "intermediate", "advanced"] | None = Field(
default=None, description="Optional difficulty filter; omit or null for all levels")
class EnrollmentsInput(BaseModel):
model_config = ConfigDict(extra="forbid")
def get_weather(args: WeatherInput, ctx: ToolContext) -> str:
weather = WEATHER.get(args.city.strip().lower())
if weather is None:
raise ToolFailure(f"No weather data for {args.city!r}. Supported cities: Chennai, Bengaluru.")
return weather
def search_courses(args: CourseSearchInput, ctx: ToolContext) -> list[dict]:
topic = args.topic.lower()
matches = [{k: c[k] for k in ("slug", "title", "level", "weeks")}
for c in COURSES
if topic in c["topic"] and (args.level is None or c["level"] == args.level)]
return matches[:5]
def get_enrollments(args: EnrollmentsInput, ctx: ToolContext) -> list[dict]:
return ENROLLMENTS.get(ctx.user_id, [])
TOOLS = [
Tool("get_weather",
"Gets the current weather for a city. Use it for questions about current conditions or "
"whether the weather suits an activity. Returns temperature in Celsius and a short "
"description. It does not provide forecasts beyond today.",
WeatherInput, get_weather),
Tool("search_courses",
"Searches the GOCOURSE catalog by topic. Use it when a student asks which courses exist or "
"wants a recommendation. Do not use it for the student's own enrollments. Returns up to 5 "
"courses as JSON with slug, title, level, and duration in weeks.",
CourseSearchInput, search_courses),
Tool("get_enrollments",
"Returns the current student's enrolled courses with progress percentages, as JSON. "
"Use it for questions about the student's own courses or progress. "
"It takes no input; the student is identified automatically.",
EnrollmentsInput, get_enrollments),
]
REGISTRY = {tool.name: tool for tool in TOOLS}
Running Tools Directly
Tools can be tested without Claude, by calling run_tool() with hand-written inputs:
"""run_tools_demo.py: run tools directly, as Claude's calls would."""
from demo_tools import REGISTRY
from tools_lib import ToolContext, run_tool
ctx = ToolContext(user_id="meena")
calls = [
("get_weather", {"city": "Chennai"}),
("search_courses", {"topic": "Django", "level": "beginner"}),
("get_enrollments", {}),
("get_weather", {"city": "Mumbai"}),
("search_courses", {"level": "beginner"}),
("delete_course", {"slug": "sql-basics"}),
]
for name, raw_input in calls:
outcome = run_tool(REGISTRY, name, raw_input, ctx)
label = "ERROR" if outcome.is_error else "ok "
print(f"[{label}] {name}: {outcome.content}")
Output:
[ok ] get_weather: 31°C, humid, light rain expected at 5 PM
[ok ] search_courses: [{"slug": "django-101", "title": "Django for Beginners", "level": "beginner", "weeks": 6}]
[ok ] get_enrollments: [{"course": "django-101", "progress": 65}, {"course": "sql-basics", "progress": 100}]
[ERROR] get_weather: No weather data for 'Mumbai'. Supported cities: Chennai, Bengaluru.
[ERROR] search_courses: Invalid input for search_courses: topic: Field required
[ERROR] delete_course: Unknown tool 'delete_course'. Available tools: get_enrollments, get_weather, search_courses.
How It Works
Toolcombines a name, a description, an input model, and a function.definition()generates the API definition from the input model, withstrict: true(Lesson 83). Its schema and the validation inrun_tool()come from the same model.ToolContextcarries data from your application, here the user ID.get_enrollmentstakes no input from Claude and readsctx.user_id, so the model cannot choose whose data to read.run_tool()handles every case and always returns aToolOutcome:- unknown tool: an error that lists the available tools, which helps Claude correct itself;
- invalid input: an error listing every validation problem;
ToolFailure: an expected problem, such as an unsupported city, reported with the function's own message, which is written to be helpful to Claude;- any other exception: logged in full for developers with
logger.exception(), and reported to Claude as a generic message, so internal details never leak into the conversation; - success: the return value is converted to text.
to_text()returns strings unchanged, and converts dictionaries and lists to compact JSON.ensure_ascii=Falsekeeps non-English text readable, anddefault=strconverts values such as dates that JSON cannot represent directly. Results longer thanMAX_RESULT_CHARSare cut off, with a note telling Claude that the result was truncated.search_coursesreturns only four high-signal fields per course, not the internaltopicfield (Lesson 82).
Running Parallel Calls Concurrently
When Claude makes several calls in one response (Lesson 84), you choose how to run them. According to the documentation, independent, read-only operations are usually safe to run in parallel, while tools with side effects, shared state, or ordering requirements may be better run in sequence.
Running independent calls concurrently saves time when tools wait on networks or databases:
from concurrent.futures import ThreadPoolExecutor
def run_calls(calls, ctx: ToolContext) -> list[ToolOutcome]:
"""Run tool_use blocks concurrently; results come back in the same order as the calls."""
with ThreadPoolExecutor(max_workers=4) as pool:
return list(pool.map(lambda call: run_tool(REGISTRY, call.name, call.input, ctx), calls))
pool.map() returns outcomes in the same order as the calls, which makes it easy to pair each outcome with its call's id (Lesson 87). In async applications, use asyncio.gather() with async tool functions instead (Lesson 74).
Safety Considerations
Running tools means running your code with model-chosen inputs. Keep these rules in mind:
| Risk | Rule |
|---|---|
| Tools that change data | Validate business rules, check permissions, and consider confirmation for important actions (Lessons 176–177) |
| Repeated calls | Make actions idempotent where possible, so running the same call twice does not, for example, create two bookings |
| Slow external services | Give every network call in a tool its own timeout, so one slow service cannot hang the conversation |
| Dangerous capabilities | Never expose tools that run arbitrary code, shell commands, or SQL from model input, unless they are carefully sandboxed |
| Sensitive results | Return only what the user is allowed to see, because tool results become part of the conversation |
Common Mistakes
Calling Functions with Raw, Unvalidated Input
What happens: missing fields, wrong types, or unexpected fields cause crashes or wrong behavior.
Fix: validate with the tool's input model before calling the function.
Letting Claude Choose Security-Relevant Values
What happens: a tool parameter such as user_id lets the model read or change other users' data.
Fix: pass identity and permissions through an application-controlled context.
Raising Exceptions Out of the Tool Loop
What happens: one failing tool crashes the whole request, and the conversation is left with an unanswered tool call.
Fix: convert every failure into an error outcome.
Returning Internal Error Details
What happens: stack traces or database errors appear in Claude's context, and may be repeated to users.
Fix: log details for developers, and return short, safe messages.
Returning Huge Results
What happens: context and cost grow, and important information gets lost in noise.
Fix: return only needed fields, and truncate very long results.
Best Practices
- Keep each tool's name, description, input model, and function together.
- Validate inputs with the same model that generates the schema.
- Supply identity and permissions through a context object.
- Always return an outcome, with an error flag for failures.
- Distinguish expected failures (helpful messages) from unexpected ones (logged, generic messages).
- Return compact, high-signal results, and cap their size.
- Run independent calls concurrently, and calls with side effects carefully.
Exercise
- Add a
get_course_detailstool that takes aslugand returns the course's title, level, and weeks, or raisesToolFailurefor an unknown slug. - Call
run_tool()with{"topic": "SQL", "level": "expert"}. What error do you get? - Add a tool whose function raises
ZeroDivisionError, and confirm that Claude would receive the generic message while your logs contain the traceback. (Calllogging.basicConfig(level=logging.INFO)to see the log.) - Use
run_calls()with threeSimpleNamespace(name=..., input=...)objects and confirm that outcomes keep their order.
Step 1:
class CourseDetailsInput(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str = Field(description="Course slug from search_courses, e.g. 'django-101'")
def get_course_details(args: CourseDetailsInput, ctx: ToolContext) -> dict:
for course in COURSES:
if course["slug"] == args.slug:
return {k: course[k] for k in ("title", "level", "weeks")}
raise ToolFailure(f"No course with slug {args.slug!r}. Use search_courses to find valid slugs.")
TOOLS.append(Tool(
"get_course_details",
"Returns details for one course, identified by its slug. Use it after search_courses when the "
"student wants more information about a specific course. Returns title, level, and duration "
"in weeks as JSON. It does not return prices.",
CourseDetailsInput, get_course_details,
))
REGISTRY = {tool.name: tool for tool in TOOLS}
The failure message tells Claude how to recover (use search_courses), which makes errors useful rather than dead ends (Lesson 90).
Step 2: Invalid input for search_courses: level: Input should be 'beginner', 'intermediate' or 'advanced'. The Literal type restricts the allowed values, and Pydantic's message lists them, which helps Claude correct the call. (With strict: true on the tool, Claude cannot normally send "expert" at all, because the schema's allowed values constrain its output.)