Validate Tool Inputs
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:
| Input | Shape is fine | But… |
|---|---|---|
course_slug="rust-101" | It is a string | That course does not exist |
day="2026-11-01" | A string in date format | The date is in the past |
day="2026-11-08" | A valid future date | It is a Sunday, when no sessions run |
start="07:30" | A string in time format | Sessions start between 09:00 and 20:00 |
minutes=45 | An integer | Only 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
| Layer | What it checks | How |
|---|---|---|
| 1. Type hints and schema | Types, required fields, allowed values (Literal) | Generated by @beta_tool; guides Claude |
| 2. Normalization | Harmless variations | Strip spaces, lowercase slugs, parse dates |
| 3. Format checks | Strings that must follow a format | date.fromisoformat(), regular expressions |
| 4. Business rules | Existence, ranges, schedules, limits | Your own checks against your data |
| 5. Permissions | Whether this user may do this | Context 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 message | Helpful 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
todayas 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
InvalidInputis a subclass ofValueError, 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.- Normalization runs first:
strip()andlower()turn"Django-101"into"django-101", so harmless differences are accepted instead of rejected. - Format checks use the standard library:
date.fromisoformat()andtime.fromisoformat()raiseValueErrorfor anything that is not ISO format, such as"12/11/2026".from Nonehides the original exception, because the new message already explains the problem. - 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. validate_booking()takestodayas a parameter, so the tests use a fixed date (7 November 2026) and give the same results every day.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).- When used with the tool runner, any
InvalidInputraised here becomes anis_errortool result. Given "Book Django for next Sunday at 7pm", Claude might first senddayas a Sunday andstartas"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
Literalso 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
- Add a rule that rejects sessions on 25 December, with a clear message.
- Change
minutestoLiteral[30, 60, 90]and print the generated schema withbook_session.to_dict(). How does theminutesproperty change? - Write
pytesttests forvalidate_booking()that cover every error message. - Run
book_sessionwith the tool runner and the request "Book SQL Basics this Sunday at 7pm". Observe how Claude responds to the validation errors.
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.