The Microsoft Graph (Outlook) alternative for AI agents
Microsoft Graph gives an agent a real Outlook mailbox, at the cost of an Entra app registration, admin-consented (tenant-wide) mail permissions, and push subscriptions that expire and need renewing. MailKite (which we build) gives the agent its own scoped address on a domain you control and pushes parsed JSON to a receive→reply loop. For builders wiring an autonomous email agent.
The friction isn’t the mail API itself, which is fine. It’s everything wrapped around it before an agent reads one message: a directory app, a consent grant that touches every mailbox in the tenant, and a webhook subscription with an expiry date. Here is that difference in one picture: the same inbound email, and everything an autonomous agent operates to receive it on each side.
Here is the whole MailKite side: the agent’s receive→think→reply loop. It runs as pasted on Node 18+ (npm install mailkite express):
import express from "express";
import { MailKite } from "mailkite";
const app = express();
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
app.use("/hooks/agent", express.raw({ type: "application/json" }));
app.post("/hooks/agent", async (req, res) => {
// signature check, replay window, constant-time compare — one call
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200); // ack fast; run the agent out of band
const event = JSON.parse(req.body);
if (event.type !== "email.received") return;
// Treat the body as untrusted INPUT, never as instructions (see the security section).
const answer = await runAgent({
task: event.text,
from: event.from.address,
trusted: event.auth.spf === "pass" && event.auth.dmarc === "pass",
});
await mk.send({
from: event.to[0].address, // reply from the address it was sent to
to: event.from.address,
subject: `Re: ${event.subject}`,
inReplyTo: event.id, // threads the reply
html: answer.html,
});
});
app.listen(3000);
No token cache, no subscription, no directory app. Inbound mail is parsed to JSON and POSTed as an email.received event; the agent replies with mk.send(), which returns { id, status }. The same handler shape exists for Python, Ruby, Go, PHP, and Java: see the receiving docs and sending docs. There’s a runnable companion in demo-microsoft-graph-ai-agent you can open in StackBlitz.
Where Graph wins for agents, honestly
Graph is the right tool, and sometimes the only tool, when the agent must live inside a real Microsoft 365 tenant. If your agent is an assistant sitting in an actual employee’s Outlook, or it needs to touch the rest of that person’s work (calendar, contacts, Teams, OneDrive, SharePoint), Graph is one consistent API across all of it, and nothing on the market matches that reach into M365. It carries the mailbox’s real history, its org’s compliance and retention and eDiscovery, and its existing corporate identity. An agent that has to be an employee inside a tenant belongs on Graph. This post isn’t “Graph is bad.” It’s “Graph makes you stand up a tenant-scoped identity and a subscription lifecycle before the agent reads a word,” and for an agent that just needs its own inbox, that’s a lot of machinery.
What Graph asks of an agent builder
To read mail with no signed-in user (the daemon shape an autonomous agent needs), you register an app in Entra ID, get it admin consent for application mail permissions, and then keep a change-notification subscription alive. Application permissions like Mail.Read are tenant-wide by default: the grant reads “read mail in all mailboxes,” and Mail.Send sends as any user. To confine the agent to one mailbox you add RBAC for Applications in Exchange Online (or the legacy Application Access Policy), and you have to remember that an unscoped Entra grant is not narrowed by that scope, so you also remove the org-wide grant. Then the subscription itself expires and has to be renewed. Here is the honest inbound path, top to bottom:
And a change notification isn’t the message. It’s a pointer: Graph tells you something changed at this resource, and you go GET it. Here’s the real daemon flow, app token to subscription to handshake to fetch, as it actually looks in code:
// Graph app-only agent inbox: token → subscribe → validation handshake → GET the message.
// Needs an Entra app with admin-consented application Mail.Read, ideally RBAC-scoped to one mailbox.
import { createServer } from "node:http";
const { TENANT, CLIENT_ID, CLIENT_SECRET, MAILBOX, NOTIFY_URL } = process.env;
async function token() {
const r = await fetch(`https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET, // or a certificate; secrets expire too
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials",
}),
});
return (await r.json()).access_token;
}
// Create the subscription. expirationDateTime is capped and lapses — you must re-PATCH it.
async function subscribe() {
const t = await token();
await fetch("https://graph.microsoft.com/v1.0/subscriptions", {
method: "POST",
headers: { authorization: `Bearer ${t}`, "content-type": "application/json" },
body: JSON.stringify({
changeType: "created",
notificationUrl: NOTIFY_URL,
resource: `/users/${MAILBOX}/mailFolders('inbox')/messages`,
expirationDateTime: new Date(Date.now() + 6 * 24 * 3600 * 1000).toISOString(),
}),
});
}
createServer(async (req, res) => {
const url = new URL(req.url, "https://x");
// 1. Validation handshake: on subscribe, Graph POSTs ?validationToken=…; echo it back,
// plain text, HTTP 200, within 10 seconds — or the subscription is never created.
const validationToken = url.searchParams.get("validationToken");
if (validationToken) {
res.writeHead(200, { "content-type": "text/plain" }).end(validationToken);
return;
}
// 2. A change notification is just a pointer. GET the actual message with an app token.
let raw = ""; for await (const c of req) raw += c;
const { value } = JSON.parse(raw);
const t = await token();
const msg = await fetch(`https://graph.microsoft.com/v1.0/${value[0].resource}`, {
headers: { authorization: `Bearer ${t}` },
}).then((r) => r.json());
// msg.body.content is HTML by default; from is msg.from.emailAddress.address.
// Nothing here tells you if SPF/DKIM/DMARC passed — that's still on you.
console.log(msg.from.emailAddress.address, "·", msg.subject);
res.writeHead(202).end();
}).listen(3000);
await subscribe();
None of this is exotic if you already run an M365 tenant. But it’s a directory identity, a consent grant, a scoping decision, and a renewal loop standing between the agent and “an email came in, do something with it.”
The comparison, for an agent
| Microsoft Graph (Outlook) | MailKite | |
|---|---|---|
| Give the agent an inbox | Licensed M365 mailbox + Entra app | Scoped address on a domain you control |
| Inbound delivery | Change-notification ping → you GET the message | One parsed JSON webhook |
| Push subscription | Expires (≤7 days, ≤1 day with body); renew on a cron | Durable; nothing to renew |
| Setup to start | App registration + admin consent (tenant-wide) | DNS-verify, one webhook URL |
| Scope to one mailbox | RBAC for Applications / access policy | The address is the scope |
| Sender auth for the agent | You derive SPF/DKIM/DMARC yourself | auth block in every payload |
| Throttling | 10,000 requests / 10 min per app per mailbox (429) | Metered, no per-mailbox cap |
| Cost floor | Per-user license (~$4–6/mo) + your infra | Free tier: 3,000 msg/mo, no per-domain fee |
The through-line: Graph wins when the agent must operate inside a real tenant and reach the rest of M365. MailKite wins when the agent just needs its own inbox: no directory app, no consent, no subscription to keep alive, and inbound that arrives already parsed and authenticated.
What actually hits your agent’s webhook
The same inbound email, delivered parsed. No subscription, no second round-trip to GET the body, and the auth block means the agent never re-derives SPF/DKIM/DMARC:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "agent@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{ "id": "msg_2Hk9…:0", "filename": "po.pdf", "contentType": "application/pdf",
"size": 18213, "url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…" }
]
}
That auth block is load-bearing for safety. Inbound email is untrusted input: From: is plain text, so a sender can forge who they are and then simply tell your agent what to do. Check SPF/DKIM/DMARC before you weight instructions, and treat the body as data the agent reasons over, never as commands it obeys. That check is necessary, not sufficient; bound the agent’s authority too. The full argument is in agent inbox security by design.
MailKite, which we build, is that whole receiving stack assembled: the MX edge, the MIME parse, the SPF/DKIM/DMARC verdict, a signed and retried webhook, and the Send API for the reply. To start you DNS-verify a domain (MX to receive, SPF + DKIM to send), point a webhook at it, and pick an address like agent@yourco.dev. There’s no app registration, no admin to chase for consent, and no subscription to renew. If you’d rather not host the loop at all, a route with action: 'agent' runs the model turns for you on a durable queue and hands back a per-run transcript; the pillar post walks both paths. SMTP-only apps can still send through the submission edge with AUTH on :587.
FAQ
Can an AI agent read an Outlook mailbox with Microsoft Graph?
Yes. For an autonomous, no-user agent you register an app in Entra ID, get admin consent for application mail permissions (Mail.Read, Mail.ReadWrite, Mail.Send), and either poll or subscribe to change notifications, then GET each message. It works well, especially when the agent lives inside a real M365 tenant. It’s more setup than pointing a domain at a webhook, which is the MailKite path.
Do Microsoft Graph mail subscriptions expire? Yes, and this is the part that bites. A messages subscription caps at 10,080 minutes (just under 7 days) for basic notifications, and 1,440 minutes (under 1 day) if you include the message body in the notification. You renew it with a PATCH before it lapses, or resubscribe. If your renewal cron dies, inbound quietly stops. MailKite’s webhook is durable, so there’s nothing to renew.
Do Graph application permissions give the agent access to every mailbox?
By default, yes. Application Mail.Read grants read access to all mailboxes in the tenant and requires admin consent; Mail.Send can send as any user. To confine the agent to one mailbox you scope it with RBAC for Applications in Exchange Online (the current method) or a legacy Application Access Policy. Note the two grants are additive: an unscoped Entra grant isn’t narrowed by an RBAC scope, so you also remove the org-wide grant.
Does an agent mailbox on Microsoft 365 need a paid license? Yes. The mailbox has to be a licensed account, roughly Exchange Online Plan 1 at about $4/user/month or Microsoft 365 Business Basic at about $6/user/month (annual, USD; check current pricing). MailKite’s free tier covers 3,000 messages a month, inbound plus outbound, with no per-domain fee, and the agent’s address lives on a domain you already control.
What’s the real difference between Graph and MailKite for an agent inbox? Graph is an API onto a full M365 mailbox and the rest of the tenant; it’s the right call when the agent must operate as someone inside that tenant. MailKite gives the agent its own scoped address that pushes parsed, authenticated JSON to a receive→reply loop, with no directory app, no admin consent, and no subscription lifecycle. Pick Graph for “an agent inside our tenant,” MailKite for “an agent with its own inbox.”
If Graph has you standing up an Entra app, chasing admin consent for a tenant-wide grant, and babysitting a subscription that expires every few days just so an agent can read its mail, there’s a simpler shape for an agent that only needs its own inbox. Clone the demo repo (or run it in your browser), then point a domain at MailKite and the agent’s next inbound email arrives as parsed JSON.
Related: the pillar on giving your agent an inbox and agent inbox security by design.