The Microsoft Graph (Outlook) alternative for AI agents MailKite launches an alternative to Microsoft Graph for AI agents, offering scoped email addresses and parsed JSON webhooks without requiring Entra app registration, tenant-wide admin consent, or expiring push subscriptions. The service targets builders who need autonomous email agents with a simpler setup, while acknowledging Graph remains superior for agents that must operate inside a real Microsoft 365 tenant. 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 : python 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 /docs/receiving and sending docs /docs/sending . There’s a runnable companion in demo-microsoft-graph-ai-agent https://github.com/mailkite/demo-microsoft-graph-ai-agent you can open in StackBlitz https://stackblitz.com/github/mailkite/demo-microsoft-graph-ai-agent?file=server.mjs . 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": "