n4nAI

CrewAI custom tools: adding input validation with Pydantic

Learn how to add robust input validation to CrewAI custom tools using Pydantic models, with step-by-step code examples and verification steps.

n4n Team4 min read857 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  • location is required (... as default) and has length bounds.
  • units uses a Literal type, which Pydantic validates and exposes as an enum in the generated JSON schema.
  • include_forecast defaults to False so callers can omit it.
  • The @field_validator runs 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 _run signature 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 name and description are 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 -v passes all tests
  • python main.py completes a full crew run and prints structured JSON output
  • Invalid arguments (empty location, wrong units) produce a ValidationError with a readable message
  • The tool’s name and description are specific enough for the LLM to select it correctly
  • The _run method handles all valid input combinations (metric/imperial, forecast on/off)
  • No print statements or side effects in _run — return strings only
  • If using async, _arun is 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.

Tagscrewaicustom-toolspydanticvalidation

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All crewai custom tools & integrations posts →