Introduction to Tool Use Patterns with CrewAI
CrewAI is a powerful framework for orchestrating role-playing autonomous AI agents. One of its most compelling features is the ability to equip agents with tools — external functions, APIs, or services that extend what an agent can actually do beyond just generating text. Understanding tool use patterns is essential for building production-grade multi-agent systems that interact with the real world.
In this guide, we'll explore the core tool use patterns in CrewAI, from basic tool definitions to advanced composition strategies. Whether you're building a research assistant, a data pipeline orchestrator, or a customer support swarm, mastering these patterns will dramatically improve the reliability and capability of your crews.
What Are CrewAI Tools?
Tools in CrewAI are callable functions that agents can invoke during their reasoning process. They bridge the gap between an LLM's language capabilities and external systems like databases, web services, file systems, or custom business logic. When an agent decides it needs information or needs to perform an action, it can call a tool, receive the result, and incorporate that into its next step.
CrewAI tools are built on top of the BaseTool abstraction and integrate with the underlying LLM's function-calling capabilities. The framework handles the serialization of arguments, execution, and result injection back into the agent's context window.
Why Tool Use Matters
- Grounding in reality: Tools let agents fetch live data instead of relying on potentially outdated training knowledge.
- Action execution: Agents can send emails, update records, trigger webhooks, or modify files — not just talk about doing so.
- Reduced hallucination: When an agent can verify facts through a tool, it's less likely to fabricate answers.
- Composability: Tools can be shared across agents, enabling specialization and collaboration.
- Auditability: Tool calls create a traceable log of what the agent actually did, which is critical for debugging and compliance.
Pattern 1: The Simple Custom Tool
The most fundamental pattern is defining a custom tool from scratch. CrewAI provides a BaseTool class you can subclass, but the idiomatic approach is to use the @tool decorator, which handles boilerplate for you.
from crewai.tools import tool
@tool("Get Stock Price")
def get_stock_price(symbol: str) -> str:
"""Fetch the current price for a given stock symbol.
Args:
symbol: The ticker symbol, e.g. 'AAPL' or 'GOOGL'.
Returns:
A string with the current price.
"""
# In a real app, call a financial API here
prices = {"AAPL": "189.45", "GOOGL": "141.80", "TSLA": "245.30"}
price = prices.get(symbol.upper(), "Unknown symbol")
return f"The current price of {symbol.upper()} is ${price}"
Notice three critical elements: the tool name passed to the decorator, the type-annotated function signature, and the docstring. CrewAI uses the docstring and type hints to generate the schema the LLM sees, so writing clear, descriptive docstrings directly impacts how well the agent chooses and uses the tool.
Wiring the Tool to an Agent
from crewai import Agent, Task, Crew
analyst = Agent(
role="Financial Analyst",
goal="Provide accurate stock price information to users",
backstory="You are a meticulous analyst who always verifies data "
"before reporting it.",
tools=[get_stock_price],
verbose=True,
)
task = Task(
description="Find the current price of AAPL and summarize it.",
expected_output="A one-sentence summary of AAPL's current stock price.",
agent=analyst,
)
crew = Crew(agents=[analyst], tasks=[task])
result = crew.kickoff()
print(result)
Pattern 2: StructuredTool for Complex Arguments
When a tool needs complex, nested, or optional arguments, the decorator approach can get unwieldy. CrewAI supports StructuredTool, which lets you define arguments using Pydantic models. This gives you validation, defaults, and richer schemas for the LLM.
from crewai.tools import StructuredTool
from pydantic import BaseModel, Field
class SearchParams(BaseModel):
query: str = Field(..., description="The search query string")
max_results: int = Field(default=5, description="Maximum number of results")
include_snippets: bool = Field(
default=True, description="Whether to include text snippets"
)
def _run_search(query: str, max_results: int, include_snippets: bool) -> str:
# Simulated search logic
results = [f"Result {i+1} for '{query}'" for i in range(max_results)]
if include_snippets:
results = [f"{r} — snippet preview..." for r in results]
return "\n".join(results)
search_tool = StructuredTool.from_function(
name="Web Search",
description="Search the web and return relevant results.",
func=_run_search,
args_schema=SearchParams,
)
This pattern shines when you have tools with many parameters or when you want to enforce strict input validation before the tool ever executes.
Pattern 3: Using Built-in and Third-Party Tools
CrewAI ships with a growing collection of pre-built tools, and the ecosystem includes integrations with LangChain tools, LlamaIndex tools, and more. Using existing tools saves time and reduces bugs.
from crewai.tools import SerperDevTool, ScrapeWebsiteTool, FileReadTool
web_search = SerperDevTool()
scraper = ScrapeWebsiteTool()
file_reader = FileReadTool()
researcher = Agent(
role="Researcher",
goal="Gather and synthesize information from the web",
backstory="An experienced researcher skilled at finding "
"authoritative sources quickly.",
tools=[web_search, scraper, file_reader],
verbose=True,
)
You can also wrap any LangChain tool using CrewAI's compatibility layer:
from crewai.tools.base_tool import BaseTool
from langchain_community.tools import WikipediaQueryRun
from langchain_community.utilities import WikipediaAPIWrapper
wiki = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper())
class WikiTool(BaseTool):
name: str = "Wikipedia Lookup"
description: str = wiki.description
def _run(self, query: str) -> str:
return wiki.run(query)
Pattern 4: Tool Sharing Across Agents
A common architectural pattern is sharing a tool among multiple agents so they can collaborate on a common resource. For example, a shared database tool lets both a "Reader" agent and a "Writer" agent interact with the same data store.
from crewai.tools import tool
@tool("Query Database")
def query_database(sql: str) -> str:
"""Execute a read-only SQL query against the analytics database."""
# Simulated DB call
return f"Rows for query: {sql}"
@tool("Insert Record")
def insert_record(table: str, data: str) -> str:
"""Insert a JSON record into the specified table."""
return f"Inserted {data} into {table}"
reader = Agent(
role="Data Reader",
goal="Answer questions by querying the analytics database",
backstory="A careful data analyst who writes precise SQL.",
tools=[query_database],
)
writer = Agent(
role="Data Writer",
goal="Persist new findings into the database",
backstory="A reliable engineer who ensures data integrity.",
tools=[insert_record, query_database], # writer can also read
)
Sharing read tools across agents is generally safe. Be more cautious with write tools — consider scoping them to a single agent to avoid conflicting writes.
Pattern 5: The Tool-Chain Pattern
In multi-agent crews, tools often form implicit chains: one agent's tool output becomes the input to another agent's task. This is the backbone of complex workflows like research-and-report pipelines.
from crewai import Agent, Task, Crew, Process
from crewai.tools import SerperDevTool, ScrapeWebsiteTool
search = SerperDevTool()
scrape = ScrapeWebsiteTool()
researcher = Agent(
role="Researcher",
goal="Find authoritative URLs about the topic",
backstory="Expert at finding high-quality sources.",
tools=[search],
)
scraper_agent = Agent(
role="Content Extractor",
goal="Extract key information from provided URLs",
backstory="Skilled at parsing and summarizing web content.",
tools=[scrape],
)
writer = Agent(
role="Report Writer",
goal="Write a polished report from extracted findings",
backstory="A professional writer with a talent for clarity.",
)
research_task = Task(
description="Search for recent articles about CrewAI and list the top 3 URLs.",
expected_output="A list of 3 URLs with brief descriptions.",
agent=researcher,
)
scrape_task = Task(
description="Scrape each URL from the previous task and extract key points.",
expected_output="Bullet-point summaries of each article.",
agent=scraper_agent,
)
write_task = Task(
description="Write a 500-word report synthesizing the scraped summaries.",
expected_output="A well-structured markdown report.",
agent=writer,
)
crew = Crew(
agents=[researcher, scraper_agent, writer],
tasks=[research_task, scrape_task, write_task],
process=Process.sequential,
verbose=True,
)
result = crew.kickoff()
The key insight here is that each task's output flows automatically into the next agent's context. You don't need to manually pipe tool results between agents — CrewAI handles the handoff.
Pattern 6: Error Handling and Fallbacks
Real-world tools fail. APIs rate-limit, networks drop, and data is messy. Robust tool implementations handle errors gracefully and return informative messages the LLM can act on.
import requests
from crewai.tools import tool
@tool("Fetch Weather")
def fetch_weather(city: str) -> str:
"""Get current weather for a city. Handles errors gracefully."""
try:
resp = requests.get(
"https://wttr.in/" + city,
params={"format": "3"},
timeout=10,
)
resp.raise_for_status()
return resp.text.strip()
except requests.Timeout:
return "Error: Weather service timed out. Try again later."
except requests.RequestException as e:
return f"Error: Could not fetch weather — {e}"
Returning error strings rather than raising exceptions is important. If a tool raises, the agent's run may abort entirely. If it returns a descriptive error string, the agent can reason about the failure and try an alternative approach.
Pattern 7: Stateful Tools with Caching
Some tools are expensive — they hit rate-limited APIs or perform heavy computation. Caching results prevents redundant calls and speeds up your crew significantly.
from functools import lru_cache
from crewai.tools import tool
@tool("Cached Translation")
def translate(text: str, target_lang: str) -> str:
"""Translate text to the target language. Results are cached."""
return _translate_cached(text, target_lang)
@lru_cache(maxsize=256)
def _translate_cached(text: str, target_lang: str) -> str:
# Expensive API call happens here, but only once per unique input
import requests
resp = requests.post(
"https://libretranslate.de/translate",
json={"q": text, "source": "auto", "target": target_lang},
timeout=15,
)
return resp.json().get("translatedText", text)
Separating the cached inner function from the tool wrapper keeps the schema clean while still benefiting from memoization.
Pattern 8: Human-in-the-Loop Tools
For high-stakes actions — sending payments, deleting records, publishing content — you often want a human to approve before the tool executes. You can implement this by pausing for input inside the tool.
from crewai.tools import tool
@tool("Send Email")
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email. Requires human confirmation before sending."""
print("\n--- EMAIL DRAFT ---")
print(f"To: {to}")
print(f"Subject: {subject}")
print(f"Body: {body}")
print("-------------------\n")
approval = input("Approve sending this email? (yes/no): ").strip().lower()
if approval != "yes":
return "Email was NOT sent — user declined approval."
# Actual send logic here
return f"Email successfully sent to {to}."
This pattern is simple but powerful. For asynchronous or web-based crews, you'd replace the blocking input() with a callback or webhook mechanism, but the principle is the same: gate dangerous actions behind explicit confirmation.
Best Practices
Write Excellent Docstrings
The docstring is the single most important factor in how well an LLM uses your tool. Be specific about what the tool does, what each argument means, and what the return value looks like. Vague docstrings lead to misused tools and wasted tokens.
Keep Tools Focused
Each tool should do one thing well. Resist the urge to build "god tools" with many parameters and branching logic. If a tool is doing too much, split it into multiple smaller tools. The LLM will choose more accurately when options are granular.
Return Strings, Not Objects
LLMs consume text. Even if your tool internally works with rich objects, serialize the result to a clear, structured string before returning. JSON strings work well for complex data.
Limit Tools Per Agent
Giving an agent too many tools increases the chance it picks the wrong one or gets confused. As a rule of thumb, aim for three to seven tools per agent. If you need more, consider splitting responsibilities across additional agents.
Test Tools Independently
Before integrating a tool into a crew, test it in isolation as a plain Python function. This catches bugs early and is far faster than debugging through a full multi-agent run.
Log Tool Calls
Instrument your tools with logging so you can see exactly what arguments the agent passed and what was returned. This is invaluable for debugging unexpected agent behavior.
import logging
from crewai.tools import tool
logger = logging.getLogger("crew_tools")
@tool("Lookup User")
def lookup_user(user_id: str) -> str:
"""Look up a user by ID."""
logger.info(f"lookup_user called with user_id={user_id}")
result = f"User {user_id}: Jane Doe, jane@example.com"
logger.info(f"lookup_user returned: {result}")
return result
Conclusion
Tool use is what transforms CrewAI agents from conversational chatbots into capable, action-taking systems. By mastering the patterns in this guide — from simple custom tools and structured schemas to tool chaining, error handling, caching, and human-in-the-loop approval — you can build crews that reliably interact with the real world. The key principles to remember are clarity (write great docstrings), focus (one responsibility per tool), and resilience (handle errors gracefully and return informative strings). Start simple, test tools in isolation, and gradually compose them into sophisticated multi-agent workflows. With these patterns in your toolkit, you're well-equipped to build production-grade AI agent systems with CrewAI.