When you're building intelligent agents with CrewAI, one of the coolest things you can do is hook them up to custom tools and external APIs. This completely changes the game. Instead of just having your agents generate text, they can actually do things. Call functions, query services, execute real actions. It opens up a whole world of possibilities for complex workflows.

Now, here's the thing. Most simple tool examples you'll find online just pass a single string input. Sure, that works for basic stuff, but let me tell you, it gets limiting really fast. I learned this the hard way when I was building an agent for a side project last year. In the real world, you need your agent to handle structured data with multiple fields, optional parameters, the works. You need richer payloads if you want to interact with sophisticated APIs and internal logic in any meaningful way.

Uploaded image

So in this post, I'm going to show you how to build smarter tools by:

  • Using the @tool decorator for those quick, lightweight tools you need to spin up fast

  • Leveraging the BaseTool class when you need more control over structured inputs and internal logic

  • Passing a single payload attribute that cleanly carries multiple arguments into your tools. This one's particularly useful when your parameters can vary, like when you have optional fields depending on the situation

Setup

First things first, you'll need CrewAI. Just install it via pip:

%pip install crewai

And let's import everything we need:

import json
from crewai.tools import tool, BaseTool
from pydantic import BaseModel, Field
from typing import Type, List
from crewai import Agent, Crew
from crewai.task import Task
from crewai.tasks.task_output import TaskOutput

Approach #1: Using the @tool Decorator

The @tool decorator is probably the simplest way to create a tool in CrewAI. What I like about it is that the syntax stays really close to how you'd write a regular Python function. No fancy abstractions to worry about.

Actually, when I first started with CrewAI, I tried to overcomplicate everything. But then I realized, sometimes simple is better.

To show you how to call a tool with several parameters (not just a simple string), let's use a practical business case. In a lot of agent workflows I've built, you often need a multi-step process. First step: understand and classify what the user wants. Next step: actually do something about it.

For this example, we'll focus on that first step. We're going to build a tool that receives structured information about a user and logs their intent. Think of it as setting the stage for whatever action comes next.

Creating a Simple Tool with the @tool Decorator:

@tool("User Intent Logger")
def user_intent_logger(user_name: str, intent: str, is_premium_user: bool) -> str:
    """Processes user intent and returns a summary based on user type and action intent."""
    user_type = "Premium" if is_premium_user else "Standard"
    return (
        f"User '{user_name}' ({user_type} User) expressed intent to '{intent}'. "
        f"Action logged and analyzed for next steps."
    )

Defining the Agent:

# Output of the classify_intent task
class IntentClassificationOutput(BaseModel):
    user_name: str
    intent: str
    is_premium_user: bool

# Agent definition
retrieval_specialist = Agent(
    role="Intelligent Retrieval and Response Generator",
    goal="Deliver precise and well-formatted responses based on user needs.",
    backstory=(
        "You're a results-driven AI agent skilled in using structured inputs like classified intent "
        "and extracted metadata to retrieve accurate information. "
        "You always maintain a helpful, professional tone and follow a markdown-friendly formatting style "
        "with clarity and structure."
    ),
    verbose=True
)

# classify_intent task (to provide context)
classify_intent = Task(
    description="Analyze the user's input and determine the user name, intent, and if the user is premium. User input: ```{user_input}```",
    expected_output="The user intent details.",
    agent=retrieval_specialist,
    output_pydantic=IntentClassificationOutput
)

# log_and_refresh conditional task
log_and_refresh = Task(
    description="Log the user's intent metadata for analytics purposes.",
    expected_output="The caching status after logging the intent.",
    agent=retrieval_specialist,
    context=[classify_intent],
    tools=[user_intent_logger]  # The tool is passed directly without instantiation
)

# Define and run the crew
crew = Crew(
    agents=[retrieval_specialist],
    tasks=[classify_intent, log_and_refresh],
    verbose=False
)

inputs = {
    "user_input": "I'm John, a premium user, and I want to know the price of laptops."
}
result = crew.kickoff(inputs=inputs)
print("Results:", result)

Approach #2: Subclassing BaseTool

Now, this approach is where things get more interesting. You define your expected inputs by creating a Pydantic BaseModel, then you subclass BaseTool to build the tool itself.

One thing to keep in mind here, and this is important: your agent has to use the exact parameter names you defined in the input schema. Get those wrong and nothing works. Trust me, I spent an embarrassing amount of time debugging this once because I had typed "user_name" in one place and "username" in another.

Defining a Structured Tool with BaseTool and BaseModel:

# Define the input to the tool
class MyToolInput(BaseModel):
    """Input schema for MyCustomTool."""
    user_name: str = Field(..., description="Name of the user.")
    intent: str = Field(..., description="Intent of the user.")
    is_premium_user: bool = Field(..., description="Whether the user is a premium customer.")

# Define the Tool
class UserIntentLoggerTool(BaseTool):
    name: str = "Log Data"
    description: str = "Logs user metadata such as name, intent, and premium status for analytics or further processing."
    args_schema: Type[BaseModel] = MyToolInput

    def _run(self, user_name: str, intent: str, is_premium_user: bool) -> str:
        # Logic to process the user metadata
        return f"user_name: {user_name}, intent: {intent}, is_premium_user: {is_premium_user}"

Using the Structured Tool in a Task:

# Define the log_and_refresh task
log_and_refresh = Task(
    description="Log the user's intent metadata to analytics.",
    expected_output="The caching status after logging the intent.",
    agent=retrieval_specialist,
    context=[classify_intent],
    tools=[UserIntentLoggerTool()]  # Instantiating the structured tool
)

# Define and run the crew
crew = Crew(
    agents=[retrieval_specialist],
    tasks=[classify_intent, log_and_refresh],
    verbose=False
)

inputs = {
    "user_input": "I'm John, a premium user, and I want to know the price of laptops."
}

result = crew.kickoff(inputs=inputs)
print("Results:", result)

Approach #3: Using a Single payload Argument

Okay, this approach is really useful when you're dealing with varying input parameters. Sometimes you don't know exactly what structure your inputs will have from one tool call to another. I discovered this while working on a personal project where the API I was calling had about fifteen optional fields. When that happens, you can define your tool to accept a single payload argument.

But here's the catch: it must be called exactly "payload", and it needs to be a string representation of a dictionary containing all your attributes. Not a dictionary. A string. This tripped me up for hours.

Define the tool with a single payload string argument:

@tool("User Payload Logger")
def user_payload_logger(payload: str) -> str:
    """
    Process a user intent payload and return a structured summary based on the user's type and action.
    """
    payload_data = json.loads(payload)

    user_name = payload_data.get("user_name")
    intent = payload_data.get("intent")
    is_premium_user = payload_data.get("is_premium_user")

    user_type = "Premium" if is_premium_user else "Standard"
    return (
        f"User '{user_name}' ({user_type} User) expressed intent to '{intent}'. "
        f"Action logged and analyzed for next steps."
    )

Here's how to define it properly to avoid any issues during execution

Let me share what I learned the hard way. I tested several different approaches to make this work:

  • First, I tried adding a detailed input explanation inside the tool definition itself. Didn't work. The agent kept passing a dictionary instead of a string, and everything would fail.

  • Then I tried adding a detailed input explanation in the task definition. Still no luck. The agent continued to format the input incorrectly.

Actually, wait. Let me be more specific about what was happening. The agent would literally pass {"key": "value"} as a Python dict object, not as the string '{"key": "value"}' that the tool expected. Such a small difference, but it broke everything.

Here's what actually worked:

  • Start your task description by clearly defining how the input should be structured

  • Explicitly tell the agent in the task description that the tool only accepts an input called payload, and that payload must be a string

  • Clearly indicate which attributes are optional, so the agent knows when it can leave fields out

# Define the log_and_refresh task
log_and_refresh = Task(
    description="""
    Build a payload for the `user_payload_logger` that MUST be a string called `payload`, which must be a string representation of a dictionary containing the following keys:

    Mandatory:
    - `user_name`: The name of the user (e.g., "Alice").
    - `intent`: The action or goal the user wants to achieve (e.g., "upgrade subscription").

    Optional:
    - `is_premium_user`: A boolean indicating that the user is a premium customer. Only include this key if the user is a premium user, and ALWAYS set its value to `true`.

    After building the payload, call the `user_payload_logger` tool to log the user details.
    """,
    expected_output="The caching status after logging the intent.",
    agent=retrieval_specialist,
    context=[classify_intent],
    tools=[user_payload_logger]  # The tool is passed directly without instantiation
)

# Define and run the crew
crew = Crew(
    agents=[retrieval_specialist],
    tasks=[classify_intent, log_and_refresh],
    verbose=False
)

inputs = {
    "user_input": "I'm Alex. I'm not a premium user and I want to know the price of PCs."
}

result = crew.kickoff(inputs=inputs)
print("Results:", result)

Conclusion

When you're extending your agents with tools in CrewAI, picking the right approach for defining tool inputs makes all the difference. Each method has its sweet spot depending on what you're trying to build.

For simple tools where you just need a few fixed inputs, the @tool decorator is your friend. It keeps your code clean and close to standard Python functions. Perfect for small utilities or when you're working with well-defined APIs. Honestly, I use this one probably 60% of the time.

When you need the full package, complete control over input validation, custom error handling, or you need to manage persistent state, then subclassing BaseTool is the way to go. You get to tightly structure your inputs with Pydantic models and customize your tool's behavior exactly how you want it. This was a lot more complicated than I imagined when I first tried it, but once you get the hang of it, it's incredibly powerful.

And finally, when your tool inputs need to be flexible, with some fields being optional depending on the situation, handling everything through a single payload argument gives you maximum flexibility. This method lets you pass dynamic data structures safely. Just remember to be crystal clear in your task description about how the agent should format that payload. I cannot stress this enough.

Master all three approaches, and you'll be able to design smarter, more reliable agents that can interact with pretty much any tool or API you throw at them. That's when things get really powerful and you can tackle real-world applications with confidence.