---
title: Build an email agent on the Cloudflare Agents SDK
metaTitle: Build an AI email agent with the Cloudflare Agents SDK
description: A working email agent as a Durable Object: connect the MCP server, react to inbound mail through a webhook, hold a thread across days, and keep the whole thing idempotent.
date: 2026-07-27
author: MCPmailer
tags: Tutorials
---

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).

![An agent running as a Durable Object, connected to an MCP email server](/blog/build-email-agent-agents-sdk/hero.webp "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](/docs/quickstart), or let the agent [provision its own](/blog/agent-self-signup-email) if it is being spawned at runtime. Put the key in a Worker secret, never in code.

```bash
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.

```ts
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](/blog/multi-agent-email-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.

```ts
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](/blog/webhooks-vs-polling-agent-email). 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.

```ts
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](/blog/build-email-agent-agents-sdk/flow.webp "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.

```ts
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](/blog/wait-for-reply-long-running-agents).

## 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.

```ts
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.

```ts
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](/docs/tunnels), and the wider testing setup in [test inboxes for agent development](/blog/test-email-addresses-for-agents).

## Before it talks to anyone real

1. Give it a [system prompt](/blog/email-agent-system-prompt) with explicit escalation triggers.
2. Put the identity in whitelist mode until you trust it.
3. Verify [authentication on the sending domain](/blog/dmarc-record-generator-agents).
4. Replay a [golden thread suite](/blog/evaluating-ai-email-agents) 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](/with/claude), [ChatGPT](/with/gpt), the [Vercel AI SDK](/with/vercel-ai-sdk), [LangChain](/with/langchain), the [OpenAI Agents SDK](/with/openai-agents), [n8n](/with/n8n), [OpenClaw](/with/openclaw), and [Hermes](/with/hermes).

### Where should the API key live?

In a Worker secret, injected as an environment variable. Never in code, and never in a prompt.

## Related

- [Email for Cloudflare Agents SDK agents](/with/cloudflare-agents)
- [Webhooks or polling for agent email](/blog/webhooks-vs-polling-agent-email)
- [Long-running email conversations](/blog/wait-for-reply-long-running-agents)
- [SDKs](/docs/sdks)
