Inbox agents Beta
MailKite's job is the plumbing — inbound email becomes clean JSON at your webhook, and one API sends the reply. Inbox agents are the optional AI layer on top: they read each message, decide what it means, and act — replying in-thread or calling any MailKite tool (send, open a route, look up past mail…) the way your prompt tells them to. It's the same agent that powers the assistant in your dashboard, pointed at inbound mail. Switch one on per address, or leave a domain as a plain email→webhook pipe — email stays the product; AI is a capability you opt into.
Two ways to put AI on inbound
Both are first-class. Pick per address.
| Approach | Who runs the agent | Status |
|---|---|---|
| Bring your own agent | You do — inbound webhook → your model/loop → the Send API to reply in-thread. See Agent inboxes & MCP. | Available today |
| Built-in inbox agent | MailKite does — point a route at action: "agent" with a prompt; it runs in our pipeline, replies, and can call any tool. No server, no loop to host. | Beta |
This is the inverse of Agent inboxes & MCP, which gives your agent an email identity. Inbox agents are about MailKite acting on the mail that arrives — and the two compose.
Configure one
There's no separate object to manage: an inbox agent is a
route whose action is agent, carrying
the agentPrompt that programs it. Point an address at it and you're
done — no webhook endpoint to host.
Dashboard → Routes → [ + Add route ]
Match support@myapp.ai
Receiver Agent
Prompt Answer billing & account questions for myapp.ai.
Reply in-thread. Escalate anything you can't
resolve to humans@myapp.ai.
Click [ Add route ]Set up support@myapp.ai so an AI agent answers billing and account
questions, replies in-thread, and escalates the rest 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; the
// agent can reply to the sender or forward to an address you approve (agentForwardTo).
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"])
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'));
$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"));
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", java.util.List.of("humans@myapp.ai")
));mk := mailkite.New(os.Getenv("MAILKITE_API_KEY"))
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"])
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 <session-token>" \
-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"]
}'
When mail arrives at support@myapp.ai, MailKite runs the agent with
your prompt and the parsed message. It reasons, calls whatever
tools it needs, and (per the prompt) replies in-thread from
the receiving address. The run happens in the background, so inbound stays fast.
What the agent can do
The sender of an inbound email is untrusted — anyone can email your address — so an inbox agent is deliberately locked down. It has exactly two actions, both constrained so a hostile email can't turn the agent against your account:
- Reply to the person who wrote in — in-thread, from the address that received the mail. It chooses the words; it cannot change who the reply goes to or send from another domain.
- Forward the email to an address you control — your account
email, any address on a domain you own, or an address you pre-approve on the route
(
agentForwardTo). Any other destination is refused. This is how you let it escalate or hand off.
That's it. An inbox agent cannot read your other messages, list or change
your domains, routes, or webhooks, or send to an arbitrary address — those are not tools it
is given. (Your dashboard assistant / MCP server
can do all of that, because they're authenticated as you, not driven by an inbound
stranger.) So “answer billing questions; escalate refunds to refunds@myapp.ai”
becomes a reply and a forward — with refunds@myapp.ai
in agentForwardTo.
Guardrails. The email body is treated as untrusted input (instructions inside it are data, not commands); the agent won't reply to no-reply/automated senders, acts at most once per message, and can only reply to the sender or forward to an address you control. It can never read or change the rest of your account.
The dashboard assistant
The same agent runs as a chat in your dashboard (the Assistant tab). Ask it to add a domain, show DNS, wire a webhook, set up an inbox-agent route, or find a message — it calls the same tools and streams its work. It's the fastest way to try a prompt before you put it on a route.
Planned: structured spam, tags & escalation
Today everything is expressed through the free-text agentPrompt plus
tools, which already covers triage, replies, and escalation. First-class,
typed configuration is the next step:
| Field | Values | Purpose |
|---|---|---|
agentPrompt | string · available | The agent's instructions — its job, tone, and rules. |
agentForwardTo | string[] · available | Extra addresses the agent may forward to (beyond your account email and your own domains). Your escalation/hand-off inboxes. |
reply.mode | auto · draft · off · planned | Send the reply, hold it for human approval, or never reply. |
spam.action | quarantine · tag · planned | Drop junk before the model runs, or just label it. |
tags | string[] · planned | A first-class label vocabulary, emitted on the webhook. |
When that lands, an agent route can also forward the inbound event to your own endpoint, enriched with the spam verdict, tags, and what the agent did — so your code stays in the loop. The planned shape:
{
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Can't update my card",
"text": "Hi — my payment keeps failing…",
// ↓ planned: added when first-class spam/tagging runs in front of the agent
"spam": { "verdict": "ham", "score": 0.03 },
"tags": ["billing"],
"agent": { "id": "agt_…", "action": "replied", "replyId": "msg_…" }
} Bring your own instead
Already have an agent? Skip the built-in one: route the address to a
webhook and run your own loop — read the parsed
JSON, reason over it, and reply with the SDK's
send + inReplyTo. The full pattern, in seven
languages, is in Agent
inboxes & MCP.
Here's the whole loop in one handler — verify the signature
with the MailKite SDK, hand the message to Claude, then
reply in-thread with send + inReplyTo.
Node and Python drive Claude with the
Claude Agent SDK;
the other SDKs have no Agent SDK, so they call Claude through the Anthropic
Messages API. Set
ANTHROPIC_API_KEY and MAILKITE_WEBHOOK_SECRET in the
environment. The handler runs inside a try/catch: on
success it returns 200; if the agent or send throws, it returns a
non-2xx and MailKite redelivers the event, retrying with exponential backoff.
Dashboard → Routes → [ + Add route ]
Match support@myapp.ai
Receiver Webhook
URL https://myapp.ai/hooks/mailkite
Click [ Add route ]
# Inbound now POSTs to your handler, which verifies the
# signature, runs your agent, and replies (code tabs →).Create a webhook route for support@myapp.ai
that delivers to https://myapp.ai/hooks/mailkite.import express from "express";
import { MailKite } from "mailkite";
import { query } from "@anthropic-ai/claude-agent-sdk"; // reads ANTHROPIC_API_KEY
const mk = new MailKite(process.env.MAILKITE_API_KEY);
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();
// Verify the exact bytes — capture the RAW body, not a re-serialized object.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", async (req, res) => {
// 1. Verify the signature with the SDK before trusting anything.
if (!mk.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type !== "email.received") return res.sendStatus(200);
try {
// 2. Hand the message to Claude via the Claude Agent SDK; collect its reply.
const prompt = `New email from ${event.from.address}: ${event.subject}\n\n${event.text}`;
const stream = query({ prompt, options: { model: "claude-opus-4-8" } });
let reply = "";
for await (const msg of stream) {
if (msg.type === "result" && msg.subtype === "success") reply = msg.result;
}
// 3. Send Claude's reply back in-thread with the MailKite SDK.
await mk.send({
from: event.to[0].address, // the agent's own address
to: event.from.address,
subject: `Re: ${event.subject}`,
text: reply,
inReplyTo: event.threadId, // keeps it in the same conversation
});
res.sendStatus(200);
} catch (err) {
// Non-2xx → MailKite redelivers, retrying with exponential backoff.
console.error(err);
res.sendStatus(500);
}
});import os
import asyncio
from flask import Flask, request, abort
from mailkite import MailKite, verify_webhook
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
mk = MailKite(os.environ["MAILKITE_API_KEY"])
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
app = Flask(__name__)
async def ask_claude(prompt): # the Claude Agent SDK is async
reply = ""
stream = query(prompt=prompt, options=ClaudeAgentOptions(model="claude-opus-4-8"))
async for msg in stream:
if isinstance(msg, ResultMessage):
reply = msg.result
return reply
@app.post("/hooks/mailkite")
def mailkite():
# 1. Verify the signature with the SDK. Pass the RAW body bytes.
signature = request.headers.get("x-mailkite-signature", "")
if not verify_webhook(signature, request.get_data(), SECRET):
abort(401)
event = request.get_json()
if event["type"] != "email.received":
return "", 200
try:
# 2. Hand the message to Claude via the Claude Agent SDK; collect its reply.
prompt = (f"New email from {event['from']['address']}: "
f"{event['subject']}\n\n{event['text']}")
reply = asyncio.run(ask_claude(prompt))
# 3. Send Claude's reply back in-thread with the MailKite SDK.
mk.send({
"from": event["to"][0]["address"], # the agent's own address
"to": event["from"]["address"],
"subject": f"Re: {event['subject']}",
"text": reply,
"inReplyTo": event["threadId"], # keeps it in the same conversation
})
return "", 200
except Exception:
# Non-2xx → MailKite redelivers, retrying with exponential backoff.
app.logger.exception("inbox agent failed")
return "", 500<?php
require "vendor/autoload.php";
use MailKite\Client;
use Anthropic\Client as Anthropic;
$mk = new Client(getenv("MAILKITE_API_KEY"));
$claude = new Anthropic(apiKey: getenv("ANTHROPIC_API_KEY"));
$secret = getenv("MAILKITE_WEBHOOK_SECRET");
$signature = $_SERVER["HTTP_X_MAILKITE_SIGNATURE"] ?? "";
$rawBody = file_get_contents("php://input"); // the RAW request body
// 1. Verify the signature with the SDK before trusting anything.
if (!$mk->verifyWebhook($signature, $rawBody, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
if (($event['type'] ?? '') !== 'email.received') {
http_response_code(200);
exit;
}
try {
// 2. Ask Claude (Anthropic SDK Messages API) for a reply.
$message = $claude->messages->create(
model: 'claude-opus-4-8',
maxTokens: 1024,
messages: [['role' => 'user', 'content' =>
"New email from {$event['from']['address']}: "
. "{$event['subject']}\n\n{$event['text']}"]],
);
$reply = '';
foreach ($message->content as $block) {
if ($block->type === 'text') { $reply = $block->text; break; }
}
// 3. Send Claude's reply back in-thread with the MailKite SDK.
$mk->send([
'from' => $event['to'][0]['address'], // the agent's own address
'to' => $event['from']['address'],
'subject' => "Re: {$event['subject']}",
'text' => $reply,
'inReplyTo' => $event['threadId'], // keeps it in the same conversation
]);
http_response_code(200);
} catch (\Throwable $e) {
// Non-2xx → MailKite redelivers, retrying with exponential backoff.
error_log($e->getMessage());
http_response_code(500);
}MailKite mk = new MailKite(System.getenv("MAILKITE_API_KEY"));
String secret = System.getenv("MAILKITE_WEBHOOK_SECRET");
AnthropicClient claude = AnthropicOkHttpClient.fromEnv(); // reads ANTHROPIC_API_KEY
// Spring Boot — take the RAW body so the signature matches the exact bytes.
@PostMapping("/hooks/mailkite")
public ResponseEntity<Void> mailkite(
@RequestHeader("x-mailkite-signature") String signature,
@RequestBody byte[] rawBody) throws Exception {
String body = new String(rawBody, StandardCharsets.UTF_8);
// 1. Verify the signature with the SDK before trusting anything.
if (!mk.verifyWebhook(signature, body, secret)) {
return ResponseEntity.status(401).build();
}
Map<String, Object> event = new ObjectMapper().readValue(body, Map.class);
if (!"email.received".equals(event.get("type"))) return ResponseEntity.ok().build();
var from = (Map<String, Object>) event.get("from");
var to = ((List<Map<String, Object>>) event.get("to")).get(0);
try {
// 2. Ask Claude (Anthropic SDK Messages API) for a reply.
Message aiResp = claude.messages().create(MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_4_8)
.maxTokens(1024L)
.addUserMessage("New email from " + from.get("address") + ": "
+ event.get("subject") + "\n\n" + event.get("text"))
.build());
String reply = aiResp.content().stream()
.flatMap(b -> b.text().stream()).map(t -> t.text())
.findFirst().orElse("");
// 3. Send Claude's reply back in-thread with the MailKite SDK.
mk.send(Map.of(
"from", to.get("address"), // the agent's own address
"to", from.get("address"),
"subject", "Re: " + event.get("subject"),
"text", reply,
"inReplyTo", event.get("threadId") // keeps it in the same conversation
));
return ResponseEntity.ok().build();
} catch (Exception e) {
// Non-2xx → MailKite redelivers, retrying with exponential backoff.
return ResponseEntity.status(500).build();
}
}mk := mailkite.New(os.Getenv("MAILKITE_API_KEY"))
secret := os.Getenv("MAILKITE_WEBHOOK_SECRET")
claude := anthropic.NewClient() // reads ANTHROPIC_API_KEY
// net/http — read the RAW body so the signature matches the exact bytes.
http.HandleFunc("/hooks/mailkite", func(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
// 1. Verify the signature with the SDK before trusting anything.
if !mailkite.VerifyWebhook(r.Header.Get("x-mailkite-signature"), string(rawBody), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event map[string]any
json.Unmarshal(rawBody, &event)
if event["type"] != "email.received" {
w.WriteHeader(http.StatusOK)
return
}
from := event["from"].(map[string]any)
to := event["to"].([]any)[0].(map[string]any)
// 2. Ask Claude (Anthropic SDK Messages API) for a reply.
aiResp, err := claude.Messages.New(r.Context(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus4_8,
MaxTokens: 1024,
Messages: []anthropic.MessageParam{anthropic.NewUserMessage(
anthropic.NewTextBlock(fmt.Sprintf("New email from %s: %s\n\n%s",
from["address"], event["subject"], event["text"]))),
},
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // MailKite retries with backoff
return
}
var reply string
for _, block := range aiResp.Content {
if t, ok := block.AsAny().(anthropic.TextBlock); ok {
reply = t.Text
break
}
}
// 3. Send Claude's reply back in-thread with the MailKite SDK.
if _, err := mk.Send(mailkite.Message{
From: to["address"].(string), // the agent's own address
To: from["address"].(string),
Subject: "Re: " + event["subject"].(string),
Text: reply,
InReplyTo: event["threadId"].(string), // keeps it in the same conversation
}); err != nil {
w.WriteHeader(http.StatusInternalServerError) // MailKite retries with backoff
return
}
w.WriteHeader(http.StatusOK)
})require "mailkite"
require "anthropic"
mk = Mailkite::Client.new(ENV["MAILKITE_API_KEY"])
claude = Anthropic::Client.new # reads ANTHROPIC_API_KEY
secret = ENV["MAILKITE_WEBHOOK_SECRET"]
# Sinatra — read the RAW body so the signature matches the exact bytes.
post "/hooks/mailkite" do
raw_body = request.body.read
signature = request.env["HTTP_X_MAILKITE_SIGNATURE"]
# 1. Verify the signature with the SDK before trusting anything.
halt 401 unless Mailkite.verify_webhook(signature, raw_body, secret)
event = JSON.parse(raw_body)
next status 200 unless event["type"] == "email.received"
begin
# 2. Ask Claude (Anthropic SDK Messages API) for a reply.
ai = claude.messages.create(
model: :"claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content:
"New email from #{event['from']['address']}: " \
"#{event['subject']}\n\n#{event['text']}" }]
)
reply = ai.content.find { |b| b.type == :text }&.text.to_s
# 3. Send Claude's reply back in-thread with the MailKite SDK.
mk.send(
"from" => event["to"][0]["address"], # the agent's own address
"to" => event["from"]["address"],
"subject" => "Re: #{event['subject']}",
"text" => reply,
"inReplyTo" => event["threadId"] # keeps it in the same conversation
)
status 200
rescue => e
# Non-2xx → MailKite redelivers, retrying with exponential backoff.
warn e.message
status 500
end
end# Verification is an HMAC-SHA256 check your handler runs over the raw body
# (see Webhook security). Once verified, it runs your agent and POSTs the
# reply to the Send API — in-thread via inReplyTo. If the handler fails,
# return a non-2xx and MailKite redelivers with exponential backoff:
curl https://api.mailkite.dev/v1/send \
-H "Authorization: Bearer $MAILKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "support@myapp.ai",
"to": "ada@example.com",
"subject": "Re: Can'\''t update my card",
"text": "<the agent reply>",
"inReplyTo": "<a1b2c3@mail.example.com>"
}' What it costs
The built-in inbox agent runs on Claude, and pricing is simple and pay-as-you-go: $0.10 per AI action — one agent run over one inbound email — the same on every paid plan, with no bundles or token math. Prefer to run on your own key? Add your Anthropic API key in the dashboard and AI is free — we never bill it. Bringing your own agent over a plain webhook is free too; you just pay your own model provider. See Pricing for the full breakdown.
Next: Agent inboxes & MCP for the bring-your-own pattern, or Inbound webhooks for the raw event your agent reasons over.