
When you're wiring up AI agents to external tools, the gap between "what the model thought it sent" and "what the tool actually expects" will bite you. Every. Single. Time.
The fix isn't prompting discipline — it's validation at the boundary. And the most practical tool for that job is JSON Schema.
Most AI agent tutorials hand-wave input validation or skip it entirely. That works until a model decides to pass {"count": "five"} instead of {"count": 5} — and your downstream tool chrashes at 2am.
JSON Schema gives you:
Every tool your agent calls should declare its input schema. Here's a practical example:
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query, max 200 chars",
"maxLength": 200
},
"limit": {
"type": "integer",
"description": "Number of results, 1-50",
"minimum": 1,
"maximum": 50,
"default": 10
},
"filters": {
"type": "object",
"properties": {
"source": {"type": "string", "enum": ["web", "news", "images"]},
"date_range": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"description": "ISO date, e.g. 2024-01-15"
}
},
"additionalProperties": false
}
},
"required": ["query"],
"additionalProperties": false
}Key moves here:
required marks non-optional fields so missing params fail fastadditionalProperties: false catches typos in parameter namespattern on date strings prevents garbage dates like yesterdayenum constrains categorical values to known valid optionsfrom jsonschema import validate, ValidationError
import jsonschema
# Use Draft-7 (widely supported, stable)
Validator = jsonschema.Draft7Validator
def validate_tool_input(schema: dict, payload: dict) -> list[str]:
"""Returns list of validation errors, empty if valid."""
validator = Validator(schema)
errors = [e.message for e in validator.iter_errors(payload)]
return errors
# In your tool dispatch
errors = validate_tool_input(tool_schema, agent_output)
if errors:
raise ToolInputError(f"Invalid input: {'; '.join(errors)}")Don't hand-write schemas for complex objects — derive them:
from pydantic import BaseModel, Field
import json
class SearchRequest(BaseModel):
query: str = Field(..., max_length=200)
limit: int = Field(default=10, ge=1, le=50)
filters: dict | None = None
# One line to schema
schema = SearchRequest.model_json_schema()
print(json.dumps(schema, indent=2))This gives you validation _and_ a shareable schema for your agent's system prompt.
A robust tool integration validates at three points:
1. Before tool dispatch — catch bad output from the model before it reaches the tool 2. At the tool boundary — re-validate in the tool adapter (models can hallucinate tool call payloads) 3. At the tool response — validate what the tool returns before passing it back to the model
# Middle layer: tool adapter
class ToolAdapter:
def __init__(self, tool_schema: dict):
self.validator = jsonschema.Draft7Validator(tool_schema)
def dispatch(self, validated_input: dict) -> dict:
errors = [e.message for e in self.validator.iter_errors(validated_input)]
if errors:
raise ValidationError(f"Schema violation: {errors}")
return self._execute(validated_input)additionalProperties: false** — allows extra garbage fields to sneak throughnull vs missing** — use type: ["string", "null"] if null is a valid value, not absencepattern or $ref to date-time formatspattern or format checks