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:
from jsonschema import validate, ValidationError
import jsonschema
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
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
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
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)