A copy-pasteable recipe for demo@yourdomain.com — an email address that joins scheduling threads, proposes meeting times, follows the back-and-forth, and emails an .ics calendar invite once everyone confirms. The whole thing runs on a single Cloudflare Worker plus a Claude Code session driving the conversation in real time.
This gist is meant to be dropped into a new Claude Code session as build context. By the end you will have:
demo@yourdomain.comaccepting inbound mail through Cloudflare Email Routing- A Worker that parses MIME, persists each thread message to D1, and exposes a small HTTP API
- A local poller that streams new messages into the session via the
Monitortool - A live agent loop that reads the thread, replies in-thread with proper threading headers, and attaches an RFC 5545
.icson the final confirmation
Estimated build time, cold start to working demo: about an hour.
- Alice emails Bob, CC'ing
demo@yourdomain.com: "Looping in our scheduling assistant — can you two find 30 minutes next week to talk?" - Bot replies in the same thread: "Happy to help. Three options, all PT — Tue May 12 at 10 AM PT, Wed May 13 at 2 PM PT, Thu May 14 at 11 AM PT. Which works for everyone?"
- Bob replies "Wed works for me." Alice replies "Yes, Wed is good."
- Bot replies in-thread: "Locked in: Wed May 13 at 2:00 PM PT. Calendar invite attached." — with
invite.icsattached. - Both inboxes show "Add to Calendar" — event organized by
demo@yourdomain.com.
(loop kill-switch:
ignore from === demo@)
│
Inbound email ──► CF Email Routing ──► Worker.email() ──► D1 events
│
POST /send ◄──────┤
GET /events ────┤
POST /events/:id/ack
│
▼
Local poll-events.sh
│ one JSON line per event
▼
Claude Code session via Monitor
(read thread → decide → curl /send)
Two architectural decisions worth committing to before you start:
- Outbound goes through the Worker, not the CF Email Sending REST API. The Worker exposes
POST /sendand uses its nativeEMAILbinding internally. This avoids creating a separate Cloudflare API token — auth is one shared bearer secret, set once viawrangler secret put. - Polling hits the Worker over HTTP, not
wrangler d1 execute. Acurlper tick is ~100ms; shelling out to wrangler is 1–2s of cold start. Same data, ~10× faster.
These are one-time, dashboard-or-CLI steps. Most are already done if you've used Email Service before:
- Email Sending enabled on your domain.
bunx wrangler email sending list # is your domain listed? bunx wrangler email sending enable yourdomain.com # if not, run this
- Email Routing enabled on your domain.
bunx wrangler email routing list bunx wrangler email routing enable yourdomain.com # if needed
- Workers Paid plan active (required for Email Sending).
wranglerlogged in (bunx wrangler whoami).
scheduling-bot/
├── package.json
├── tsconfig.json
├── wrangler.jsonc
├── schema.sql
├── poll-events.sh # local poller, executed by Monitor
├── src/index.ts # the Worker
├── .env # gitignored: DEMO_WORKER_URL, DEMO_BEARER_TOKEN
└── .gitignore
{
"name": "demo-inbound",
"private": true,
"scripts": { "deploy": "wrangler deploy", "types": "wrangler types" },
"dependencies": { "postal-mime": "^2.4.4" },
"devDependencies": {
"@cloudflare/workers-types": "^4.20260101.0",
"typescript": "^5.6.0",
"wrangler": "^4.90.0"
}
}{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler",
"lib": ["ES2022"], "types": ["@cloudflare/workers-types"],
"strict": true, "skipLibCheck": true, "esModuleInterop": true,
"isolatedModules": true, "noEmit": true, "resolveJsonModule": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
received_at TEXT NOT NULL,
payload TEXT NOT NULL, -- JSON of the parsed email
processed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_unprocessed ON events (processed, received_at);import PostalMime, { type Address } from "postal-mime";
interface Env {
DB: D1Database;
EMAIL: SendEmail;
BEARER_TOKEN: string;
DEMO_ADDRESS: string;
}
interface SendBody {
to: string[];
cc?: string[];
subject: string;
text: string;
html?: string;
in_reply_to?: string;
references?: string;
ics?: string;
}
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
const authed = (req: Request, env: Env) =>
(req.headers.get("authorization") ?? "") === `Bearer ${env.BEARER_TOKEN}`;
const pickAddrs = (xs?: Address[]) =>
(xs ?? []).filter(a => a.address).map(a => ({
name: a.name ?? "", address: (a.address as string).toLowerCase(),
}));
export default {
async email(message, env): Promise<void> {
// Loop kill-switch — never ingest our own outbound.
if ((message.from ?? "").toLowerCase() === env.DEMO_ADDRESS.toLowerCase()) return;
const rawBuf = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(rawBuf);
const id = crypto.randomUUID();
const payload = {
id,
received_at: new Date().toISOString(),
envelope_from: message.from,
envelope_to: message.to,
from: parsed.from
? { name: parsed.from.name ?? "", address: (parsed.from.address ?? "").toLowerCase() }
: { name: "", address: message.from.toLowerCase() },
to: pickAddrs(parsed.to),
cc: pickAddrs(parsed.cc),
subject: parsed.subject ?? "",
message_id: parsed.messageId ?? "",
in_reply_to: parsed.inReplyTo ?? "",
references: parsed.references ?? "",
body_text: parsed.text ?? "",
body_html: parsed.html ?? "",
};
await env.DB.prepare(
"INSERT INTO events (id, received_at, payload, processed) VALUES (?, ?, ?, 0)"
).bind(id, payload.received_at, JSON.stringify(payload)).run();
},
async fetch(req, env): Promise<Response> {
if (!authed(req, env)) return new Response("unauthorized", { status: 401 });
const url = new URL(req.url);
if (req.method === "GET" && url.pathname === "/events") {
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? "20")));
const rows = await env.DB.prepare(
"SELECT id, received_at, payload FROM events WHERE processed = 0 ORDER BY received_at ASC LIMIT ?"
).bind(limit).all<{ id: string; received_at: string; payload: string }>();
return json({ events: (rows.results ?? []).map(r => JSON.parse(r.payload)) });
}
const ackMatch = url.pathname.match(/^\/events\/([0-9a-fA-F-]{36})\/ack$/);
if (req.method === "POST" && ackMatch) {
await env.DB.prepare("UPDATE events SET processed = 1 WHERE id = ?").bind(ackMatch[1]).run();
return json({ ok: true });
}
if (req.method === "POST" && url.pathname === "/send") {
const body = (await req.json()) as SendBody;
const demo = env.DEMO_ADDRESS.toLowerCase();
const dedupe = (a: string[]) =>
Array.from(new Set(a.map(s => s.toLowerCase()).filter(s => s && s !== demo)));
const to = dedupe(body.to);
const cc = body.cc ? dedupe(body.cc) : [];
if (!to.length) return json({ ok: false, error: "no_recipients" }, 400);
const headers: Record<string, string> = {};
if (body.in_reply_to) headers["In-Reply-To"] = body.in_reply_to;
if (body.references) headers["References"] = body.references;
const attachments = body.ics ? [{
content: body.ics,
filename: "invite.ics",
type: "text/calendar; method=REQUEST; charset=UTF-8",
disposition: "attachment" as const,
}] : undefined;
try {
const res = await env.EMAIL.send({
to,
...(cc.length ? { cc } : {}),
from: { email: env.DEMO_ADDRESS, name: "Demo Bot" },
subject: body.subject,
text: body.text,
html: body.html ?? `<pre style="font-family:inherit;white-space:pre-wrap">${
body.text.replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]!))
}</pre>`,
...(Object.keys(headers).length ? { headers } : {}),
...(attachments ? { attachments } : {}),
});
return json({ ok: true, messageId: (res as { messageId?: string })?.messageId });
} catch (err) {
const e = err as { code?: string; message?: string };
return json({ ok: false, code: e.code, message: e.message }, 500);
}
}
return new Response("not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;bun install
# 1. Create D1 and copy the database_id into wrangler.jsonc
bunx wrangler d1 create calendar-bot
# 2. Apply the schema
bunx wrangler d1 execute calendar-bot --remote --file=schema.sql
# 3. Generate types & deploy
bunx wrangler types
bunx wrangler deploy
# → https://demo-inbound.<your-subdomain>.workers.dev
# 4. Mint and install the bearer secret
TOKEN=$(openssl rand -hex 32)
echo "$TOKEN" > .token # remember this; also goes in .env below
printf '%s' "$TOKEN" | bunx wrangler secret put BEARER_TOKEN
# 5. Wire the inbound route to the Worker
bunx wrangler email routing rules create yourdomain.com \
--enabled --name "demo-inbound bot" \
--match-type literal --match-field to --match-value "demo@yourdomain.com" \
--action-type worker --action-value "demo-inbound".env (gitignored):
DEMO_WORKER_URL=https://demo-inbound.<your-subdomain>.workers.dev
DEMO_BEARER_TOKEN=<the value of $TOKEN above>
DEMO_ADDRESS=demo@yourdomain.com
.gitignore:
.env
.dev.vars
node_modules/
.wrangler/
worker-configuration.d.ts
*.log
Quick smoke check before going further:
set -a && . ./.env && set +a
curl -sS -H "Authorization: Bearer $DEMO_BEARER_TOKEN" "$DEMO_WORKER_URL/events" # → {"events":[]}
curl -sS -o /dev/null -w "%{http_code}\n" "$DEMO_WORKER_URL/events" # → 401#!/usr/bin/env bash
set -u
cd "$(dirname "$0")"
[ -f .env ] && set -a && . ./.env && set +a
: "${DEMO_WORKER_URL:?}" "${DEMO_BEARER_TOKEN:?}"
INTERVAL="${POLL_INTERVAL:-5}"
echo "{\"poller\":\"started\",\"url\":\"$DEMO_WORKER_URL\",\"interval_s\":$INTERVAL,\"ts\":\"$(date -u +%FT%TZ)\"}"
while true; do
body=$(curl -sS --max-time 10 -H "Authorization: Bearer $DEMO_BEARER_TOKEN" \
"$DEMO_WORKER_URL/events?limit=20" 2>/dev/null) || { echo "poll error" >&2; sleep "$INTERVAL"; continue; }
count=$(printf '%s' "$body" | jq -r '.events | length' 2>/dev/null || echo 0)
if [ "$count" -gt 0 ] 2>/dev/null; then
while IFS= read -r ev; do
[ -z "$ev" ] && continue
printf '%s\n' "$ev" # ← becomes a Monitor notification
eid=$(printf '%s' "$ev" | jq -r '.id')
curl -sS --max-time 10 -X POST -H "Authorization: Bearer $DEMO_BEARER_TOKEN" \
"$DEMO_WORKER_URL/events/$eid/ack" >/dev/null 2>&1 || echo "ack failed for $eid" >&2
done < <(printf '%s' "$body" | jq -c '.events[]')
fi
sleep "$INTERVAL"
donechmod +x poll-events.shPrint first, then ack: a crash mid-loop re-delivers an event next tick. Make outbound idempotent (e.g., reuse the same .ics UID if you re-send the final invite).
Open a Claude Code session in this directory and tell it:
Start
Monitor ./poll-events.shwithpersistent: trueandtimeout_ms: 3600000. Each stdout line that arrives is one inbound email event JSON. The first line is a startup heartbeat with"poller":"started"— ignore it. For every real event, do this:
- Read the full payload. Notification text may be truncated. If
body_text,message_id,in_reply_to, orreferenceslook cut off, query D1 for the row by id:bunx wrangler d1 execute calendar-bot --remote --json --command "SELECT payload FROM events WHERE id = '<id>'"- Build the participant set. Union of
from.address+ everyto[].address+ everycc[].addressacross the thread, lowercased, minusdemo@yourdomain.com. The Worker auto-stripsdemo@from outboundto/cctoo — defense in depth.- Classify the message in context (re-read, don't track state):
- Initial ask (someone introduces the bot, or asks for time directly) → propose three concrete 30-min slots in the next 5 business days, all in PT, with "PT" written out in the prose.
- Counter-offer or conflict ("none of those work, how about Friday morning?") → propose three new options in the requested window.
- Partial confirmation (one yes, others silent) → re-state the leading time, prompt the holdouts. Re-include any participants who got dropped from a reply.
- All confirmed on a single time → send the final reply with an
.icsattached.- Auto-RSVP from Google Calendar (
Declined:/Accepted:subjects,<calendar-…@google.com>message-id) → ignore. Reschedule is out of scope.- Reply via
POST /sendto the Worker. Always set:
to: participant setsubject: originalsubject, withRe:prefixed if not alreadyin_reply_to: the latest event'smessage_id(angle-bracket-wrapped)references: previousreferencesvalue + a single space + the latestmessage_id(or justmessage_idifreferencesis empty)text: required. Sign as "— Demo Bot".- On the final confirmation only:
ics(RFC 5545,METHOD:REQUEST, UTCDTSTART/DTEND, ORGANIZER =demo@yourdomain.com, oneATTENDEEper participant).- One-line summary in chat of what you did per event.
Curl helpers (all auth-required):
set -a && . ./.env && set +a
curl -sS -H "Authorization: Bearer $DEMO_BEARER_TOKEN" "$DEMO_WORKER_URL/events" | jq
curl -sS -X POST "$DEMO_WORKER_URL/send" \
-H "Authorization: Bearer $DEMO_BEARER_TOKEN" -H "Content-Type: application/json" \
--data @reply.jsonEasiest path inside the session: a tiny uv run Python script that emits the JSON body. Times go out as UTC (clients render in the recipient's local TZ). For PT in May, that's UTC-7 (PDT).
# /// script
# requires-python = ">=3.11"
# ///
import json, uuid
from datetime import datetime, timezone
DEMO = "demo@yourdomain.com"
ATTENDEES = [("Alice", "alice@example.com"), ("Bob", "bob@example.com")]
# 2:00 PM PT on Wed May 13, 2026 = 21:00 UTC (PDT is UTC-7)
ics = "\r\n".join([
"BEGIN:VCALENDAR", "VERSION:2.0",
"PRODID:-//YourCo//Demo Bot//EN", "METHOD:REQUEST", "CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
f"UID:{uuid.uuid4()}@yourdomain.com",
f"DTSTAMP:{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}",
"DTSTART:20260513T210000Z", "DTEND:20260513T213000Z",
"SUMMARY:Alice <> Bob (intro)",
"DESCRIPTION:Scheduled by the Demo Bot. 30 minutes. Wed May 13 at 2:00 PM PT.",
f"ORGANIZER;CN=Demo Bot:mailto:{DEMO}",
*(f"ATTENDEE;CN={n};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{e}" for n, e in ATTENDEES),
"STATUS:CONFIRMED", "SEQUENCE:0", "TRANSP:OPAQUE",
"END:VEVENT", "END:VCALENDAR",
]) + "\r\n"
body = {
"to": [e for _, e in ATTENDEES],
"subject": "Re: i'd like to meet",
"in_reply_to": "<latest-message-id-from-the-thread>",
"references": "<root-id> <…> <latest-id>",
"text": "Locked in: Wed May 13 at 2:00 PM PT (30 min). Calendar invite attached.\n\n— Demo Bot",
"ics": ics,
}
print(json.dumps(body))Run it: uv run build_ics.py > reply.json && curl ... --data @reply.json.
Run end-to-end before you go on stage:
- Single-recipient happy path. Email
demo@yourdomain.comdirectly: "Find me 30 min next Tuesday afternoon." Bot proposes three times. Reply "the 2pm slot works." Bot replies in-thread with aninvite.icsattached. Gmail shows the calendar preview. - Multi-recipient with friction. You + a confederate, with
demo@CC'd. Bot proposes three. One says "none of those work, how about Friday morning?" Bot proposes Friday options. Both confirm. Bot sends the.icsto both. - Loop check. Confirm that the bot's outbound replies do not generate fresh events in
GET /events. (The WorkersEMAILbinding doesn't loop back through Email Routing; thefrom === demo@short-circuit inemail()is belt-and-suspenders.) - Threading check. Open the thread in Gmail. The bot's replies collapse inside the same thread, not as new threads — proves your
In-Reply-To/Referencesheaders are correct.
- Custom-header whitelist. Cloudflare Email Sending only accepts whitelisted custom headers.
In-Reply-ToandReferencesare both on it. Wrap message-IDs in angle brackets.Referencesis space-separated for multi-message chains. - Notifications can truncate. The
Monitornotification view abbreviates long lines. The full payload is always in D1 — re-fetch by id withwrangler d1 execute --remote --jsonwhen the body is cut off. - Don't ack before printing. Print first, then ack. A crash between them re-delivers the event next tick — much better than losing it.
- Stdout is the API. A stray
echoin the poller becomes a stray notification. Use>&2for any non-event output. - Calendar auto-RSVPs come back as inbound mail. The kill-switch is
from === demo@, but the Google Calendar RSVP messages arefrom: <participant>with subjectDeclined: …orAccepted: …. Detect by subject prefix +<calendar-…@google.com>message-id and ignore unless you also want to do reschedule flows. UIDis a reserved bash variable. Don't name your shell variableUIDwhen generating the.ics. UseMEETING_UIDor similar.
- An auth UI. The bot's identity is its email address; anyone in a thread can address it.
- A separate state store. D1 holds the queue; the email thread itself holds the conversation. The agent re-reads from those primary sources every event.
- A queue. D1 + the
processedcolumn is the queue. - A scheduler. The inbound channel triggers the agent.
The Monitor tool is doing surprising work here — it converts a fundamentally synchronous chat session into a long-running async service without leaving Claude Code.
{ "name": "demo-inbound", "main": "src/index.ts", "compatibility_date": "2026-04-01", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true }, "vars": { "DEMO_ADDRESS": "demo@yourdomain.com" }, "send_email": [{ "name": "EMAIL" }], "d1_databases": [{ "binding": "DB", "database_name": "calendar-bot", "database_id": "<paste from `wrangler d1 create`>" }] }