How my WhatsApp scheduler actually works

cloudflaredurable-objectsclaudewhatsappfamsync

For a while, I was my family’s scheduling system. School schedules, my husband’s travel, our own commitments — I held all of it in my head and hand-managed our shared calendar. That worked right up until it didn’t, and it fell apart in two predictable ways.

Things collided, and things got dropped. A school event my husband actually wanted to be at would land on the same afternoon as a work trip, and we wouldn’t catch the clash until it was too late to do anything about it. And often the calendar wasn’t even the problem — I’d read a message about some event while I was out, plan to add it later, and it would just stay in my head and never make it onto the calendar at all. There was no system. There was just me, remembering — and that doesn’t scale.

So I built the system. FamSync’s scheduler is a bot that owns the part I kept dropping: you tell it about an event in plain language, it works out what you meant, and writes it to our shared Google Calendar — so the thing that used to live in my head becomes a thing on the calendar, without me being the one who has to remember to type it in. No forms, no new app to check, no me as the single point of failure. It runs on Cloudflare with no server I have to babysit.

Two pains, then: things colliding and things never getting captured. The bot goes hard at the second one — capture — and helps with the first mostly as a side effect: once everything actually lands on one shared calendar, the clashes are at least visible to us. What it deliberately does not do yet is hunt for conflicts and warn me — I’ll be honest about that (and the rest of the rough edges) at the end.

Here’s how it’s wired.

The messages themselves arrive over the official WhatsApp Cloud API (Meta). Twilio is in the stack too, but only as the number provider — the webhooks that actually deliver the messages are Meta’s.

The shape of it

One WhatsApp contact = the scheduler. (There’s a second contact for the tasker — that’s its own post.) Here’s the whole system, drawn by ownership boundary — Meta, Cloudflare, Anthropic, Google — because who owns what is half the design:

flowchart TB
  subgraph fam[Family]
    U["WhatsApp<br/>scheduler # · tasker #"]
  end

  subgraph edge[Meta / Twilio]
    WA["WhatsApp Cloud API<br/>webhooks in · messages out"]
    TW["Twilio<br/>number provider only"]
  end

  subgraph cf[Cloudflare]
    W["Worker — stateless<br/>verify webhook · route by bot #"]
    DO["Durable Object — ChatAgent<br/>one per bot × person · key wa:{bot}:{id}<br/>runs the tool-loop · keeps history (last 24)"]
    GW["AI Gateway<br/>observability · caching · limits"]
    SS[("Secret Store")]
  end

  subgraph ai[Anthropic]
    CL["Claude Sonnet<br/>Messages API · tool-use loop ≤16"]
  end

  subgraph goog[Google — systems of record]
    GC[("Calendar<br/>events")]
    GS[("Sheets<br/>tasks")]
  end

  U -->|message| WA --> W
  TW -.->|provisions #| WA
  W -->|route to its DO| DO
  DO -->|prompt + tool defs| GW --> CL
  CL -->|tool_use · final text| DO
  DO -->|create · move · cancel| GC
  DO -->|read · write| GS
  SS -.->|secrets| W
  SS -.->|secrets| DO
  DO -->|reply| WA -->|message| U

Two things jump out of that picture. First, no box is a long-running server: the Worker is stateless and only exists for the milliseconds it takes to handle a request. Second, almost nothing is stored inside my own system — the one piece of memory that survives between messages is the recent back-and-forth of this particular chat, held in the Durable Object; everything that feels like real data (events, tasks) lives in Google. Both of those are deliberate, and I’ll come back to why.

One detail worth calling out: there’s a single Worker for both bots. It tells the scheduler and the tasker apart by which WhatsApp number the message arrived on, then hands the conversation off to its own Durable Object — a class literally named ChatAgent, which is where the Claude loop actually runs. The Worker itself does almost nothing but verify the webhook and route.

Why a Durable Object per conversation

A plain Worker has no memory between requests, and a chat assistant is nothing but memory: “move that to Friday” only means something if you remember what “that” was. So every conversation gets its own Durable Object — a tiny, single-threaded, addressable unit that both runs that conversation and remembers it — keyed by who’s talking to which bot:

wa:schedule:<my-wa-id>       ← my WhatsApp thread with the scheduler
wa:tasks:<my-wa-id>          ← my thread with the tasker (separate bot, separate DO)
wa:schedule:<partner-wa-id>  ← my partner's scheduler thread (separate again)

Each of those is an independent instance with its own memory — my scheduler history is not my tasker history, and neither is my partner’s. And the memory is deliberately tiny: the DO holds exactly one key, history — an array of the user and assistant turns for that one chat, including Claude’s tool_use calls and the tool_result blocks that answer them. On every write it’s trimmed to the last 24 messages (slice(-24)), so it’s a rolling window, not an ever-growing log. That’s the entire storage surface. One key. A capped transcript.

What the DO very deliberately does not hold is anything that looks like data:

  • Calendar events live in Google Calendar.
  • Tasks and projects (that’s the tasker’s job) live in Google Sheets.
  • Secrets live in Cloudflare’s secret store.

So if I wiped a DO, I’d lose only that one chat’s recent context — which the bot just rebuilds as we keep talking. No event, no task, nothing real, is ever at risk. The Durable Object is where each conversation’s agent runs, but the only thing it durably keeps is that short-term memory.

What Claude actually does

The bot doesn’t parse language with regexes — Claude does. The model is Claude Sonnet, reached through the Cloudflare AI Gateway to the Anthropic Messages API.

And it drives everything with native tool calling — not “write JSON in prose and regex it back out.” The calendar, task, and personal operations are real tools with real schemas, so arguments get validated at the tool boundary instead of by hopeful string parsing. The loop is the standard Anthropic one:

  1. Send the conversation (that capped history) plus the tool definitions.
  2. If the response comes back with stop_reason: "tool_use", run each tool_use block — e.g. actually call the Google Calendar API — and feed the outcome back as a tool_result.
  3. Repeat until Claude stops asking for tools (capped at 16 iterations per turn), then send its final text to WhatsApp.

A tool_use block for booking something looks about like:

{
  "type": "tool_use",
  "name": "create_event",
  "input": {
    "title": "Maya — swim",
    "start": "2026-09-10T17:00:00-07:00",
    "duration_minutes": 60,
    "attendees": ["Maya"]
  }
}

The split — Claude for the fuzzy part (human sentence → which tool, with what arguments), plain code for the exact part (the actual API call) — is what makes it feel reliable instead of magical-but-flaky.

The one regex I kept

There’s exactly one place I still parse the model’s text, and it’s a guardrail, not data extraction. Sonnet will occasionally reply ”✅ added!” when no mutating tool actually succeeded — a confident little lie. So a regex checks the final reply for that kind of claim and cross-references whether a mutating tool really ran. If the words say “done” but nothing was, the bot has to either do it for real or retract. Given that the entire point of this thing is to stop dropping events, a bot that says “got it” without getting it is the one failure mode I refuse to ship.

Why there’s no server (and why that matters here)

This is the second time I’ve built this bot. The first version tried to run in a container sandbox and it fell over — I wrote up that whole failure separately. The short version: a family scheduler is spiky and mostly idle. It gets a burst of messages at dinnertime and then nothing for hours. Paying for a container to sit warm all day, and babysitting the thing, was the wrong shape for the problem.

Workers + Durable Objects invert that. There’s nothing running when no one’s texting. It wakes on a message, does its work in milliseconds, and goes back to sleep. For a bot that’s idle 95% of the day, that’s the difference between a project I maintain and one I forget about — in the good way.

The principles behind it (and what they cost)

None of the shape above is accidental. A handful of design rules pushed it here, and every one of them buys something at a price — so here they are with the bill attached:

  • Stateless edge, stateful core. The Worker is a thin, stateless router; each conversation’s actual work and its memory live together in a Durable Object. Cost: all the interesting logic runs in the DO — that’s where you go to debug.
  • Keep durable state minimal and disposable. The DO stores only a rolling 24-message history — wipe it and you lose nothing that matters. Cost: no long-term memory, no learned preferences.
  • One source of truth, and it isn’t my app. Events live in Google Calendar, tasks in Google Sheets; the DO never becomes a shadow database. Cost: every action is an external API call — more latency, and I’m along for the ride on Google’s uptime.
  • Isolate by conversation. One DO per (bot, person), so a busy or broken chat can’t touch another. Cost: the bots can’t see each other’s context.
  • Least privilege per bot. The scheduler gets only calendar tools; the tasker only task tools. Cost: “block time for my top task” can’t happen in a single message — neither bot can reach into the other’s world.
  • Claude for ambiguity, code for correctness. The model decides which tool and what arguments; deterministic code performs the actual side effect, with schemas enforced at the tool boundary. Cost: the tool loop adds latency and tokens, and the model can still pick the wrong tool.
  • Don’t trust the model’s self-report. The regex guardrail checks “done” claims against whether a mutating tool really ran. Cost: it’s a heuristic, not a proof.
  • Match the architecture to the workload. A spiky, mostly-idle bot wants serverless scale-to-zero, not a warm box. Cost: you inherit platform limits — like WhatsApp’s 24-hour messaging window, which is exactly why proactive nudges are hard.

Read the next section with these in mind: several of the rough edges aren’t bugs, they’re the bill for the choices up here — no cross-bot actions (isolation + least privilege), no long-term memory (disposable state), no proactive reminders (workload + platform limits).

What’s still rough

It’s in daily use, but there’s still work to do and a fair amount of tuning left:

  • Text only. parseInbound drops anything that isn’t a text message, so I can’t forward a screenshot of a school flyer or a voice note — I still have to type events out. Given that capturing school events is the entire reason this exists, this is the gap I feel most.
  • It doesn’t catch clashes — on purpose. The prompt treats conflicts as secondary: add the event first, don’t go hunting for overlaps. So it’ll cheerfully drop two things on the same slot and usually won’t warn me. Fast, but it’s the exact pain I opened with. Everything landing on one shared calendar at least makes clashes visible; the bot actually flagging them is still on the list.
  • The two bots don’t share a brain. The scheduler only has calendar tools; the tasker only has task tools. So “block time on my calendar to do my top task” can’t happen in one message — neither bot can see the other’s world.
  • Short, siloed memory. That 24-message window per (bot, person) is genuinely short. Reference something from further back and it’s gone — and there’s no long-term memory of preferences, so it doesn’t just know that swim is always at Rinconada.
  • No commute time, no undo, one calendar. Drive-time is stubbed “coming soon.” There’s no undo — a wrong add or delete is a manual fix (deletes at least ask for confirmation first). And everything lands on a single family calendar (mirrored into our personal ones as guests), so there’s no clean per-kid filtering yet.
  • Reminders are limited. WhatsApp won’t let the bot cold-message outside a 24-hour window without an approved template, so there’s no “kid’s dentist in an hour” nudge from the scheduler yet. Cross-notifying the other parent when something’s added does work.

None of these are blockers — just the next things on the list as I keep tuning it.


Next up: the scheduler has a sibling. FamSync’s tasker lives in a separate WhatsApp contact and handles the “what should I actually do next” problem instead of the “when is it” problem. Here’s why they’re two bots, not one →


← all writing