
how to build a crewai custom tool with basetool for email
Build a production-ready CrewAI email tool using BaseTool. Step-by-step guide with Pydantic schemas, error handling, and a working code example.
CrewAI ships with a solid set of built-in tools, but none of them send email well. The search tools work. The scraping tools work. But when your agent needs to fire off a transactional email, confirm a signup, or follow up with a lead, you're on your own.
That's where BaseTool comes in. It's CrewAI's extension point for building custom tools that do exactly what your agent needs. And email is one of the most useful things you can wire up, because almost every multi-step agent workflow eventually needs to communicate with a human.
This guide walks through building a CrewAI custom tool with BaseTool for email, from the Pydantic input schema all the way to retry logic and multi-agent sharing. If you want to skip the manual SMTP plumbing entirely, and let your agent provision its own address in one call.
How to build a CrewAI email tool with BaseTool (step-by-step)#
- Import
BaseToolfromcrewai.toolsandBaseModelfrompydantic - Define a Pydantic input schema with fields for
recipient,subject, andbody - Subclass
BaseTooland setname,description, andargs_schema - Implement the
_runmethod with your email-sending logic - Add error handling, timeouts, and retry logic inside
_run - Instantiate the tool and pass it to your
AgentorTask
Here's the full implementation.
What is BaseTool in CrewAI?#
BaseTool is a base class in CrewAI that you subclass to create custom tools. Every custom tool needs three things: a name string, a description string (this is what the LLM reads to decide when to use the tool), and a _run method containing your actual logic.
The @tool decorator exists as a shortcut for simple cases, but BaseTool gives you typed input schemas via Pydantic, better IDE support, and cleaner organization when your tool has multiple parameters or complex logic. For an email tool with recipient, subject, and body fields, BaseTool is the right choice.
If your agent workflow is async, you can also override _arun alongside _run. CrewAI will call _arun automatically in async contexts, so defining both lets your tool work correctly in synchronous crews and async ones without any change to your calling code.
One common source of confusion: the import path. In CrewAI 0.114.0 and later, you import from crewai.tools, not the older crewai_tools package. If you see an ImportError, check which version you're running.
CrewAI >= 0.114.0#
from crewai.tools import BaseTool
Defining the input schema#
Pydantic's BaseModel lets you declare exactly what fields your tool accepts. CrewAI reads the Field descriptions to help the LLM format its tool calls correctly, so write descriptions that are specific.
from pydantic import BaseModel, Field
class SendEmailInput(BaseModel):
recipient: str = Field(..., description="The recipient's email address")
subject: str = Field(..., description="Email subject line, under 80 characters")
body: str = Field(..., description="Plain text email body")
The args_schema class attribute on your tool points to this model. When an agent invokes the tool, CrewAI validates the input against the schema before calling _run. If the LLM passes malformed data, you get a clear validation error instead of a silent failure.
Implementing _run with real email logic#
Here's where most tutorials stop at the happy path. They show a _run method that calls smtplib.SMTP, fires off a message, and returns "Email sent successfully". That works in a demo. It falls apart in production.
Real email sending needs to handle three things: authentication failures, network timeouts, and rate limits. Here's a version using an HTTP API backend instead of raw SMTP, which is more reliable for agent workflows:
import httpx
from crewai.tools import BaseTool
class SendEmailTool(BaseTool):
name: str = "send_email"
description: str = "Sends a plain text email to a single recipient."
args_schema: type[BaseModel] = SendEmailInput
def _run(self, recipient: str, subject: str, body: str) -> str:
try:
response = httpx.post(
"https://api.lobstermail.ai/v1/inboxes/my-inbox/send",
headers={"Authorization": "Bearer lm_sk_live_xxx"},
json={
"to": recipient,
"subject": subject,
"text": body,
},
timeout=15.0,
)
response.raise_for_status()
data = response.json()
return f"Email sent. Message ID: {data['messageId']}"
except httpx.TimeoutException:
return "Error: email send timed out after 15 seconds. Retry later."
except httpx.HTTPStatusError as e:
return f"Error: email send failed with status {e.response.status_code}"
A few things to notice. The _run method returns a string in all cases, including errors. CrewAI agents read the return value to decide what to do next, so a descriptive error message lets the agent retry or adjust its approach. Raising an exception inside _run kills the entire crew execution, which is almost never what you want.
The timeout is explicit. Without one, a hanging SMTP connection can stall your whole agent pipeline indefinitely.
If you need retry logic, the tenacity library integrates cleanly. Wrap the httpx.post call with @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) and you get automatic exponential backoff on transient network errors without complicating your _run structure.
Why SMTP and Gmail break at scale#
The most common email backend in CrewAI tutorials is Gmail via SMTP or the Gmail API with OAuth. This works for sending five emails from your personal account. It breaks down fast for agent workflows.
Gmail enforces a 500-email daily send limit for consumer accounts and 2,000 for Workspace. OAuth tokens expire and need human re-authentication. Google's abuse detection flags automated sending patterns and locks accounts without warning. I've seen agents lose Gmail access after 48 hours of moderate sending.
Beyond rate limits, raw SMTP setups often skip proper SPF, DKIM, and DMARC configuration. Without those DNS records aligned to your sending domain, major inbox providers will route your messages to spam or reject them silently — and your agent will have no idea it happened.
A transactional email API designed for programmatic sending avoids all of these problems. LobsterMail's free tier gives you 1,000 emails per month with no OAuth, no token refresh, and no human signup required. Your agent provisions its own inbox and starts sending. The Builder tier at $9/month gets you 5,000 emails per month and up to 10 inboxes if you need more volume.
Wiring the tool to an agent and task#
Once your tool class is defined, you instantiate it and pass it to an agent, a task, or both. There's an important distinction here.
from crewai import Agent, Task, Crew
email_tool = SendEmailTool()
outreach_agent = Agent(
role="Outreach Specialist",
goal="Send personalized follow-up emails to leads",
backstory="You are a professional outreach agent.",
tools=[email_tool],
)
follow_up_task = Task(
description="Send a follow-up email to {lead_email} about {topic}.",
expected_output="Confirmation that the email was sent with a message ID.",
agent=outreach_agent,
)
crew = Crew(agents=[outreach_agent], tasks=[follow_up_task])
crew.kickoff(inputs={"lead_email": "frank@example.com", "topic": "Q3 pricing"})
When you pass a tool to an Agent, every task that agent handles can use that tool. When you pass a tool directly to a Task, only that specific task gets access, regardless of what the agent has. Use task-level tools when you want tighter control over which steps can send email.
If the agent says a tool "doesn't exist" even though you instantiated it, check two things. First, make sure you're passing tool instances (not the class) to the tools list. Second, verify the name attribute on your tool is unique across all tools in the crew. Duplicate names cause silent conflicts.
Sharing a tool across multiple agents#
You can pass the same SendEmailTool() instance to multiple agents in a crew. CrewAI doesn't copy tools per agent, so they all reference the same object. This is fine for stateless tools like an email sender. If your tool holds mutable state (a counter, a cache, a connection pool), you'll need to handle thread safety yourself, because CrewAI can run agents concurrently.
shared_email = SendEmailTool()
researcher = Agent(role="Researcher", tools=[shared_email], ...)
writer = Agent(role="Writer", tools=[shared_email], ...)
Testing a BaseTool in isolation#
Don't wait until your full crew is assembled to test a custom tool. Call _run directly:
tool = SendEmailTool()
result = tool._run(
recipient="test@example.com",
subject="Test from CrewAI",
body="This is a test email sent from a BaseTool."
)
print(result)
This skips the LLM entirely and lets you verify that your HTTP calls, error handling, and return values work correctly. Once the tool behaves in isolation, wire it into your crew.
Handling bounces and delivery failures#
This is the gap that every CrewAI email tutorial ignores. Sending an email and getting a 200 response doesn't mean it arrived. The recipient's server might bounce it minutes later. A spam filter might quarantine it. The address might not exist.
For production agent workflows, you need a feedback loop. LobsterMail's webhook system sends delivery status notifications (bounced, complained, delivered) to a URL you specify, so your agent can track whether its emails actually landed. Without this, your agent is sending messages into a black hole and assuming success.
Bounce handling also protects your sender reputation. Repeatedly sending to invalid addresses raises your bounce rate, which signals to inbox providers that your sending practices are poor. A high bounce rate compounds: each additional bounce makes it harder for future emails — even to valid addresses — to reach the inbox. Keeping your list clean and reacting to bounce events programmatically is the only way to maintain deliverability at scale.
If you're building with raw SMTP, you'd need to monitor a bounce mailbox, parse DSN (Delivery Status Notification) messages, and match them back to the original send. It's a significant amount of work that most agent builders skip, and their deliverability suffers for it.
Frequently asked questions
What is BaseTool in CrewAI and why should I subclass it instead of using the @tool decorator?
BaseTool is a base class you extend to create custom tools with typed Pydantic input schemas, named attributes, and structured _run methods. The @tool decorator works for simple single-argument tools, but BaseTool gives you validation, multi-field inputs, and better maintainability for anything non-trivial like an email sender.
How do I import BaseTool correctly in CrewAI 0.114.0 and later?
Use from crewai.tools import BaseTool. The older crewai_tools package is a separate repo with pre-built tools. If you get an ImportError, check your CrewAI version with pip show crewai and upgrade if needed.
What is the minimum required structure of a BaseTool subclass?
You need three things: a name: str attribute, a description: str attribute, and a _run method that accepts your arguments and returns a string. Adding an args_schema pointing to a Pydantic BaseModel is optional but strongly recommended for multi-field tools.
Why does CrewAI say my custom tool 'doesn't exist' even though I defined it?
Two common causes. First, make sure you're passing an instance (SendEmailTool()) to the tools list, not the class itself (SendEmailTool). Second, check that no two tools in the same crew share the same name attribute. Duplicate names cause one tool to shadow the other.
Can a BaseTool call an external HTTP API inside _run?
Yes. Use httpx or requests to make HTTP calls inside _run. Always set an explicit timeout (10-15 seconds is reasonable) and catch exceptions so a failed API call returns an error string instead of crashing the crew.
What is the difference between passing a tool to an Agent vs. a Task?
Agent-level tools are available for every task that agent handles. Task-level tools are scoped to that single task only. Use task-level assignment when you want to restrict which steps can perform sensitive actions like sending email.
How do I test a BaseTool before wiring it into a full Crew?
Call _run directly on an instance: tool = SendEmailTool(); result = tool._run(recipient="test@example.com", subject="Test", body="Hello"). This bypasses the LLM and lets you verify your logic, error handling, and return values.
What email backends work best with a CrewAI BaseTool?
A transactional email API (like LobsterMail, Postmark, or Resend) is more reliable than raw SMTP or Gmail for agent workflows. APIs handle authentication, rate limiting, and delivery tracking. Gmail's OAuth token expiration and send limits make it a poor fit for automated agents.
How do I handle email delivery failures inside a CrewAI agent workflow?
Return descriptive error strings from _run so the agent can decide to retry or skip. For bounce tracking after the initial send, use a webhook-based delivery notification system. LobsterMail's webhook support sends bounce and delivery events to a URL you configure.
How do I share one BaseTool instance across multiple agents in the same Crew?
Pass the same instance to each agent's tools list. CrewAI doesn't copy tools, so all agents share the same object. This is safe for stateless tools like email senders. If your tool has mutable state, add thread-safety measures since CrewAI may run agents concurrently.
Can CrewAI agents send emails automatically without human intervention?
Yes. Once you wire a SendEmailTool to an agent and kick off a crew, the agent calls the tool autonomously based on its task description. Pair it with an email API that doesn't require human authentication (like LobsterMail's free tier) and the entire flow runs unattended.
How do I return structured data from _run instead of a plain string?
_run must return a string, but you can serialize structured data as JSON: return json.dumps({"messageId": "abc", "status": "sent"}). The LLM agent will parse the JSON from the string in its reasoning step.


