AgentMail in The Orchestrator: Complete Usage Guide

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

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

Walkthrough

  1. Open /agentmail — you’ll see your inbox list (or an empty state)
  2. Click + New Inbox to create one. Optionally set a username, domain, and display name
  3. Click an inbox card to see its threads
  4. Click a thread to read all messages in the conversation
  5. Use Reply or Forward buttons at the top of a thread
  6. 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"}.