Skip to content

ADR 002: Cloudflare Worker replaces launchd for evening check-in

Status: accepted Date: 2026-05-04 Supersedes: —

Context

The evening check-in flow originally lived on Casey's laptop:

  1. evening_checkin.py + launchd com.casey.warehouse-checkin at 8pm — read intervals.icu for today's planned events, check completion_log.jsonl for what's logged, send Telegram if gaps exist
  2. telegram_listener.py + launchd com.casey.warehouse-listener at 8:30pm/9:30pm/6:30am (next day) — poll Telegram for replies, parse via Anthropic, write to completion_log
  3. GitHub Actions evening-checkin.yml — laptop-independence stopgap that ran a stripped-down evening_checkin.py from CI when the Mac was asleep

Three problems with this:

  1. Mac-dependence — laptop closed at 8pm = no check-in. The GHA stopgap fired but couldn't read Casey's local completion_log, so it asked about everything every time
  2. Polling — telegram_listener did 3 launchd-fired polls instead of 1 push-based webhook. Wasteful + delayed
  3. Three places for one feature — evening_checkin.py + telegram_listener.py + GHA workflow all had pieces of the truth

Decision

Replace all three with one Cloudflare Worker (otq-checkin), using:

  • Durable Object for state (checkins + processed_replies + daily_summaries SQL tables)
  • Worker cron for scheduled fires (0 3 * * * UTC = 8pm PDT, later 30 15 * * * for morning summary)
  • Webhook mode for Telegram replies (push, not poll)
  • R2 for shared state with the laptop (completion_log_worker_* files merged on next daily_sync)

Code at ~/garmin-warehouse/cloudflare/otq-checkin/. Live at https://otq-checkin.manoscasey.workers.dev.

Consequences

Good: - No Mac-dependence — fires at 8pm regardless of laptop state - Push webhook = instant reply parsing (vs polling 8:30/9:30/6:30) - One codebase to maintain (TypeScript + Anthropic SDK) - DO state survives restarts - Worker observability (CF dashboard) gives live tail + cron history - Once verified working, retires evening_checkin.py + telegram_listener.py + GHA workflow (all 3 tagged .disabled after teardown)

Tradeoffs: - Requires a Cloudflare account with Workers + DO billing - Wrangler OAuth token rotates ~6mo (re-login needed periodically) - DO SQL is sqlite-flavored, slightly different from DuckDB used elsewhere - Anthropic SDK in TypeScript doesn't have .parse(); uses tool_use with Zod for structured output - Local laptop still has fallback launchd plists during shadow mode (~1 week) — cloudflare/otq-checkin/teardown-laptop.sh retires them after verified clean

Shadow-mode protocol

Both paths run in parallel for ~1 week. Both write to completion_log.jsonl (the laptop side) and state/<date>/completion_log_worker_<msgId>.jsonl (Worker side). daily_sync.sh merges Worker-captured replies into the local log with dedup. As long as both sides agree on what got logged each day for ~7 days, the laptop side is safe to retire.

Alternatives considered

  1. Keep laptop-based but improve reliability — doesn't solve Mac-dependence. Pmset wakeorpoweron helps but isn't bulletproof.
  2. Move to a cheap VPS — works but $5/mo + more infra to maintain than CF Worker. CF Worker is ~$0/mo at this scale.
  3. AWS Lambda + DynamoDB — works but Casey doesn't have an AWS account in heavy use; CF account is already load-bearing.
  4. Inngest / Trigger.dev for the cron + webhook — adds a third-party service for what's a simple Worker.

CF Worker won on: already-have-account, lowest cost, simplest mental model (one codebase, one platform), best DX with MCPs configured.

Notes

The 8:30am morning summary + 2-way reply (corrections / questions / chat) was added to the same Worker on the same night (2026-05-04). That feature didn't replace anything — it's net new. But it benefits from the same Worker plumbing (DO state, R2 cache, webhook router).

References