Run Python Tools

Su Suriya Ravichandran Updated 10 Nov 2026
11 min read ·Lesson 86 of 200

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

  1. Tool combines a name, a description, an input model, and a function. definition() generates the API definition from the input model, with strict: true (Lesson 83). Its schema and the validation in run_tool() come from the same model.
  2. ToolContext carries data from your application, here the user ID. get_enrollments takes no input from Claude and reads ctx.user_id, so the model cannot choose whose data to read.
  3. run_tool() handles every case and always returns a ToolOutcome:
    • 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.
  4. to_text() returns strings unchanged, and converts dictionaries and lists to compact JSON. ensure_ascii=False keeps non-English text readable, and default=str converts values such as dates that JSON cannot represent directly. Results longer than MAX_RESULT_CHARS are cut off, with a note telling Claude that the result was truncated.
  5. search_courses returns only four high-signal fields per course, not the internal topic field (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:

RiskRule
Tools that change dataValidate business rules, check permissions, and consider confirmation for important actions (Lessons 176–177)
Repeated callsMake actions idempotent where possible, so running the same call twice does not, for example, create two bookings
Slow external servicesGive every network call in a tool its own timeout, so one slow service cannot hang the conversation
Dangerous capabilitiesNever expose tools that run arbitrary code, shell commands, or SQL from model input, unless they are carefully sandboxed
Sensitive resultsReturn 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

  1. Add a get_course_details tool that takes a slug and returns the course's title, level, and weeks, or raises ToolFailure for an unknown slug.
  2. Call run_tool() with {"topic": "SQL", "level": "expert"}. What error do you get?
  3. Add a tool whose function raises ZeroDivisionError, and confirm that Claude would receive the generic message while your logs contain the traceback. (Call logging.basicConfig(level=logging.INFO) to see the log.)
  4. Use run_calls() with three SimpleNamespace(name=..., input=...) objects and confirm that outcomes keep their order.
Show answers for steps 1 and 2

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.)

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Run Python Tools and get answers drawn from it.

Signed-in readers only.