Help us improve
Share bugs, ideas, or general feedback.
From e2a
Mental model and workflows for e2a (email for AI agents) — send/receive email, HITL approval, agent + domain management. Covers send_email vs reply_to_message threading, pending_approval status, multi-agent disambiguation, custom-domain DNS, and webhook SDK examples.
npx claudepluginhub mnexa-ai/e2a --plugin e2aHow this skill is triggered — by the user, by Claude, or both
Slash command
/e2a:using-e2aThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
<!-- version: 10 -->
Enables AI agents to create @theagentmail.net accounts, send/receive emails, manage webhooks, and check karma via REST API. Use for signups to GitHub/AWS/Slack and verification flows.
Sets up a secure email inbox for AI agents to receive and respond to emails via Resend webhooks, with sender allowlists and content filtering.
Gives AI Agents a dedicated mailbox to send/receive emails, distribute tasks, and communicate asynchronously. Supports ClawEmail (NetEase).
Share bugs, ideas, or general feedback.
e2a is an authenticated email gateway for AI agents. It gives an agent a real email address (agent@agents.e2a.dev or agent@your-domain.com), verifies sender identity (SPF/DKIM), threads conversations, and optionally pauses outbound mail for human review.
This skill works two ways:
mcp__e2a__* — 18 of them covering agents, messages, HITL, and domains.e2a CLI / SDKs. Same operations, different surface: e2a commands on the shell, or the TypeScript / Python SDK in your own webhook handler.The mental model and gotchas are identical across both surfaces. Workflow steps below call out the MCP tool name and the CLI equivalent — pick whichever fits the current session.
If you got this skill by curling into ~/.claude/skills/e2a/SKILL.md (the non-plugin path), run this once per day to check for updates. If you're using the Claude Code plugin, the plugin manager handles updates — skip this section.
_E2A_STATE="$HOME/.e2a/skill"
_E2A_CHECK="$_E2A_STATE/last-check"
_E2A_LOCAL=10
_E2A_NOW=$(date +%s)
_E2A_SKIP=0
if [ -f "$_E2A_CHECK" ]; then
_E2A_LAST=$(cat "$_E2A_CHECK")
[ $((_E2A_NOW - _E2A_LAST)) -lt 86400 ] && _E2A_SKIP=1
fi
if [ "$_E2A_SKIP" -eq 0 ]; then
mkdir -p "$_E2A_STATE"
echo "$_E2A_NOW" > "$_E2A_CHECK"
_E2A_REMOTE=$(curl -sf https://raw.githubusercontent.com/Mnexa-AI/e2a/main/skills/using-e2a/SKILL.md | sed -n 's/.*version: \([0-9]*\).*/\1/p' | head -1)
if [ -n "$_E2A_REMOTE" ] && [ "$_E2A_REMOTE" -gt "$_E2A_LOCAL" ] 2>/dev/null; then
echo "E2A_SKILL_UPDATE_AVAILABLE $_E2A_LOCAL $_E2A_REMOTE"
fi
fi
If output contains E2A_SKILL_UPDATE_AVAILABLE: tell the user a new version of the e2a skill is available and ask if they'd like to update. If yes, run:
curl -sf -o ~/.claude/skills/e2a/SKILL.md https://raw.githubusercontent.com/Mnexa-AI/e2a/main/skills/using-e2a/SKILL.md
Then re-read the updated file and follow its instructions instead.
Six load-bearing facts. Internalize these before you start calling tools or running CLI commands.
An agent is an email address. support-bot@agents.e2a.dev is an agent. When you send mail, the recipient sees a message FROM that address — not from "the user." When you list messages, you are reading the agent's own inbox, not the user's personal mail. You are not a secretary; you are the mailbox owner.
Replies preserve threads; new sends do not. A reply tool/command carries the In-Reply-To and References headers from the original message, so the response lands in the same email thread. A fresh send creates a new thread every time. If a user (or an inbound message) is asking you to respond to something specific, reply with the original message_id — even when you could synthesize an equivalent body as a new send. Thread fragmentation is the #1 visible symptom of getting this wrong.
pending_approval is success, not failure. When the agent has HITL enabled, outbound mail returns { status: "pending_approval", message_id: "msg_..." }. The message was accepted by the server and is being held for a human to review. Do NOT retry. Do NOT report this as an error to the user. Tell them the draft was queued for approval, and (if asked) check on it via the pending tools / commands.
Multi-agent accounts need agent_email per call. If the account owns exactly one agent (the common case), tools/commands auto-resolve to that one. If the account owns more than one, you'll get "agentEmail required." The fix is to enumerate once (list_agents MCP, or e2a agents list), then pass agent_email explicitly to subsequent calls. Don't guess; don't pick at random; don't ask the user to pick if context already makes the choice obvious (e.g. they said "my support inbox").
Custom domains are a two-step async dance. Registering a domain returns DNS records (MX + TXT) to publish — it does NOT make the domain live. The user (or a DNS-provider MCP, if one is loaded) must add those records out-of-band, wait for DNS propagation (minutes to hours), then verify. Verification is idempotent and safe to retry. Until verification succeeds, the domain cannot send or receive mail. Don't promise the user their domain works the moment registration returns.
HITL is not in the consent flow — toggle it explicitly. Creating a new agent does not enable HITL. To turn on approval gates for an existing agent, update the agent with hitl_enabled: true (optionally with hitl_ttl_seconds and hitl_expiration_action). Same path applies to disabling it.
which e2a || npm install -g @e2a/cli
Skip this if you're driving everything through the MCP tools — the plugin needs no CLI.
Read ~/.e2a/config.json. If it exists and has both api_key and agent_email, you're set — skip ahead.
Otherwise:
e2a login (opens a browser; saves api_key and agent_email to ~/.e2a/config.json)./plugin in Claude Code.create_agent with a slug (e.g. support-bot). Registers <slug>@<shared-domain>. Defaults to local mode (poll-based — fine for MCP/CLI clients). Use agent_mode: "cloud" with webhook_url only if there's a real HTTPS endpoint to receive pushes.e2a agents register <slug>.list_messages (defaults to status: unread)e2a inboxget_message with the message_ide2a read <message-id>reply_to_message with that same message_ide2a reply <message-id> --body "..."For attachment bytes, use get_attachment_data (MCP) with a 0-based index. Indexes are stable within a message.
send_email with to, subject, bodye2a send --to <recipient> --subject "..." --body "..."status: sent — done.status: pending_approval — the agent has HITL on; the message is queued. Tell the user it's awaiting review. They can review in the dashboard, via the magic link in their notification email, or:
list_pending_messages / get_pending_message / approve_pending_message / reject_pending_messagee2a pending list / e2a pending approve <id> / e2a pending reject <id>update_agent with hitl_enabled: true (optionally hitl_ttl_seconds, hitl_expiration_action).e2a agents update <slug> --hitl --hitl-ttl 3600 --hitl-expiration-action reject.mail.acme.com)register_domain with the FQDN — returns MX + TXT records and an unverified domain row.e2a domains register <domain>.create_dns_record-style tool with the returned values).verify_domain with the same FQDN.e2a domains verify <domain>.
If it returns verified: true, the domain is live. If still false, the response shows what DNS state was resolved so the user can debug. Retry as needed.If the user is building a cloud agent that handles inbound mail in their own service:
create_agent with agent_mode: "cloud" and webhook_url: "https://...".E2A_WEBHOOK_SECRET in the webhook environment so the SDK can verify inbound payloads automatically.pip install e2a
import e2a
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
# agent_email is optional — auto-resolves from the payload in webhook mode.
client = e2a.E2AClient(api_key="e2a_...") # or set E2A_API_KEY
@app.post("/webhook")
async def webhook(request: Request):
# parse_webhook = parse + HMAC-verify in one call.
# Reads E2A_WEBHOOK_SECRET from the env — set it or first request raises.
try:
email = client.parse_webhook(await request.body())
except PermissionError:
raise HTTPException(401, "bad signature")
print(f"From: {email.sender}")
print(f"Subject: {email.subject}")
print(f"Body: {email.text_body}")
# Threaded reply — agent_email auto-resolves from email.recipient
email.reply("Thanks for your email!")
return {"ok": True}
npm install @e2a/sdk
import { E2AClient } from "@e2a/sdk";
import express from "express";
const app = express();
app.use(express.json());
const client = new E2AClient({ apiKey: process.env.E2A_API_KEY! });
app.post("/webhook", async (req, res) => {
let email;
try {
email = await client.parseWebhook(req.body);
} catch {
return res.status(401).end();
}
console.log(`From: ${email.sender} — ${email.subject}`);
await email.reply("Thanks for your email!");
res.json({ ok: true });
});
app.listen(3000);
Inbound payload shape:
{
"message_id": "msg_abc123",
"conversation_id": "conv_xyz",
"from": "alice@example.com",
"to": ["agent@agents.e2a.dev"],
"cc": [],
"recipient": "agent@agents.e2a.dev",
"raw_message": "<base64-encoded RFC 2822 email>",
"auth_headers": {
"X-E2A-Auth-Verified": "true",
"X-E2A-Auth-Sender": "alice@example.com",
"X-E2A-Auth-Domain-Check": "spf=pass; dkim=pass"
},
"received_at": "2026-03-28T10:00:00Z"
}
to and cc are the parsed headers from the original message; recipient is this delivery's per-agent target. The SDK gates field access behind verification — accessing email.sender etc. on an unverified payload raises UnverifiedEmailError. parse_webhook / parseWebhook handles the verify step. client.parse(...) returns an unverified email and requires email.verify_signature() / email.verifySignature() before claim fields are readable. Check email.is_verified / email.isVerified to confirm.
If the user wants a local agent to receive emails in real time without writing a webhook, e2a listen --forward opens a WebSocket to e2a and POSTs each inbound email to a local HTTP endpoint:
e2a listen --forward http://localhost:3000/inbox
There's no MCP equivalent — this is a CLI-only pattern. Useful for local development or for proxying into a gateway (e.g. OpenClaw on localhost:18789) without exposing a public URL.
data field expects base64 produced by another tool (a file reader, a doc generator, get_attachment_data). If you have plain text and want to attach it, write it to a file first and read it back, or generate the encoding via a Bash call — don't try to construct base64 from a Markdown string in your head.{filename, content_type, data} tuple from get_attachment_data straight into the next send's attachments[]. No re-encoding, no re-naming necessary.get_message deliberately omits raw MIME and attachment bytes. Don't ask for the "full message" — you have what you need (decoded text/html bodies, headers, attachment metadata). Use get_attachment_data for actual bytes when you need them.confirm: true. delete_agent and delete_domain (MCP), and their --yes CLI equivalents, refuse without explicit confirmation. This is a guard against hallucinated deletes; pass it only when the user has clearly asked for the destructive action.approve_pending_message with attachments: [] strips attachments. An omitted attachments field keeps the original draft's attachments; an explicit empty array removes them. Same shape applies to other override fields — omit to keep, specify (including empty) to override.get_pending_message returns the full body only while status is pending_approval. Once approved or rejected, body columns are wiped server-side for compliance./plugin in Claude Code.whoami (MCP) is cheaper for the common single-agent case; list_agents is only needed when whoami errors with the multi-agent diagnostic.~/.e2a/config.json — JSON with api_key, api_url, agent_email, shared_domain.E2A_API_KEY, E2A_URL, E2A_SHARED_DOMAIN (CLI). E2A_AGENT_EMAIL is honored by the Python/TS SDK constructors when you don't pass agent_email explicitly. E2A_WEBHOOK_SECRET is read by parse_webhook / parseWebhook and verify_signature() / verifySignature() for inbound HMAC verification.get_attachment_data (shared).description for the precise contract.