Skip to content

Instantly share code, notes, and snippets.

@knowsuchagency
Last active May 8, 2026 22:40
Show Gist options
  • Select an option

  • Save knowsuchagency/ce7905102deef8b12884742755c3130e to your computer and use it in GitHub Desktop.

Select an option

Save knowsuchagency/ce7905102deef8b12884742755c3130e to your computer and use it in GitHub Desktop.
Claude Code as a long-running agent: Monitor tool + Cloudflare Worker + D1 pattern

Build an email scheduling bot with Claude Code

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.com accepting 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 Monitor tool
  • A live agent loop that reads the thread, replies in-thread with proper threading headers, and attaches an RFC 5545 .ics on the final confirmation

Estimated build time, cold start to working demo: about an hour.


End-user flow

  1. Alice emails Bob, CC'ing demo@yourdomain.com: "Looping in our scheduling assistant — can you two find 30 minutes next week to talk?"
  2. 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?"
  3. Bob replies "Wed works for me." Alice replies "Yes, Wed is good."
  4. Bot replies in-thread: "Locked in: Wed May 13 at 2:00 PM PT. Calendar invite attached." — with invite.ics attached.
  5. Both inboxes show "Add to Calendar" — event organized by demo@yourdomain.com.

Architecture

                                                   (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 /send and uses its native EMAIL binding internally. This avoids creating a separate Cloudflare API token — auth is one shared bearer secret, set once via wrangler secret put.
  • Polling hits the Worker over HTTP, not wrangler d1 execute. A curl per tick is ~100ms; shelling out to wrangler is 1–2s of cold start. Same data, ~10× faster.

Prerequisites on the Cloudflare account

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).
  • wrangler logged in (bunx wrangler whoami).

Project layout

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

package.json

{
  "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"
  }
}

wrangler.jsonc

{
  "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`>"
  }]
}

tsconfig.json

{
  "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"]
}

schema.sql

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

src/index.ts — the entire Worker

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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[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>;

Deploy + secrets + routing rule

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

poll-events.sh — the bridge to the Claude Code session

#!/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"
done
chmod +x poll-events.sh

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


The agent loop — what to do at session start

Open a Claude Code session in this directory and tell it:

Start Monitor ./poll-events.sh with persistent: true and timeout_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:

  1. Read the full payload. Notification text may be truncated. If body_text, message_id, in_reply_to, or references look 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>'"
  2. Build the participant set. Union of from.address + every to[].address + every cc[].address across the thread, lowercased, minus demo@yourdomain.com. The Worker auto-strips demo@ from outbound to/cc too — defense in depth.
  3. 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 .ics attached.
    • Auto-RSVP from Google Calendar (Declined: / Accepted: subjects, <calendar-…@google.com> message-id) → ignore. Reschedule is out of scope.
  4. Reply via POST /send to the Worker. Always set:
    • to: participant set
    • subject: original subject, with Re: prefixed if not already
    • in_reply_to: the latest event's message_id (angle-bracket-wrapped)
    • references: previous references value + a single space + the latest message_id (or just message_id if references is empty)
    • text: required. Sign as "— Demo Bot".
    • On the final confirmation only: ics (RFC 5545, METHOD:REQUEST, UTC DTSTART/DTEND, ORGANIZER = demo@yourdomain.com, one ATTENDEE per participant).
  5. 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.json

Generating the .ics

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


Acceptance tests

Run end-to-end before you go on stage:

  1. Single-recipient happy path. Email demo@yourdomain.com directly: "Find me 30 min next Tuesday afternoon." Bot proposes three times. Reply "the 2pm slot works." Bot replies in-thread with an invite.ics attached. Gmail shows the calendar preview.
  2. 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 .ics to both.
  3. Loop check. Confirm that the bot's outbound replies do not generate fresh events in GET /events. (The Workers EMAIL binding doesn't loop back through Email Routing; the from === demo@ short-circuit in email() is belt-and-suspenders.)
  4. 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/References headers are correct.

Common gotchas (the ones that bit me building this live)

  • Custom-header whitelist. Cloudflare Email Sending only accepts whitelisted custom headers. In-Reply-To and References are both on it. Wrap message-IDs in angle brackets. References is space-separated for multi-message chains.
  • Notifications can truncate. The Monitor notification view abbreviates long lines. The full payload is always in D1 — re-fetch by id with wrangler d1 execute --remote --json when 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 echo in the poller becomes a stray notification. Use >&2 for any non-event output.
  • Calendar auto-RSVPs come back as inbound mail. The kill-switch is from === demo@, but the Google Calendar RSVP messages are from: <participant> with subject Declined: … or Accepted: …. Detect by subject prefix + <calendar-…@google.com> message-id and ignore unless you also want to do reschedule flows.
  • UID is a reserved bash variable. Don't name your shell variable UID when generating the .ics. Use MEETING_UID or similar.

What you don't need to build

  • 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 processed column 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment