Agent email with Microsoft Graph
Give an AI agent an Outlook or Microsoft 365 mailbox and it can hold a real email conversation — field a support request, follow up on a thread, confirm a booking. This guide wires that up with Microsoft Graph, then shows the same thing the MailKite way, where the agent gets email as native tools over MCP.
What you'll need
- An Entra ID (Azure AD) tenant and rights to register an app.
- An Outlook / Microsoft 365 mailbox for the agent to send and receive from.
- An agent runtime that can call tools (function calling or MCP).
- For change notifications: a public HTTPS endpoint for the webhook.
Part 1 — Give your agent a mailbox with Microsoft Graph
1. Register the app and get a token
Graph is OAuth 2.0. Register an app in Entra ID, grant it the Microsoft Graph
mail permissions — delegated Mail.Send / Mail.Read
to act as a signed-in user, or the application-permission equivalents (which
need admin consent) to run headless — then exchange for a bearer token:
# Register an app in Entra ID (Azure portal → App registrations), then add
# Microsoft Graph Mail.Send / Mail.Read. Application permissions need admin
# consent; delegated permissions are consented by the signed-in user.
# App-only token via the client-credentials flow:
curl -X POST https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials"
# → { "access_token": "eyJ0...", "expires_in": 3599, ... } 2. Send from the agent
Expose sending as a tool your agent can call. Under the hood it's a
POST to /sendMail with a message object.
A successful send returns 202 Accepted with no body:
# App-only: address the mailbox with /users/{id}. Delegated: use /me/sendMail.
curl -X POST https://graph.microsoft.com/v1.0/users/agent@yourtenant.com/sendMail \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"subject": "Re: your order #1042",
"body": { "contentType": "HTML", "content": "<p>Shipped — tracking is attached.</p>" },
"toRecipients": [{ "emailAddress": { "address": "ada@example.com" } }]
}
}'
# → 202 Accepted 3. Receive
Read mail with GET /me/messages, or subscribe to change
notifications so a new message reaches your agent's loop in real time. Graph
POSTs to your notificationUrl — first a validation
handshake, then a created event you feed back into the model:
// Pull the inbox on demand…
// GET https://graph.microsoft.com/v1.0/me/messages?$top=20 (Authorization: Bearer ...)
// …or get real-time change notifications. Subscribe on the messages resource:
// POST https://graph.microsoft.com/v1.0/subscriptions
// {
// "changeType": "created",
// "notificationUrl": "https://your.app/hooks/graph",
// "resource": "users/agent@yourtenant.com/mailFolders('inbox')/messages",
// "expirationDateTime": "2026-07-24T18:00:00Z", // short-lived — you renew it
// "clientState": "secret123"
// }
app.post("/hooks/graph", (req, res) => {
// Validation handshake: on subscribe, Graph POSTs ?validationToken=... —
// echo it back as text/plain within 10 seconds or the subscription fails.
if (req.query.validationToken) {
return res.type("text/plain").send(req.query.validationToken);
}
// Otherwise it's a change notification. Verify clientState, then fetch the
// new message and hand it to your agent's loop.
for (const n of req.body.value) {
if (n.clientState !== "secret123") continue;
agent.handle({ messageId: n.resourceData.id }); // GET the message for the full body
}
res.sendStatus(202);
}); That's an agent mailbox on Graph: register the app, wrap send and the notification webhook as tools, and let your agent run the conversation. Note the overhead — an Entra app registration, admin consent for application permissions, and subscriptions that expire and have to be renewed on a timer.
Part 2 — The same, the MailKite way
MailKite gives the agent its own address on your domain — no Entra app registration, no subscription renewals. It's MCP-native, so your agent doesn't call a REST API you wrapped by hand — it speaks MCP and gets email as first-class tools. Connect the server once and the same send-and-reply loop is a few tool calls.
1. Connect the MCP server
Add the hosted server. On the first connect it signs you in over OAuth in the browser — no key to copy — or pass your account key for headless runs:
# Hosted MCP server — OAuth in the browser on first connect, no key to copy
claude mcp add --transport http mailkite https://mcp.mailkite.dev/mcp
# Headless / CI — pass your account key instead of the browser flow
claude mcp add --transport http mailkite https://mcp.mailkite.dev/mcp \
--header "Authorization: Bearer mk_live_..." Any MCP client — Cursor, Claude Desktop, Cline, Zed — takes the same URL:
{
"mcpServers": {
"mailkite": { "url": "https://mcp.mailkite.dev/mcp" }
}
} 2. Send from your agent
The agent calls mailkite_send over any
verified domain — HTML, text, cc/bcc, attachments,
and in-thread replies. Pick AI in the card for the agent's
view, or any language to make the same send from your own code:
Messages → Compose
From hello@myapp.ai
To ada@example.com
Subject Your invoice #1042
Body Thanks! Receipt attached.
Click [ Send ]Email ada@example.com from hello@myapp.ai
with subject "Your invoice #1042" and body "Thanks! Receipt attached."import { MailKite } from "mailkite";
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const { id, status } = await mk.send({
from: "hello@myapp.ai",
to: "ada@example.com",
subject: "Your invoice #1042",
html: "<p>Thanks! Receipt attached.</p>",
});import os
from mailkite import MailKite
mk = MailKite(os.environ["MAILKITE_API_KEY"])
res = mk.send({
"from": "hello@myapp.ai",
"to": "ada@example.com",
"subject": "Your invoice #1042",
"html": "<p>Thanks! Receipt attached.</p>",
})<?php
$mk = new \MailKite\Client(getenv('MAILKITE_API_KEY'));
$res = $mk->send([
'from' => 'hello@myapp.ai',
'to' => 'ada@example.com',
'subject' => 'Your invoice #1042',
'html' => '<p>Thanks! Receipt attached.</p>',
]);MailKite mk = new MailKite(System.getenv("MAILKITE_API_KEY"));
Object res = mk.send(Map.of(
"from", "hello@myapp.ai",
"to", "ada@example.com",
"subject", "Your invoice #1042",
"html", "<p>Thanks! Receipt attached.</p>"
));mk := mailkite.New(os.Getenv("MAILKITE_API_KEY"))
res, err := mk.Send(mailkite.Message{
From: "hello@myapp.ai",
To: "ada@example.com",
Subject: "Your invoice #1042",
HTML: "<p>Thanks! Receipt attached.</p>",
})require "mailkite"
mk = Mailkite::Client.new(ENV["MAILKITE_API_KEY"])
res = mk.send(
"from" => "hello@myapp.ai",
"to" => "ada@example.com",
"subject" => "Your invoice #1042",
"html" => "<p>Thanks! Receipt attached.</p>"
)curl https://api.mailkite.dev/v1/send \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@myapp.ai",
"to": "ada@example.com",
"subject": "Your invoice #1042",
"html": "<p>Thanks! Receipt attached.</p>"
}' Messages → Sent
● queued msg_2Hk9… ada@example.com Your invoice #1042Sent — message msg_2Hk9…, status queued.← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" }← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" }← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" }← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" }← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" }← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" }← 202 Accepted
{ "id": "msg_2Hk9…", "status": "queued" } 3. Receive — or hand the whole inbox to the agent
Every address on the domain is receivable. The agent can read inbound with its own tools, or you turn an address into an inbox agent with one route — the model reads and replies on its own:
Dashboard → Routes → [ + New route ]
Match support@myapp.ai
Action AI agent
Prompt Answer billing and account questions for myapp.ai.
Reply in-thread. Escalate anything you can't
resolve to humans@myapp.ai.
Forward to humans@myapp.ai
Click [ Save ]Turn support@myapp.ai into an AI inbox agent: answer billing and
account questions for myapp.ai, reply in-thread, and escalate
anything it can't resolve to humans@myapp.ai.import { MailKite } from "mailkite";
const mk = new MailKite(process.env.MAILKITE_API_KEY);
// One route turns an address into an AI inbox agent — the prompt
// is the program, and unresolved mail escalates to a human.
await mk.createRoute({
match: "support@myapp.ai",
action: "agent",
agentPrompt:
"Answer billing and account questions for myapp.ai. Reply in-thread. " +
"Escalate anything you can't resolve to humans@myapp.ai.",
agentForwardTo: ["humans@myapp.ai"],
});import os
from mailkite import MailKite
mk = MailKite(os.environ["MAILKITE_API_KEY"])
# One route turns an address into an AI inbox agent — the prompt
# is the program, and unresolved mail escalates to a human.
mk.createRoute({
"match": "support@myapp.ai",
"action": "agent",
"agentPrompt": (
"Answer billing and account questions for myapp.ai. Reply in-thread. "
"Escalate anything you can't resolve to humans@myapp.ai."
),
"agentForwardTo": ["humans@myapp.ai"],
})<?php
$mk = new \MailKite\Client(getenv('MAILKITE_API_KEY'));
// One route turns an address into an AI inbox agent — the prompt
// is the program, and unresolved mail escalates to a human.
$mk->createRoute([
'match' => 'support@myapp.ai',
'action' => 'agent',
'agentPrompt' => 'Answer billing and account questions for myapp.ai. '
. "Reply in-thread. Escalate anything you can't resolve to humans@myapp.ai.",
'agentForwardTo' => ['humans@myapp.ai'],
]);MailKite mk = new MailKite(System.getenv("MAILKITE_API_KEY"));
// One route turns an address into an AI inbox agent — the prompt
// is the program, and unresolved mail escalates to a human.
mk.createRoute(Map.of(
"match", "support@myapp.ai",
"action", "agent",
"agentPrompt", "Answer billing and account questions for myapp.ai. "
+ "Reply in-thread. Escalate anything you can't resolve to humans@myapp.ai.",
"agentForwardTo", List.of("humans@myapp.ai")
));mk := mailkite.New(os.Getenv("MAILKITE_API_KEY"))
// One route turns an address into an AI inbox agent — the prompt
// is the program, and unresolved mail escalates to a human.
_, err := mk.CreateRoute(map[string]any{
"match": "support@myapp.ai",
"action": "agent",
"agentPrompt": "Answer billing and account questions for myapp.ai. " +
"Reply in-thread. Escalate anything you can't resolve to humans@myapp.ai.",
"agentForwardTo": []string{"humans@myapp.ai"},
})require "mailkite"
mk = Mailkite::Client.new(ENV["MAILKITE_API_KEY"])
# One route turns an address into an AI inbox agent — the prompt
# is the program, and unresolved mail escalates to a human.
mk.createRoute(
"match" => "support@myapp.ai",
"action" => "agent",
"agentPrompt" => "Answer billing and account questions for myapp.ai. " \
"Reply in-thread. Escalate anything you can't resolve to humans@myapp.ai.",
"agentForwardTo" => ["humans@myapp.ai"]
)curl https://api.mailkite.dev/api/routes \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"match": "support@myapp.ai",
"action": "agent",
"agentPrompt": "Answer billing and account questions for myapp.ai. Reply in-thread. Escalate anything you cannot resolve to humans@myapp.ai.",
"agentForwardTo": ["humans@myapp.ai"]
}' // Or read inbound yourself — the agent has tools for it
mailkite_list_messages({ limit: 20 }) // newest first
mailkite_get_message({ id: "msg_2Hk9…" }) // full body, headers, attachment links
Same key manages the domain too — mailkite_create_domain,
mailkite_verify_domain — so the agent can provision its own inbox
end to end. See Connect your agent for the full
tool list.
A little about MailKite
MailKite is programmable email for developers and AI agents. Connect the MCP server once and your agent can send over a verified domain, read its inbound as clean JSON, spin up new inboxes, manage domains, and become an inbox agent that answers mail on its own — all as native tools, no REST wrapper to maintain. It works the same over the SDKs, the CLI, or raw HTTP, and agent inboxes are on the free tier.
Next: Connect your agent for every MCP tool, or Set up your agent's email to go from zero to a working inbox.