
Agentmail Sdk
- 396 installs
- 21 repo stars
- Updated July 21, 2026
- agentmail-to/agentmail-skills
agentmail-sdk is an AgentMail integration skill that teaches Python and TypeScript SDK usage for developers who need agents to send, receive, thread, and manage programmatic email inboxes.
About
agentmail-sdk is a MIT-licensed skill (version 1.0) from agentmail-to/agentmail-skills that documents the AgentMail API-first email platform for AI agents. Unlike one-way transactional providers, AgentMail provisions millisecond inbox creation, two-way threads with extracted_text reply stripping, webhook and WebSocket inbound events, human-in-the-loop drafts, pods for multi-tenant isolation, and allow/block sender lists. The SKILL.md covers pip install agentmail and npm install agentmail, programmatic agent sign-up with OTP verification, inbox CRUD, attachment handling, IMAP/SMTP access, and production patterns. Developers reach for it when building support agents, ops automations, or customer-facing email bots that must read replies—not just send blasts. It spans setup, send/receive flows, and real-time notification wiring across multiple build steps.
- Installs and configures AgentMail SDK clients
- Sends and receives agent-driven email threads
- Sets up webhooks, templates, and inbox routing
- Supports autonomous customer and ops messaging flows
Agentmail Sdk by the numbers
- 396 all-time installs (skills.sh)
- Ranked #1,051 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/agentmail-to/agentmail-skills --skill agentmail-sdkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 396 |
|---|---|
| repo stars | ★ 21 |
| Last updated | July 21, 2026 |
| Repository | agentmail-to/agentmail-skills ↗ |
How do AI agents send and receive email programmatically?
Integrate AgentMail SDK so agents can send, receive, and thread email programmatically with auth, webhooks, templates, and inbox routing for autonomous customer or ops workflows.
Who is it for?
Developers building AI agents that need full two-way email inboxes with threads, drafts, and real-time inbound events.
Skip if: Teams sending one-way marketing or transactional blasts where Resend-style send-only APIs without agent inboxes are sufficient.
When should I use this skill?
The developer asks to add agent email, inbox creation, email webhooks, threaded replies, or AgentMail SDK integration.
What you get
SDK-initialized clients, inbox instances, threaded message handlers, and webhook listeners for two-way agent email.
- SDK client initialization
- Inbox and thread handlers
- Webhook or WebSocket listeners
By the numbers
- Skill metadata version 1.0 from agentmail-to
- Covers 2 SDK languages: Python and TypeScript
Files
AgentMail SDK
AgentMail is an API-first email platform built for AI agents. Unlike transactional email APIs (Resend, SendGrid) that focus on one-way sending, AgentMail provides full two-way email inboxes that agents can create, send from, receive into, and manage programmatically.
Key capabilities:
- Instant inbox creation (milliseconds, no domain setup needed)
- Two-way conversations with native thread management
- Reply extraction (
extracted_text) strips quoted history automatically - WebSocket and webhook support for real-time inbound
- Human-in-the-loop drafts for agent oversight
- Multi-tenant isolation with pods
- Allow/block lists for sender filtering
- IMAP and SMTP access for legacy integrations
Installation and setup
# Python
pip install agentmail
# TypeScript / Node.js
npm install agentmailGet your API key from https://console.agentmail.to/ or via the Agent sign-up API (see below).
Python:
from agentmail import AgentMail
client = AgentMail(api_key="YOUR_API_KEY")
# Or set AGENTMAIL_API_KEY env var and omit api_key:
# client = AgentMail()TypeScript:
import { AgentMailClient } from "agentmail";
const client = new AgentMailClient({ apiKey: "YOUR_API_KEY" });Agent sign-up (programmatic, no console needed)
Create an account and get an API key entirely from code. No browser required.
Requiresagentmail>=0.4.15(Python) /agentmail>=0.x(TypeScript). If your installed
SDK raises AttributeError: 'AgentMail' object has no attribute 'agent', upgrade first.client = AgentMail() # no api_key needed for sign-up
response = client.agent.sign_up(
human_email="you@example.com",
username="my-agent",
)
# response.api_key -> store this securely
# response.inbox_id -> my-agent@agentmail.to
# response.organization_id
# Verify with OTP sent to your email
client = AgentMail(api_key=response.api_key)
client.agent.verify(otp_code="123456")const client = new AgentMailClient();
const response = await client.agent.signUp({
humanEmail: "you@example.com",
username: "my-agent",
});
// response.apiKey, response.inboxId, response.organizationId
const authedClient = new AgentMailClient({ apiKey: response.apiKey });
await authedClient.agent.verify({ otpCode: "123456" });The sign-up endpoint is idempotent: calling again with the same email rotates the API key and resends the OTP.
Inboxes
Create scalable inboxes on-demand. Each inbox has a unique email address. No domain verification needed for @agentmail.to.
from agentmail.inboxes.types import CreateInboxRequest
# Create inbox (auto-generated address)
inbox = client.inboxes.create()
# inbox.inbox_id, inbox.email
# Create with options. All create kwargs go inside a CreateInboxRequest.
inbox = client.inboxes.create(
request=CreateInboxRequest(
username="support",
domain="yourdomain.com", # optional, defaults to agentmail.to
display_name="Support Agent",
client_id="support-v1", # idempotency key, safe to retry
),
)
# List all inboxes
inboxes = client.inboxes.list()
# Paginate: client.inboxes.list(limit=20, page_token=inboxes.next_page_token)
# Get, update, delete
inbox = client.inboxes.get(inbox_id="support@agentmail.to")
client.inboxes.update(inbox_id="support@agentmail.to", display_name="New Name")
client.inboxes.delete(inbox_id="support@agentmail.to")const inbox = await client.inboxes.create({
username: "support",
domain: "yourdomain.com",
displayName: "Support Agent",
clientId: "support-v1",
});
const inboxes = await client.inboxes.list();
const fetched = await client.inboxes.get("support@agentmail.to");
await client.inboxes.update("support@agentmail.to", { displayName: "New Name" });
await client.inboxes.delete("support@agentmail.to");Custom domains require a paid plan. Default @agentmail.to inboxes are free.
Messages
Send
Always provide both text and html for best deliverability. Maximum 50 recipients across to + cc + bcc combined.
sent = client.inboxes.messages.send(
inbox_id="agent@agentmail.to",
to="recipient@example.com", # string or list
subject="Hello from AgentMail",
text="Plain text body",
html="<p>HTML body</p>", # optional but recommended
cc="cc@example.com", # optional, string or list
bcc="bcc@example.com", # optional, string or list
reply_to="replies@example.com", # optional
labels=["outreach"], # optional
attachments=[{ # optional
"filename": "report.pdf",
"content": base64_content, # Base64-encoded
"content_type": "application/pdf",
}],
)
# sent.message_id, sent.thread_idconst sent = await client.inboxes.messages.send("agent@agentmail.to", {
to: "recipient@example.com",
subject: "Hello from AgentMail",
text: "Plain text body",
html: "<p>HTML body</p>",
cc: "cc@example.com",
labels: ["outreach"],
attachments: [{
filename: "report.pdf",
content: base64Content,
contentType: "application/pdf",
}],
});List and get
# List messages in an inbox. Note: .list() returns MessageItem objects
# (metadata only — subject, from, labels, timestamps, etc.) with NO body
# content. To read .text / .html / .extracted_text you must fetch the full
# message with .get().
response = client.inboxes.messages.list(
inbox_id="agent@agentmail.to",
limit=10, # optional, default varies
labels=["unread"], # optional, filter by label
)
for item in response.messages:
# item is a MessageItem (metadata only). Fetch the full Message for body:
msg = client.inboxes.messages.get(
inbox_id=item.inbox_id,
message_id=item.message_id,
)
# Use extracted_text for reply content without quoted history
content = msg.extracted_text or msg.text
print(msg.subject, content)
# Paginate
while response.next_page_token:
response = client.inboxes.messages.list(
inbox_id="agent@agentmail.to",
page_token=response.next_page_token,
)
# Get a specific message
msg = client.inboxes.messages.get(
inbox_id="agent@agentmail.to",
message_id="<abc123@agentmail.to>",
)
# Get raw MIME content
raw = client.inboxes.messages.get_raw(
inbox_id="agent@agentmail.to",
message_id="<abc123@agentmail.to>",
)const response = await client.inboxes.messages.list("agent@agentmail.to", {
limit: 10,
labels: ["unread"],
});
const msg = await client.inboxes.messages.get(
"agent@agentmail.to",
"<abc123@agentmail.to>",
);Important: when processing inbound replies, always use extracted_text / extracted_html instead of text / html. These fields strip quoted history and signatures, giving you only the new content. This is powered by Talon reply extraction.
Also note: some email clients (Gmail, Outlook) send forwards as HTML-only. Always treat html as the primary content source and text as optional.
Reply
Replying adds the message to the existing thread.
reply = client.inboxes.messages.reply(
inbox_id="agent@agentmail.to",
message_id="<abc123@agentmail.to>",
text="Thanks for your email!",
html="<p>Thanks for your email!</p>", # optional
attachments=[...], # optional
reply_all=False, # optional, defaults to False
)const reply = await client.inboxes.messages.reply(
"agent@agentmail.to",
"<abc123@agentmail.to>",
{ text: "Thanks for your email!" },
);Forward
client.inboxes.messages.forward(
inbox_id="agent@agentmail.to",
message_id="<abc123@agentmail.to>",
to="colleague@example.com",
text="FYI, see below.", # optional prepended text
)await client.inboxes.messages.forward(
"agent@agentmail.to",
"<abc123@agentmail.to>",
{
to: "colleague@example.com",
text: "FYI, see below.",
},
);Update labels
Use labels to track message processing state. AgentMail does not have a built-in "read/unread" flag. Use labels instead.
client.inboxes.messages.update(
inbox_id="agent@agentmail.to",
message_id="<abc123@agentmail.to>",
add_labels=["processed", "replied"],
remove_labels=["unread"],
)await client.inboxes.messages.update(
"agent@agentmail.to",
"<abc123@agentmail.to>",
{
addLabels: ["processed", "replied"],
removeLabels: ["unread"],
},
);Attachments
import base64
# Send with attachment
with open("report.pdf", "rb") as f:
content = base64.b64encode(f.read()).decode()
client.inboxes.messages.send(
inbox_id="agent@agentmail.to",
to="user@example.com",
subject="Report attached",
text="See attached.",
attachments=[{
"filename": "report.pdf",
"content": content,
"content_type": "application/pdf",
}],
)
# Retrieve attachment from received message
attachment = client.inboxes.messages.get_attachment(
inbox_id="agent@agentmail.to",
message_id="<abc123@agentmail.to>",
attachment_id="att_456",
)import { readFileSync } from "node:fs";
const content = readFileSync("report.pdf").toString("base64");
await client.inboxes.messages.send("agent@agentmail.to", {
to: "user@example.com",
subject: "Report attached",
text: "See attached.",
attachments: [{ filename: "report.pdf", content, contentType: "application/pdf" }],
});
const attachment = await client.inboxes.messages.getAttachment(
"agent@agentmail.to",
"<abc123@agentmail.to>",
"att_456",
);Threads
Threads group related messages in a conversation. When you send a new message, a thread is created. Replies are added to the same thread automatically.
# List threads in an inbox
threads = client.inboxes.threads.list(
inbox_id="agent@agentmail.to",
labels=["unreplied"], # optional filter
)
# Get a specific thread with all messages
thread = client.inboxes.threads.get(
inbox_id="agent@agentmail.to",
thread_id="thd_123",
)
for msg in thread.messages:
print(msg.subject, msg.extracted_text)
# Org-wide thread listing (across all inboxes)
all_threads = client.threads.list()
# Delete a thread
client.inboxes.threads.delete(
inbox_id="agent@agentmail.to",
thread_id="thd_123",
)const threads = await client.inboxes.threads.list("agent@agentmail.to", {
labels: ["unreplied"],
});
const thread = await client.inboxes.threads.get("agent@agentmail.to", "thd_123");
const allThreads = await client.threads.list();Drafts
Create drafts for human-in-the-loop approval. The agent composes a draft, a human reviews, then the draft is sent.
# Create draft
draft = client.inboxes.drafts.create(
inbox_id="agent@agentmail.to",
to="recipient@example.com",
subject="Pending approval",
text="Draft content for review",
html="<p>Draft content for review</p>",
)
# List drafts
drafts = client.inboxes.drafts.list(inbox_id="agent@agentmail.to")
# Get, update
draft = client.inboxes.drafts.get(inbox_id="agent@agentmail.to", draft_id=draft.draft_id)
client.inboxes.drafts.update(
inbox_id="agent@agentmail.to",
draft_id=draft.draft_id,
text="Updated draft content",
)
# Send draft (converts to message, removes from drafts)
client.inboxes.drafts.send(inbox_id="agent@agentmail.to", draft_id=draft.draft_id)
# Delete draft without sending
client.inboxes.drafts.delete(inbox_id="agent@agentmail.to", draft_id=draft.draft_id)const draft = await client.inboxes.drafts.create("agent@agentmail.to", {
to: "recipient@example.com",
subject: "Pending approval",
text: "Draft content",
});
await client.inboxes.drafts.send("agent@agentmail.to", draft.draftId, {});Pods (multi-tenant isolation)
Pods provide isolated environments for SaaS platforms. Each pod has its own set of inboxes.
# Create pod per customer
pod = client.pods.create(
name="customer-acme",
client_id="pod-acme-v1", # idempotent
)
# Create inbox within pod (pods.inboxes.create accepts flat kwargs)
inbox = client.pods.inboxes.create(
pod_id=pod.pod_id,
username="notifications",
client_id="acme-notifications-v1",
)
# List inboxes scoped to pod
inboxes = client.pods.inboxes.list(pod_id=pod.pod_id)
# List threads scoped to pod
threads = client.pods.threads.list(pod_id=pod.pod_id)
# List, get, delete pods
pods = client.pods.list()
pod = client.pods.get(pod_id=pod.pod_id)
client.pods.delete(pod_id=pod.pod_id)const pod = await client.pods.create({ name: "customer-acme", clientId: "pod-acme-v1" });
const inbox = await client.pods.inboxes.create(pod.podId, {
username: "notifications",
clientId: "acme-notifications-v1",
});
const inboxes = await client.pods.inboxes.list(pod.podId);Allow/block lists
Control which external senders can deliver to an inbox. Block list takes priority over allow list.
Lists are flat. Each entry is one (direction, type, entry) tuple — there is no batch update, no .allow / .block sub-namespace. direction is "send", "receive", or "reply". type is "allow" or "block".
# Allow a sender on incoming mail
client.inboxes.lists.create(
inbox_id="agent@agentmail.to",
direction="receive",
type="allow",
entry="boss@company.com",
)
# Block a sender on incoming mail
client.inboxes.lists.create(
inbox_id="agent@agentmail.to",
direction="receive",
type="block",
entry="spammer@example.com",
)
# List entries for one (direction, type) pair
allow = client.inboxes.lists.list(
inbox_id="agent@agentmail.to",
direction="receive",
type="allow",
)
# Check a single entry
entry = client.inboxes.lists.get(
inbox_id="agent@agentmail.to",
direction="receive",
type="allow",
entry="boss@company.com",
)
# Remove an entry
client.inboxes.lists.delete(
inbox_id="agent@agentmail.to",
direction="receive",
type="allow",
entry="boss@company.com",
)await client.inboxes.lists.create(
"agent@agentmail.to",
"receive",
"allow",
{ entry: "boss@company.com" },
);
await client.inboxes.lists.create(
"agent@agentmail.to",
"receive",
"block",
{ entry: "spammer@example.com" },
);
const allow = await client.inboxes.lists.list(
"agent@agentmail.to",
"receive",
"allow",
);
await client.inboxes.lists.delete(
"agent@agentmail.to",
"receive",
"allow",
"boss@company.com",
);Domains
Custom domains let agents send from your own domain (e.g., agent@yourdomain.com). SPF, DKIM, and DMARC records are auto-generated. Requires paid plan.
# Add domain. feedback_enabled is required: set True to route
# bounce/complaint notifications to your inboxes.
domain = client.domains.create(domain="yourdomain.com", feedback_enabled=True)
# domain.records -> list of VerificationRecord objects to add at your registrar
# Verify after DNS records are set
client.domains.verify(domain_id=domain.domain_id)
# List, get, delete
domains = client.domains.list()
domain = client.domains.get(domain_id=domain.domain_id)
client.domains.delete(domain_id=domain.domain_id)const domain = await client.domains.create({
domain: "yourdomain.com",
feedbackEnabled: true,
});
await client.domains.verify(domain.domainId);Real-time events
AgentMail supports both WebSockets and webhooks for real-time notifications. See references/webhooks.md and references/websockets.md for detailed setup and full code examples.
WebSockets (recommended for agents)
No public URL needed. Persistent connection with instant delivery.
Python (sync):
from agentmail import AgentMail, Subscribe, Subscribed, MessageReceivedEvent
client = AgentMail()
with client.websockets.connect() as socket:
socket.send_subscribe(Subscribe(inbox_ids=["agent@agentmail.to"]))
for event in socket:
if isinstance(event, Subscribed):
print(f"Subscribed to: {event.inbox_ids}")
elif isinstance(event, MessageReceivedEvent):
print(f"From: {event.message.from_}")
print(f"Subject: {event.message.subject}")
print(f"Body: {event.message.extracted_text}")Python (async):
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
client = AsyncAgentMail()
async with client.websockets.connect() as socket:
await socket.send_subscribe(Subscribe(inbox_ids=["agent@agentmail.to"]))
async for event in socket:
if isinstance(event, MessageReceivedEvent):
await process_email(event.message)TypeScript:
const socket = await client.websockets.connect();
socket.on("open", () => {
socket.sendSubscribe({ type: "subscribe", inboxIds: ["agent@agentmail.to"] });
});
socket.on("message", (event) => {
// Use event.eventType (not event.type — event.type is always "event")
if (event.eventType === "message.received") {
// TypeScript uses .from directly; only Python needs .from_ (reserved keyword)
console.log("From:", event.message.from);
console.log("Subject:", event.message.subject);
}
});Webhooks
HTTP POST to your endpoint on email events. Requires a public URL.
event_types is required — you must pick at least one event to subscribe to. Pass an explicit list of every event you want to receive.
webhook = client.webhooks.create(
url="https://your-server.com/webhooks",
event_types=["message.received", "message.bounced"],
)
# webhook.webhook_id, webhook.secret
# List, get, delete
webhooks = client.webhooks.list()
client.webhooks.delete(webhook_id=webhook.webhook_id)Typed webhook event types (listed in the SDK's Literal): message.received, message.sent, message.delivered, message.bounced, message.complained, message.rejected, domain.verified.
Runtime-only events — accepted by the API but not in the SDK's typed Literal — include message.received.spam and message.received.blocked. Pass them as plain strings if you need them. Type checkers will flag them; that's expected.
Always verify webhook signatures before processing. See references/webhooks.md.
Idempotency
Pass client_id / clientId on create operations to make them safe to retry:
from agentmail.inboxes.types import CreateInboxRequest
inbox = client.inboxes.create(
request=CreateInboxRequest(client_id="my-unique-key"),
)
# Calling again with the same client_id returns the existing inbox, not a duplicate
pod = client.pods.create(client_id="pod-unique-key")
# pods.create takes flat kwargs; same idempotency behaviorError handling
Both SDKs raise/throw on 4xx and 5xx responses. On 429 (rate limit), read the Retry-After header and use exponential backoff. Both SDKs retry automatically (default: 2 retries).
try:
client.inboxes.messages.send(inbox_id, to="user@example.com", subject="Hi", text="Hello")
except Exception as e:
print(f"Error: {e}")
# e.body.message contains details if available
# Python: override retries per call via request_options
# (the AgentMail constructor has no max_retries argument)
client.inboxes.messages.send(
inbox_id,
to="user@example.com",
subject="Hi",
text="Hello",
request_options={"max_retries": 5},
)try {
await client.inboxes.messages.send(inboxId, {
to: "user@example.com",
subject: "Hi",
text: "Hello",
});
} catch (err) {
console.error("Error:", err.message);
// err.statusCode, err.body for details
}
// TypeScript: override retries globally on the client, or per-call via requestOptions
const client = new AgentMailClient({ apiKey: "...", maxRetries: 5 });IMAP and SMTP
AgentMail inboxes are accessible via standard IMAP and SMTP protocols, enabling integration with traditional email clients and legacy systems. See https://docs.agentmail.to/imap-smtp for setup details.
Pagination
All list endpoints use cursor-based pagination:
response = client.inboxes.messages.list(inbox_id, limit=20)
while response.next_page_token:
response = client.inboxes.messages.list(
inbox_id, limit=20, page_token=response.next_page_token
)Reference files
For detailed coverage of specific topics:
references/webhooks.md-- webhook setup, event types, payload structure, signature verificationreferences/websockets.md-- WebSocket connection, sync/async patterns, event handler pattern, subscribe optionsreferences/full-api-reference.md-- complete endpoint and SDK method table with all parameters
Full API Reference
Complete endpoint and SDK method mapping for AgentMail.
Base URL: https://api.agentmail.to/v0 Authentication: Authorization: Bearer am_...
Agent
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Sign up | POST /agent/sign-up | client.agent.sign_up(human_email, username) | client.agent.signUp({ humanEmail, username }) |
| Verify | POST /agent/verify | client.agent.verify(otp_code) | client.agent.verify({ otpCode }) |
Returns: api_key, inbox_id, organization_id. Sign-up is idempotent.
Inboxes
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Create | POST /inboxes | client.inboxes.create(request=CreateInboxRequest(username?, domain?, display_name?, client_id?)) | client.inboxes.create({ username?, domain?, displayName?, clientId? }) |
| List | GET /inboxes | client.inboxes.list(limit?, page_token?) | client.inboxes.list({ limit?, pageToken? }) |
| Get | GET /inboxes/:inbox_id | client.inboxes.get(inbox_id) | client.inboxes.get(inboxId) |
| Update | PATCH /inboxes/:inbox_id | client.inboxes.update(inbox_id, display_name) | client.inboxes.update(inboxId, { displayName }) |
| Delete | DELETE /inboxes/:inbox_id | client.inboxes.delete(inbox_id) | client.inboxes.delete(inboxId) |
The Python inboxes.create only takes a single request=CreateInboxRequest(...) argument — not flat kwargs. Import it from agentmail.inboxes.types import CreateInboxRequest. To create inboxes scoped to a pod, use client.pods.inboxes.create(pod_id, ...) (which does accept flat kwargs).
Inbox response fields: inbox_id, email, display_name, client_id, pod_id, created_at, updated_at. Note: username and domain are only inputs on CreateInboxRequest — they are not returned as separate fields on the response; the full address is in email.
Messages
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Send | POST /inboxes/:id/messages/send | client.inboxes.messages.send(inbox_id, to, subject, text, html?, cc?, bcc?, reply_to?, labels?, attachments?, headers?) | client.inboxes.messages.send(inboxId, { to, subject, text, html?, cc?, bcc?, replyTo?, labels?, attachments?, headers? }) |
| List | GET /inboxes/:id/messages | client.inboxes.messages.list(inbox_id, limit?, page_token?, labels?) | client.inboxes.messages.list(inboxId, { limit?, pageToken?, labels? }) |
| Get | GET /inboxes/:id/messages/:msg_id | client.inboxes.messages.get(inbox_id, message_id) | client.inboxes.messages.get(inboxId, messageId) |
| Reply | POST /inboxes/:id/messages/:msg_id/reply | client.inboxes.messages.reply(inbox_id, message_id, text, html?, attachments?, reply_all?) | client.inboxes.messages.reply(inboxId, messageId, { text, html?, attachments?, replyAll? }) |
| Forward | POST /inboxes/:id/messages/:msg_id/forward | client.inboxes.messages.forward(inbox_id, message_id, to, subject?, text?, html?) | client.inboxes.messages.forward(inboxId, messageId, { to, subject?, text?, html? }) |
| Update | PATCH /inboxes/:id/messages/:msg_id | client.inboxes.messages.update(inbox_id, message_id, add_labels?, remove_labels?) | client.inboxes.messages.update(inboxId, messageId, { addLabels?, removeLabels? }) |
| Get raw | GET /inboxes/:id/messages/:msg_id/raw | client.inboxes.messages.get_raw(inbox_id, message_id) | client.inboxes.messages.getRaw(inboxId, messageId) |
| Get attachment | GET /inboxes/:id/messages/:msg_id/attachments/:att_id | client.inboxes.messages.get_attachment(inbox_id, message_id, attachment_id) | client.inboxes.messages.getAttachment(inboxId, messageId, attachmentId) |
Neither SDK has a messages.delete method — deleting individual messages is not supported. To remove a conversation, delete the whole thread with client.inboxes.threads.delete(inbox_id, thread_id) (Python) / client.inboxes.threads.delete(inboxId, threadId) (TypeScript).
Reply cannot change the subject. reply(...) has no subject parameter — AgentMail automatically reuses the parent's subject (prefixed with Re: if not already present). If you need to change the subject, send a new message with messages.send(...) instead of replying.
Message fields (received)
| Field | Description |
|---|---|
message_id | Unique message identifier |
thread_id | Thread this message belongs to |
inbox_id | Inbox that received/sent this message |
from_ / from | Sender address(es) |
to | Recipient address(es) |
cc, bcc | CC and BCC addresses |
subject | Subject line |
text | Plain text body (may be absent on forwarded emails) |
html | HTML body (primary content source) |
extracted_text | Reply content only, quoted history stripped (Talon) |
extracted_html | Reply HTML content only, quoted history stripped |
preview | Short preview text |
attachments | List of attachment metadata |
labels | List of labels |
headers | Email headers |
created_at | Timestamp |
Always prefer extracted_text / extracted_html for processing replies.
Send parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
to | string or list | Yes | Recipient(s) |
subject | string | Yes | Subject line |
text | string | Yes | Plain text body |
html | string | No | HTML body (recommended) |
cc | string or list | No | CC recipients |
bcc | string or list | No | BCC recipients |
reply_to | string or list | No | Reply-to address |
labels | list of strings | No | Labels for organization |
attachments | list | No | Base64-encoded files |
headers | dict | No | Custom email headers |
Max 50 recipients across to + cc + bcc combined.
Threads
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| List (inbox) | GET /inboxes/:id/threads | client.inboxes.threads.list(inbox_id, limit?, page_token?, labels?) | client.inboxes.threads.list(inboxId, { limit?, pageToken?, labels? }) |
| Get | GET /inboxes/:id/threads/:thd_id | client.inboxes.threads.get(inbox_id, thread_id) | client.inboxes.threads.get(inboxId, threadId) |
| Delete | DELETE /inboxes/:id/threads/:thd_id | client.inboxes.threads.delete(inbox_id, thread_id) | client.inboxes.threads.delete(inboxId, threadId) |
| List (org-wide) | GET /threads | client.threads.list(limit?, page_token?, labels?) | client.threads.list({ limit?, pageToken?, labels? }) |
| List (pod-scoped) | GET /pods/:pod_id/threads | client.pods.threads.list(pod_id, limit?, page_token?, labels?) | client.pods.threads.list(podId, { limit?, pageToken?, labels? }) |
Top-level threads.list lists threads across all inboxes in the organization — there is no pod_id filter. To scope to a single pod, use pods.threads.list(pod_id).
Drafts
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Create | POST /inboxes/:id/drafts | client.inboxes.drafts.create(inbox_id, to?, subject?, text?, html?, cc?, bcc?, reply_to?, attachments?, labels?, in_reply_to?, send_at?, client_id?) | client.inboxes.drafts.create(inboxId, { to?, subject?, text?, html?, cc?, bcc?, replyTo?, attachments?, labels?, inReplyTo?, sendAt?, clientId? }) |
| List | GET /inboxes/:id/drafts | client.inboxes.drafts.list(inbox_id) | client.inboxes.drafts.list(inboxId) |
| Get | GET /inboxes/:id/drafts/:draft_id | client.inboxes.drafts.get(inbox_id, draft_id) | client.inboxes.drafts.get(inboxId, draftId) |
| Update | PATCH /inboxes/:id/drafts/:draft_id | client.inboxes.drafts.update(inbox_id, draft_id, ...) | client.inboxes.drafts.update(inboxId, draftId, { ... }) |
| Send | POST /inboxes/:id/drafts/:draft_id/send | client.inboxes.drafts.send(inbox_id, draft_id) | client.inboxes.drafts.send(inboxId, draftId, {}) |
| Delete | DELETE /inboxes/:id/drafts/:draft_id | client.inboxes.drafts.delete(inbox_id, draft_id) | client.inboxes.drafts.delete(inboxId, draftId) |
Webhooks
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Create | POST /webhooks | client.webhooks.create(url, event_types, inbox_ids?, pod_ids?, client_id?) | client.webhooks.create({ url, eventTypes, inboxIds?, podIds?, clientId? }) |
| List | GET /webhooks | client.webhooks.list() | client.webhooks.list() |
| Get | GET /webhooks/:id | client.webhooks.get(webhook_id) | client.webhooks.get(webhookId) |
| Update | PATCH /webhooks/:id | client.webhooks.update(webhook_id, add_inbox_ids?, remove_inbox_ids?, add_pod_ids?, remove_pod_ids?) | client.webhooks.update(webhookId, { addInboxIds?, removeInboxIds?, addPodIds?, removePodIds? }) |
| Delete | DELETE /webhooks/:id | client.webhooks.delete(webhook_id) | client.webhooks.delete(webhookId) |
event_types / eventTypes is required on create. Typed values: message.received, message.sent, message.delivered, message.bounced, message.complained, message.rejected, domain.verified. Runtime-only (accepted but not in the SDK Literal): message.received.spam, message.received.blocked.
webhooks.update can ONLY add or remove inbox_ids and pod_ids. You cannot change url or event_types on an existing webhook — to change them, delete the webhook and create a new one.
Domains
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Create | POST /domains | client.domains.create(domain, feedback_enabled) | client.domains.create({ domain, feedbackEnabled }) |
| List | GET /domains | client.domains.list() | client.domains.list() |
| Get | GET /domains/:id | client.domains.get(domain_id) | client.domains.get(domainId) |
| Verify | POST /domains/:id/verify | client.domains.verify(domain_id) | client.domains.verify(domainId) |
| Delete | DELETE /domains/:id | client.domains.delete(domain_id) | client.domains.delete(domainId) |
feedback_enabled / feedbackEnabled is required on create. Set it to True to route bounce and complaint notifications to your inboxes.
Lists (allow/block)
Lists are flat, entry-per-call. There is no batch .update and no .allow / .block sub-namespace. Each entry is identified by (inbox_id, direction, type, entry). direction is one of "send", "receive", "reply". type is one of "allow", "block".
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| List entries | GET /inboxes/:id/lists/:direction/:type | client.inboxes.lists.list(inbox_id, direction, type, limit?, page_token?) | client.inboxes.lists.list(inboxId, direction, type, { limit?, pageToken? }) |
| Get entry | GET /inboxes/:id/lists/:direction/:type/:entry | client.inboxes.lists.get(inbox_id, direction, type, entry) | client.inboxes.lists.get(inboxId, direction, type, entry) |
| Create entry | POST /inboxes/:id/lists/:direction/:type | client.inboxes.lists.create(inbox_id, direction, type, entry, reason?) | client.inboxes.lists.create(inboxId, direction, type, { entry, reason? }) |
| Delete entry | DELETE /inboxes/:id/lists/:direction/:type/:entry | client.inboxes.lists.delete(inbox_id, direction, type, entry) | client.inboxes.lists.delete(inboxId, direction, type, entry) |
To allow boss@company.com to send mail to the inbox: client.inboxes.lists.create(inbox_id, direction="receive", type="allow", entry="boss@company.com"). Replace an allow list by deleting existing entries and creating new ones — there is no bulk update.
Pods
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Create | POST /pods | client.pods.create(name?, client_id?) | client.pods.create({ name?, clientId? }) |
| List | GET /pods | client.pods.list() | client.pods.list() |
| Get | GET /pods/:id | client.pods.get(pod_id) | client.pods.get(podId) |
| Delete | DELETE /pods/:id | client.pods.delete(pod_id) | client.pods.delete(podId) |
API Keys
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Create | POST /api-keys | client.api_keys.create(name, permissions?) | client.apiKeys.create({ name, permissions? }) |
| List | GET /api-keys | client.api_keys.list() | client.apiKeys.list() |
| Delete | DELETE /api-keys/:id | client.api_keys.delete(api_key_id) | client.apiKeys.delete(apiKeyId) |
name is required on create.
Metrics
| Operation | Method | Python | TypeScript |
|---|---|---|---|
| Query | GET /metrics | client.metrics.query(event_types?, start?, end?, period?, limit?, descending?) | client.metrics.query({ eventTypes?, start?, end?, period?, limit?, descending? }) |
The method is query, not get, in both Python and TypeScript.
Pagination
All list endpoints use cursor-based pagination with limit and page_token / pageToken. The response includes next_page_token / nextPageToken when more results are available.
Rate limits
429 responses include Retry-After header. Both SDKs retry automatically with exponential backoff (default: 2 retries).
- TypeScript: override globally via
new AgentMailClient({ apiKey, maxRetries: 5 })or per-call viarequestOptions.maxRetries. - Python:
AgentMail(...)has nomax_retriesconstructor arg. Override per-call withrequest_options={"max_retries": 5}.
Webhooks
Webhooks provide real-time HTTP notifications when email events occur.
When to use
- Production applications with public endpoints
- Serverless architectures (Lambda, Cloud Functions, Vercel)
- When you need to process events on your server
- When you need automatic retries on failure
For agents without a public URL, use WebSockets instead (see websockets.md).
Setup
event_types is required — there is no "receive all events" default. Pass the full set you care about.
from agentmail import AgentMail
client = AgentMail()
webhook = client.webhooks.create(
url="https://your-server.com/webhooks",
event_types=["message.received", "message.bounced"],
)
# webhook.webhook_id, webhook.secret
# List, get, delete
webhooks = client.webhooks.list()
webhook = client.webhooks.get(webhook_id=webhook.webhook_id)
client.webhooks.delete(webhook_id=webhook.webhook_id)const webhook = await client.webhooks.create({
url: "https://your-server.com/webhooks",
eventTypes: ["message.received", "message.bounced"],
});
const webhooks = await client.webhooks.list();
await client.webhooks.delete(webhook.webhookId);Event types
The SDK's typed Literal accepts these seven:
| Event | Description |
|---|---|
message.received | New email received in inbox |
message.sent | Email successfully sent |
message.delivered | Email delivered to recipient's server |
message.bounced | Email failed to deliver |
message.complained | Recipient marked email as spam |
message.rejected | Email rejected before sending |
domain.verified | Custom domain verification completed |
The API also accepts message.received.spam and message.received.blocked at runtime, but these are not in the SDK's typed Literal, so type checkers will flag them. Pass as plain strings if you need them.
Payload structure
{
"type": "event",
"event_type": "message.received",
"event_id": "evt_123abc",
"message": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "msg_123abc",
"from": "Jane Doe <jane@example.com>",
"to": ["Agent <agent@agentmail.to>"],
"subject": "Question about my account",
"text": "Full text body",
"html": "<html>...</html>",
"extracted_text": "Just the reply content",
"labels": ["received"],
"attachments": [
{
"attachment_id": "att_pqr678",
"filename": "document.pdf",
"content_type": "application/pdf",
"size": 123456
}
],
"created_at": "2025-10-27T10:00:00Z"
},
"thread": {}
}Handling webhooks
Your endpoint must return 200 OK quickly. Process the payload asynchronously.
Express (TypeScript)
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks", (req, res) => {
const payload = req.body;
if (payload.event_type === "message.received") {
// Queue for async processing
processEmail(payload.message);
}
res.status(200).send("OK");
});Flask (Python)
from flask import Flask, request
app = Flask(__name__)
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
payload = request.json
if payload["event_type"] == "message.received":
process_email(payload["message"])
return "OK", 200FastAPI (Python)
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhooks")
async def handle_webhook(request: Request):
payload = await request.json()
if payload["event_type"] == "message.received":
await process_email(payload["message"])
return {"status": "ok"}Verifying webhook signatures
Always verify signatures in production to prevent spoofed payloads.
Python
import hmac
import hashlib
def verify_signature(payload: bytes, signature, secret: str) -> bool:
# compare_digest raises TypeError on None, bytes, or any non-str value.
# Reject anything that isn't a string up front.
if not isinstance(signature, str) or not signature:
return False
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-AgentMail-Signature")
if not verify_signature(request.data, signature, WEBHOOK_SECRET):
return "Invalid signature", 401
payload = request.json
# Safe to process
return "OK", 200TypeScript
import crypto from "crypto";
function verifySignature(
payload: Buffer,
signature: string | undefined,
secret: string,
): boolean {
// Reject unsigned requests before touching timingSafeEqual, which
// throws RangeError on mismatched buffer lengths.
if (!signature) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const signatureBuf = Buffer.from(signature, "hex");
if (expectedBuf.length !== signatureBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, signatureBuf);
}
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-agentmail-signature"];
const signatureStr = Array.isArray(signature) ? signature[0] : signature;
if (!verifySignature(req.body, signatureStr, WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Process event...
res.status(200).send("OK");
});Local development
Use ngrok or similar to expose your local server:
ngrok http 3000
# Use the ngrok HTTPS URL when creating the webhookRetry behavior
AgentMail automatically retries failed webhook deliveries with exponential backoff. A delivery is considered failed if your endpoint returns a non-2xx status code or does not respond within 30 seconds.
WebSockets
WebSockets provide real-time, low-latency email event streaming over a persistent connection. No public URL required.
When to use
- AI agents that need instant email notifications
- Local development (no ngrok needed)
- Client-side applications
- When you need bidirectional communication
For production servers with public endpoints, webhooks may be simpler (see webhooks.md).
Comparison
| Feature | WebSocket | Webhook |
|---|---|---|
| Public URL required | No | Yes |
| Connection | Persistent | HTTP request per event |
| Latency | Lowest (streaming) | HTTP round-trip |
| Firewall | Outbound only | Must expose port |
| Retries | You handle reconnection | AgentMail retries automatically |
| Best for | Agents, bots, local dev | Servers, serverless |
Python SDK
Sync usage
from agentmail import AgentMail, Subscribe, Subscribed, MessageReceivedEvent
client = AgentMail()
with client.websockets.connect() as socket:
# Subscribe to inboxes
socket.send_subscribe(Subscribe(inbox_ids=["agent@agentmail.to"]))
# Process events
for event in socket:
if isinstance(event, Subscribed):
print(f"Subscribed to: {event.inbox_ids}")
elif isinstance(event, MessageReceivedEvent):
print(f"From: {event.message.from_}")
print(f"Subject: {event.message.subject}")
print(f"Body: {event.message.extracted_text}")Async usage
import asyncio
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
client = AsyncAgentMail()
async def main():
async with client.websockets.connect() as socket:
await socket.send_subscribe(Subscribe(inbox_ids=["agent@agentmail.to"]))
async for event in socket:
if isinstance(event, MessageReceivedEvent):
print(f"New: {event.message.subject}")
await process_email(event.message)
asyncio.run(main())Event handler pattern
import threading
from agentmail import AgentMail, Subscribe, EventType
client = AgentMail()
with client.websockets.connect() as socket:
socket.on(EventType.OPEN, lambda _: print("Connected"))
socket.on(EventType.MESSAGE, lambda msg: print("Received:", msg))
socket.on(EventType.CLOSE, lambda _: print("Disconnected"))
socket.on(EventType.ERROR, lambda err: print("Error:", err))
socket.send_subscribe(Subscribe(inbox_ids=["agent@agentmail.to"]))
# Run listener in background thread
listener = threading.Thread(target=socket.start_listening, daemon=True)
listener.start()
listener.join()TypeScript SDK
Basic usage
import { AgentMailClient, AgentMail } from "agentmail";
const client = new AgentMailClient({ apiKey: process.env.AGENTMAIL_API_KEY });
async function main() {
const socket = await client.websockets.connect();
socket.on("open", () => {
console.log("Connected");
socket.sendSubscribe({
type: "subscribe",
inboxIds: ["agent@agentmail.to"],
});
});
socket.on("message", (event: AgentMail.MessageReceivedEvent) => {
if (event.eventType === "message.received") {
console.log("From:", event.message.from);
console.log("Subject:", event.message.subject);
}
});
socket.on("close", (event) => console.log("Disconnected:", event.code));
socket.on("error", (error) => console.error("Error:", error));
}
main();React hook
import { useEffect, useState } from "react";
import { AgentMailClient, AgentMail } from "agentmail";
function useAgentMailWebSocket(apiKey: string, inboxIds: string[]) {
const [lastMessage, setLastMessage] = useState<AgentMail.MessageReceivedEvent | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const client = new AgentMailClient({ apiKey });
let socket: Awaited<ReturnType<typeof client.websockets.connect>>;
async function connect() {
socket = await client.websockets.connect();
socket.on("open", () => {
setIsConnected(true);
socket.sendSubscribe({ type: "subscribe", inboxIds });
});
socket.on("message", (event) => {
if (event.eventType === "message.received") {
setLastMessage(event);
}
});
socket.on("close", () => setIsConnected(false));
}
connect();
return () => socket?.close();
}, [apiKey, inboxIds.join(",")]);
return { lastMessage, isConnected };
}Subscribe options
Filter events by inbox, pod, or event type.
# By inboxes
Subscribe(inbox_ids=["inbox1@agentmail.to", "inbox2@agentmail.to"])
# By pods
Subscribe(pod_ids=["pod_123", "pod_456"])
# By event types
Subscribe(
inbox_ids=["agent@agentmail.to"],
event_types=["message.received", "message.sent"],
)socket.sendSubscribe({
type: "subscribe",
inboxIds: ["agent@agentmail.to"],
eventTypes: ["message.received", "message.sent"],
});
// By pods
socket.sendSubscribe({
type: "subscribe",
podIds: ["pod_123"],
});Event types
| Event | Python Class | TypeScript Type |
|---|---|---|
| Subscription confirmed | Subscribed | AgentMail.Subscribed |
| New email received | MessageReceivedEvent | AgentMail.MessageReceivedEvent |
| Email sent | MessageSentEvent | AgentMail.MessageSentEvent |
| Email delivered | MessageDeliveredEvent | AgentMail.MessageDeliveredEvent |
| Email bounced | MessageBouncedEvent | AgentMail.MessageBouncedEvent |
| Spam complaint | MessageComplainedEvent | AgentMail.MessageComplainedEvent |
| Email rejected | MessageRejectedEvent | AgentMail.MessageRejectedEvent |
| Domain verified | DomainVerifiedEvent | AgentMail.DomainVerifiedEvent |
Message properties
The event.message object on received events (Python snake_case / TypeScript camelCase):
| Python | TypeScript | Description |
|---|---|---|
inbox_id | inboxId | Inbox that received the email |
message_id | messageId | Unique message ID |
thread_id | threadId | Conversation thread ID |
from_ | from | Sender address (string) |
to | to | Recipients (list of strings) |
subject | subject | Subject line |
text | text | Plain text body |
html | html | HTML body |
extracted_text | extractedText | Reply content only, quoted history stripped |
extracted_html | extractedHtml | Reply HTML only, quoted history stripped |
attachments | attachments | List of attachments |
labels | labels | List of labels |
Python uses from_ because from is a reserved keyword. TypeScript uses from directly.
Error handling
import asyncio
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
from agentmail.core.api_error import ApiError
client = AsyncAgentMail()
async def process_email(message) -> None:
# Your inbound email handler goes here.
print(f"New message from {message.from_}: {message.subject}")
async def main():
try:
async with client.websockets.connect() as socket:
await socket.send_subscribe(Subscribe(inbox_ids=["agent@agentmail.to"]))
async for event in socket:
if isinstance(event, MessageReceivedEvent):
await process_email(event.message)
except ApiError as e:
print(f"API error: {e.status_code} - {e.body}")
except Exception as e:
print(f"Connection error: {e}")
asyncio.run(main())import { AgentMailError } from "agentmail";
try {
const socket = await client.websockets.connect();
// ...
} catch (err) {
if (err instanceof AgentMailError) {
console.error(`API error: ${err.statusCode} - ${err.message}`);
} else {
console.error("Connection error:", err);
}
}Reconnection
The SDK does not auto-reconnect. Implement reconnection with exponential backoff:
import asyncio
from agentmail import AsyncAgentMail, Subscribe, MessageReceivedEvent
async def listen_with_reconnect(inbox_ids: list[str]):
client = AsyncAgentMail()
backoff = 1
while True:
try:
async with client.websockets.connect() as socket:
await socket.send_subscribe(Subscribe(inbox_ids=inbox_ids))
backoff = 1 # reset on successful connection
async for event in socket:
if isinstance(event, MessageReceivedEvent):
await process_email(event.message)
except Exception as e:
print(f"Disconnected: {e}. Reconnecting in {backoff}s...")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)Related skills
How it compares
Choose agentmail-sdk for agent-owned two-way inboxes; use transactional email skills when only outbound notifications are required.
FAQ
What languages does agentmail-sdk cover?
agentmail-sdk documents both Python (pip install agentmail) and TypeScript (npm install agentmail) SDKs with shared patterns for inboxes, threads, attachments, drafts, pods, and real-time webhooks or WebSockets.
How is AgentMail different from transactional email APIs?
agentmail-sdk targets AgentMail's two-way agent inboxes with thread management, reply extraction, and inbound webhooks. Transactional providers focus on one-way sends, while AgentMail supports full conversational email loops for autonomous agents.