What is myagentmail?
A multi-tenant email API purpose-built for AI agents. Real inboxes with IMAP and SMTP, real threading, real custom domains — none of the compromises you'd hit trying to use a transactional provider for agent workflows.
The problem
Most email APIs are built around one assumption: the sender is a product that blasts out receipts and marketing mail. When you try to build an agent on top of them, the rough edges show up fast:
- No real inbox. Transactional providers only speak outbound. Replies either vanish or go to a shared catch-all you can't query per-inbox.
- No threading. You end up writing your own
In-Reply-To and References plumbing on top of a send-only API.
- No multi-tenancy. When your reseller customer needs isolated email infra for their own customers, you either create a new account per end-user or punt on isolation and pray no key leaks.
- No real-time intake. Polling IMAP from an agent loop is slow and flaky, and standing up a webhook receiver for every deployment is a deployment headache.
What we are
myagentmail is an email backend designed around the way agents actually work:
- Real mailboxes. Every inbox has IMAP + SMTP credentials. You can use the REST API, a stock mail client, or both. Threads, attachments, replies, forwards, drafts — everything agentmail.to-compatible, everything accessible via a single key.
- Custom domains as aliases, not new inboxes. Add a custom domain and its address becomes an alias on your existing inbox. All prior thread history is preserved. Replies to the old
@myagentmail.com address still land in the same mailbox.
- Workspaces. Isolation containers under your tenant. Hand a scoped
wk_ key to each of your reseller customers and they can only see their own inboxes, domains, and threads — but you still have one billing relationship with us.
- WebSocket real-time. Agents open a socket, subscribe, and receive events the moment they arrive. Perfect for the 2FA-code-in-one-turn flow where you don't want to run a public webhook receiver.
- HTTP webhooks too. When you do want durable fan-out, every event also fires your configured webhooks with HMAC signatures.
When to pick something else
myagentmail is intentionally not a transactional-email firehose. If you're sending 10M receipt emails a day to an opt-in mailing list, a dedicated high-volume provider like SES, Postmark, or Resend will serve you better. We're the right pick when:
- Your sender is an agent that reads replies and reasons about them
- Each user needs their own real inbox, not a shared one
- You need to give your customers isolated email infra under your own brand
Inbox capabilities
Every inbox is a full two-way mailbox. Here's what you get out of the box.
Send
- Send via REST (
POST /v1/inboxes/{id}/send) or SMTP on port 587 with STARTTLS
to, cc, bcc, reply_to — all standard RFC 5322 envelope fields
- Plain text, HTML, or both in the same message
- File attachments (via drafts, or raw SMTP)
- Reply, reply-all, and forward endpoints that preserve
In-Reply-To and References correctly
Receive
- Inbound SMTP on the default
myagentmail.com domain and any verified custom domain
- Parsed bodies (plain + HTML) and attachments available via the API
- The full raw
.eml source is preserved and downloadable via GET /messages/{id}/raw for debugging or signature verification
- Read via REST, IMAP on port 993, or the WebSocket event stream
- Mark read / unread, soft-delete messages — list queries hide deleted rows but thread history is preserved
Threading
Threading is automatic. When a reply comes in, we match it against existing threads via In-Reply-To and References headers. Outbound replies get the same headers set automatically when you use the /reply, /reply-all, or /forward endpoints — so agents can reason about conversations as coherent units instead of isolated messages.
Drafts
Drafts let agents compose a message across multiple turns and send in one action. Start with just a recipient, come back later and add a subject, refine the body on the next turn, then fire POST /drafts/{id}/send when everything's ready. See the Drafts guide.
Lists
Named groups of addresses stored per inbox. Useful when an agent needs to track stable audiences — "customers", "investors", "active leads" — without round-tripping to your primary DB.
Addresses & custom domains
Each inbox can accept mail at multiple addresses. Add a verified custom domain alias and it becomes the outbound From header — the canonical @myagentmail.com address stays as an accepting alias so existing threads survive. See Custom domain setup.
Real-time events
Three intake options, pick whichever fits the agent's runtime:
- WebSocket —
wss://myagentmail.com/v1/ws. Subscribe to event types + inbox ids, receive JSON frames. Compare the options.
- HTTP webhooks — register a URL, receive signed POSTs for every event.
- Polling — pull
GET /messages?direction=inbound on a schedule.
Creating your first inbox
About ninety seconds end-to-end.
1. Get your tenant master key
Sign up at myagentmail.com/signup. Your tenant is created automatically, and your tk_ master key is visible in the dashboard overview.
2. Create an inbox
# With the SDK
import { MyAgentMail } from "myagentmail";
const client = new MyAgentMail({ apiKey: "tk_your_key" });
const inbox = await client.inboxes.create({ username: "scout", displayName: "Scout" });
# Or with curl
curl -X POST https://myagentmail.com/v1/inboxes \
-H "X-API-Key: tk_your_key" \
-H "Content-Type: application/json" \
-d '{"username": "scout", "displayName": "Scout"}'
The response returns a full inbox object including an ak_ key scoped to that single inbox, plus IMAP and SMTP credentials. Store these — the inbox-scoped key and IMAP password are only shown once.
{
"id": "2ceac37e-0bb1-4d31-a0cc-d3dac9da6a5d",
"email": "scout-a8f41@myagentmail.com",
"apiKey": "ak_1a2b3c...",
"imapPassword": "abc123...",
"imap": { "host": "imap.myagentmail.com", "port": 993, "tls": true, ... },
"smtp": { "host": "smtp.myagentmail.com", "port": 587, "starttls": true, ... }
}
3. Send a test message
curl -X POST https://myagentmail.com/v1/inboxes/INBOX_ID/send \
-H "X-API-Key: tk_your_key" \
-H "Content-Type: application/json" \
-d '{
"to": "you@example.com",
"subject": "Hello from my agent",
"plainBody": "If you see this, outbound is working.",
"verified": true
}'
The verified: true flag tells us you've checked the recipient address out-of-band. Without it, we only let agents send to addresses that have previously replied to the inbox — a deliverability safeguard against agents blasting unvalidated lists.
4. Receive a reply
Reply to that email from your real inbox. The reply arrives in real time. Pick your intake style:
# Poll
curl https://myagentmail.com/v1/inboxes/INBOX_ID/messages?direction=inbound \
-H "X-API-Key: tk_your_key"
# Or open a WebSocket for real-time push
wscat -c "wss://myagentmail.com/v1/ws?api_key=tk_your_key"
> {"type":"subscribe","event_types":["message.received"],"inbox_ids":["INBOX_ID"]}
API keys & scopes
Four key tiers. Pick the narrowest one that fits the job.
| Prefix | Scope | Use it for |
| sa_ | Super admin | Platform operator only. Creates and deletes tenants. |
| tk_ | Tenant master | Every workspace, inbox, domain, and webhook in your tenant. The key you log into the dashboard with. |
| wk_ | Workspace master | One workspace only. Hand these to reseller customers so they can manage their own inboxes without seeing anyone else's. |
| ak_ | Inbox scoped | A single inbox — its messages, drafts, threads, lists. Give this to the agent runtime. |
Least privilege
If the agent only needs to read its own inbox and send from it, give it an ak_ key. A tk_ key leaking exposes every workspace in your tenant; an ak_ key leaking only exposes one inbox. Rotate compromised keys by deleting and recreating the inbox (or, for workspace keys, via DELETE /v1/workspaces/{id}/keys/{key_id}).
Passing the key
Two headers work:
X-API-Key: tk_...
# or
Authorization: Bearer tk_...
WebSocket connections accept the ?api_key= query parameter too, for browser clients that can't set headers on new WebSocket.
!
Don't embed tk_ keys in client-side code. Tenant master keys grant total access to your tenant. Browsers, mobile apps, and agents running on untrusted devices should use inbox or workspace scoped keys instead.
TypeScript SDK
Install myagentmail from npm and get typed access to every endpoint in one import. View on npm
Install
npm install myagentmail
Initialize
import { MyAgentMail } from "myagentmail";
const client = new MyAgentMail({
apiKey: process.env.MYAGENTMAIL_KEY!,
});
Quick example
// Create an inbox
const inbox = await client.inboxes.create({
username: "scout",
displayName: "Scout",
});
// Send an email
const sent = await client.messages.send(inbox.id, {
to: "ceo@acme.com",
subject: "Quick question",
plainBody: "Hi there.",
verified: true,
});
// List inbound messages
const { messages } = await client.messages.list(inbox.id, {
direction: "inbound",
});
// Reply in-thread
await client.messages.reply(inbox.id, messages[0].id, {
plainBody: "Thanks!",
});
// Draft for human review
const draft = await client.drafts.create(inbox.id, {
to: "investor@fund.com",
subject: "Monthly update",
});
await client.drafts.update(inbox.id, draft.id, {
plainBody: "Here are this month's numbers...",
});
// Human reviews in dashboard, then:
await client.drafts.send(inbox.id, draft.id);
Available resources
| Resource | Methods |
client.inboxes | create, list, get, update, delete, addAddress, removeAddress |
client.messages | send, reply, replyAll, forward, list, get, markRead, delete, listAttachments, downloadAttachment, downloadRaw |
client.threads | list, get |
client.drafts | create, list, get, update, delete, send |
client.lists | create, list, get, delete, addEntry, removeEntry |
client.domains | create, list, get, verify, zoneFile, delete |
client.workspaces | create, list, get, delete, createKey, listKeys, revokeKey |
client.webhooks | create, list, get, update, delete |
client | verifyEmail, metrics |
Error handling
import { MyAgentMailError } from "myagentmail";
try {
await client.messages.send(inboxId, { ... });
} catch (err) {
if (err instanceof MyAgentMailError) {
console.log(err.status); // 403
console.log(err.code); // "PLAN_LIMIT_REACHED"
console.log(err.message);
}
}
MCP server
For Claude Desktop / Cursor / Windsurf, the MCP server wraps the same API as native tools. Install separately:
npx -y myagentmail-mcp
Source
The SDK source is at github.com/kamskans/myagentmail/sdk. Zero runtime dependencies — just native fetch (Node 18+).
Install the agent skill
Drop-in Claude Code / Cursor / OpenClaw skill that teaches your coding assistant the full myagentmail API surface in one file.
What is a skill?
A skill is a single markdown file (plus optional reference files) with YAML frontmatter that tools like Claude Code and OpenClaw load into an agent's context. It explains the API, shows the patterns, and lets the agent answer "send an email from my support inbox" without you having to hand-feed docs every turn.
We publish ours at a stable URL so you can install it with one curl.
Files
| File | Purpose |
| SKILL.md | Main skill. YAML frontmatter + every endpoint with TypeScript and Python examples. Covers inboxes, messages, threads, drafts, lists, addresses, custom domains, workspaces, metrics, and error handling. |
| references/webhooks.md | Deep dive on HTTP webhook setup, signature verification, and retry behavior. |
| references/websockets.md | Deep dive on real-time events, including the inline 2FA-code pattern (full TypeScript implementation). |
Install — Claude Code
mkdir -p ~/.claude/skills/myagentmail/references
curl -o ~/.claude/skills/myagentmail/SKILL.md \
https://myagentmail.com/skills/myagentmail/SKILL.md
curl -o ~/.claude/skills/myagentmail/references/webhooks.md \
https://myagentmail.com/skills/myagentmail/references/webhooks.md
curl -o ~/.claude/skills/myagentmail/references/websockets.md \
https://myagentmail.com/skills/myagentmail/references/websockets.md
Claude Code auto-loads skills from ~/.claude/skills/. Restart Claude Code and the myagentmail skill is available — say "create an inbox and send a test email" and the assistant will use the right endpoints automatically.
Install — Cursor
mkdir -p .cursor/rules
curl -o .cursor/rules/myagentmail.md \
https://myagentmail.com/skills/myagentmail/SKILL.md
Cursor loads project rules from .cursor/rules/. The skill becomes part of the active context for that workspace.
Install — OpenClaw
curl -o ~/.openclaw/skills/myagentmail/SKILL.md --create-dirs \
https://myagentmail.com/skills/myagentmail/SKILL.md
Set your API key
The skill expects MYAGENTMAIL_KEY in the environment:
export MYAGENTMAIL_KEY="tk_..." # add to your shell rc file
Use your tenant master key for development. For production agent deployments, issue an inbox-scoped ak_ key (returned when you create an inbox) — it's the narrowest scope and limits blast radius if the key leaks.
→
The skill is a live file served from myagentmail.com — we keep it updated as we ship new endpoints. curl it again whenever you want the latest. View the current version at myagentmail.com/skills/myagentmail/SKILL.md.
→
The TypeScript SDK (npm install myagentmail) and MCP server (npx -y myagentmail-mcp) are now published on npm. Use the SDK for programmatic access, the MCP server for Claude Desktop / Cursor, or the skill for agent context.
Using it without an agent tool
The SKILL.md is just markdown. Even if you're not using Claude Code, Cursor, or OpenClaw, you can read it as a complete self-contained API reference — it's designed to work as a standalone document. No exporter, no build step, no runtime.
Handling inbound email
Three ways to receive mail in your agent. They compose — you can use more than one.
The three options
| WebSocket | HTTP webhook | Polling |
| Latency | Real-time (sub-second) | Real-time (sub-second) | Whatever your interval is |
| Needs public URL? | No | Yes | No |
| Durable / retried? | No — push only while connected | Yes — delivery retried on failure | Yes — you own the cursor |
| Best for | Agent turns waiting inline for a reply or 2FA code | Server-side workers, background processing, persistent storage | Batch jobs, scheduled digests |
WebSocket — in-line intake
The simplest pattern for an agent that's actively reasoning about email. Open a socket, subscribe, wait for the event, act on it, close. No webhook receiver to deploy.
import WebSocket from "ws";
const ws = new WebSocket("wss://myagentmail.com/v1/ws", {
headers: { "X-API-Key": process.env.MYAGENTMAIL_KEY }
});
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
event_types: ["message.received"],
inbox_ids: [process.env.INBOX_ID]
}));
});
ws.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type === "event" && frame.event_type === "message.received") {
console.log("New reply from", frame.message.from);
}
});
See Receiving 2FA codes inline for the full signup-and-wait-for-code flow.
HTTP webhooks — durable fan-out
When you want guaranteed delivery to a long-running service — crm sync, support ticket creation, logging — register a webhook. Every event is POSTed to your URL with an HMAC signature header.
curl -X POST https://myagentmail.com/v1/webhooks \
-H "X-API-Key: tk_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/myagentmail",
"events": ["message.received", "message.bounced"]
}'
Response includes a secret. Verify incoming payloads with HMAC-SHA256 using that secret.
Polling — when you own the schedule
Simplest path. Good for batch jobs, daily digests, or environments where you can't open WebSockets and don't have a public URL.
curl "https://myagentmail.com/v1/inboxes/INBOX_ID/messages?direction=inbound&limit=50" \
-H "X-API-Key: tk_your_key"
Track the most recent message ID you've processed and filter client-side, or rely on isRead flipping to true after you fetch each message.
Using them together
Nothing stops you from using all three. A common setup: an HTTP webhook writes every inbound message to your database (durable), a WebSocket powers the agent's in-line "wait for reply" turns (fast), and nightly polling reconciles any gaps (safety net).
Threaded conversations
How myagentmail assembles a thread, and how to stay in one when you reply.
How threading works
When a message arrives — inbound or outbound — we look at the RFC 5322 Message-ID, In-Reply-To, and References headers to decide which thread it belongs to:
- If
In-Reply-To matches a known message, the new message joins that message's thread.
- Otherwise, if any
References header entry matches, join that thread.
- Otherwise, start a new thread with a fresh UUID.
Every message we store has a threadId. You can fetch a whole thread with GET /v1/inboxes/{id}/threads/{threadId}.
Replying inside a thread
Always use the reply endpoints. They do the header plumbing for you:
POST /v1/inboxes/{id}/reply/{messageId}
{
"plainBody": "Thanks — scheduling that call now."
}
The endpoint looks up the original message's Message-ID and References, sets In-Reply-To on the outgoing mail, appends to the References chain, and re-uses the same threadId. The recipient's mail client sees a proper continuation of the same conversation.
!
If you send with POST /send instead of POST /reply, you start a new thread. Always reach for /reply (or /reply-all, /forward) when the agent is continuing an existing conversation.
Reply-all
POST /reply-all/{messageId} sends to the original sender plus every address in the original To list, with any of your own inbox's accepted addresses filtered out to avoid echoing yourself.
Forward
POST /forward/{messageId} prepends a "Forwarded message" header block and sends the combined body to new recipients. Forwards start a new thread by design.
Drafts & iterative composition
Agents rarely build a message in one shot. Drafts let you persist the in-progress work between turns.
The draft lifecycle
- Create with whatever fields you have so far. Partial drafts are fine — to, subject, plain body, html body, reply target, are all optional.
- Update on subsequent turns. PATCH is merge-style — omitted fields stay as-is, so the agent can refine one field at a time.
- Send when ready. Sending atomically delivers the message and deletes the draft.
When to use drafts
- Multi-turn composition. The agent researches the recipient, drafts a subject, drafts a body, refines the body, then sends.
- Human-in-the-loop. The agent produces a draft, a human reviews it in your UI, and clicks send. See the Human-in-the-loop guide.
- Reply composition. Create a draft with
replyToMessageId set. When sent, the message is threaded automatically with the original.
- Queued sends. Hold a finished draft until a signal fires, then POST to
/send.
Worked example
# Turn 1 — start the draft
DRAFT=$(curl -sX POST https://myagentmail.com/v1/inboxes/$ID/drafts \
-H "X-API-Key: ak_..." -H "Content-Type: application/json" \
-d '{"to":"ceo@acme.com","subject":"Quick question"}' \
| jq -r .id)
# Turn 2 — add a body after researching the recipient
curl -X PATCH https://myagentmail.com/v1/inboxes/$ID/drafts/$DRAFT \
-H "X-API-Key: ak_..." -H "Content-Type: application/json" \
-d '{"plainBody":"Hey — noticed you joined Acme last month ..."}'
# Turn 3 — send, draft is deleted atomically
curl -X POST https://myagentmail.com/v1/inboxes/$ID/drafts/$DRAFT/send \
-H "X-API-Key: ak_..."
Drafts vs webhooks
A draft is a server-side staging area. A webhook is a notification fired by the server. They're not alternatives — you'd typically use both together: the agent composes a draft over several turns, then (once sent) a webhook fires to record the sent message in your CRM.
Human-in-the-loop workflows
Add approval gates to agent-generated email so a human signs off before anything goes out.
The canonical pattern
- Agent researches the recipient and composes a draft via
POST /v1/inboxes/{id}/drafts. The response has a draft id.
- Your app notifies a reviewer (in-app banner, Slack DM, email to an approver mailbox, whatever you use).
- Reviewer opens the draft in your UI. Your UI calls
GET /drafts/{id} to show the current state.
- Reviewer edits inline —
PATCH /drafts/{id} — or clicks Send — POST /drafts/{id}/send — or discards — DELETE /drafts/{id}.
Why drafts not "pending sends"
We deliberately don't have a "queue a send for later approval" endpoint. A draft IS the approval object. This keeps the API surface small and the state model unambiguous: a message either exists as a draft (not yet sent) or as a message (delivered to the relay). There's no third "pending approval" limbo state that can get wedged if your approval service is down.
Audit trail
Pair human-in-the-loop with an HTTP webhook on message.sent. Every send produces a webhook call you can log in your audit system, correlated by draft id → message id. You can also store reviewer metadata on the draft via the inbox.metadata field, if you need per-draft notes.
Workspaces for multi-tenant email
Isolate inboxes and domains across your customers without opening a new myagentmail account per customer.
The structure
Tenant (your account, one Stripe subscription)
├── Workspace "Default"
│ └── Your own inboxes + domains
├── Workspace "Acme Corp" ← reseller customer 1
│ ├── Inboxes
│ └── Domains
└── Workspace "TalentHub" ← reseller customer 2
├── Inboxes
└── Domains
A workspace is a hard isolation boundary. Inboxes, domains, threads, messages, drafts, and lists all belong to one workspace. A workspace master key (wk_) can only see its own workspace. Your tenant master key (tk_) can see all workspaces across your tenant.
When you need them
- You're building a SaaS that gives each customer their own inbox (recruiter tools, support platforms, sales tools)
- You want to offer custom domains to your customers and each needs its own deliverability posture
- Your customers would be upset if another customer's domain leaked into their dashboard
- You need one billing relationship with us but many tenants of your own underneath
Provisioning a workspace
# 1. Create the workspace (requires your tenant master key)
curl -X POST https://myagentmail.com/v1/workspaces \
-H "X-API-Key: tk_..." -H "Content-Type: application/json" \
-d '{"name": "Acme Corp"}'
# → { "id": "81b7be7b-...", "slug": "acme-corp", ... }
# 2. Issue a workspace-scoped key
curl -X POST https://myagentmail.com/v1/workspaces/81b7be7b-.../keys \
-H "X-API-Key: tk_..." -H "Content-Type: application/json" \
-d '{"name": "Acme API key"}'
# → { "key": "wk_...", "prefix": "wk_...", ... }
# 3. Hand that wk_ key to Acme's agent runtime
What the scoped key can do
A wk_ key behaves like a tenant master key, but every list, create, update, and delete is scoped to its workspace. If Acme's agent tries to create an inbox, it lands in Acme's workspace automatically. If it tries to list inboxes, it sees only its workspace. Cross-workspace access is impossible at the API layer.
Billing
Billing lives at the tenant level. Your plan limits (inboxes / emails / domains) are tenant-wide totals that roll up across all workspaces. If you want per-workspace usage visibility, hit GET /v1/metrics?workspace=<id> to get scoped counters.
→
Workspaces are agentmail.to's "pods" with a different name. Our WebSocket subscribe frame accepts both workspace_ids and pod_ids for wire compatibility.
Allowlists & blocklists
Filter who can reach an agent's inbox before the message hits your downstream processing.
Where filtering happens
myagentmail doesn't currently run a first-class allow/blocklist filter server-side. Instead, agents typically filter at the webhook or event-handler layer — check the from address against your own list before acting. Since the List resource is inbox-scoped, it's a natural place to store allowed senders:
# Create an allowlist for this inbox
curl -X POST https://myagentmail.com/v1/inboxes/$ID/lists \
-H "X-API-Key: ak_..." -H "Content-Type: application/json" \
-d '{"name":"allowlist","description":"addresses the agent will act on"}'
# Add entries
curl -X POST https://myagentmail.com/v1/inboxes/$ID/lists/$LIST_ID/entries \
-H "X-API-Key: ak_..." -H "Content-Type: application/json" \
-d '{"email":"customer@acme.com"}'
In your inbound handler, fetch the list once (cache it) and drop any message whose sender isn't on it. Same pattern for blocklists — just flip the match logic.
Bounces and mailer-daemon
One category is already filtered for you: bounces. When an inbound message arrives from mailer-daemon@, postmaster@, or matches standard DSN subject heuristics, we track it against the inbox's bounce rate and auto-pause sending if the rate exceeds the safeguard threshold. See Bounces and auto-pause.
Roadmap
A server-side allow/blocklist feature (reject at SMTP DATA time instead of storing the message) is on the roadmap. Let us know if that's blocking you and we'll prioritize it.
Receiving 2FA codes inline
The flagship WebSocket use case: an agent fills out a signup form, waits for the verification email, extracts the code, enters it — all in one turn without a public webhook receiver.
The problem
An agent needs to sign up for a third-party service on behalf of its user. The service requires email verification. Three bad options:
- Use the user's real email — leaks the agent into the user's personal inbox and requires them to hand off the code mid-flow.
- Use a shared catch-all — multiple signups race each other for the inbox.
- Spin up a webhook receiver — means you need a public URL for every agent instance, which defeats the point of running agents in ephemeral environments.
The solution
Create a fresh inbox, open a WebSocket subscribed to it, fill the form with the inbox's address, and wait for the message.received event. Extract the code, close the socket, continue.
import WebSocket from "ws";
async function signUpFor3rdParty(siteUrl: string) {
// 1. Create a one-off inbox for this signup
const inbox = await fetch("https://myagentmail.com/v1/inboxes", {
method: "POST",
headers: {
"X-API-Key": process.env.MYAGENTMAIL_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ username: `signup-${crypto.randomUUID()}` }),
}).then(r => r.json());
// 2. Open a WebSocket subscribed to this inbox
const ws = new WebSocket("wss://myagentmail.com/v1/ws", {
headers: { "X-API-Key": process.env.MYAGENTMAIL_KEY! },
});
const codePromise = new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("2FA timeout")), 60_000);
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
event_types: ["message.received"],
inbox_ids: [inbox.id],
}));
});
ws.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type !== "event") return;
const body = frame.message.plain_body || "";
const code = body.match(/\b\d{4,8}\b/)?.[0];
if (code) {
clearTimeout(timer);
ws.close();
resolve(code);
}
});
});
// 3. Fill the signup form with inbox.email (using your browser automation)
await fillSignupForm(siteUrl, inbox.email);
// 4. Wait for the code email
const code = await codePromise;
// 5. Enter the code to complete verification
await submitVerificationCode(siteUrl, code);
return inbox; // keep the inbox around if you need future mail for this account
}
→
This pattern works without any public URL. The WebSocket is outbound-only — you can run it from a container, a Lambda, a laptop, anywhere.
Custom domain setup
Send as you@yourcompany.com instead of you@myagentmail.com, without losing any existing thread history.
1. Register the domain
curl -X POST https://myagentmail.com/v1/domains \
-H "X-API-Key: tk_..." -H "Content-Type: application/json" \
-d '{"domain": "mail.yourcompany.com"}'
The response returns the DNS records you need to set. Typically:
- A TXT record for DKIM (long RSA public key)
- A CNAME record for bounce handling
- An MX record pointing to
mail.myagentmail.com
2. Add the records at your DNS provider
Copy the records into your registrar's DNS editor. See the provider-specific guides for Cloudflare, Route 53, GoDaddy, or Namecheap.
!
If your apex domain already receives mail at another provider, don't add myagentmail's MX record on the apex — you'll break your existing mail flow. Use a subdomain like mail.yourcompany.com instead. See MX records without breaking your primary domain.
3. Verify
curl https://myagentmail.com/v1/domains/mail.yourcompany.com/verify \
-H "X-API-Key: tk_..."
DKIM propagation usually takes 1-15 minutes. If it's not verified yet, wait and retry.
4. Attach the domain as an alias on your inbox
This is the step that makes custom domains not lose thread history. Instead of creating a brand-new inbox on the custom domain, we attach the new address as an alias to your existing inbox:
curl -X POST https://myagentmail.com/v1/inboxes/$ID/addresses \
-H "X-API-Key: tk_..." -H "Content-Type: application/json" \
-d '{"email": "you@mail.yourcompany.com", "primary": true}'
Now the inbox accepts mail at both the original @myagentmail.com address AND the new custom address. Outbound From uses the new primary alias. Replies to the old address still land in the same inbox. IMAP and SMTP credentials don't change.
Mental model: one inbox, many addresses
An inbox has one canonical identity (the original username and domain it was born with) and a list of accepted addresses. One of those addresses is marked primary — that's the outbound From. Changing the primary is cheap; the canonical identity is forever and is what IMAP/SMTP authenticate against.
SPF, DKIM, and DMARC
What the three authentication standards do, and what myagentmail needs from each.
DKIM — required
DKIM signs every outbound message with your domain's private key. Receiving servers fetch the public key from DNS and verify the signature, proving the mail really came from an authorized sender for that domain.
When you register a custom domain, we generate a keypair and return the public key as a TXT record. You add it to DNS; we sign every send with the matching private key. Without DKIM, recipient servers like Gmail and Outlook will either spam-filter or reject your mail outright.
SPF — recommended
SPF lists the IP addresses authorized to send mail for your domain. A Gmail server receiving mail from your domain checks: "is this sending IP on the SPF list?"
For myagentmail you want an SPF record that includes our relay. The exact include: depends on which upstream provider your tenant is on (check the dnsRecords array in your domain verify response). A typical value:
v=spf1 include:zeptomail.eu ~all
DMARC — strongly recommended
DMARC tells receiving servers what to do when SPF or DKIM fails: reject, quarantine, or just report. It also sets up aggregate reports so you can see who's sending mail claiming to be from your domain.
A good starting DMARC record:
_dmarc.yourcompany.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourcompany.com"
Start with p=none (report only), monitor the reports for a week, then tighten to p=quarantine and eventually p=reject once you've confirmed everything legitimate is passing.
Order of operations
- Add DKIM (we give you the record) — required for sends to work at all
- Add SPF — improves deliverability
- Add DMARC with
p=none — unlocks reporting
- Tighten DMARC to
quarantine → reject after a monitoring period
Why my mail is landing in spam
The most common reasons, in order of how often we see them.
1. DKIM isn't verified
Check GET /v1/domains/{domain}. If status isn't verified, Gmail and Outlook will treat your mail as unauthenticated and heavily filter it. Run GET /v1/domains/{domain}/verify to re-check DNS, and wait for propagation (up to an hour for some nameservers).
2. You're on a brand-new domain
New domains have zero sending reputation. Even with perfect auth, Gmail is wary. The fix is domain warming: send low volumes for the first week, prefer recipients who have engaged (replied to you) before, and gradually ramp.
3. Your content looks spammy
Content filters still matter. Things that raise red flags:
- ALL CAPS subject lines
- Many exclamation marks
- Tracking-heavy HTML (hidden pixels, URL shorteners)
- Large image-to-text ratios
- URLs that don't match the sender domain
- Generic "Hi {first_name}" template skeletons that didn't get filled in
Agent-generated mail tends to be clean — short, conversational, personalized. That's a deliverability advantage. Don't undo it with HTML email templates styled like marketing newsletters.
4. Recipient address was never validated
Sending to a garbage address (typo, catch-all, spam trap) triggers a bounce, and bounces hurt your reputation. We soft-enforce this: if you try to send to an address that has never engaged with your inbox, you must set verified: true in the body to assert you've validated it out-of-band (e.g. via POST /v1/verify-email).
5. You're sending through an inbox that bounced too much
When the bounce rate on an inbox crosses our safeguard threshold we auto-pause sending from that inbox. See Bounces and auto-pause for the threshold and how to unpause.
Diagnosis checklist
- Send a test to mail-tester.com. It grades your auth, content, and blacklist status.
- Check DKIM + SPF + DMARC records with
dig TXT.
- Inspect the raw headers of one of your sent messages — look for
Authentication-Results: dkim=pass.
- Check
GET /v1/metrics for your recent bounce count.
Domain warming
A new domain has no sending reputation. Ramp slowly or you'll land in spam.
The rough schedule
| Week | Max sends/day | Focus |
| 1 | 10-20 | Only to recipients likely to engage (existing customers, warm contacts) |
| 2 | 50-100 | Widen to qualified prospects; watch bounce and complaint rate |
| 3 | 200-500 | Normal volume for most agent workloads |
| 4+ | 1000+ | Steady-state if you're actually sending that much |
What actually matters
Volume ramping is the visible part. The invisible part is engagement. ESPs look at:
- Reply rate — did anyone reply? Replies are the strongest positive signal.
- Bounce rate — did the address exist? Under 2% is the safe ceiling.
- Spam complaints — did recipients click "report spam"? Anything over 0.1% is catastrophic.
- Open rate — lesser signal, and you can't rely on it with Apple Mail Privacy Protection blunting tracking pixels.
The fastest way to warm a domain is to send mail that gets replied to. That's exactly the agent use case — if your agent is doing useful work, the warming takes care of itself.
The one rule
Never send a cold blast to a freshly-added domain. 1000 unverified recipients on day one is the fastest way to get your domain flagged.
MX records without breaking your primary domain
Your main company domain probably already has mail. Here's how to add myagentmail without blowing up your existing inbox.
The problem
A domain can only have one set of MX records. If you point yourcompany.com's MX at myagentmail, all mail for yourcompany.com — including your existing you@yourcompany.com inbox — starts flowing into myagentmail, and your Google Workspace/Outlook/Fastmail inbox goes dark.
The fix: use a subdomain
Register the domain as mail.yourcompany.com (or agents.yourcompany.com, or any subdomain that isn't already in use) instead of the apex:
curl -X POST https://myagentmail.com/v1/domains \
-H "X-API-Key: tk_..." -H "Content-Type: application/json" \
-d '{"domain": "mail.yourcompany.com"}'
MX records for a subdomain are completely independent of the apex. Your you@yourcompany.com inbox keeps working at its existing provider; mail to agent@mail.yourcompany.com routes to myagentmail.
What to set
In your DNS editor, under the mail subdomain, add exactly what the domain verify response gives you:
- MX:
mail.yourcompany.com. IN MX 10 mail.myagentmail.com.
- TXT DKIM: the long RSA record on the selector hostname we return
- CNAME bounce handler: on
bounce.mail.yourcompany.com.
Why not just use the apex?
You can, if the apex has no existing MX or you're migrating away from another provider. But for most existing businesses the apex is load-bearing and a subdomain is a zero-risk move. SPF and DKIM alignment still work correctly for subdomain sends; DMARC aspf=r / adkim=r (relaxed alignment, the default) treats subdomains as aligned with the organizational domain.
Rate limits & plan limits
Two kinds of ceilings to be aware of.
Plan limits
Each plan has three hard monthly caps:
- Inboxes — maximum active inboxes at any time across your tenant
- Emails — maximum sent emails per billing period (inbound is unlimited)
- Custom domains — maximum registered custom domains
When you hit one, the relevant endpoint returns 403 with code: "PLAN_LIMIT_REACHED" and a body containing the current and max values. Check your current usage with GET /v1/metrics. Upgrade via the dashboard billing page.
Per-request rate limits
We apply a sliding-window rate limit on /v1/* requests per API key. Typical limit is a few hundred requests per minute — more than enough for agent workloads. If you hit it you'll see 429 Too Many Requests. Back off exponentially and retry.
Send rate
Outbound send has a separate, lower rate governed by your upstream ESP and your domain's warming state. Most agents never notice. If you're trying to push thousands of sends in a tight loop, batch the work across multiple inboxes or spread over time.
Bounces and auto-pause
We auto-pause sending from an inbox that's bouncing too much, to protect your domain reputation.
How it works
- Every time an inbox sends, we increment its send count.
- Every time an inbound message is classified as a bounce (DSN subject,
mailer-daemon sender, etc.) we increment the bounce count.
- If the bounce rate over a recent window crosses our threshold, we set the inbox to paused.
- A paused inbox returns
403 INBOX_PAUSED on any send attempt. Inbound still works.
Why pause instead of just warn
A high bounce rate burns domain reputation fast. If we didn't pause, one agent stuck in a retry loop against a bad address could mark your whole sending domain as spam to Gmail and Outlook. Pausing is aggressive but it protects your deliverability.
Unpausing
Currently, unpausing is manual — contact support. Usually we want to understand what caused the bounce burst before unpausing, so we can help you avoid it next time. A planned future feature is a self-service unpause endpoint with a cooldown.
Avoiding the problem
- Always validate recipient addresses before sending. Use
POST /v1/verify-email as the pre-flight MX check, then pass verified: true on the send.
- Don't retry on a permanent bounce. If a send fails with a 5xx from the relay, assume the address is dead and move on.
- Watch
GET /v1/metrics — it exposes sent, received, and bounced counts you can alert on.
Domain verification failures
Common reasons a domain gets stuck in pending and how to fix each.
DKIM record missing or malformed
Most common failure. Double-check:
- The record is a TXT record, not a CNAME (different providers handle this differently — some auto-flatten DKIM keys into TXT, others don't).
- The record name is the selector we returned, not the apex. Typically something like
1234567._domainkey.yourcompany.com.
- The record value is the full
k=rsa; p=... string with no line breaks. Some DNS editors silently wrap long values; paste into a tool that shows you the raw stored value.
CNAME for bounce handler missing
The CNAME on bounce.yourdomain.com is required. Without it, bounces aren't routed back to us and we can't track deliverability.
DNS propagation delay
After adding records, wait 5-15 minutes. Some registrars (looking at you, GoDaddy) take up to an hour. Use dig TXT _domainkey.yourdomain.com to confirm the record is live before retrying verify.
Wildcard TXT records interfering
If you have a wildcard TXT * record, it can shadow specific records on some DNS servers. Set the DKIM record with the exact hostname, not a wildcard.
Still stuck?
Fetch the current status and raw DNS records we expected:
curl https://myagentmail.com/v1/domains/yourdomain.com/verify \
-H "X-API-Key: tk_..."
The response shows each record's individual verification state. Whichever is pending is the one missing in DNS.
Avoiding duplicate sends
What to do when your agent retries a send and you're not sure whether the first one went through.
The scenario
Agent calls POST /send. The response times out. Did the message go or not? Naive retry risks sending twice.
What the API gives you today
Every sent message has a unique messageId returned in the response. You can cross-check by listing recent outbound messages:
curl "https://myagentmail.com/v1/inboxes/$ID/messages?direction=outbound&limit=10" \
-H "X-API-Key: ak_..."
Scan the results for a matching subject and to within the last few seconds. If it's there, your first send succeeded — don't retry.
What's coming
A first-class Idempotency-Key header is on the roadmap. Send the same key twice and the second call returns the first call's response without sending again. If that's blocking you, let us know.
Design advice until then
- If a
POST /send call times out or returns a 5xx, don't retry immediately. List recent messages first to check whether the send went through.
- For agent flows where duplicates are catastrophic (e.g. sending contracts), use drafts as a staging step. An agent can't accidentally send a draft twice — the second
POST /drafts/{id}/send call returns a 404 because the first one deleted the draft.
MCP server
Plug myagentmail into Claude Desktop, Cursor, Windsurf, Cline, or any MCP-compatible client. Your AI assistant gets a full set of email tools instantly.
What is MCP?
The Model Context Protocol is an open standard from Anthropic for connecting AI assistants to external tools and data sources. An MCP server runs as a subprocess (or remote service) and exposes a list of tools the LLM can call. We ship one for myagentmail at myagentmail-mcp.
Install
myagentmail-mcp is published on npm. Use npx -y myagentmail-mcp — no global install needed. Each MCP client has its own config file. Below is the setup for the most common ones; the format is identical because MCP is standardized.
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or %APPDATA%\Claude\claude_desktop_config.json on Windows:
{
"mcpServers": {
"myagentmail": {
"command": "npx",
"args": ["-y", "myagentmail-mcp"],
"env": {
"MYAGENTMAIL_KEY": "tk_your_key_here"
}
}
}
}
Restart Claude Desktop. The myagentmail tools appear in the tool picker — try "create a new inbox and send a hello to me at you@example.com" and Claude will call create_inbox followed by send_message automatically.
Cursor
Edit ~/.cursor/mcp.json (or your project's .cursor/mcp.json):
{
"mcpServers": {
"myagentmail": {
"command": "npx",
"args": ["-y", "myagentmail-mcp"],
"env": { "MYAGENTMAIL_KEY": "tk_your_key_here" }
}
}
}
Windsurf
Edit ~/.codeium/windsurf/mcp_config.json with the same shape.
Tools exposed
The server wraps the most useful slice of our REST API as MCP tools. The LLM sees a full JSON schema for each:
- Inboxes —
create_inbox, list_inboxes, get_inbox, delete_inbox
- Messages —
send_message, reply_to_message, reply_all_to_message, forward_message, list_messages, get_message, mark_message_read, delete_message
- Threads —
list_threads, get_thread
- Drafts —
create_draft, update_draft, send_draft, list_drafts, delete_draft
- Attachments —
list_attachments, download_attachment
- Custom domains —
register_domain, verify_domain, list_domains, delete_domain, add_inbox_address
- Workspaces —
list_workspaces, create_workspace, issue_workspace_key
- Webhooks —
create_webhook, list_webhooks, delete_webhook
- Metrics + utility —
get_metrics, verify_email
Source
The server source lives in the myagentmail/mcp directory. It's a thin wrapper — every tool just fetches the matching REST endpoint and returns the JSON.
→
Paired with our agent skill, an MCP-equipped client gets both the tool definitions (from MCP) and the prose documentation (from the skill). Use both for the best experience.
Vercel AI SDK
Wire myagentmail into the Vercel AI SDK as tool definitions for generateText and streamText.
The Vercel AI SDK uses Zod schemas to define tools. We have no SDK package — you wrap our REST endpoints inline in a tools object. Below is a minimal kit you can paste into any project.
import { generateText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const API = "https://myagentmail.com/v1";
const KEY = process.env.MYAGENTMAIL_KEY!;
async function mam(method: string, path: string, body?: unknown) {
const r = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}: ${await r.text()}`);
return r.json();
}
const myagentmailTools = {
create_inbox: tool({
description: "Create a new email inbox.",
parameters: z.object({
username: z.string().optional(),
displayName: z.string().optional(),
}),
execute: async (args) => mam("POST", "/inboxes", args),
}),
send_message: tool({
description:
"Send an email from an inbox. Pass verified=true when you've validated the recipient.",
parameters: z.object({
inboxId: z.string(),
to: z.union([z.string().email(), z.array(z.string().email())]),
subject: z.string(),
plainBody: z.string().optional(),
htmlBody: z.string().optional(),
verified: z.boolean().default(true),
}),
execute: async ({ inboxId, ...body }) =>
mam("POST", `/inboxes/${inboxId}/send`, body),
}),
list_messages: tool({
description: "List messages in an inbox, filtered by direction.",
parameters: z.object({
inboxId: z.string(),
direction: z.enum(["inbound", "outbound"]).optional(),
limit: z.number().int().min(1).max(100).default(20),
}),
execute: async ({ inboxId, direction, limit }) => {
const q = new URLSearchParams({ limit: String(limit) });
if (direction) q.set("direction", direction);
return mam("GET", `/inboxes/${inboxId}/messages?${q}`);
},
}),
reply_to_message: tool({
description: "Reply to a message in-thread.",
parameters: z.object({
inboxId: z.string(),
messageId: z.string(),
plainBody: z.string(),
}),
execute: async ({ inboxId, messageId, ...body }) =>
mam("POST", `/inboxes/${inboxId}/reply/${messageId}`, body),
}),
};
// Use it
const result = await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt: "Create an inbox called 'sales-bot', then send a hello to ceo@acme.com.",
tools: myagentmailTools,
});
Add as many tools as your agent needs — the pattern is identical for every endpoint. The full surface is in the API reference; copy the input schema from the corresponding OpenAPI requestBody.
LangChain
Wrap myagentmail endpoints as LangChain tools (TS or Python).
TypeScript (LangChain.js)
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
const KEY = process.env.MYAGENTMAIL_KEY!;
const API = "https://myagentmail.com/v1";
async function mam(method: string, path: string, body?: unknown) {
const r = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.json();
}
export const sendEmailTool = new DynamicStructuredTool({
name: "send_email",
description: "Send an email from a specific myagentmail inbox.",
schema: z.object({
inboxId: z.string(),
to: z.string().email(),
subject: z.string(),
plainBody: z.string(),
}),
func: async ({ inboxId, ...body }) =>
JSON.stringify(await mam("POST", `/inboxes/${inboxId}/send`, { ...body, verified: true })),
});
export const listInboxMessagesTool = new DynamicStructuredTool({
name: "list_inbox_messages",
description: "List inbound messages in a myagentmail inbox.",
schema: z.object({
inboxId: z.string(),
limit: z.number().int().default(10),
}),
func: async ({ inboxId, limit }) =>
JSON.stringify(await mam("GET", `/inboxes/${inboxId}/messages?direction=inbound&limit=${limit}`)),
});
Python (LangChain)
import os, requests
from langchain_core.tools import tool
KEY = os.environ["MYAGENTMAIL_KEY"]
API = "https://myagentmail.com/v1"
H = {"X-API-Key": KEY, "Content-Type": "application/json"}
def mam(method, path, body=None):
r = requests.request(method, f"{API}{path}", headers=H, json=body)
r.raise_for_status()
return r.json()
@tool
def send_email(inbox_id: str, to: str, subject: str, plain_body: str) -> dict:
"""Send an email from a myagentmail inbox."""
return mam("POST", f"/inboxes/{inbox_id}/send",
{"to": to, "subject": subject, "plainBody": plain_body, "verified": True})
@tool
def list_inbox_messages(inbox_id: str, limit: int = 10) -> dict:
"""List recent inbound messages in a myagentmail inbox."""
return mam("GET", f"/inboxes/{inbox_id}/messages?direction=inbound&limit={limit}")
Pass these tools into your agent constructor (create_react_agent, AgentExecutor, LangGraph node, etc.) and the LLM will call them when relevant.
OpenAI function calling
JSON schema function definitions you can drop straight into the OpenAI Chat Completions or Responses API.
import OpenAI from "openai";
const openai = new OpenAI();
const KEY = process.env.MYAGENTMAIL_KEY!;
const API = "https://myagentmail.com/v1";
async function mam(method: string, path: string, body?: unknown) {
const r = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.json();
}
const tools = [
{
type: "function" as const,
function: {
name: "send_email",
description: "Send an email from a myagentmail inbox.",
parameters: {
type: "object",
required: ["inboxId", "to", "subject", "plainBody"],
properties: {
inboxId: { type: "string" },
to: { type: "string", format: "email" },
subject: { type: "string" },
plainBody: { type: "string" },
},
},
},
},
{
type: "function" as const,
function: {
name: "list_inbox_messages",
description: "List inbound messages in a myagentmail inbox.",
parameters: {
type: "object",
required: ["inboxId"],
properties: {
inboxId: { type: "string" },
limit: { type: "number", default: 10 },
},
},
},
},
];
async function dispatch(name: string, args: any) {
if (name === "send_email") {
return mam("POST", `/inboxes/${args.inboxId}/send`, {
to: args.to, subject: args.subject, plainBody: args.plainBody, verified: true,
});
}
if (name === "list_inbox_messages") {
return mam("GET", `/inboxes/${args.inboxId}/messages?direction=inbound&limit=${args.limit ?? 10}`);
}
throw new Error(`unknown tool: ${name}`);
}
// Standard tool-call loop
const messages: any[] = [
{ role: "user", content: "Check my inbox abc-123 for new mail and reply to anything urgent." },
];
while (true) {
const res = await openai.chat.completions.create({
model: "gpt-5",
messages,
tools,
});
const msg = res.choices[0].message;
messages.push(msg);
if (!msg.tool_calls?.length) break;
for (const call of msg.tool_calls) {
const result = await dispatch(call.function.name, JSON.parse(call.function.arguments));
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(result),
});
}
}
Anthropic tool use
Tool definitions for the Claude Messages API.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const KEY = process.env.MYAGENTMAIL_KEY!;
const API = "https://myagentmail.com/v1";
async function mam(method: string, path: string, body?: unknown) {
const r = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.json();
}
const tools: Anthropic.Tool[] = [
{
name: "create_inbox",
description: "Provision a new myagentmail inbox.",
input_schema: {
type: "object",
properties: {
username: { type: "string" },
displayName: { type: "string" },
},
},
},
{
name: "send_email",
description: "Send an email from an inbox. Set verified=true if you've validated the address.",
input_schema: {
type: "object",
required: ["inboxId", "to", "subject", "plainBody"],
properties: {
inboxId: { type: "string" },
to: { type: "string" },
subject: { type: "string" },
plainBody: { type: "string" },
verified: { type: "boolean", default: true },
},
},
},
];
async function runTool(name: string, input: any) {
if (name === "create_inbox") return mam("POST", "/inboxes", input);
if (name === "send_email") {
return mam("POST", `/inboxes/${input.inboxId}/send`, {
to: input.to,
subject: input.subject,
plainBody: input.plainBody,
verified: input.verified ?? true,
});
}
throw new Error(`unknown tool: ${name}`);
}
// Standard tool-use loop
let messages: Anthropic.MessageParam[] = [
{ role: "user", content: "Create an inbox 'sales-bot' and send a hello to ceo@acme.com." },
];
while (true) {
const res = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 4096,
tools,
messages,
});
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") break;
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const result = await runTool(block.name, block.input);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(result),
});
}
messages.push({ role: "user", content: toolResults });
}
OpenClaw extension
Drop a single TypeScript file into ~/.openclaw/extensions/ to give every OpenClaw agent native myagentmail tools.
OpenClaw loads any .ts file in ~/.openclaw/extensions/ as a tool extension at startup. Below is the full extension that registers four core tools — copy it to ~/.openclaw/extensions/myagentmail.ts and set MYAGENTMAIL_KEY in the OpenClaw env.
// ~/.openclaw/extensions/myagentmail.ts
//
// Native myagentmail tools for OpenClaw agents.
// Set MYAGENTMAIL_KEY in the OpenClaw process env.
import type { ExtensionApi } from "openclaw/extensions";
const API = "https://myagentmail.com/v1";
function key(): string {
const k = process.env.MYAGENTMAIL_KEY || "";
if (!k) throw new Error("MYAGENTMAIL_KEY env var is not set");
return k;
}
async function mam(method: string, path: string, body?: unknown) {
const r = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": key(), "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`mam ${method} ${path} → ${r.status}: ${await r.text()}`);
return r.json();
}
export function register(api: ExtensionApi) {
api.registerTool({
name: "MAM_SEND_EMAIL",
description: "Send an email from a myagentmail inbox. Set verified=true after MX-checking the recipient.",
parameters: {
type: "object",
required: ["inboxId", "to", "subject"],
properties: {
inboxId: { type: "string" },
to: { type: "string", description: "Recipient email or array of emails." },
subject: { type: "string" },
plainBody: { type: "string" },
htmlBody: { type: "string" },
verified: { type: "boolean", default: true },
},
},
async execute(_id, params: any) {
const body = { ...params };
delete body.inboxId;
const result = await mam("POST", `/inboxes/${params.inboxId}/send`, body);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
},
});
api.registerTool({
name: "MAM_LIST_INBOX",
description: "List inbound messages in a myagentmail inbox.",
parameters: {
type: "object",
required: ["inboxId"],
properties: {
inboxId: { type: "string" },
limit: { type: "number", default: 20 },
},
},
async execute(_id, params: any) {
const result = await mam(
"GET",
`/inboxes/${params.inboxId}/messages?direction=inbound&limit=${params.limit ?? 20}`
);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
});
api.registerTool({
name: "MAM_REPLY",
description: "Reply to a myagentmail message in-thread.",
parameters: {
type: "object",
required: ["inboxId", "messageId", "plainBody"],
properties: {
inboxId: { type: "string" },
messageId: { type: "string" },
plainBody: { type: "string" },
},
},
async execute(_id, params: any) {
const result = await mam(
"POST",
`/inboxes/${params.inboxId}/reply/${params.messageId}`,
{ plainBody: params.plainBody }
);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
},
});
api.registerTool({
name: "MAM_CREATE_INBOX",
description: "Provision a new myagentmail inbox.",
parameters: {
type: "object",
properties: {
username: { type: "string" },
displayName: { type: "string" },
},
},
async execute(_id, params: any) {
const result = await mam("POST", "/inboxes", params);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
},
});
}
Restart OpenClaw and the four MAM_* tools become available to every agent. Extend the file with whatever endpoints your agents need — the pattern repeats for every route in the API reference.
Example: Inline 2FA-code signup
Sign up for a third-party service on behalf of a user, wait for the verification code email, extract the code, and complete the signup — all in one agent turn without any public webhook receiver.
This is the flagship WebSocket use case. Full implementation:
import WebSocket from "ws";
import { randomUUID } from "node:crypto";
const API = "https://myagentmail.com/v1";
const KEY = process.env.MYAGENTMAIL_KEY!;
async function mam(method: string, path: string, body?: unknown) {
const r = await fetch(`${API}${path}`, {
method,
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.json();
}
async function getVerificationCode(timeoutMs = 120_000): Promise<{ inbox: any, code: string }> {
// 1. Spin up a fresh inbox just for this signup
const inbox = await mam("POST", "/inboxes", {
username: `signup-${randomUUID().slice(0, 8)}`,
});
// 2. Open a WebSocket subscribed to that inbox
const ws = new WebSocket("wss://myagentmail.com/v1/ws", {
headers: { "X-API-Key": KEY },
});
const code = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for 2FA code"));
}, timeoutMs);
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
event_types: ["message.received"],
inbox_ids: [inbox.id],
}));
});
ws.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type !== "event" || frame.event_type !== "message.received") return;
const haystack = (frame.message.subject || "") + " " + (frame.message.plain_body || "");
const m = haystack.match(/\b\d{4,8}\b/);
if (m) {
clearTimeout(timer);
ws.close();
resolve(m[0]);
}
});
ws.on("error", reject);
});
return { inbox, code };
}
// Use it
const { inbox, code } = await getVerificationCode();
console.log("Inbox:", inbox.email);
console.log("Code:", code);
// → Submit the code to whatever signup page you're automating
The key insight: the WebSocket is outbound-only. You don't need a public URL or a deployed webhook receiver. This works from a Lambda, a container, a CI runner, or your laptop.
Example: Cold outreach with reply tracking
Send personalized cold emails, watch for replies, and trigger follow-ups when nothing comes back after N days.
The flow
- Sourcing — agent finds prospects (your own pipeline, an enrichment API, etc.) and gets verified email addresses.
- Initial send — for each prospect, agent calls
send_message with verified: true.
- Reply tracking — agent registers a webhook on
message.received OR polls list_messages?direction=inbound on a schedule. Each reply is matched back to the original outbound thread.
- Follow-up — for prospects with no reply after N days, agent uses
create_draft + send_draft to send a different-angle follow-up.
Cron-style implementation
This is a simplified version of what Scout (our reference outbound agent) does:
// cron: every 4 hours
async function outboundExecuteCycle(inboxId: string, leads: Lead[]) {
for (const lead of leads) {
const sinceLastTouch = Date.now() - (lead.lastTouchAt ?? 0);
const FOLLOW_UP_AFTER = 3 * 24 * 60 * 60 * 1000; // 3 days
// First contact
if (lead.touchCount === 0) {
const sent = await mam("POST", `/inboxes/${inboxId}/send`, {
to: lead.email,
subject: pickSubject(lead),
plainBody: composeOpener(lead),
verified: true,
});
await db.updateLead(lead.id, {
threadId: sent.threadId,
touchCount: 1,
lastTouchAt: Date.now(),
});
continue;
}
// Reply check — if they replied, the inbox cycle handles it
if (lead.replied) continue;
// Follow-up due
if (sinceLastTouch > FOLLOW_UP_AFTER && lead.touchCount < 3) {
const sent = await mam("POST", `/inboxes/${inboxId}/send`, {
to: lead.email,
subject: `Re: ${lead.lastSubject}`, // keeps the original conversation
plainBody: composeFollowUp(lead),
verified: true,
});
await db.updateLead(lead.id, {
touchCount: lead.touchCount + 1,
lastTouchAt: Date.now(),
});
}
}
}
// Separate cron: every 30 min — check replies and mark leads
async function inboxMonitorCycle(inboxId: string) {
const { messages } = await mam("GET", `/inboxes/${inboxId}/messages?direction=inbound&limit=100`);
for (const m of messages) {
if (m.isRead) continue;
const lead = await db.findLeadByEmail(extractEmail(m.from));
if (!lead) continue;
await db.updateLead(lead.id, { replied: true, lastReplyAt: Date.now() });
await mam("PATCH", `/inboxes/${inboxId}/messages/${m.id}`, { isRead: true });
}
}
Real Scout adds a lot more — bounce handling, send-window enforcement, angle rotation, plan-limit checks — but the core loop is exactly this shape.
Example: Customer support triage
Watch a support inbox, classify each new message into a category, draft a reply, and either auto-send routine answers or hand off to a human reviewer for sensitive ones.
The flow
- Open a WebSocket subscribed to the support inbox's
message.received events.
- For each new message: read the body, run an LLM classifier ("billing" / "bug" / "feature request" / "general").
- For low-risk categories ("general", "FAQ"), draft a reply and auto-send via
create_draft + send_draft.
- For high-risk categories ("billing", "bug"), draft the reply and notify a human reviewer in your dashboard. The draft persists until they approve.
import WebSocket from "ws";
const ws = new WebSocket("wss://myagentmail.com/v1/ws", {
headers: { "X-API-Key": process.env.MYAGENTMAIL_KEY! },
});
ws.on("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
event_types: ["message.received"],
inbox_ids: [process.env.SUPPORT_INBOX_ID!],
}));
});
ws.on("message", async (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type !== "event" || frame.event_type !== "message.received") return;
const m = frame.message;
const category = await classifyWithLLM(m.subject, m.plain_body);
const replyText = await draftReplyWithLLM(m.subject, m.plain_body, category);
// Always create a draft first — it's our staging area
const draft = await mam("POST", `/inboxes/${m.inbox_id}/drafts`, {
replyToMessageId: m.message_id,
plainBody: replyText,
});
if (category === "general" || category === "faq") {
// Auto-send routine answers
await mam("POST", `/inboxes/${m.inbox_id}/drafts/${draft.id}/send`);
} else {
// Hand off to a human — record the draft id for the reviewer UI
await db.createReviewTask({
inboxId: m.inbox_id,
draftId: draft.id,
category,
originalMessageId: m.message_id,
});
await notifySlack(`New ${category} ticket needs review`);
}
});
The reviewer UI calls GET /drafts/{id} to show the proposed reply, PATCH to edit, and POST /send when satisfied. Drafts let you run a true human-in-the-loop without inventing your own queue.
Example: Lead enrichment + personalized outreach
Pull a list of leads from a CRM, enrich each with public data, and send a personalized opener that references something specific about their company.
async function enrichAndOutreach(inboxId: string, leads: Lead[]) {
for (const lead of leads) {
// 1. Pre-flight verify the email
const verification = await mam("POST", "/verify-email", { email: lead.email });
if (!verification.valid) {
console.log(`skipping ${lead.email} — invalid`);
continue;
}
// 2. Enrich (your own enrichment provider)
const enriched = await enrichLead(lead.email);
// 3. Compose with the LLM
const subject = await llm.compose({
task: "subject_line",
lead: enriched,
max_chars: 60,
});
const body = await llm.compose({
task: "cold_opener",
lead: enriched,
tone: "professional, concise, references the specific thing",
});
// 4. Send
const sent = await mam("POST", `/inboxes/${inboxId}/send`, {
to: lead.email,
subject,
plainBody: body,
verified: true,
});
// 5. Track the outbound thread for reply matching
await db.createOutreach({
leadId: lead.id,
inboxId,
threadId: sent.threadId,
messageId: sent.id,
sentAt: Date.now(),
});
// Friendly rate-limit for warming domains
await sleep(2000);
}
}
For domain warming, keep this tight (10-20 sends/day) for the first week before ramping. See Domain warming.
Example: Calendar booking via email
When a prospect emails "let's chat next week", an agent reads the message, checks calendar availability, replies with three slot options, and books on confirmation.
// Triggered by a webhook on message.received OR a WebSocket subscriber
async function handleInboundReply(event: MessageReceivedEvent) {
// 1. Pull the full message
const msg = await mam(
"GET",
`/inboxes/${event.inbox_id}/messages/${event.message_id}`
);
// 2. LLM intent: is this a meeting request?
const intent = await llm.classify(msg.plain_body, [
"meeting_request", "thank_you", "decline", "more_info", "other",
]);
if (intent === "meeting_request") {
// 3. Pull free/busy from calendar API
const slots = await calendar.findFreeSlots({
duration: 30,
withinDays: 7,
count: 3,
});
// 4. Reply in-thread with the proposed slots
const replyBody = `Happy to chat. I have these times open:
${slots.map((s, i) => ` ${i + 1}. ${formatSlot(s)}`).join("\n")}
Reply with the number that works and I'll send a calendar invite.`;
await mam("POST", `/inboxes/${event.inbox_id}/reply/${event.message_id}`, {
plainBody: replyBody,
});
// 5. Remember which slots we offered, keyed by thread
await db.savePendingMeeting({
threadId: msg.threadId,
slots,
proposedAt: Date.now(),
});
return;
}
// 6. On their next reply ("number 2 works"), look up the pending slots
// by threadId, parse the number, create the calendar event, and confirm.
const pending = await db.findPendingMeetingByThread(msg.threadId);
if (pending) {
const choice = parseSlotChoice(msg.plain_body);
if (choice !== null) {
const slot = pending.slots[choice - 1];
await calendar.createEvent({ ...slot, attendees: [extractEmail(msg.from)] });
await mam("POST", `/inboxes/${event.inbox_id}/reply/${event.message_id}`, {
plainBody: `Booked for ${formatSlot(slot)}. Calendar invite is in your inbox.`,
});
await db.deletePendingMeeting(pending.id);
}
}
}
The whole loop runs entirely on top of the email API plus your calendar provider — no scheduling links, no Calendly redirects, just a conversational booking agent.
Cloudflare DNS setup
Adding myagentmail's records in the Cloudflare dashboard.
- Log into the Cloudflare dashboard and select your domain.
- Click DNS in the sidebar, then Records.
- For each record we returned from
POST /v1/domains:
- Type: TXT / CNAME / MX (as shown in the response)
- Name: the host — strip the root domain, Cloudflare adds it automatically (e.g. for
1234567._domainkey.yourcompany.com, enter 1234567._domainkey)
- Content / Target: the record value
- Proxy status: DNS only (the orange cloud must be off for mail records)
- TTL: Auto
- Save each record.
- Wait 1-5 minutes, then hit
GET /v1/domains/{domain}/verify to re-check.
!
The orange cloud (proxy) must be off for MX and DKIM records. Cloudflare only proxies HTTP/HTTPS traffic; proxying DNS records for mail will break them.
AWS Route 53 setup
Adding myagentmail's records to an existing Route 53 hosted zone.
- Open the Route 53 console. Click Hosted zones and select your domain.
- Click Create record for each record.
- For the DKIM TXT record:
- Record name: the selector prefix (e.g.
1234567._domainkey)
- Record type: TXT
- Value: the full
k=rsa; p=... value, wrapped in quotes. Route 53 splits long TXT into chunks automatically.
- TTL: 300
- For the CNAME bounce record: record name
bounce, type CNAME, value as returned.
- For the MX record: record name is the subdomain (e.g.
mail) or the root, type MX, value 10 mail.myagentmail.com. — note the priority prefix and trailing dot.
- Save and wait for propagation.
GoDaddy DNS setup
Adding records in the GoDaddy domain manager.
- Log into GoDaddy. Go to My Products → Domains → your domain → DNS.
- Scroll to DNS Records and click Add for each record.
- GoDaddy's TXT editor has a known issue with long strings — it silently truncates. If your DKIM isn't verifying after adding it, open the record in edit mode and confirm the full value is stored.
- For MX records, use GoDaddy's dedicated MX entry type (priority and host in separate fields).
- GoDaddy propagation can be slow — budget 30-60 minutes before re-verifying.
!
If GoDaddy's DNS editor refuses to save the DKIM TXT record because it's "too long", switch to their advanced DNS editor or temporarily disable any security plugins on the domain.
Namecheap DNS setup
Adding records to a Namecheap-managed domain.
- Log into Namecheap. Go to Domain List and click Manage on your domain.
- Click the Advanced DNS tab.
- Under Host Records, click Add New Record for each record:
- TXT Record for DKIM — use the selector as the host, the full value in the Value field, TTL Automatic.
- CNAME Record for the bounce handler.
- MX Record — Namecheap asks for priority and destination separately; enter
10 and mail.myagentmail.com.
- Namecheap's free DNS typically propagates within 30 minutes.