CrewAI custom tools become significantly more reliable when you add input validation with Pydantic. The framework’s BaseTool class accepts a Pydantic model for its args_schema, giving you automatic validation, serialization, and clear error messages before your tool logic ever runs. This guide walks through building a validated tool from scratch, wiring it into a crew, and verifying the behavior end to end.
Step 1: Set up the project structure
Create a clean project directory and install the dependencies. You need CrewAI and Pydantic v2 (the current default). If you’re on an older codebase still using Pydantic v1, the migration is straightforward but outside this guide’s scope.
mkdir crewai-validated-tool && cd crewai-validated-tool
python -m venv .venv && source .venv/bin/activate
pip install "crewai>=0.28" "pydantic>=2.0"
Create the following file structure:
crewai-validated-tool/
├── pyproject.toml
├── src/
│ └── tools/
│ ├── __init__.py
│ └── weather_tool.py
├── main.py
└── tests/
└── test_weather_tool.py
Your pyproject.toml should declare the package so imports work cleanly:
[project]
name = "crewai-validated-tool"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"crewai>=0.28",
"pydantic>=2.0",
]
[tool.setuptools.packages.find]
where = ["src"]
Run pip install -e . to make the package importable.
Step 2: Define the Pydantic model for input validation
The args_schema parameter on BaseTool expects a Pydantic BaseModel subclass. Every field becomes a required or optional argument to the tool. Use Field to add descriptions, constraints, and examples — these flow directly into the JSON schema that CrewAI passes to the LLM, so the model knows what values are valid.
Create src/tools/weather_tool.py with the schema first:
from pydantic import BaseModel, Field, field_validator
from typing import Literal
class WeatherRequest(BaseModel):
"""Input schema for the weather lookup tool."""
location: str = Field(
...,
description="City name, optionally followed by state/country code (e.g., 'San Francisco, US')",
min_length=2,
max_length=100,
examples=["San Francisco, US", "Tokyo, JP", "London, GB"],
)
units: Literal["metric", "imperial"] = Field(
default="metric",
description="Temperature units to return",
)
include_forecast: bool = Field(
default=False,
description="Whether to include a 3-day forecast in the response",
)
@field_validator("location")
@classmethod
def location_not_empty(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("location cannot be empty or whitespace")
return stripped
A few notes on this schema:
locationis required (...as default) and has length bounds.unitsuses aLiteraltype, which Pydantic validates and exposes as an enum in the generated JSON schema.include_forecastdefaults toFalseso callers can omit it.- The
@field_validatorruns after type coercion and lets you enforce business rules (here, rejecting whitespace-only strings).
Step 3: Create the custom tool class
Subclass BaseTool and assign your Pydantic model to args_schema. Implement the _run method — this is where your actual logic lives. The validated arguments arrive as keyword arguments matching your model’s field names.
Continue src/tools/weather_tool.py:
from crewai.tools import BaseTool
from typing import Type, Any
import json
class WeatherTool(BaseTool):
name: str = "weather_lookup"
"Get current weather and optional forecast for a given location. "
"Returns temperature, conditions, and humidity."
)
args_schema: Type[BaseModel] = WeatherRequest
def _run(
self,
location: str,
units: str = "metric",
include_forecast: bool = False,
) -> str:
# In production, call a real weather API here.
# This stub demonstrates the validated input flow.
temp_c = 22
temp_f = 72
condition = "Partly cloudy"
humidity = 65
if units == "imperial":
temp_display = f"{temp_f}°F"
else:
temp_display = f"{temp_c}°C"
result = {
"location": location,
"temperature": temp_display,
"condition": condition,
"humidity": f"{humidity}%",
}
if include_forecast:
result["forecast"] = [
{"day": "Tomorrow", "high": temp_display, "low": "18°C", "condition": "Sunny"},
{"day": "Day 2", "high": "24°C", "low": "16°C", "condition": "Rain"},
{"day": "Day 3", "high": "20°C", "low": "14°C", "condition": "Cloudy"},
]
return json.dumps(result, indent=2)
Key points:
- The
_runsignature mirrors the Pydantic model fields. CrewAI handles the mapping automatically. - Return a string. CrewAI tools communicate with the agent via string output; JSON is a convenient structured format.
- The
nameanddescriptionare used by the LLM to decide when to invoke the tool. Keep them specific.
Export the tool in src/tools/__init__.py:
from .weather_tool import WeatherTool, WeatherRequest
__all__ = ["WeatherTool", "WeatherRequest"]
Step 4: Register and use the tool in a crew
Now wire the tool into an agent and a task. The agent receives the tool instance in its tools list. When the LLM decides to call the tool, CrewAI validates the arguments against WeatherRequest before invoking _run.
Create main.py:
from crewai import Agent, Task, Crew, Process
from src.tools import WeatherTool
def build_crew() -> Crew:
weather_tool = WeatherTool()
researcher = Agent(
role="Weather Researcher",
goal="Provide accurate weather information for user-requested locations",
backstory=(
"You are a meticulous weather researcher who always verifies "
"location names and uses the appropriate units for the user's region."
),
tools=[weather_tool],
verbose=True,
allow_delegation=False,
)
task = Task(
description=(
"Get the current weather for San Francisco in imperial units "
"and include the 3-day forecast."
),
expected_output=(
"A JSON object with current temperature in Fahrenheit, conditions, "
"humidity, and a 3-day forecast array."
),
agent=researcher,
)
return Crew(
agents=[researcher],
tasks=[task],
process=Process.sequential,
verbose=True,
)
if __name__ == "__main__":
crew = build_crew()
result = crew.kickoff()
print("\n=== CREW RESULT ===")
print(result)
Run it:
python main.py
You should see the agent invoke the tool with location: "San Francisco", units: "imperial", include_forecast: true, and receive the JSON response.
Step 5: Test validation behavior directly
Before relying on the LLM to produce valid arguments, verify the validation logic yourself. This catches schema mistakes early and documents expected behavior.
Create tests/test_weather_tool.py:
import pytest
from pydantic import ValidationError
from src.tools import WeatherTool, WeatherRequest
class TestWeatherRequestValidation:
def test_valid_minimal_input(self):
req = WeatherRequest(location="Paris, FR")
assert req.location == "Paris, FR"
assert req.units == "metric"
assert req.include_forecast is False
def test_valid_full_input(self):
req = WeatherRequest(
location="Tokyo, JP",
units="imperial",
include_forecast=True,
)
assert req.units == "imperial"
assert req.include_forecast is True
def test_location_whitespace_rejected(self):
with pytest.raises(ValidationError) as exc:
WeatherRequest(location=" ")
assert "location cannot be empty or whitespace" in str(exc.value)
def test_location_too_short(self):
with pytest.raises(ValidationError) as exc:
WeatherRequest(location="A")
assert "at least 2 characters" in str(exc.value)
def test_invalid_units_rejected(self):
with pytest.raises(ValidationError) as exc:
WeatherRequest(location="London", units="kelvin")
assert "kelvin" in str(exc.value).lower()
class TestWeatherToolExecution:
def test_tool_runs_with_valid_args(self):
tool = WeatherTool()
result = tool._run(location="Berlin", units="metric", include_forecast=False)
assert "Berlin" in result
assert "temperature" in result
assert "22°C" in result or "22" in result # stub value
def test_tool_includes_forecast_when_requested(self):
tool = WeatherTool()
result = tool._run(location="Madrid", include_forecast=True)
data = eval(result) # safe here because we control the stub output
assert "forecast" in data
assert len(data["forecast"]) == 3
Run the tests:
pytest tests/test_weather_tool.py -v
All tests should pass. The validation tests confirm that Pydantic rejects bad input before _run is ever called. The execution tests confirm the tool produces the expected output shape.
Step 6: Verify LLM-driven invocation handles errors gracefully
When the LLM calls the tool with invalid arguments, CrewAI catches the ValidationError and returns a structured error message to the agent. The agent can then retry with corrected arguments. To observe this, temporarily modify main.py to force a bad call:
# Temporary test — remove after verification
if __name__ == "__main__":
from src.tools import WeatherTool
tool = WeatherTool()
# This mimics what happens when the LLM sends bad args
try:
tool._run(location="", units="metric") # empty location
except Exception as e:
print(f"Caught expected error: {e}")
Run it and you’ll see a ValidationError with a clear message about the location field. In a real crew run, the agent sees this error in the tool result and can self-correct — this is the primary benefit of crewai tool input validation pydantic integration.
Common pitfalls and advanced patterns
Pitfall: Forgetting args_schema type annotation
If you omit the type annotation Type[BaseModel], static analyzers and some runtime checks may complain. Always annotate:
args_schema: Type[BaseModel] = WeatherRequest
Pitfall: Mismatched _run signature
The _run parameter names must match the Pydantic model field names exactly. A mismatch causes a TypeError at invocation time. Use **kwargs only if you have a dynamic schema (rare).
Pattern: Nested models for complex inputs
For tools that accept structured payloads, nest Pydantic models:
class Coordinates(BaseModel):
lat: float = Field(..., ge=-90, le=90)
lon: float = Field(..., ge=-180, le=180)
class WeatherRequest(BaseModel):
location: str | None = None
coordinates: Coordinates | None = None
# ... validator to require exactly one of location or coordinates
Pattern: Async tools
If your tool calls external APIs, implement _arun instead of (or alongside) _run:
async def _arun(self, location: str, units: str = "metric") -> str:
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://api.weather.com/{location}")
# ...
CrewAI will prefer _arun when running in an async context.
Pattern: Reusing schemas across tools
Define shared schemas in a separate module (e.g., src/schemas/weather.py) and import them into multiple tool files. This keeps validation consistent when you have related tools like WeatherLookupTool and WeatherAlertTool.
Verification checklist
Before considering the tool production-ready, confirm each item:
-
pip install -e .works without errors -
pytest tests/test_weather_tool.py -vpasses all tests -
python main.pycompletes a full crew run and prints structured JSON output - Invalid arguments (empty location, wrong units) produce a
ValidationErrorwith a readable message - The tool’s
nameanddescriptionare specific enough for the LLM to select it correctly - The
_runmethod handles all valid input combinations (metric/imperial, forecast on/off) - No
printstatements or side effects in_run— return strings only - If using async,
_arunis implemented and tested
What this buys you
Adding crewai tool input validation pydantic schemas transforms fragile string-parsing tools into typed, self-documenting components. The LLM gets a precise JSON schema, invalid calls fail fast with actionable errors, and your tool logic stays clean because it never receives garbage input. This pattern scales: as you add more tools, each carries its own contract, and the overall system becomes easier to debug and extend.