Every day at 12pm Sydney, a Cloudflare Worker wakes up, asks Claude Sonnet 4.5 to "run the daily follow-up cycle," and hands it 6 tools for reading/writing Gmail, Sheets, and Slack. Claude decides per-venue whether to resolve (reply found), draft a follow-up (cycle 1 or 2), or escalate (cycle 3). All email drafts land in Alec's drafts folder — nothing auto-sends until you flip a flag.
Two control paths into the Worker:
wrangler.toml declares crons = ["0 1 * * *"]. Cloudflare triggers scheduled(event) in src/index.ts once per day.GET /run?key=<last16> triggers fetch(req). Used by the FlowTest dashboard and curl smoke tests.| File | Role | Lines |
|---|---|---|
| wrangler.toml | Worker config: cron schedule, env vars (SHEET_ID, RUN_MODE, model), secret placeholders | ~30 |
| package.json | Declares @anthropic-ai/sdk + wrangler + typescript | ~25 |
| tsconfig.json | Strict TS + Cloudflare Workers types | ~15 |
| src/index.ts | Entrypoint. scheduled handler for cron, fetch handler for manual /run + /health | ~75 |
| src/agent.ts | Claude tool-use loop. Builds messages, parses tool_use blocks, dispatches, appends tool_result, loops to end_turn. 30-turn safety cap. | ~110 |
| src/tools.ts | 6 tool schemas + runtime handlers + dispatcher | ~170 |
| src/google.ts | OAuth refresh-token → access token. Gmail thread/draft ops. Sheets batch read/update. RFC2822 builder with UTF-8 base64 body encoding. | ~160 |
| src/slack.ts | Tiny webhook POST helper | ~10 |
| src/prompts.ts | System prompt + Day 1/2/3 email templates. Edit here to change tone. | ~60 |
| DEPLOY.md | Deploy runbook (wrangler login → secrets → deploy) | ~80 |
| scripts/seed-test-data.py | Inserts 5 fake venue threads + Sheet rows for testing every branch | ~200 |
Each run is a conversation with Claude. The loop in agent.ts:
// Pseudocode messages = ["Today is 2026-04-19. Run the daily venue follow-up cycle now. [+ templates]"] for turn in 0..30: response = claude.messages.create({ system, tools, messages }) if response.stop_reason == "end_turn": break for block in response.content: if block.type == "tool_use": result = runToolWithRetry(block.name, block.input) messages.append(tool_result) messages.append(claude_response)
The system block carries cache_control: { type: "ephemeral" }. First run writes the cache (~1.6K tokens). Subsequent turns within the same run read from cache (~8K tokens cache-read). Cost savings: ~40% on an average run.
Every tool call runs through runToolWithRetry. If the error message matches /\b(429|500|502|503|504|ECONNRESET|ETIMEDOUT)\b/, it sleeps 500ms and retries once. Hard errors (404, auth failures) are passed straight back to Claude as is_error:true — Claude then decides whether to skip the venue and include it in the Slack summary.
| Tool | Input | Output | Side effects |
|---|---|---|---|
| get_active_venues | none | Array of Venue objects | read-only |
| check_thread_replies | thread_id | { messages: [{from, date, snippet}] } | read-only |
| read_email_content | message_id | { from, subject, body } | read-only |
| create_followup_draft | thread_id, to, subject, body, in_reply_to? | { draftId } | writes to Gmail Drafts |
| update_venue_row | row_number, updates{status, cycle_day, last_action_date, resolved_date} | { ok: true } | writes to Sheet |
| send_slack_alert | text | { ok: true } | posts to #make_team |
Early testing showed Claude was flexible enough to interpret cycle_day loosely — once it picked Day 3 "last nudge" for a cycle_day=2 venue. The prompt was tightened to an explicit table: cycle_day=1 → Day 1 template, cycle_day=2 → Day 2, cycle_day=3 → escalate (no draft). No skipping allowed.
| Col | Header | Format | Written by |
|---|---|---|---|
| A | thread_id | Gmail thread hex | intake (never rewritten) |
| B | venue_name | free text | intake |
| C | sender_email | intake | |
| D | cycle_day | 1–3 | agent updates |
| E | status | active · resolved · escalated | agent updates |
| F | last_action_date | YYYY-MM-DD | agent updates |
| G | thread_link | URL | intake |
| H | created_date | YYYY-MM-DD | intake |
| I | resolved_date | YYYY-MM-DD | agent updates (on resolve) |
| J | venue_email | intake |
The agent only ever writes D, E, F, I. A/B/C/G/H/J are set at intake (row creation) and never modified.
ANTHROPIC_API_KEY — Claude keyGOOGLE_CLIENT_ID — OAuth appGOOGLE_CLIENT_SECRET — OAuth appGOOGLE_REFRESH_TOKEN — Alec's gmail+sheets scopesSLACK_WEBHOOK_URL — #make_team webhook
SHEET_IDSHEET_TAB = "Tracking"RUN_MODE = "draft" (flip to "send" later)CLAUDE_MODEL = claude-sonnet-4-5-20250929AGENT_VERSION = 0.1.0
The Google refresh token mints a fresh access token on each Worker invocation via oauth2.googleapis.com/token. Cached in-memory for the duration of a single request (~60 min typically). Tokens survive Worker cold starts because the refresh token lives in Secrets, not memory.
get_active_venues. Worker dispatches, returns 5 rows. ~1.6K system tokens cached.check_thread_replies for all 5 thread_ids in parallel.update_venue_row + 2× create_followup_draft fired. Gmail + Sheets respond 200.send_slack_alert: "5 checked · 2 replies · 2 drafts · 1 escalated". end_turn.| Token type | Rate (Sonnet 4.5) | Sample run (5 venues) | Cost |
|---|---|---|---|
| Input | $3 / 1M | 15,254 | $0.0458 |
| Output | $15 / 1M | 1,583 | $0.0237 |
| Cache read | $0.30 / 1M | 8,230 | $0.0025 |
| Cache write | $3.75 / 1M | 1,646 | $0.0062 |
| Per run | — | 26,713 | $0.0781 |
| Monthly (30 runs) | — | — | ~$2.34 |
Versus Make.com: old scenarios cost ~100 operations/day = 3000 ops/month = close to the 60K/month plan limit. Agent runs in a different budget (Anthropic API, not Make.com operations) so frees up all 100 daily ops for other automations.
GET /health — liveness probe (no auth)GET /run?key=<last16> — manual triggerscheduled(event) — cron entrypoint
wrangler tail — live log stream/venue-agent page — manual trigger UI + last-run stats
| Failure | Detection | Recovery |
|---|---|---|
| OAuth refresh fails | runAgent throws | index.ts catches, posts ⚠️ Slack alert, returns error JSON |
| Single tool call errors (transient) | runToolWithRetry | Auto-retry once after 500ms |
| Single tool call errors (hard) | is_error:true in tool_result | Claude continues, skips venue, notes in Slack summary |
| Claude rate-limit | 429 response from Anthropic | Anthropic SDK retries internally; agent errors on exhaustion |
| Runaway loop | turn counter | 30-turn safety cap in agent.ts |
| Bad prompt output | Drafts caught in Drafts folder | Human review — nothing auto-sent in RUN_MODE=draft |
| Sheet schema drift | update_venue_row writes to wrong column | Manual test via /run; check sheet diff before trusting cron |
This is the full prompt Claude sees on every run. Stored in src/prompts.ts — edit and redeploy to change behaviour.
You are the Venue Follow-Up Agent for Easy Weddings.
Your job, once per day at 12pm Sydney time:
1. Call get_active_venues to fetch every venue awaiting a reply.
2. For each venue, call check_thread_replies with the thread_id.
3. Decide:
- REPLIED — if any message in the thread is from an address NOT in our
internal list (alec@, lauren@, travel@easyweddings.com.au). Call
update_venue_row with status="resolved" and resolved_date=today.
- NEEDS FOLLOW-UP — if only internal messages, and cycle_day is 1 or 2.
Draft a follow-up using create_followup_draft, then update_venue_row
with cycle_day += 1 and last_action_date = today.
- ESCALATE — if cycle_day is 3 and still no reply. Call update_venue_row
with status="escalated". Send ONE combined Slack alert at end of run.
4. End of every run: ONE Slack summary to #make_team (X checked / Y replies /
Z drafts / N escalations). Keep it under 5 lines.
Rules:
- NEVER send emails directly — only create drafts. A human reviews before sending.
- The draft must reply inside the original thread (same subject with "Re: ").
- Template selection is a STRICT mapping from cycle_day:
cycle_day=1 → use Day 1 template, then bump cycle_day to 2
cycle_day=2 → use Day 2 template, then bump cycle_day to 3
cycle_day=3 → DO NOT DRAFT. Escalate. Mention in Slack summary.
- If a tool errors, retry it once. If it fails again, skip that venue and
include the failure in the Slack summary.
wrangler secret put. Worker live at venue-agent.alec-3c0.workers.dev. Cron registered.| Dimension | Make.com (old) | Venue Agent (new) |
|---|---|---|
| Engine | Make.com bundles + routers + filters | Claude Sonnet 4.5 tool-use loop |
| Triggers | 2 scenarios (intake + daily) | 1 Worker, 1 cron + 1 HTTP endpoint |
| Modules / steps | 22 (7 Set Variables alone to dodge pills bug) | 6 tools |
| Personalisation | Static templates | Per-venue opening + closing, references details from email |
| Reply detection | Domain-based or Gmail search query | Thread-based (any non-internal sender in thread) |
| Cost | ~100 ops/day | ~$0.08/run (different budget) |
| Deploy changes | Make.com UI or API blueprint push (pills bug) | Edit TS file, wrangler deploy |
| Debug | Make.com execution log | wrangler tail + FlowTest dashboard |
| Extensibility | Add modules, wire carefully | Add a tool function + schema — Claude figures out when to call it |
Generated 2026-04-19 · venue-agent v0.1.0 · FlowTest dashboard