The Orchestrator integrates AgentMail directly — no npm proxy, no local Node.js process. Email operations flow through The Orchestrator’s access control layer, which means every action is permission-gated, audited, and governed by the same capability-based model as every other module.
This guide covers all four access methods: MCP tools (for AI agents), Web UI, REST API, and IEx console.
Architecture
MCP Client / Web Browser / REST Client / IEx Console
↓
Permissions.AgentMail (access control gate — permission key 110_001)
↓
AgentMail (stateless HTTP client)
↓
api.agentmail.to/v0
The AgentMail module is a thin HTTP client that maps function calls to AgentMail’s REST API. The Permissions.AgentMail module wraps every function with an allowed? check. Users without the agentmail permission key (110_001) get {:not_allowed, "AgentMail"} — no API call is ever made.
Prerequisites
- The Orchestrator running with an AgentMail API key configured
-
The
agentmailpermission (110_001) granted to your user account - An AgentMail account at agentmail.to
Configuration
The API key can be set in config/dev.exs:
config :mcp,
agentmail_api_key: "am_us_your_key_here"
Or via environment variable in production:
export AGENTMAIL_API_KEY="am_us_your_key_here"
Granting Permission
In IEx, grant the agentmail permission to a user:
AccessControl.grant_permission("james", Permissions.Keys.agentmail())
MCP Tools (AI Agents)
When connected via MCP (Claude Code, Claude Desktop, Cursor), 11 tools are available. The AI agent calls these tools with natural language — “check my inbox”, “send an email to [email protected]”, “reply to the latest message”.
Available Tools
| Tool | Description |
|---|---|
agentmail_list_inboxes |
List all inboxes (paginated) |
agentmail_get_inbox |
Get inbox details by ID |
agentmail_create_inbox |
Create a new inbox |
agentmail_delete_inbox |
Permanently delete an inbox |
agentmail_list_threads |
List threads in an inbox (with filters) |
agentmail_get_thread |
Get a thread with all messages |
agentmail_get_attachment |
Get attachment metadata and download URL |
agentmail_send_message |
Send a new email |
agentmail_reply_to_message |
Reply to an existing message |
agentmail_forward_message |
Forward a message |
agentmail_update_message |
Add or remove message labels |
Example Conversations
Checking email:
“List my inboxes” “Show me the latest threads in inbox abc123” “Read the thread about the proposal”
Sending email:
“Send an email from inbox abc123 to [email protected] with subject ‘Meeting Tomorrow’ saying ‘Can we move the meeting to 3pm?’”
Replying:
“Reply to that last message saying ‘Sounds good, see you then’”
Forwarding:
“Forward the proposal thread to [email protected]”
Organizing:
“Label that message as ‘urgent’” “Remove the ‘draft’ label from message xyz789”
Tool Parameters
agentmail_list_inboxes
{
"limit": 20,
"pageToken": "token_from_previous_response"
}
All parameters optional. Default limit is 10.
agentmail_create_inbox
{
"username": "sales",
"domain": "agentmail.to",
"displayName": "Sales Team"
}
All parameters optional. AgentMail generates a random address if username is omitted.
agentmail_list_threads
{
"inboxId": "inbox_abc123",
"limit": 10,
"labels": ["important"],
"after": "2026-03-01T00:00:00Z",
"before": "2026-03-09T23:59:59Z",
"ascending": false
}
Only inboxId is required. Filters are optional.
agentmail_send_message
{
"inboxId": "inbox_abc123",
"to": ["[email protected]", "[email protected]"],
"subject": "Q1 Report",
"text": "Please find the Q1 report attached.",
"html": "<p>Please find the <b>Q1 report</b> attached.</p>",
"cc": ["[email protected]"],
"bcc": ["[email protected]"],
"labels": ["sent", "reports"],
"replyTo": "[email protected]"
}
inboxId and to are required. Everything else is optional. You can send plain text, HTML, or both.
agentmail_reply_to_message
{
"inboxId": "inbox_abc123",
"messageId": "msg_xyz789",
"text": "Thanks, I'll review this today.",
"replyAll": true
}
inboxId and messageId are required. Set replyAll: true to reply to all recipients. You can override the to, cc, and bcc fields.
agentmail_forward_message
{
"inboxId": "inbox_abc123",
"messageId": "msg_xyz789",
"to": ["[email protected]"],
"text": "FYI — please process this invoice."
}
inboxId, messageId, and to are required.
agentmail_update_message
{
"inboxId": "inbox_abc123",
"messageId": "msg_xyz789",
"addLabels": ["reviewed", "important"],
"removeLabels": ["unread"]
}
inboxId and messageId are required. Provide addLabels, removeLabels, or both.
Web UI
Navigate to /agentmail in your browser (e.g. https://localhost:8443/agentmail). The page is a self-contained single-page application that communicates with the REST API.
Features
- Inbox list — see all your inboxes at a glance, create new ones
- Thread view — browse threads in an inbox, sorted by most recent
- Message reader — read full conversation threads with HTML rendering
- Compose — send new emails with To, CC, and Subject fields
- Reply — reply to a message (with Reply All option)
- Forward — forward a message to new recipients
- Delete inbox — permanently remove an inbox (with confirmation dialog)
Walkthrough
-
Open
/agentmail— you’ll see your inbox list (or an empty state) - Click + New Inbox to create one. Optionally set a username, domain, and display name
- Click an inbox card to see its threads
- Click a thread to read all messages in the conversation
- Use Reply or Forward buttons at the top of a thread
- Click + Compose to send a new email from the current inbox
The web UI requires the agentmail permission (110_001). Users without it see a 403 page.
REST API
All endpoints are under /api/agentmail/. Authentication is handled by The Orchestrator’s session cookie (web) or bearer token (API clients).
Inbox Endpoints
List Inboxes
GET /api/agentmail/inboxes?limit=20&pageToken=abc
Response:
{
"count": 3,
"inboxes": [
{
"inboxId": "inbox_abc123",
"displayName": "Sales",
"createdAt": "2026-03-09T10:00:00Z"
}
],
"nextPageToken": "token_for_next_page"
}
Get Inbox
GET /api/agentmail/inboxes/inbox_abc123
Create Inbox
POST /api/agentmail/inboxes
Content-Type: application/json
{
"username": "support",
"displayName": "Support Team"
}
Returns the created inbox object (201).
Delete Inbox
DELETE /api/agentmail/inboxes/inbox_abc123
Returns {"deleted": true}. This is permanent — all threads and messages are lost.
Thread Endpoints
List Threads
GET /api/agentmail/inboxes/inbox_abc123/threads?limit=10&after=2026-03-01T00:00:00Z
Optional query parameters: limit, pageToken, labels (JSON array), before, after, ascending.
Response:
{
"threads": [
{
"threadId": "thread_def456",
"subject": "Q1 Planning",
"preview": "Let's schedule a meeting to discuss...",
"senders": ["[email protected]"],
"recipients": ["[email protected]"],
"messageCount": 3,
"timestamp": 1741500000,
"labels": ["inbox"]
}
]
}
Get Thread (with all messages)
GET /api/agentmail/inboxes/inbox_abc123/threads/thread_def456
Response includes the full messages array with every message in the thread.
Get Attachment
GET /api/agentmail/inboxes/inbox_abc123/threads/thread_def456/attachments/att_ghi789
Returns attachment metadata including a downloadUrl for fetching the file content.
Message Endpoints
Send Message
POST /api/agentmail/inboxes/inbox_abc123/messages/send
Content-Type: application/json
{
"to": ["[email protected]"],
"subject": "Hello",
"text": "Just checking in.",
"cc": ["[email protected]"]
}
Response:
{
"messageId": "msg_xyz789",
"threadId": "thread_new123"
}
Reply to Message
POST /api/agentmail/inboxes/inbox_abc123/messages/msg_xyz789/reply
Content-Type: application/json
{
"text": "Got it, thanks!",
"replyAll": true
}
Forward Message
POST /api/agentmail/inboxes/inbox_abc123/messages/msg_xyz789/forward
Content-Type: application/json
{
"to": ["[email protected]"],
"text": "Please review the attached invoice."
}
Update Message Labels
PATCH /api/agentmail/inboxes/inbox_abc123/messages/msg_xyz789
Content-Type: application/json
{
"addLabels": ["reviewed"],
"removeLabels": ["unread"]
}
cURL Examples
Using a bearer token:
# List inboxes
curl -s https://localhost:8443/api/agentmail/inboxes \
-H "Authorization: Bearer $TOKEN" | jq
# Create inbox
curl -s -X POST https://localhost:8443/api/agentmail/inboxes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"demo","displayName":"Demo Inbox"}' | jq
# Send email
curl -s -X POST https://localhost:8443/api/agentmail/inboxes/inbox_abc123/messages/send \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": ["[email protected]"],
"subject": "Test from Orchestrator",
"text": "This email was sent through The Orchestrator REST API."
}' | jq
# List threads with date filter
curl -s "https://localhost:8443/api/agentmail/inboxes/inbox_abc123/threads?limit=5&after=2026-03-01" \
-H "Authorization: Bearer $TOKEN" | jq
# Reply to a message
curl -s -X POST https://localhost:8443/api/agentmail/inboxes/inbox_abc123/messages/msg_xyz789/reply \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Acknowledged.","replyAll":false}' | jq
Error Responses
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created (inbox) |
| 400 |
Bad request — check the error field in the response |
| 403 |
Forbidden — user lacks agentmail permission |
| 404 | Not found — invalid route |
IEx Console
The AgentMail module provides direct access to all API endpoints without permission checks. The Permissions.AgentMail module adds permission gating. Use AgentMail directly in IEx for development; use Permissions.AgentMail when testing the access control flow.
Direct API Calls (no permission check)
List Inboxes
{:ok, data} = AgentMail.list_inboxes()
data["inboxes"] |> Enum.each(fn inbox ->
IO.puts("#{inbox["inboxId"]} — #{inbox["displayName"]}")
end)
With pagination:
{:ok, data} = AgentMail.list_inboxes(%{"limit" => 5})
# If there are more results:
{:ok, page2} = AgentMail.list_inboxes(%{"pageToken" => data["nextPageToken"]})
Get Inbox Details
{:ok, inbox} = AgentMail.get_inbox("inbox_abc123")
IO.inspect(inbox, label: "Inbox")
Create an Inbox
# Auto-generated address
{:ok, inbox} = AgentMail.create_inbox()
IO.puts("Created: #{inbox["inboxId"]}")
# Custom address
{:ok, inbox} = AgentMail.create_inbox(%{
"username" => "support",
"domain" => "agentmail.to",
"displayName" => "Support Team"
})
Delete an Inbox
{:ok, _} = AgentMail.delete_inbox("inbox_abc123")
List Threads
{:ok, data} = AgentMail.list_threads("inbox_abc123")
data["threads"] |> Enum.each(fn t ->
IO.puts("#{t["threadId"]} | #{t["subject"]} | #{t["messageCount"]} msgs")
end)
With filters:
# Threads from the last 7 days, labeled "important"
{:ok, data} = AgentMail.list_threads("inbox_abc123", %{
"limit" => 20,
"after" => "2026-03-02T00:00:00Z",
"labels" => ["important"]
})
Read a Thread
{:ok, thread} = AgentMail.get_thread("inbox_abc123", "thread_def456")
IO.puts("Subject: #{thread["subject"]}")
IO.puts("Messages: #{length(thread["messages"])}")
for msg <- thread["messages"] do
IO.puts("---")
IO.puts("From: #{msg["from"]}")
IO.puts("To: #{Enum.join(msg["to"], ", ")}")
IO.puts("Date: #{msg["createdAt"]}")
IO.puts("")
IO.puts(msg["text"] || msg["extractedText"] || "(no text)")
end
Get Attachment Info
{:ok, att} = AgentMail.get_attachment("inbox_abc123", "thread_def456", "att_ghi789")
IO.puts("Download URL: #{att["downloadUrl"]}")
IO.puts("Filename: #{att["name"]}")
IO.puts("Size: #{att["size"]} bytes")
To download the actual file:
{:ok, att} = AgentMail.get_attachment("inbox_abc123", "thread_def456", "att_ghi789")
{:ok, %Req.Response{body: body}} = Req.get(att["downloadUrl"])
File.write!("/tmp/#{att["name"]}", body)
IO.puts("Saved #{byte_size(body)} bytes to /tmp/#{att["name"]}")
Send an Email
{:ok, result} = AgentMail.send_message("inbox_abc123", %{
"to" => ["[email protected]"],
"subject" => "Hello from IEx",
"text" => "This email was sent directly from the Elixir console."
})
IO.puts("Sent! Message ID: #{result["messageId"]}, Thread ID: #{result["threadId"]}")
With HTML body and CC:
{:ok, result} = AgentMail.send_message("inbox_abc123", %{
"to" => ["[email protected]"],
"cc" => ["[email protected]"],
"subject" => "Monthly Report",
"text" => "Please see the report below.",
"html" => "<h1>Monthly Report</h1><p>Revenue is up <b>15%</b> this quarter.</p>"
})
Reply to a Message
{:ok, result} = AgentMail.reply_to_message("inbox_abc123", "msg_xyz789", %{
"text" => "Thanks for the update. I'll review it today."
})
Reply all:
{:ok, result} = AgentMail.reply_to_message("inbox_abc123", "msg_xyz789", %{
"text" => "Acknowledged — copying everyone on this.",
"replyAll" => true
})
Forward a Message
{:ok, result} = AgentMail.forward_message("inbox_abc123", "msg_xyz789", %{
"to" => ["[email protected]"],
"text" => "FYI — please process the attached invoice."
})
Update Message Labels
# Add labels
{:ok, msg} = AgentMail.update_message("inbox_abc123", "msg_xyz789", %{
"addLabels" => ["reviewed", "priority"]
})
# Remove labels
{:ok, msg} = AgentMail.update_message("inbox_abc123", "msg_xyz789", %{
"removeLabels" => ["unread"]
})
# Add and remove in one call
{:ok, msg} = AgentMail.update_message("inbox_abc123", "msg_xyz789", %{
"addLabels" => ["archived"],
"removeLabels" => ["inbox", "unread"]
})
Permission-Gated Calls (testing access control)
To test the full access control flow in IEx:
# Get a user's permissions
{:ok, user} = User.find_by_username("james")
perms = AccessControl.get_permissions(user.id)
# These go through the permission gate
{:ok, inboxes} = Permissions.AgentMail.list_inboxes(perms)
{:ok, inbox} = Permissions.AgentMail.get_inbox(perms, "inbox_abc123")
{:ok, threads} = Permissions.AgentMail.list_threads(perms, "inbox_abc123")
{:ok, result} = Permissions.AgentMail.send_message(perms, "inbox_abc123", %{
"to" => ["[email protected]"],
"subject" => "Permission test",
"text" => "If you see this, the permission gate passed."
})
# Test with empty permissions (should be denied)
{:not_allowed, "AgentMail"} = Permissions.AgentMail.list_inboxes(%{})
Useful IEx Recipes
Print all inboxes in a table
{:ok, data} = AgentMail.list_inboxes(%{"limit" => 50})
data["inboxes"]
|> Enum.each(fn i ->
id = i["inboxId"] || i["inbox_id"]
name = i["displayName"] || i["display_name"] || "(unnamed)"
IO.puts("#{String.pad_trailing(id, 30)} #{name}")
end)
Find unread threads across all inboxes
{:ok, data} = AgentMail.list_inboxes(%{"limit" => 50})
for inbox <- data["inboxes"] do
id = inbox["inboxId"] || inbox["inbox_id"]
{:ok, threads} = AgentMail.list_threads(id, %{"limit" => 5})
for t <- (threads["threads"] || []) do
IO.puts("[#{id}] #{t["subject"]} — #{t["messageCount"]} msgs")
end
end
Send the same message to multiple inboxes
inbox_ids = ["inbox_abc", "inbox_def", "inbox_ghi"]
for inbox_id <- inbox_ids do
{:ok, _} = AgentMail.send_message(inbox_id, %{
"to" => ["[email protected]"],
"subject" => "System Update",
"text" => "Scheduled maintenance tonight at 11pm."
})
IO.puts("Sent from #{inbox_id}")
Process.sleep(1000) # space out sends
end
Export a thread to a text file
{:ok, thread} = AgentMail.get_thread("inbox_abc123", "thread_def456")
content =
thread["messages"]
|> Enum.map(fn msg ->
"""
From: #{msg["from"]}
To: #{Enum.join(msg["to"] || [], ", ")}
Date: #{msg["createdAt"]}
Subject: #{msg["subject"]}
#{msg["text"] || msg["extractedText"] || "(no body)"}
"""
end)
|> Enum.join("\n" <> String.duplicate("=", 60) <> "\n")
File.write!("/tmp/thread_export.txt", content)
IO.puts("Exported #{length(thread["messages"])} messages to /tmp/thread_export.txt")
Module Reference
| Module | Role |
|---|---|
AgentMail |
Stateless HTTP client — wraps api.agentmail.to/v0 with Req |
Permissions.AgentMail |
Access control gate — checks permission 110_001 before calling AgentMail |
Router.AgentMailApi |
REST API router — /api/agentmail/* endpoints |
AgentMailWeb |
Web SPA — served at /agentmail |
All functions in AgentMail return {:ok, result} or {:error, reason}. All functions in Permissions.AgentMail return {:ok, result}, {:error, reason}, or {:not_allowed, "AgentMail"}.