All posts
Guide·Jul 7, 2026·8 min read

How to give your CrewAI agent its own email inbox

CrewAI email guide: give a CrewAI agent a real inbox with the AgenticEmail Python SDK, wrap send and read as a custom tool, and handle inbound over webhooks.

Dvir Atias

Dvir Atias

Founder

To give a CrewAI agent its own email inbox, you create a real inbox with the AgenticEmail Python SDK, wrap the send and read calls as a CrewAI custom tool (either a BaseTool subclass or an @tool function), and hand that tool to the agent. There is no CrewAI-specific package to install and none to maintain: CrewAI tools are just Python callables, AgenticEmail is just a Python SDK, and the two connect in about thirty lines. The result is a crew member with a live address it can send from, receive at, and reply to over REST, with inbound mail pushed to you as a webhook.

Why CrewAI crews need real inboxes

CrewAI is built around crews of agents that hand work to each other and to the outside world. The moment one of those agents has to talk to a human who is not sitting in your app, or to a system that only speaks email, a chat loop is not enough. It needs an address. Support triage, outbound follow-ups, vendor back-and-forth, and agent-to-agent handoffs are all email-shaped problems, and email is still the one channel every person and system already has. This is the same reason AI agents need email in general, and it applies doubly to a multi-agent crew where each role may want its own return address.

The usual workaround is to point an agent at a shared Gmail account through OAuth and IMAP. That falls over in production: tokens expire, one mailbox becomes a bottleneck for the whole crew, and you cannot cleanly give each agent, or each of your customers' crews, a separate address. An AI email agent wants an inbox that is a resource, created and destroyed by API, not a human mailbox it borrows.

Create an inbox with the AgenticEmail Python SDK

Install the SDK and create an inbox. Creating an inbox is one call and returns a live address immediately.

import os
from agenticemail import AgenticEmail

client = AgenticEmail(api_key=os.environ["AGENTICEMAIL_API_KEY"])

inbox = client.inboxes.create(username="research-crew")
print(inbox["id"], inbox["address"])
# ibx_... research-crew@inbox.agenticemail.dev

On paid plans you attach your own domain and the address becomes research-crew@yourdomain.com. Keep the inbox["id"] around; every send and read call is scoped to it. Sending is also one call:

client.messages.send(
    inbox["id"],
    to=["customer@example.com"],
    subject="Following up on your request",
    text="Hi, the report you asked for is attached.",
)

Inbound mail arrives as parsed JSON, so you never touch a MIME tree. You read it with client.messages.list(inbox_id) or fetch one with client.messages.get(inbox_id, message_id). That is the entire surface a CrewAI tool needs to wrap.

Build a CrewAI custom tool for email

CrewAI exposes two ways to define a tool: subclass BaseTool with a typed args schema, or decorate a plain function with @tool. Use BaseTool when you want an explicit input schema the agent must fill; use @tool for the quick cases. Here is a send tool as a BaseTool subclass:

from typing import Type
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

INBOX_ID = os.environ["AGENTICEMAIL_INBOX_ID"]

class SendEmailInput(BaseModel):
    to: str = Field(..., description="Recipient email address")
    subject: str = Field(..., description="Subject line")
    body: str = Field(..., description="Plain-text message body")

class SendEmailTool(BaseTool):
    name: str = "send_email"
    description: str = "Send an email from the crew's inbox to one recipient."
    args_schema: Type[BaseModel] = SendEmailInput

    def _run(self, to: str, subject: str, body: str) -> str:
        result = client.messages.send(
            INBOX_ID, to=[to], subject=subject, text=body
        )
        return f"Sent message {result['id']} to {to}"

Reading and replying are a natural fit for the @tool decorator, where the docstring becomes the description the agent reasons over:

from crewai.tools import tool

@tool("Read inbox")
def read_inbox() -> str:
    """List the most recent messages in the crew's inbox as text."""
    messages = client.messages.list(INBOX_ID).get("data", [])
    return "\n".join(
        f"[{m['id']}] from {m['from']}: {m['subject']}" for m in messages[:10]
    )

@tool("Reply to email")
def reply_to_email(message_id: str, body: str) -> str:
    """Reply to a message by id, staying in the same thread."""
    result = client.messages.reply(INBOX_ID, message_id, text=body)
    return f"Replied in thread as {result['id']}"

Threading headers on a reply are handled for you when you pass the message id, so the recipient sees a proper conversation rather than a fresh email. This is the same wrapping approach you would take for any framework; the LangChain agent email inbox guide follows the identical shape with LangChain's tool primitives instead of CrewAI's.

Assign the tool to a crew agent

Once the tools exist, giving them to an agent is one keyword argument. The agent can now send, read, and reply as part of its normal reasoning loop.

from crewai import Agent, Task, Crew

support_agent = Agent(
    role="Customer support agent",
    goal="Answer inbound customer email accurately and close the loop",
    backstory="You triage the shared inbox and reply on behalf of the team.",
    tools=[SendEmailTool(), read_inbox, reply_to_email],
    verbose=True,
)

triage = Task(
    description="Read the inbox, find unanswered questions, and reply to each.",
    expected_output="A short summary of who you replied to and what you said.",
    agent=support_agent,
)

Crew(agents=[support_agent], tasks=[triage]).kickoff()

Give different roles different inboxes by pointing each tool instance at a different inbox_id, and scope each agent's API key to its own inbox so a compromised agent can at worst read and send from its own address, not touch your whole account.

Handle inbound mail with a webhook

Polling with read_inbox is fine for a kickoff run, but a crew that reacts to mail in real time should be driven by a webhook. Register one endpoint and every inbound message is delivered as signed JSON:

client.webhooks.create(
    url="https://your-app.com/hooks/email",
    event_types=["message.received"],
)

Then verify the signature and hand the message to your crew. verify_webhook raises if the signature does not match, so a forged request never reaches your agents:

from flask import Flask, request
from agenticemail import verify_webhook

app = Flask(__name__)
SECRET = os.environ["AGENTICEMAIL_WEBHOOK_SECRET"]

@app.post("/hooks/email")
def inbound():
    event = verify_webhook(request.get_data(as_text=True), dict(request.headers), SECRET)
    message = event["data"]
    Crew(agents=[support_agent], tasks=[triage]).kickoff(
        inputs={"message_id": message["id"], "from": message["from"]}
    )
    return "", 204

Now inbound mail triggers the crew, the agent reads the thread, and it replies through the same tools, no polling loop required.

Encrypt agent-to-agent mail

When two of your agents email each other, the body often carries exactly what you would not want sitting in plaintext on a server: customer records, scoped tokens, raw tool output. AgenticEmail offers opt-in, agent-to-agent encrypted email that is zero-knowledge by design. The private key is generated in your runtime and never reaches the server, so the platform only ever stores ciphertext. It is opt-in and only applies between two AgenticEmail inboxes that have each published a key; it is not on by default and does not cover mail from the outside world.

Turn it on by generating an identity and passing it to the client:

from agenticemail.e2e import generate_identity

identity = generate_identity("research-crew@inbox.agenticemail.dev")

client = AgenticEmail(
    api_key=os.environ["AGENTICEMAIL_API_KEY"],
    e2e={
        "identity": identity,
        "auto_publish": True,
        "verify_sender": True,
        "allow_plaintext_fallback": False,
    },
)
client.publish_public_key(inbox["id"])

With allow_plaintext_fallback off, a send to a peer with no published key fails loudly instead of leaking a body, and verify_sender makes the receiving agent check the signature before trusting a byte. Your existing SendEmailTool needs no changes; the SDK encrypts under the hood whenever the recipient has a key.

Where to go next

That is a full CrewAI agent email setup: a real inbox from the Python SDK, send and read wrapped as CrewAI tools, an agent that owns those tools, webhooks for inbound, and optional encryption for agent-to-agent traffic. The same inbox is reachable over REST, the CLI, or the hosted MCP server if part of your stack is not Python. For the complete method reference see the docs, and if you are weighing this against stitching together Gmail or a transactional API, the alternatives page lays out the tradeoffs.

Talk to a real person