Create Tool Schemas
What a Tool Schema Is
Every tool definition has an input_schema: a JSON Schema that describes the input object Claude must produce when it calls the tool (Lesson 82). JSON Schema is a standard format for describing the structure of JSON data: which fields exist, their types, which are required, and which values are allowed.
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. Chennai"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
}
The schema serves two purposes:
- It guides Claude: Claude reads it to understand what to send.
- It defines a contract: your code can rely on, and verify, the shape of the input.
Schema Building Blocks
The Top Level
The top level of an input_schema is always an object:
| Keyword | Purpose |
|---|---|
"type": "object" | The input is a JSON object (a Python dict) |
"properties" | The fields the object may contain, each with its own schema |
"required" | The fields that must always be present |
"additionalProperties": False | No fields other than those listed are allowed |
Types
| JSON Schema type | Python value | Example |
|---|---|---|
"string" | str | "Chennai" |
"integer" | int | 3 |
"number" | int or float | 2.5 |
"boolean" | bool | True |
"array" | list | ["django", "sql"] |
"object" | dict | {"min": 1, "max": 5} |
"null" | None | None |
Useful Keywords
| Keyword | Example | Effect |
|---|---|---|
description | "description": "Date in YYYY-MM-DD format" | Explains meaning and format to Claude |
enum | "enum": ["beginner", "intermediate", "advanced"] | Allows only the listed values |
items | "items": {"type": "string"} | The schema of each element in an array |
format | "format": "date" | A known string format, such as a date |
default | "default": 60 | Documents the default; your code must apply it |
Note:
defaultonly describes a default value. Claude may omit the field, and the API does not fill it in for you. Your Python function must use the same default, for exampleduration_minutes: int = 60.
Arrays and Nested Objects
"properties": {
"topics": {
"type": "array",
"items": {"type": "string"},
"description": "Topics to cover, e.g. ['models', 'views']",
},
"contact": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "Contact email address"},
"phone": {"type": "string", "description": "Optional phone number with country code"},
},
"required": ["email"],
},
}
Nested objects have their own properties and required lists. Keep nesting shallow: flat inputs are easier for Claude to fill correctly, and easier for your code to validate.
Required vs. Optional Fields
| Field | When to make it required |
|---|---|
| Required | The tool cannot work without it, for example city for a weather tool |
| Optional | It has a sensible default, or it only refines the request, for example unit or level |
For optional fields, say in the description what happens when the field is omitted: "Omit it to search all levels." This helps Claude decide whether to include it.
Strict Tool Use: Guaranteed Schema Conformance
Without extra settings, Claude follows your schema very reliably, but it is not guaranteed: an input could occasionally contain "2" instead of 2, or miss a required field. For tools where a malformed input would break your code, set "strict": True on the tool definition:
{
"name": "schedule_session",
"description": "...",
"strict": True,
"input_schema": {
"type": "object",
"properties": {...},
"required": [...],
"additionalProperties": False,
},
}
According to the strict tool use documentation, strict: true constrains the model's output so that the tool's input always follows the schema, and the tool name is always valid. It uses the same technique as structured outputs (Lesson 36), and it is available on every model.
Without strict | With strict: true | |
|---|---|---|
| Inputs follow the schema | Almost always | Guaranteed |
| Types | Usually correct | Always correct (2, never "2") |
| Required fields | Usually present | Always present |
| Schema features | Full JSON Schema as hints | A supported subset (see the structured outputs documentation's "JSON Schema limitations") |
Use "additionalProperties": False in strict schemas, as the documentation's examples do. If the API rejects a strict schema, check it against the documented limitations.
Note:
strictguarantees the shape of the input, not that the values make sense. A date can be a valid string in the right format and still be in the past. Business rules still need validation in your code (Lessons 86 and 94).
Generating Schemas from Pydantic Models
Writing JSON Schemas by hand is repetitive and error-prone. Pydantic (installed with the SDK) can generate a schema from a Python class, and also validate inputs against that same class. One model then serves as the single source of truth:
from pydantic import BaseModel, ConfigDict, Field
class WeatherInput(BaseModel):
model_config = ConfigDict(extra="forbid")
city: str = Field(description="City name, e.g. Chennai")
unit: str = Field(default="celsius", description="Temperature unit: celsius or fahrenheit")
schema = WeatherInput.model_json_schema()
Field(description=...)becomes the property'sdescription.- Fields without defaults become
required. ConfigDict(extra="forbid")produces"additionalProperties": false, and also makes validation reject unknown fields.model_json_schema()returns the schema as a dictionary.
Pydantic also adds title entries to the schema, for the model and for each field. They are harmless, but they add tokens without helping Claude, so the helper in the practical example removes them, carefully, without touching a field that happens to be named title.
Practical Example: Tools from Pydantic Models
This module builds tool definitions from Pydantic models and uses the same models to validate tool inputs. The schema Claude sees and the validation your code performs can never drift apart.
"""pydantic_tools.py: generate tool schemas from Pydantic models, and validate inputs with them."""
import json
from pydantic import BaseModel, ConfigDict, Field, ValidationError
def strip_titles(schema):
"""Remove Pydantic's 'title' annotations, but keep fields that are named 'title'."""
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 # A title annotation: drop it
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 tool_from_model(name: str, description: str, model: type[BaseModel], strict: bool = True) -> dict:
definition = {
"name": name,
"description": description,
"input_schema": strip_titles(model.model_json_schema()),
}
if strict:
definition["strict"] = True
return definition
class SessionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
course_id: str = Field(description="Course slug, e.g. 'django-101'")
date: str = Field(description="Session date in YYYY-MM-DD format")
time: str = Field(description="Start time in 24-hour HH:MM format, e.g. '19:00'")
duration_minutes: int = Field(default=60, description="Length in minutes; omit for 60")
topics: list[str] = Field(default_factory=list, description="Topics to cover; may be empty")
SCHEDULE_SESSION = tool_from_model(
name="schedule_session",
description=(
"Schedules a live tutoring session for a course. Use it when a student asks to book "
"or arrange a live session. Do not use it to check existing bookings. "
"Returns a confirmation with the session ID, or an error if the slot is unavailable."
),
model=SessionRequest,
)
if __name__ == "__main__":
schema = SCHEDULE_SESSION["input_schema"]
print("properties:", list(schema["properties"]))
print("required: ", schema["required"])
print("strict: ", SCHEDULE_SESSION.get("strict"))
print("additionalProperties:", schema["additionalProperties"])
print()
samples = [
{"course_id": "django-101", "date": "2026-11-12", "time": "19:00"},
{"course_id": "django-101", "time": "19:00"},
{"course_id": "django-101", "date": "2026-11-12", "time": "19:00", "room": "A1"},
]
for sample in samples:
try:
request = SessionRequest.model_validate(sample)
print("valid: ", request.model_dump())
except ValidationError as error:
first = error.errors()[0]
print("invalid:", first["loc"], first["msg"])
print()
print(json.dumps(schema["properties"]["duration_minutes"], indent=2))
Output:
properties: ['course_id', 'date', 'time', 'duration_minutes', 'topics']
required: ['course_id', 'date', 'time']
strict: True
additionalProperties: False
valid: {'course_id': 'django-101', 'date': '2026-11-12', 'time': '19:00', 'duration_minutes': 60, 'topics': []}
invalid: ('date',) Field required
invalid: ('room',) Extra inputs are not permitted
{
"default": 60,
"description": "Length in minutes; omit for 60",
"type": "integer"
}
How It Works
SessionRequestdescribes the tool's input as a Python class. Field types become schema types,Field(description=...)becomes descriptions, and fields without defaults becomerequired.ConfigDict(extra="forbid")adds"additionalProperties": falseto the schema and makes validation reject unknown fields, as the third sample shows.strip_titles()walks the schema recursively and removes Pydantic'stitleannotations, the"title": "..."string entries on the model and each field. Insideproperties(and$defs, used for nested models), the keys are field names, so they are always kept. A model with a field calledtitle, such as a course or blog post, therefore keeps that field. Only string-valuedtitlekeys elsewhere are dropped.tool_from_model()assembles the tool definition withstrict: trueby default, so Claude's inputs are guaranteed to match the schema.model_validate()checks an input against the same model. A valid input becomes a typedSessionRequestobject with defaults applied:duration_minutes=60andtopics=[]were filled in by Pydantic, not by Claude or the API.error.errors()returns structured error details:locis the path to the problem field, andmsgis a human-readable message. Lesson 90 sends such messages back to Claude so it can correct its input.
Even with strict: true, validating with the model is useful: it converts the input into a typed object, applies defaults, and protects functions that may also be called from other code paths.
Common Mistakes
Missing Descriptions on Properties
What happens: Claude guesses formats, for example 12/11/2026 instead of 2026-11-12.
Fix: describe every property, including its format and an example.
Relying on default to Fill Values
What happens: your function receives no value and fails, because the API does not apply schema defaults.
Fix: apply defaults in your code, or with Pydantic.
Overly Deep Nesting
What happens: more complex inputs, more mistakes, harder validation.
Fix: keep schemas flat where possible, and split very complex tools.
Using Unsupported Features in Strict Schemas
What happens: the API rejects the tool definition.
Fix: check the documented JSON Schema limitations for strict mode, and keep strict schemas simple.
Treating strict as Business Validation
What happens: well-formed but invalid values, such as past dates or unknown course IDs, reach your systems.
Fix: validate values in your code before acting on them.
Best Practices
- Describe every property, with formats and examples.
- Use
enumfor fixed sets of values. - Mark only truly necessary fields as required, and explain optional ones.
- Use
strict: truewhen malformed inputs would break your code. - Add
"additionalProperties": Falseto object schemas. - Generate schemas from Pydantic models and validate with the same models.
- Apply defaults in code, and validate business rules separately.
Exercise
- Write a Pydantic model
TicketInputwithsubject(string),category(one ofbilling,technical,account), andpriority(integer, default 3). Generate a tool definition from it. Hint: usetyping.Literal["billing", "technical", "account"]for the category. - Print the generated schema for
category. Which JSON Schema keyword did Pydantic use? - Add a field named
titletoSessionRequest, generate the tool again, and confirm thattitlestill appears inpropertiesandrequired. - Explain why
topics: list[str] = Field(default_factory=list)is better thantopics: list[str] = []in a Pydantic model.
Step 1:
from typing import Literal
class TicketInput(BaseModel):
model_config = ConfigDict(extra="forbid")
subject: str = Field(description="One-line summary of the issue")
category: Literal["billing", "technical", "account"] = Field(description="Ticket category")
priority: int = Field(default=3, description="Priority from 1 (urgent) to 5 (low); omit for 3")
CREATE_TICKET = tool_from_model(
"create_ticket",
"Creates a support ticket. Use it when a student reports a problem that needs follow-up "
"by the support team. Do not use it for general questions. Returns the new ticket ID.",
TicketInput,
)
Step 2: Pydantic represents Literal[...] with an enum list: ["billing", "technical", "account"], together with the type string.
Step 4: default_factory=list creates a new empty list for each object. Pydantic does copy plain mutable defaults, but default_factory makes the intent explicit, and it is the standard Python pattern elsewhere, for example in dataclasses, where a shared mutable default would be a real bug.