A CrewAI crew is a set of agents with different jobs, and email tools should follow the jobs. The agent that signs up for a service needs an inbox and a way to read a code, the agent that reports back needs to send, and the researcher needs neither. This guide builds three email tools as CrewAI BaseTool classes on the Lumbox REST API, hands each agent only the ones it needs, and deals with two CrewAI behaviours that interact badly with email: tool result caching and automatic handling of tool errors.
The code was checked against CrewAI 1.15.23 and a local mock of the Lumbox API. If you only need the signup and verification step, the CrewAI email verification post does it with the Python SDK's prebuilt functions instead.
The tools
Each tool is a BaseTool subclass with a Pydantic args_schema. The field descriptions are what the model reads when it decides how to call the tool, so they say where each value comes from. HTTP goes through one shared httpx client.
import hashlib
import json
import os
from typing import Type
import httpx
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
lumbox = httpx.Client(
base_url="https://api.lumbox.co/v1",
headers={"X-API-Key": os.environ["LUMBOX_API_KEY"]},
timeout=httpx.Timeout(10.0, read=70.0),
)
def never_cache(args, result) -> bool:
return False
class CreateInboxTool(BaseTool):
name: str = "create_inbox"
description: str = "Create a new email inbox. Returns its inbox_id and address."
def _run(self) -> str:
r = lumbox.post("/inboxes", json={})
r.raise_for_status()
d = r.json()
return f"inbox_id={d['id']} address={d['address']}"
class WaitForCodeInput(BaseModel):
inbox_id: str = Field(..., description="The inbox_id returned by create_inbox")
sender: str = Field(..., description="Sender domain to match, e.g. github.com")
class WaitForCodeTool(BaseTool):
name: str = "wait_for_code"
description: str = "Wait up to 60 seconds for a verification code. Returns the code or NO_CODE."
args_schema: Type[BaseModel] = WaitForCodeInput
def _run(self, inbox_id: str, sender: str) -> str:
r = lumbox.get(f"/inboxes/{inbox_id}/otp", params={"from": sender, "timeout": 60})
if r.status_code == 408:
return "NO_CODE"
r.raise_for_status()
return r.json()["code"]
class SendEmailInput(BaseModel):
inbox_id: str = Field(..., description="Inbox to send from")
to: str = Field(..., description="Recipient address")
subject: str
text: str = Field(..., description="Plain-text body")
class SendEmailTool(BaseTool):
name: str = "send_email"
description: str = "Send a plain-text email from an inbox. Returns the email_id."
args_schema: Type[BaseModel] = SendEmailInput
def _run(self, inbox_id: str, to: str, subject: str, text: str) -> str:
payload = {"to": to, "subject": subject, "text": text}
key = hashlib.sha256(json.dumps([inbox_id, payload], sort_keys=True).encode()).hexdigest()
r = lumbox.post(f"/inboxes/{inbox_id}/send", json=payload, headers={"Idempotency-Key": key})
r.raise_for_status()
return r.json()["email_id"]
A few details carry weight. create_inbox sends an empty body, so the API generates a unique local part on trylumbox.com; a fixed name would collide with a 409 on the second run. wait_for_code calls GET /v1/inboxes/:id/otp, which holds the request open and checks once a second until a code arrives or the timeout passes, then returns 408. The client's 70 second read timeout outlasts the 60 second wait. The send tool's idempotency key is covered below.
Giving each agent its own tools
from crewai import Agent, Crew, Process, Task
create_inbox = CreateInboxTool(cache_function=never_cache)
wait_for_code = WaitForCodeTool(cache_function=never_cache)
send_email = SendEmailTool(max_usage_count=1)
signup_agent = Agent(
role="Signup agent",
goal="Create verified accounts on web services",
backstory="You create a fresh inbox for every signup and never reuse one.",
tools=[create_inbox, wait_for_code, browser_tool],
)
reporter = Agent(
role="Reporter",
goal="Tell the owner what happened",
backstory="You write short, factual status emails.",
tools=[send_email],
)
signup = Task(
description="Sign up at {signup_url} and complete email verification.",
expected_output="The account email address and whether verification succeeded",
agent=signup_agent,
)
report = Task(
description="Email {owner} a three-line summary of the signup.",
expected_output="The email_id of the sent message",
agent=reporter,
context=[signup],
)
crew = Crew(agents=[signup_agent, reporter], tasks=[signup, report], process=Process.sequential)
crew.kickoff(inputs={"signup_url": "https://example.com/signup", "owner": "you@example.com"})
browser_tool is whatever fills the form. The reporter has no inbox tools, and the signup agent cannot send. max_usage_count=1 on send_email is a CrewAI tool field: after one use the tool answers that its limit is reached instead of running, so a looping reporter cannot send ten status emails.
All three tools share one API key, so this separation lives in the tool lists, not in credentials. If one process runs crews for different customers, give each customer a Lumbox project and mint a key with POST /v1/projects/:id/keys. A project key can only reach that project's inboxes and is refused on org-level routes such as domains and webhooks.
Tool caching will hand out the same inbox
CrewAI can cache tool results. The cache key is the tool name plus its input, and one cache is shared by every agent in the crew. For most tools that saves time. For email tools it breaks things in two ways:
create_inboxtakes no arguments, so every call after the first has the same key. The second agent that asks for a fresh inbox gets the first agent's address back from the cache, and both signups now share one mailbox.- A
NO_CODEresult is cached like any other string. When the agent presses resend and callswait_for_codeagain with the same inbox and sender, it getsNO_CODEback instantly without a request reaching the API.
Whether caching is on depends on your version. In CrewAI 0.80.0, 1.0.0, 1.10.0 and 1.15.2, the Crew class defaults to cache=True. In 1.15.23 it is opt-in. A cache_function that returns False keeps a tool out of the cache in either case, which is why the two receive-side tools are built with never_cache.
What happens when a tool raises
A tool that raises does not crash the crew. In 1.15.23, with a model that supports native function calling, the exception text goes back to the agent as Error executing tool: .... On the older text-based path, CrewAI re-runs the failing tool up to its parsing-attempt limit before reporting the error.
That re-run is a problem for sending. If the provider accepted the email but your client timed out reading the response, the tool raises and may run again. The Idempotency-Key header prevents a second email: Lumbox keeps the response for 24 hours, and a repeat with the same key and body returns the stored result with Idempotent-Replayed: true. The key is a hash of the inbox and payload, so an intentional second email with different text still goes out.
Rate limits surface as HTTP 429 and raise here. The free plan allows 3 sends a minute, and a new free account without a verified domain can send 5 in its first hour, so a crew that emails on every step hits the limit quickly.
The MCP alternative
CrewAI agents also accept MCP servers directly through the mcps field. The hosted Lumbox server has more than 80 tools, so filter it down to what the agent needs:
from crewai.mcp import MCPServerHTTP
from crewai.mcp.filters import create_static_tool_filter
signup_agent = Agent(
role="Signup agent",
goal="Create verified accounts on web services",
backstory="You create a fresh inbox for every signup.",
mcps=[MCPServerHTTP(
url="https://mcp.lumbox.co/mcp",
headers={"Authorization": f"Bearer {os.environ['LUMBOX_API_KEY']}"},
tool_filter=create_static_tool_filter(allowed_tool_names=["create_inbox", "get_otp"]),
)],
)
This is less code, and you give up control over tool names, descriptions, return strings and the caching and idempotency choices above. Connection details for other clients are in the MCP docs.
What is still missing
These tools cannot read a verification link; add a tool over GET /v1/inboxes/:id/emails, whose items carry parsed.verification_links. They also leave inboxes behind. Inboxes never expire and the free plan holds 3, so delete them with DELETE /v1/inboxes/:id after kickoff returns, unless the new account needs its inbox for password resets later. Field-level details for sending are in the sending docs.