An ayjnt agent becomes email-capable the moment you add an onEmail(message) method. The framework wires Cloudflare's Email Routing, generates the worker's email() handler, and adds a send_email binding so replyToEmail just works.
The trigger
Define onEmail(message) on the agent. That's the
source-level signal ayjnt scans for. From there the framework
fills in every wiring detail.
import { Agent } from "agents";
import type { AgentEmail } from "agents/email";
import { replyToEmail } from "agents/email";
import PostalMime from "postal-mime";
import type { GeneratedEnv } from "@ayjnt/env";
type State = { tickets: Array<{ from: string; subject: string }> };
export default class SupportAgent extends Agent<GeneratedEnv, State> {
override initialState: State = { tickets: [] };
async onEmail(message: AgentEmail): Promise<void> {
const raw = await message.getRaw();
const parsed = await PostalMime.parse(raw);
this.setState({
tickets: [
...this.state.tickets,
{ from: message.from, subject: parsed.subject ?? "(no subject)" },
],
});
// Reply, using the framework-provided helper.
await replyToEmail(message, this.env.EMAIL, {
from: "support@example.com",
subject: `Re: ${parsed.subject ?? "your message"}`,
text: "Got it — we'll follow up shortly.",
});
}
}What ayjnt wires up
Detecting onEmail on any agent flips an email feature flag. The framework then:
-
Adds a
send_emailbinding towrangler.jsonc:"send_email": [{ "name": "EMAIL", "remote": true }] -
Generates an
email(message, env, ctx)entry point in.ayjnt/dist/entry.tsthat resolves the right agent via address-based routing and forwards the message to itsonEmailmethod. -
Adds
EMAIL: SendEmailtoGeneratedEnvsothis.env.EMAILis typed and autocompletes.
Default address resolver
ayjnt's default resolver uses createAddressBasedEmailResolver with one route per
email-capable agent. The pattern matches the local part of the
recipient address against the agent's route path. So an agent
at /support answers messages addressed to support@<your-domain>.
import { routeAgentEmail, createAddressBasedEmailResolver } from "agents";
import SupportAgent from "../../agents/support/agent.ts";
const defaultEmailResolver = createAddressBasedEmailResolver([
{ localPart: "support", agent: "SupportAgent" },
]);
export default {
async email(message, env, ctx) {
return routeAgentEmail(message, env, ctx, defaultEmailResolver);
},
// ... fetch handler, etc.
};Custom resolver
Drop an email.ts file at the project root to
override the default resolver. The file's default export should
be a function that returns an agent stub for a given message:
import type { AgentEmail } from "agents/email";
import type { GeneratedEnv } from "@ayjnt/env";
export default async function resolveAgent(
message: AgentEmail,
env: GeneratedEnv,
) {
// Inspect message.to, headers, subject, body — whatever you want.
if (message.to.endsWith("@vip.example.com")) {
return env.VIP_SUPPORT_AGENT.get(env.VIP_SUPPORT_AGENT.idFromName("default"));
}
// Fall through to the default resolver:
return null;
}
When this file is present, the generated entry.ts
imports it and routes through your function first, falling back
to the address-based resolver if it returns null.
Receiving the raw message
Cloudflare delivers the message as a ForwardableEmailMessage; the framework wraps it
into the SDK's AgentEmail type. The most useful
API on it:
-
message.from,message.to— string addresses. -
message.headers— aHeadersinstance for the raw MIME headers. -
message.getRaw()→Promise<ArrayBuffer>— the raw MIME bytes. Parse withpostal-mimefor structured body/subject/attachments.
Replying
replyToEmail(message, env.EMAIL, opts) is the
SDK helper for replies. It threads the right In-Reply-To
and References headers, picks the right from, and uses the send_email binding
to post the outbound message. Don't use the raw env.EMAIL.send(...) for replies — the helper
handles the conventions a typical email client expects.
Cloudflare setup
The agent only fires when Cloudflare delivers a message to your worker. That means:
- Email Routing must be enabled on the destination domain (Cloudflare dashboard → Email → Email Routing).
- A routing rule must forward the right address(es) to the worker. ayjnt doesn't create these rules — they live in the Cloudflare dashboard.
- For sending, the
fromaddress must be verified in Email Routing's "Destination addresses" — Cloudflare won't send mail from an unverified address.
Detection rules
// ✓ detected
async onEmail(message: AgentEmail): Promise<void> { ... }
// ✓ detected (no async keyword)
onEmail(message: AgentEmail) { ... }
// ✗ NOT detected — different method name
async handleEmail(message: AgentEmail) { ... }
// ✗ NOT detected — declared via field, not method
onEmail = async (message: AgentEmail) => { ... };The detection is source-level regex; keep the method declaration plain.
Co-located UI
Real inbound email can't easily be exercised in bun run dev,
so the example's agents/support/app.tsx includes
a "simulate inbound" form that calls a @callable simulateInboundEmail(from, subject) on the
agent. It mirrors the same setState write onEmail performs in production — useful for
driving the dashboard locally without configuring Email
Routing. Once deployed, real traffic flows through onEmail and the UI updates the same way.
Reference
- Cloudflare's Email Agents docs
- Email Routing setup
-
examples/email-bot— full SupportAgent with postal-mime parsing, replies, and the simulator UI.