Setting Up Email
This guide walks the reader from a blank email mailbox to a Beach-driven inbox where the orchestrator reads each incoming message, runs through its tool loop, and replies with a properly threaded RFC 5322 email — subject, In-Reply-To, References — all correct.
The wiring uses three packages:
@cool-ai/beach-channel-email— IMAP polling inbound and SMTP outbound.@cool-ai/beach-format-email— turns the orchestrator's settled parts into a MIME-shaped artefact (HTML and plain text).@cool-ai/beach-core— theManifestthat gates "send only when ready".
A Beach orchestrator is assumed to be wired already. See Getting Started for the basics; this guide concerns the email-specific pieces.
Install
npm install \
@cool-ai/beach-channel-email \
@cool-ai/beach-format-email \
@cool-ai/beach-core \
imapflow mailparser nodemailer
imapflow, mailparser, and nodemailer are peer dependencies of @cool-ai/beach-channel-email. Install them at whatever versions the application prefers.
Pre-flight — what you need
- An IMAP-accessible mailbox (Gmail with an app password, Fastmail, an in-house provider — anything
imapflowcan reach). - An SMTP server, ideally the same provider, but it can be different.
- A way to persist the last-processed UID across server restarts. Without this, every restart re-processes old mail. Redis is the conventional choice; for a prototype, a JSON file on disk works.
Step 1 — Configure the EmailChannel
import { EmailChannel } from '@cool-ai/beach-channel-email';
import Redis from 'ioredis';
const redis = new Redis();
const emailChannel = new EmailChannel({
id: 'email',
imap: {
host: 'imap.example.com',
port: 993,
secure: true,
auth: { user: 'agent@example.com', pass: process.env.IMAP_PASS! },
mailbox: 'INBOX',
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false,
auth: { user: 'agent@example.com', pass: process.env.SMTP_PASS! },
from: 'agent@example.com',
},
pollIntervalMs: 60_000,
uidState: {
get: async (mailbox) =>
redis.get(`beach-email:lastUid:${mailbox}`)
.then((v) => (v ? Number(v) : undefined)),
set: async (mailbox, uid) => {
await redis.set(`beach-email:lastUid:${mailbox}`, String(uid));
},
},
onInbound: async (missive) => {
// We will fill this in below.
},
});
The uidState callbacks are how the channel persists what it has already processed. On restart, the channel asks get('INBOX') for the last UID and resumes from there.
Step 2 — Wire the formatter
@cool-ai/beach-format-email produces an EmailArtifact from a settled Delivery Manifest's slots. It takes a ComposerActor — an edge-positioned LLM specialist that writes the connective prose around the structured content the orchestrator emitted.
import { EmailFormatter, buildDefaultEmailComposerPrompt } from '@cool-ai/beach-format-email';
import { ComposerActor } from '@cool-ai/beach-format';
import { callActor } from '@cool-ai/beach-llm';
const composer = new ComposerActor({
promptTemplate: buildDefaultEmailComposerPrompt({
brandSignOff: 'Best regards,\nThe Travel Concierge Team',
toneGuidance: 'Warm, professional, concise. Match the recipient\'s language.',
}),
actorConfig: {
id: 'email-composer',
model: 'claude-haiku-4-5',
systemPrompt: '', // overridden per compose() call
tools: [],
},
callActor,
callActorDependencies: {
provider,
registry: tools,
slotKey: 'email.compose',
sessionId: 'composer',
},
});
const formatter = new EmailFormatter({
composer,
from: 'agent@example.com',
});
The Composer is allowed to know it is writing email. It is an edge-positioned actor, not interior. Its job is salutations, lead-ins, sign-offs, and inline transitions. It never sees structured content directly — prices, dates, item details — because those come from deterministic Content Renderers and are substituted into the prose by the formatter.
Step 3 — Open a Delivery Manifest per inbound
When a message arrives, the channel calls onInbound with a parsed Missive. Three things happen in order: the inbound is persisted for audit; a Delivery Manifest is opened, keyed to the inbound message id; the orchestrator runs, main_reply is filled from its settled parts, and the manifest's onComplete formats and sends.
import { Manifest, ManifestRegistry } from '@cool-ai/beach-core';
import { randomUUID } from 'node:crypto';
const manifestRegistry = new ManifestRegistry();
emailChannel.onInbound = async (inboundMissive) => {
// 1. Persist
await missiveStore.write(inboundMissive);
// 2. Open the Delivery Manifest
const manifestId = `email-delivery:${inboundMissive.id}`;
const manifest = new Manifest({
id: manifestId,
expected: ['main_reply'],
timeoutMs: 5 * 60_000, // 5-minute SLA on the reply
onComplete: async (filled) => {
const artifact = await formatter.format({
inbound: inboundMissive,
filledSlots: filled,
});
await emailChannel.send({
to: artifact.to,
subject: artifact.subject,
text: artifact.text,
html: artifact.html,
inReplyTo: artifact.inReplyTo,
references: artifact.references,
});
},
onTimeout: async (filled) => {
console.warn(`[email] manifest ${manifestId} timed out`);
// Optional: dispatch a fallback ("we are looking into your message and will reply soon")
},
});
manifestRegistry.register(manifest);
// 3. Resolve the conversation, run the orchestrator, fill the manifest
const sessionId = await resolveSession(inboundMissive);
const turnId = randomUUID();
const settled = await sessionManager.runTurn({
sessionId,
turnId,
slotKey: 'concierge.reply',
actorId: 'concierge',
actorConfig,
provider,
registry: tools,
inboundMessage: {
role: 'user',
content: inboundMissive.parts[0].text ?? '',
},
});
manifestRegistry.deliver(manifestId, 'main_reply', { parts: settled.parts });
};
A few details worth pointing at:
- The manifest is keyed on the inbound message id, not the session id. Two messages from the same correspondent open two distinct manifests.
turnIdis generated up front so external callers —cancelTurn()from a "stop" handler, for instance — can target the in-flight turn before it starts.onTimeoutis real ops protection. A turn that hangs on a stuck specialist must not block the email outbound forever. Five minutes is generous; tune to the application's SLAs.
Step 4 — Resolve the session
Email threading is the rule for session continuity. A reply to a message belongs to the same session as its parent. RFC 5322's In-Reply-To and References headers carry the chain; imapflow parses them and mailparser exposes them on the Missive.
async function resolveSession(missive: Missive): Promise<string> {
// 1. Try In-Reply-To — if present, look up the session that produced the original
if (missive.inReplyTo) {
const original = await missiveStore.findByExternalId(missive.inReplyTo);
if (original?.sessionId) return original.sessionId;
}
// 2. Try the References chain — older messages in the thread
if (missive.references) {
for (const ref of missive.references) {
const ancestor = await missiveStore.findByExternalId(ref);
if (ancestor?.sessionId) return ancestor.sessionId;
}
}
// 3. New thread — open a new session
return openSession({ channelId: 'email', actors: ['concierge'] }).id;
}
Variations are common. Some applications scope the session by sender address (origin.address) rather than thread; some thread by Subject after stripping Re: and Fwd: prefixes. Pick whatever matches how the users actually behave. Beach does not impose a model.
Step 5 — Start the channel
await emailChannel.start();
The channel begins polling IMAP at the configured interval, parses new messages, and calls onInbound for each.
Step 6 — Send a test message
Send an email to agent@example.com from a personal address. Within pollIntervalMs, the orchestrator runs and the reply lands in the inbox with:
Subject: Re: <original subject>In-Reply-To: <original Message-ID>References: <original chain> <original Message-ID>- An HTML and plain-text body composed by the Composer, with structured content rendered by the deterministic Content Renderers.
Reply to the agent's message and check that:
- The reply lands in the original session (continuity by threading).
- The orchestrator sees the new message as a follow-up to the conversation, not as a new query.
Common pitfalls
No uidState. Every restart will re-read the entire mailbox and reprocess everything — possibly replying to old messages from before the agent even existed. Always wire uidState to a durable store.
pollIntervalMs set too aggressively. Sixty seconds is the default for a reason. IMAP IDLE is more responsive but Beach does not yet support it; for sub-minute latency, use a webhook-style provider (SendGrid, Mailgun) and write a separate webhook inbound handler instead.
Reply lands without In-Reply-To. The formatter takes the inbound missive's origin.messageId as the parent. If the inbound parser failed to capture the Message-ID, the reply does not thread. Check imapflow's parsed envelope and verify the inbound logging.
Reply body has placeholder tokens visible — strings such as <a2ui-surface:itinerary> or <part:response> showing through. The Composer emitted prose with placeholders, but a Content Renderer was not registered for one of the part types. The formatter logs the unsubstituted token; register the missing renderer and rerun.
Long-running turns time out the Delivery Manifest. If the orchestrator dispatches research that takes six minutes and the manifest's timeout is five, the email never sends. Either raise the manifest timeout to cover the worst case, or send an interim "we are looking into this and will reply within X" email synchronously at inbound time and let the full reply come later through a separate outbound flow.
Bcc'd or forwarded mail loops. An auto-replier responding to an auto-replier responding to an auto-replier is the canonical email-bot disaster. Add a Precedence: auto_reply header to outbound mail and check inbound headers — drop messages with Precedence: list or Precedence: auto_reply rather than replying. The channel exposes raw headers so the consumer can implement the filter.
What this leaves you with
A Beach application that reads its own email and replies. Same orchestrator, same tool loop, same audit trail as the chat path; only the inbound and outbound shape differs. Switch to chat tomorrow and the orchestrator does not change.
Related
- Getting Started — the basic chat pipeline this guide extends.
- Manifests — Delivery Manifests in detail.
- Streaming vs batched edges — why email needs a Delivery Manifest and chat does not.
- Reference: envelope — the part shape the formatter consumes.