Validate Tool Inputs

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 94 of 200

Why Validation Is Still Needed

@beta_tool generates a schema from type hints (Lesson 91), and Claude follows it closely. But a schema only describes the shape of the input: strings, integers, required fields. It cannot express most business rules:

InputShape is fineBut…
course_slug="rust-101"It is a stringThat course does not exist
day="2026-11-01"A string in date formatThe date is in the past
day="2026-11-08"A valid future dateIt is a Sunday, when no sessions run
start="07:30"A string in time formatSessions start between 09:00 and 20:00
minutes=45An integerOnly 30, 60, or 90 minutes are offered

Your tool function is the last line of defense before something happens in your system: a booking, an email, a database update. Validate inside the function, and when the input is invalid, tell Claude exactly how to fix it.


Layers of Validation

LayerWhat it checksHow
1. Type hints and schemaTypes, required fields, allowed values (Literal)Generated by @beta_tool; guides Claude
2. NormalizationHarmless variationsStrip spaces, lowercase slugs, parse dates
3. Format checksStrings that must follow a formatdate.fromisoformat(), regular expressions
4. Business rulesExistence, ranges, schedules, limitsYour own checks against your data
5. PermissionsWhether this user may do thisContext from your application (Lesson 95)

Layers 2–5 run inside your function, every time.

Use Literal for Fixed Choices

When the allowed values are a small, fixed set, put them in the type hint, so the generated schema tells Claude the options:

from typing import Literal


@beta_tool
def book_session(course_slug: str, day: str, start: str, minutes: Literal[30, 60, 90] = 60) -> str:
    ...

Keep checking anyway. The function may also be called from other code, and validation inside the function protects every caller.


Reporting Invalid Input

When validation fails, raise an exception with a helpful message. The tool runner catches it and sends Claude an error result containing the exception's type and message (Lesson 91). Claude reads the message, corrects its input, and calls the tool again.

Good validation messages state the problem and the fix:

Weak messageHelpful message
"Invalid date""Invalid day '12/11/2026'. Use the YYYY-MM-DD format, e.g. '2026-11-12'."
"Bad course""Unknown course 'rust-101'. Valid slugs: django-101, django-apis, sql-basics."
"Not allowed""2026-11-08 is a Sunday. Sessions run Monday to Saturday."

A dedicated exception class, such as InvalidInput, makes validation failures easy to recognize in logs and tests, and its name tells Claude that the input was the problem.


Separate Logic from the Decorated Function

Put validation and business logic in plain functions, and keep the decorated tool as a thin wrapper:

def validate_booking(course_slug, day, start, minutes, today) -> dict:   # Plain, testable logic
    ...

@beta_tool
def book_session(course_slug: str, day: str, start: str, minutes: int = 60) -> str:
    booking = validate_booking(course_slug, day, start, minutes, date.today())
    ...

Why:

  • Testability: plain functions can be unit-tested directly, with no API calls and no tool runner.
  • Determinism: passing today as a parameter makes date-dependent rules testable with a fixed date.
  • Reuse: the same validation protects your web forms, admin scripts, and tools alike.

Practical Example: A Validated Booking Tool

This tool books live tutoring sessions. Its input is normalized, format-checked, and validated against business rules, with error messages written for Claude. Tests run the validation logic directly with a fixed date.

"""validated_booking.py: validate tool inputs inside @beta_tool functions."""

import json
from datetime import date, time, timedelta

from anthropic import beta_tool

COURSES = {"django-101", "django-apis", "sql-basics"}
BOOKINGS: list[dict] = []


class InvalidInput(ValueError):
    """Raised with a message that tells Claude how to fix its input."""


def validate_booking(course_slug: str, day: str, start: str, minutes: int, today: date) -> dict:
    slug = course_slug.strip().lower()
    if slug not in COURSES:
        raise InvalidInput(f"Unknown course {course_slug!r}. Valid slugs: {', '.join(sorted(COURSES))}.")

    try:
        session_day = date.fromisoformat(day.strip())
    except ValueError:
        raise InvalidInput(f"Invalid day {day!r}. Use the YYYY-MM-DD format, e.g. '2026-11-12'.") from None
    if session_day <= today:
        raise InvalidInput(f"{day} is not in the future. Book at least one day ahead "
                           f"(today is {today.isoformat()}).")
    if session_day > today + timedelta(days=60):
        raise InvalidInput("Sessions can be booked at most 60 days ahead.")
    if session_day.weekday() == 6:
        raise InvalidInput(f"{day} is a Sunday. Sessions run Monday to Saturday.")

    try:
        start_time = time.fromisoformat(start.strip())
    except ValueError:
        raise InvalidInput(f"Invalid start time {start!r}. Use 24-hour HH:MM, e.g. '19:00'.") from None
    if not time(9, 0) <= start_time <= time(20, 0):
        raise InvalidInput("Sessions must start between 09:00 and 20:00.")

    if minutes not in (30, 60, 90):
        raise InvalidInput(f"Invalid duration {minutes}. Choose 30, 60, or 90 minutes.")

    return {"course": slug, "day": session_day.isoformat(),
            "start": start_time.strftime("%H:%M"), "minutes": minutes}


@beta_tool
def book_session(course_slug: str, day: str, start: str, minutes: int = 60) -> str:
    """Book a live tutoring session for one course.

    Use it only when the student asks to book a session and has given the course, day, and time.
    Do not use it to check availability or to answer questions about sessions.
    Returns a confirmation as JSON, or an error explaining which input to correct.

    Args:
        course_slug: Course slug, e.g. 'django-101'
        day: Session date in YYYY-MM-DD format; must be a future Monday to Saturday within 60 days
        start: Start time in 24-hour HH:MM format, between 09:00 and 20:00
        minutes: Session length: 30, 60, or 90 minutes; defaults to 60
    """
    booking = validate_booking(course_slug, day, start, minutes, date.today())
    if booking in BOOKINGS:
        return f"Already booked, no new booking created: {json.dumps(booking)}"
    BOOKINGS.append(booking)
    return f"Booked: {json.dumps(booking)}"


if __name__ == "__main__":
    today = date(2026, 11, 7)    # A fixed date makes the checks repeatable
    cases = [
        ("Django-101", "2026-11-12", "19:00", 60),
        ("rust-101", "2026-11-12", "19:00", 60),
        ("sql-basics", "12/11/2026", "19:00", 60),
        ("sql-basics", "2026-11-08", "10:00", 60),
        ("sql-basics", "2026-11-10", "07:30", 60),
        ("sql-basics", "2026-11-10", "10:00", 45),
    ]
    for case in cases:
        try:
            print("OK  ", validate_booking(*case, today=today))
        except InvalidInput as error:
            print("ERR ", error)

Output:

OK   {'course': 'django-101', 'day': '2026-11-12', 'start': '19:00', 'minutes': 60}
ERR  Unknown course 'rust-101'. Valid slugs: django-101, django-apis, sql-basics.
ERR  Invalid day '12/11/2026'. Use the YYYY-MM-DD format, e.g. '2026-11-12'.
ERR  2026-11-08 is a Sunday. Sessions run Monday to Saturday.
ERR  Sessions must start between 09:00 and 20:00.
ERR  Invalid duration 45. Choose 30, 60, or 90 minutes.

How It Works

  1. InvalidInput is a subclass of ValueError, used for every validation failure. Its messages are written for Claude: each one names the problem and shows a valid form or the allowed values.
  2. Normalization runs first: strip() and lower() turn "Django-101" into "django-101", so harmless differences are accepted instead of rejected.
  3. Format checks use the standard library: date.fromisoformat() and time.fromisoformat() raise ValueError for anything that is not ISO format, such as "12/11/2026". from None hides the original exception, because the new message already explains the problem.
  4. Business rules follow: the day must be in the future, within 60 days, and not a Sunday (weekday() == 6); the time must be within opening hours; the duration must be offered.
  5. validate_booking() takes today as a parameter, so the tests use a fixed date (7 November 2026) and give the same results every day.
  6. book_session() is a thin wrapper: it calls the validation with the real date, then performs the action. Repeated identical calls do not create duplicate bookings, which makes the tool idempotent (Lesson 86).
  7. When used with the tool runner, any InvalidInput raised here becomes an is_error tool result. Given "Book Django for next Sunday at 7pm", Claude might first send day as a Sunday and start as "7pm", receive the error message, and retry with a valid weekday and "19:00", or ask the student to choose another day.

Common Mistakes

Trusting the Schema for Business Rules

What happens: well-formed but invalid inputs, such as past dates or unknown courses, reach your systems.

Fix: validate business rules inside the function.

Error Messages That Do Not Explain the Fix

What happens: Claude retries with the same mistake, or gives up.

Fix: state the problem, the expected format or allowed values, and an example.

Rejecting Harmless Variations

What happens: "Django-101" or " 19:00 " fail, causing unnecessary retries.

Fix: normalize inputs before validating.

Validation Mixed into Untestable Code

What happens: rules are hard to test, especially rules that depend on the current date.

Fix: put validation in plain functions, with dependencies such as today passed in.

Non-Idempotent Actions

What happens: a retried call creates duplicates.

Fix: detect repeated identical requests, or use idempotency keys.


Best Practices

  • Use type hints and Literal so the schema guides Claude.
  • Normalize, then validate formats and business rules inside the function.
  • Raise a dedicated exception with messages that explain how to fix the input.
  • Separate validation logic from the decorated tool, and unit-test it.
  • Inject time and other dependencies to keep tests deterministic.
  • Make actions idempotent where possible.

Exercise

  1. Add a rule that rejects sessions on 25 December, with a clear message.
  2. Change minutes to Literal[30, 60, 90] and print the generated schema with book_session.to_dict(). How does the minutes property change?
  3. Write pytest tests for validate_booking() that cover every error message.
  4. Run book_session with the tool runner and the request "Book SQL Basics this Sunday at 7pm". Observe how Claude responds to the validation errors.
Show answers for steps 1 and 3

Step 1: after the Sunday check:

if (session_day.month, session_day.day) == (12, 25):
    raise InvalidInput(f"{day} is a holiday. No sessions run on 25 December; choose another day.")

Step 3:

from datetime import date

import pytest

from validated_booking import InvalidInput, validate_booking

TODAY = date(2026, 11, 7)


def test_valid_booking_is_normalized():
    booking = validate_booking(" Django-101 ", "2026-11-12", "19:00", 60, TODAY)
    assert booking == {"course": "django-101", "day": "2026-11-12", "start": "19:00", "minutes": 60}


@pytest.mark.parametrize("args, message", [
    (("rust-101", "2026-11-12", "19:00", 60), "Unknown course"),
    (("sql-basics", "12/11/2026", "19:00", 60), "Use the YYYY-MM-DD format"),
    (("sql-basics", "2026-11-07", "10:00", 60), "is not in the future"),
    (("sql-basics", "2027-03-01", "10:00", 60), "at most 60 days ahead"),
    (("sql-basics", "2026-11-08", "10:00", 60), "is a Sunday"),
    (("sql-basics", "2026-11-10", "07:30", 60), "between 09:00 and 20:00"),
    (("sql-basics", "2026-11-10", "10:00", 45), "Choose 30, 60, or 90"),
])
def test_invalid_inputs(args, message):
    with pytest.raises(InvalidInput, match=message):
        validate_booking(*args, today=TODAY)

pytest.mark.parametrize runs the same test for each case, and pytest.raises(..., match=...) checks both the exception type and part of its message.

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 Validate Tool Inputs and get answers drawn from it.

Signed-in readers only.