Build an email agent on the Cloudflare Agents SDK

An email agent needs somewhere to live that survives between messages, because the conversation outlasts any single run. A Durable Object is a good fit: one instance per agent, its own state, and no server to keep warm. This is a working shape you can build on, using the [Cloudflare Agents SDK](/with/cloudflare-agents) and an [MCP email server](/blog/mcp-email-server).

4 min read

An agent running as a Durable Object, connected to an MCP email server
One instance per agent, holding the conversation between messages.

What you need first

An address the agent owns and a key scoped to it. Either create one from the dashboard following the quickstart, or let the agent provision its own if it is being spawned at runtime. Put the key in a Worker secret, never in code.

Shell
npm install agents @mcpmailer/sdk
npx wrangler secret put MCPMAILER_API_KEY

Connect the tool server

The Agents SDK has an MCP client built in, so the entire integration is one call in onStart. After it resolves, the agent holds the email tools alongside whatever else it can do.

TypeScript
import { Agent } from 'agents';

export class SupportAgent extends Agent<Env> {
  async onStart() {
    await this.addMcpServer('mcpmailer', 'https://connect.mcpmailer.com/mcp', {
      transport: {
        type: 'streamable-http',
        headers: { Authorization: `Bearer ${this.env.MCPMAILER_API_KEY}` }
      }
    });
  }
}

The key is scoped to one identity, so this agent cannot read another agent's mail or send from an address it does not own. That property is what makes running a fleet of these no more frightening than running one, as argued in running several email agents in one workspace.

React to inbound mail

Mail arriving should start a run, which means a webhook rather than a schedule. Register an endpoint for message.received, verify the signature, acknowledge immediately, and do the work afterwards.

TypeScript
export default {
  async fetch(request: Request, env: Env) {
    const raw = await request.text();
    if (!(await verify(raw, request.headers.get('x-mcpmailer-signature'), env.WEBHOOK_SECRET))) {
      return new Response('bad signature', { status: 401 });
    }
    const event = JSON.parse(raw);
    const agent = await getAgentByName(env.SupportAgent, event.identity);

    // Return fast; the model call happens inside the Durable Object.
    agent.handle(event).catch(() => {});
    return new Response('ok');
  }
};

Two mistakes to avoid here, both covered in webhooks or polling. Do not run a model call inline in the fetch handler: deliveries time out, get retried, and you answer twice. And verify the signature before parsing, or your endpoint is a way for anyone to start runs on your account.

Handle a message

The handler is short, because the interesting decisions are prompt-level rather than code-level. Read the thread, look up the sender, decide, act.

TypeScript
async handle(event: { message_id: string }) {
  if (this.sql`SELECT 1 FROM handled WHERE id = ${event.message_id}`.length) return;

  const thread = await this.callTool('mcpmailer', 'get_thread', { message_id: event.message_id });
  const sender = await this.callTool('mcpmailer', 'lookup_contact', { email: thread.from });

  const decision = await this.decide(thread, sender);   // your model call
  if (decision.action === 'escalate') return this.escalate(thread, decision);

  await this.callTool('mcpmailer', 'reply_all', {
    message_id: event.message_id,
    body: decision.body
  });
  this.sql`INSERT INTO handled (id) VALUES (${event.message_id})`;
}

The handled check is the whole of idempotency and it is not optional: webhook deliveries retry by design, so a handler without it will eventually send two replies to the same message. Record the id before the reply if you would rather risk a missed answer than a duplicate one, and after if you would rather risk the reverse. Pick deliberately.

A webhook verified, acknowledged, and handed to the Durable Object
Verify, acknowledge, then work. The model call never runs inside the request.

Hold a conversation across days

When the agent is mid-conversation and needs an answer, it does not need a scheduler. wait_for_reply parks the thread server-side and returns when the reply lands or the timeout expires.

TypeScript
await this.callTool('mcpmailer', 'send_email', { to: [lead], subject, body });
const reply = await this.callTool('mcpmailer', 'wait_for_reply', {
  thread_id: threadId,
  timeout_seconds: 172_800
});
if (!reply) return this.followUpOnce(threadId);   // silence is information

For waits longer than a run can reasonably hold, end the run and let the next webhook resume it. The Durable Object still holds the state, so the resume is cheap. Timeout choices and stopping rules are in long-running email conversations.

Handle refusals like a grown-up

Sends can be refused, and the reason tells the agent what to do. daily_send_quota_exhausted and monthly_send_quota_exhausted carry a reset time, monthly_spend_cap_reached means the workspace owner set a ceiling, recipient_suppressed lists addresses that already bounced or complained, and a 429 carries retry-after in seconds.

TypeScript
const res = await this.callTool('mcpmailer', 'send_email', payload);
if (res.status === 'rejected') {
  if (res.reason === 'recipient_suppressed') return this.escalate(thread, res);
  return this.schedule(res.retryAfter ?? 3600, 'retrySend', payload);   // wait, do not hammer
}

Retrying a refusal immediately is how a limit that exists to protect your domain becomes a reputation problem.

Develop it locally

Webhooks need a public URL. Each identity has a stable hostname that forwards to wherever you are actually running, through a connection your process holds open, which beats a fresh tunnel URL on every restart.

TypeScript
import { connect } from '@mcpmailer/sdk';
await connect({ handle: 'scout', target: 'http://localhost:8787' });

Point the webhook at https://scout.mcpmailerwire.com and it keeps working across restarts and network changes. See tunnels, and the wider testing setup in test inboxes for agent development.

Before it talks to anyone real

  1. Give it a system prompt with explicit escalation triggers.
  2. Put the identity in whitelist mode until you trust it.
  3. Verify authentication on the sending domain.
  4. Replay a golden thread suite against a test identity.
  5. Read every thread in week one.

Questions

Can I build an email agent on Cloudflare Workers?
Yes. A Durable Object per agent holds state between messages, the Agents SDK connects to the MCP email server with one addMcpServer call, and a Worker fetch handler receives webhooks.
Why a Durable Object rather than a plain Worker?
Because email conversations outlive requests. One instance per agent gives you a place to keep the handled-message set, the conversation state, and any scheduled follow-up.
How do I avoid replying twice?
Record handled message ids and check before acting. Webhook deliveries retry by design, so a handler without an idempotency check will eventually double-send.
How does the agent wait days for a reply?
wait_for_reply blocks server-side and returns when the reply arrives or the timeout expires. For very long waits, end the run and let the next webhook resume it.
Does this work with other frameworks?
Yes. The MCP endpoint and the REST API are framework-agnostic; only the connection block changes. See Claude, ChatGPT, the Vercel AI SDK, LangChain, the OpenAI Agents SDK, n8n, OpenClaw, and Hermes.
Where should the API key live?
In a Worker secret, injected as an environment variable. Never in code, and never in a prompt.

Give your agent an address it can answer from.

Create an inbox