Loops is a genuinely nice product-email tool for SaaS: a visual editor, lifecycle loops, and free transactional sends. But it's send-only — it gives an autonomous agent no inbox, so the agent can't read the verification code or the reply it's waiting on. MailKite (which we build) gives the agent a real address that arrives as parsed JSON with a receive→reply loop. For developers wiring an agent that has to read its own mail.
Picture the moment it breaks. Your agent signs up for some SaaS with agent@myapp.ai
, the service emails a six-digit code to that address, and the agent sits there waiting for a message that has nowhere to arrive. Loops sent the welcome email, the onboarding drip, and the receipt just fine. It has no idea that a code is trying to reach the agent, because receiving mail was never something it does. That gap — not price, not deliverability — is the whole reason a send-first product-email tool and an autonomous agent are a mismatch.
Everything below is a repo you can run while you read. Clone demo-loops-ai-agent and
npm start
, or open it in StackBlitz(real Node in a browser tab, no account) where it runs on load. You don’t need a domain to see the loop run: a webhook normally needs a public URL, so
npm start
boots the server and self-fires a correctly signed
email.received
event at its own localhost — the whole receive→verify→think→reply loop runs in one command (the reply is a dry-run, and managed-route.mjs
dry-runs the hosted path too). It also ships the Loops path next to it in loops-contrast/
, so the contrast is something you can diff and test, not take on faith: a clean transactional send, and the receiving half that simply isn’t there. A domain comes in only when you want
realinbound email to arrive.
Here’s the receiving half Loops doesn’t have, whole: email in, verify the signature, hand the body to your model, reply through the same client. This is the heart of server.mjs
in that repo — the repo wraps it in a dry-run harness and a stub agent so it runs with no key and no LLM, but the loop is exactly this, and 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);
That’s the full receive→think→reply loop, whole: the agent hears, thinks, and answers. npm start
boots the server and self-fires the exact payload MailKite’s delivery worker sends at its own localhost, so in one command you watch the agent read the task, take its trust verdict straight off event.auth
, and dry-run the reply — no account or LLM required. mk.send()
returns { id, status }
, and the identical handler shape exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and sending docs. The rest of this post is the honest version: where Loops genuinely wins, what a split stack asks of an agent builder who needs to receive, and what the parsed payload actually contains.
Where Loops wins, honestly #
I want to be clear that Loops is a good product, and none of this is a knock on what it’s built for. Loops is one of the nicest developer experiences in product email right now. The visual editor is genuinely pleasant, the lifecycle “loops” model (trigger an automated series off an event) maps cleanly to how SaaS onboarding actually works, and unifying marketing, lifecycle, and transactional mail in one place means you’re not stitching two vendors together. Transactional sends are free on paid plans (they used to be metered), sends are template-driven by a transactionalId
you publish in the editor, and there are clean integrations with Clerk, Stripe, Supabase, and Polar so a signup or payment can kick off a series without glue code.
If your job is “send lovely onboarding and product email from a SaaS,” Loops is a strong pick and this post isn’t trying to move you off it. The mismatch is narrower and specific: Loops is a send tool, and an autonomous agent’s hard problem is receiving.
Two things people sometimes mistake for inbound on Loops, worth clearing up. Its incoming webhooks receive events from platforms like Stripe and Clerk to create contacts and trigger loops; they don’t receive email. Its outbound webhooks notify you of send-side events (delivered, soft bounce, hard bounce), which is useful but is still telemetry about mail you sent, not mail arriving for you to read. Neither one is an inbox. I confirmed this against Loops’ own docs: there’s no MX setup, no inbound parse, no address that receives.
What Loops asks of an agent builder #
So the honest DIY shape is a split stack. Loops keeps doing the send side it’s good at, and you bolt a real receiving provider next to it for the agent’s inbox, because Loops has no seam for that half. Here’s the send side in Loops’ own idiom — a template-based transactional call, which is genuinely clean. It’s loops-contrast/handler.mjs
in the demo repo, sitting right next to the MailKite server.mjs
above so you can put the two shapes side by side:
// Loops: outbound only. This is the whole send side, and it's nice.
await fetch("https://app.loops.so/api/v1/transactional", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOPS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transactionalId: "clfq6...", // a template you published in the Loops editor
email: "ada@example.com",
dataVariables: { code: "418207" }, // fills the template
}),
});
// …now the agent needs to READ ada's reply — or the code a service just mailed it.
// Loops has no endpoint for that. You add a second, receiving provider here.
That comment is the whole story, and the repo makes it concrete: npm test
builds that transactional payload and passes — the send side genuinely works — then asks the same handler to receive and gets null
back, because loops-contrast/handler.mjs
has no inbound endpoint to hand it. To give the agent an inbox you now run a second vendor (or your own MX) for receiving, wire up its inbound parse, verify its signatures, decode MIME, and reconcile threading between the two systems. None of that is exotic, but it’s a stack you assemble and keep alive specifically because the send-only tool can’t reach the receiving half of the loop.
The comparison, no adjective inflation #
| Loops | MailKite | |
|---|---|---|
| Product/lifecycle email | Excellent visual editor + loops | API templates, no visual builder |
| Transactional send | Template-based (transactionalId ), free on paid plans |
from /to /subject /html + inReplyTo |
| Inbound email (an agent’s inbox) | None — send-only | Parsed JSON webhook per address |
| Read a verification code / reply | Not possible | text + html + threadId delivered |
| SPF/DKIM/DMARC on inbound | n/a (no inbound) | auth block on every message |
| Reply threading | n/a | inReplyTo threads the reply |
| Run the agent for you | No | Route action: 'agent' on a queue |
| Pricing model | By subscribed contacts, from $49/mo (free: ~4k sends / 1k contacts) | Metered; free 3,000/mo in + out, no per-domain fee |
The through-line: Loops wins the product-email experience — the editor and lifecycle model are better than anything MailKite offers there. MailKite wins the thing an agent actually needs, which is a real inbox and the receive→reply loop. They’re not the same job.
What actually hits your agent’s webhook #
Here’s what MailKite (which we build) POSTs when mail arrives for the agent. No MIME to parse, a resolved threadId
, attachments as short-lived signed URLs, and an auth
block:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "no-reply@acme.dev" },
"to": [{ "address": "agent@myapp.ai" }],
"subject": "Your verification code",
"text": "Your code is 418207. It expires in 10 minutes.",
"html": "<p>Your code is <b>418207</b>.</p>",
"threadId": "<a1b2c3@mail.acme.dev>",
"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=…" }
]
}
Don’t take the shape on faith — fire one and watch it come back, auth
block and all. Spoof the SPF/DKIM to fail
and see the verdict flip; this is the whole event Loops never produces, because it never receives:
The auth
block is load-bearing for agents: an email body is untrusted input, and From:
is trivially forgeable, so a naive loop that obeys what an email says is a prompt-injection target. Check SPF/DKIM/DMARC before you weight a sender’s instructions, and bound what a fooled agent can do rather than trusting the system prompt alone. That reasoning is its own post: agent inbox security by design. See webhook security for verifying the signature.
To start, DNS-verify the domain (SPF + DKIM to send, MX to receive) and set a webhook — no sandbox or approval wait. MailKite runs the loop one of two ways, both in the demo repo: bring your own (server.mjs
) — the inbound webhook hits your endpoint, your model runs, you reply with mk.send()
— or let MailKite host it (managed-route.mjs
), a route with action: 'agent'
that runs the model turns for you on a durable queue, with a transcript you can drill into and no endpoint of your own. Either way the agent gets an address that receives, which is the piece Loops leaves you to source elsewhere.
FAQ #
Does Loops support inbound email? No. Loops is send-only: marketing, lifecycle, and transactional outbound mail. Its “incoming webhooks” receive events from platforms like Stripe and Clerk (not email), and its outbound webhooks report send-side events like delivered and bounced. There’s no MX setup, no inbound parse, and no address that receives mail. Confirmed against Loops’ own docs (July 2026).
Can an AI agent read a verification code with Loops?
Not on Loops alone — it has no inbox, so a code emailed to the agent has nowhere to arrive. You’d add a separate receiving provider. MailKite delivers the message as parsed JSON (text
, html
, threadId
, auth
) to a webhook, so the agent reads the code directly.
Should I stop using Loops? Probably not, if you use it for product email. Loops’ editor and lifecycle loops are genuinely good. This is about a different job: giving an autonomous agent its own inbox. Many teams keep Loops for onboarding/marketing and add MailKite for the agent’s receiving loop.
Is Loops’ transactional API enough for an agent? For sending, yes — it’s a clean template-based call and it’s free on paid plans. For an agent, sending is the easy half. The hard half is receiving replies and codes with no human in the loop, and that’s what a send-only API can’t cover.
How do I give the agent an address that receives?
Point a domain at MailKite, pick an address like agent@myapp.ai
, and set a webhook. Inbound mail is parsed to JSON and POSTed as an email.received
event; the agent replies with mk.send()
. See the quickstart.
Keep Loops for the product email it’s good at. When the agent needs to read the code, the reply, the human’s answer, that’s a receiving problem a send-only tool can’t solve. Clone the demo repo or run it in your browser, then point a domain at MailKite and the agent’s next inbound email lands as parsed JSON.
Related: the pillar on giving your AI agent its own inbox, and agent inbox security by design.