CarlyEmail docs

Cloudflare Agents

Route signed inbound mail to a durable Agent instance, keyed by inbox or thread.

Cloudflare Agents has a native Email channel for addresses routed through Cloudflare Email Service. Use that when the mailbox itself should live entirely inside Cloudflare. Use CarlyEmail when you want the same onEmail-style model with programmatic inbox creation, CarlyEmail custom domains, hosted MCP tools, or portability to runtimes outside Cloudflare.

The bridge keeps Cloudflare's two useful properties: an incoming-email lifecycle hook, and a stable identifier that routes later messages back to the same durable agent. CarlyEmail supplies the inboxes and signed delivery event; a Worker supplies the durable execution model.

Webhooks are the right transport for this pattern. They wake the Worker, retry failures, and do not require a Durable Object to stay attached to a socket. Use the WebSocket stream for an active console or long-running agent process instead.

The JavaScript SDK's carlyemail/webhooks export uses Web Crypto only. It runs unchanged in Workers, verifies the raw body and replay window, and exposes an onEmail(event) callback.

Install

npm install carlyemail agents

Store these as Worker secrets:

npx wrangler secret put CARLYEMAIL_API_KEY
npx wrangler secret put CARLYEMAIL_WEBHOOK_SECRET

One durable agent per inbox

TypeScript

import { CarlyEmail } from "carlyemail";
import {
  createEmailHandler,
  type CarlyEmailEvent,
} from "carlyemail/webhooks";
import { Agent, callable, getAgentByName } from "agents";

export class EmailAgent extends Agent<Env> {
  @callable()
  async onCarlyEmail(event: CarlyEmailEvent) {
    const message = event.message!;
    const carly = new CarlyEmail({ apiKey: this.env.CARLYEMAIL_API_KEY });

    // Put your model or workflow here. This simple version proves the channel.
    await carly.messages.reply(
      message.inbox_id as string,
      message.message_id as string,
      { text: "Thanks — I received this and will follow up." },
    );
  }
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const handleEmail = createEmailHandler({
      secret: env.CARLYEMAIL_WEBHOOK_SECRET,
      async onEmail(event) {
        const inboxId = event.message!.inbox_id as string;
        const agent = await getAgentByName(env.EmailAgent, inboxId);
        await agent.onCarlyEmail(event);
      },
    });
    return handleEmail(request, env, ctx);
  },
} satisfies ExportedHandler<Env>;

Register the Worker's URL as a CarlyEmail webhook for message.received. Using inbox_id as the Agent name gives each inbox isolated state and ensures every later message returns to the same instance. Use thread_id instead when each email conversation should have its own durable state.

npx carlyemail webhook https://your-worker.example/hooks/carlyemail \
  --events message.received

Delivery and retries

Only message.received reaches onEmail. Spam, blocked and unauthenticated mail arrive as their own event types and the helper drops them, so a forged sender never reaches the agent. Pass inbox to drop the inbox's own mail and allowFrom to limit who can give the agent work.

The helper returns 401 for a missing, stale, or invalid signature. It answers a verified delivery with 202 and runs onEmail through ctx.waitUntil, so a long model turn does not time the delivery out and get it retried into a second reply. Set background: false to run onEmail first and let an exception become a 5xx, so CarlyEmail retries the event instead.

Delivery is at least once. The helper ignores a redelivery it has seen in the same isolate; persist event_id before side effects when duplicate replies would be harmful. Cloudflare Agents are single-threaded per instance, which removes concurrent state races; it does not by itself deduplicate a delivery retry.

Warning

An email body is untrusted input, even when the webhook signature is valid—the signature proves CarlyEmail delivered it, not that the sender's instructions are safe. Scope the CarlyEmail key to one inbox and the minimum permissions the agent needs.

No Worker required

For a Python application, create_email_router gives FastAPI the same verified callback. For an agent client that already understands MCP, connect it directly to https://api.carlyemail.com/mcp.

See also