events
Upcoming-event auto-reminder: Gmail + Google Calendar → canonical event stream
→ category-based iMessage nudges (flights, dinners, meetings, appointments,
subscription renewals) + a 7-day /upcoming dashboard with snooze/dismiss.
Status: MVP complete — all 11 phases implemented with 56 passing tests.
Standalone repo at ~/events/ (extracted from the ~/agents monorepo
2026-05-30). Code-complete but dormant — install.sh registers the launchd
jobs, but the 15-min pipeline stays paused (RunAtLoad=false) until you do the
one-time OAuth bootstrap + flip it on. See ## Activation below.
See PLAN.md for the full implementation plan.
Sibling of the standalone ~/return-tracker/ repo — same pattern (launchd tick
→ Gmail scan → Haiku extraction → iMessage via bb-send.sh) applied to what's
next rather than return deadlines.
- Install:
./install.sh— pip deps (user-site, no venv), creates config/ state/log dirs, copies + loads both launchd plists. Idempotent. No~/binsymlink (the plists point straight at~/events). - Testing:
cd ~/events && python3 -m pytest -q(56 tests;conftest.pyself-bootstrapssys.path). - Ship gate:
./verify.sh— proves the monorepo is de-leaked, both launchd jobs are loaded, the dashboard answers, and the suite is green. PrintsALL GREEN ✓.
Scope
MVP (ship first):
- Pull Google Calendar (next 30d) + Gmail (readonly) incrementally via watermark.
- Extract events from email bodies with Claude Haiku; structured fields only — no full bodies stored.
- Four categories: flight, meeting, restaurant, appointment (+ subscription renewals from Gmail).
- Fuzzy dedup across Gmail↔Calendar on normalized title + ±30-min start window; calendar wins ties.
- Schedule reminders per category (offsets below). Re-plan every 15 min so cancellations/reschedules cascade.
- Fire via iMessage (bb-send.sh +17076556006). Quiet hours 22:00–07:00 local; urgent (≤2h out) overrides.
- Dashboard at /upcoming — 7-day timeline, one card per event, snooze (1h / till tomorrow) and dismiss buttons.
- Dry-run mode (tick --dry-run) logs without sending.
Post-MVP (ambitious): Uber/Lyft rides; concert/show tickets with venue leave-by; dynamic travel time via Google Maps; conversational snooze over iMessage (BlueBubbles inbound webhook); morning/evening digests; calendar conflict detection.
Architecture
Gmail ──► Haiku extract ──┐
├─► fuzzy dedup ─► events (SQLite) ◄── /upcoming dashboard
Calendar (direct) ────────┘ │ (snooze/dismiss POST)
▼
schedule reminders
│
▼
reminders ─► tick (every 15min) ─► bb-send.sh ─► iMessage
Single launchd agent runs event-tracker.py fetch && tick every 15 min — no per-reminder cron.
Scheduling is DB-driven; reminders live in a row, not a job queue.
Interface
- CLI:
event-tracker.py {auth | fetch | tick | serve | digest} auth— one-time OAuth bootstrap (separate token from return-tracker, same client_secret).fetch— pull new gmail+calendar, extract, merge, (re)schedule reminders.tick— fire due reminders, markfired_at. Supports--dry-run.serve— Flask dashboard on127.0.0.1:8877.digest— one-shot morning/evening summary line.- Dashboard (Flask, fronted by Tailscale Funnel):
GET /upcoming— HTML 7-day timeline.GET /api/events?days=7— JSON.POST /api/action—{event_id, action: snooze_1h|snooze_tomorrow|dismiss}.- Output: iMessages to
+17076556006viabb-send.sh. - Schedule:
com.mark.event-tracker.plist— every 15 min (StartInterval=900).
Config
Surface lives in config.py. Values to tune:
- Category offsets (reminder time before event):
flight→ 7 days + (2h before departure + 45min travel to BOS)meeting→ 1hrestaurant→ 30 minappointment→ 1 day, 1hsubscription→ 1 dayride→ 15 min ·ticket→ 2h (ambitious)- Quiet hours: 22:00–07:00 America/New_York; reminders defer to 07:00 unless ≤2h to event.
- Rate limits: 5 fires/hour max, 50 emails/run max extraction cap.
- Confidence: ≥0.8 auto-fires; 0.6–0.8 stored with
needs_review=true(shown in dashboard, not sent); <0.6 dropped. - Home address:
50 Longwood Ave, Brookline MA;AIRPORT_TRAVEL_TIME = 45min(static; Google Maps Distance Matrix is ambitious).
Data
~/Library/Application Support/event-tracker/events.db — SQLite (WAL).
events— canonical merged events. Columns includesource,source_ref,category,title,start_time_utc,location,details_json,confidence,needs_review,dismissed_at,snoozed_until,cluster_id(dedup group),event_hash(change detection).reminders—event_id,trigger_at,kind,message,fired_at,cancelled_at. Scheduler re-plans on every fetch.watermarks— per-source(source, cursor)so Gmail/Calendar fetches are incremental.runs— per-invocation log (counts upserted/extracted/fired, duration, error summary).
Times stored UTC; rendered America/New_York. Concurrency: fcntl.flock on .lock (matches newsfeed).
Depends on
- Google OAuth — scopes
gmail.readonly+calendar.readonly. Separatetoken.jsonfrom return-tracker; sharedclient_secret.jsonacceptable. - Python 3.11+ libs:
google-auth-oauthlib,google-api-python-client,rapidfuzz,flask,jinja2,python-dateutil,freezegun(tests). events-extractorpoll session (started bypoll-bringup) — the Gmail extractor drops hash-keyed events for it; runs Haiku, pinned by poll-bringup. No per-runclaudesubprocess; tool scoping is instruction-level (matches return-tracker safety posture).bb-send.sh— iMessage delivery (BlueBubbles).- launchd — 15-min pipeline tick.
- Tailscale Funnel — public dashboard URL (reuse newsfeed's setup).
Secrets live at ~/.config/event-tracker/{client_secret.json,token.json} (0600). Never committed.
Activation
All 11 phases are code-complete. ./install.sh does the wiring (deps + both
launchd plists), but the tick job ships paused (RunAtLoad=false) so nothing
fires before OAuth exists. Going live is: install → OAuth → smoke-test → flip
RunAtLoad on.
# 0. Wire it up (idempotent; loads the dashboard now, registers the paused tick)
cd ~/events
./install.sh
# 1. OAuth bootstrap (can reuse return-tracker's client_secret.json)
cp ~/.config/return-tracker/client_secret.json ~/.config/event-tracker/client_secret.json
~/events/event-tracker-auth.py # opens browser, writes token.json (0600)
# 2. Smoke test (no iMessage sent)
~/events/event-tracker.py fetch # prints DIGEST: fetched N events, planned M reminders
~/events/event-tracker.py tick --dry-run # logs what would fire; no iMessage sent
~/events/event-tracker.py digest # prints today's summary
# 3. Go live — flip the tick plist on, then re-install to reload it.
# Edit LaunchAgents/com.mark.event-tracker.plist: <key>RunAtLoad</key><false/> -> <true/>
./install.sh # now fetch+tick fires every 15 min
# Dashboard is already up at http://127.0.0.1:8877/upcoming (KeepAlive plist).
Operations:
- Logs: ~/Library/Logs/event-tracker/{pipeline.log,launchd.log,launchd.out.log,launchd.err.log,dashboard.log}
- DB: ~/Library/Application Support/event-tracker/events.db
- Pause: launchctl bootout gui/$UID/com.mark.event-tracker (or ./uninstall.sh to remove both jobs)
- Re-auth: delete ~/.config/event-tracker/token.json and re-run ~/events/event-tracker-auth.py
- Funnel exposure (future, manual): the dashboard is localhost-only today.
To publish /upcoming, add a tailscale-funnel block to serve.sh mirroring
~/seinfeld/label_server_run.sh (uuid path-secret on :8443 →
127.0.0.1:8877), then reload the dashboard job. Not wired now.
Build log (what's actually implemented)
- [x] Phase 1: scaffolding —
config.py,requirements.txt,tests/conftest.py, sanity test harness. - [x] Phase 2: DB schema + queries (
db.py) —events,reminders,watermarks,runs,meta. 10 passing unit tests. - [x] Phase 3: OAuth bootstrap (
event-tracker-auth.py,sources/_auth.py) — Gmail + Calendar readonly; token at~/.config/event-tracker/token.json(0600). - [x] Phase 4: Calendar source (
sources/calendar.py) — 30-day window, categorizes (flight/meeting/restaurant/appointment/ride/ticket), skips cancelled, UTC conversion. 5 passing tests. - [x] Phase 5: Gmail + LLM extractor (
sources/gmail.py,sources/extractor.py) —newer_than:30dsearch on known sender domains + event subjects; extraction routes through theevents-extractorpoll session (hash-keyed event-drop, tools scoped by instruction — matches return-tracker); confidence gates (>=0.6 stored, >=0.8 auto-fires). 7 passing tests. - [x] Phase 6: Merge + fuzzy dedup (
merge.py) — union-find clustering on ±30-min time window +rapidfuzztoken-set-ratio ≥70 on normalized titles (prefix-stripped).prefer_canonical()picks calendar > highest-confidence gmail. 5 passing tests. - [x] Phase 7: Reminder scheduler (
schedule.py) — category→offsets→triggers with quiet-hours deferral (22:00–07:00 local) and ≤2h urgent override. Skips past triggers. 9 passing tests. - [x] Phase 8: Tick runner (
tick.py) — fires due reminders viabb-send.sh, respectsMAX_FIRES_PER_HOURbudget, supportsdry_run=True, swallows send exceptions so unsent reminders re-fire next tick. 5 passing tests. - [x] Phase 9: CLI orchestration (
pipeline.py,event-tracker.py,event-tracker.sh) —fetchpulls both sources + merges + plans reminders (errors isolated per-source),tickfires due reminders,runis fetch+tick (launchd default).fcntl.flocksingle-process lock. 5 passing integration tests. - [x] Phase 10: Dashboard (
dashboard.py,templates/upcoming.html.j2,templates/_event_card.html.j2) — Flask app on127.0.0.1:8877with/upcoming(7-day HTML timeline grouped by local day),/api/events?days=NJSON,POST /api/actionfor snooze/dismiss.format_digest()for morning summary text. 9 passing tests. - [x] Phase 11: launchd plist (
com.mark.event-tracker.plist) + activation checklist (see## Activationabove).StartInterval=900(15 min),RunAtLoad=true. Logs under~/Library/Logs/event-tracker/.
Open questions
- Inbound iMessage handling for conversational snooze ("snooze 1h" as a reply) — needs a BlueBubbles webhook listener; defer to post-MVP.
- Morning/evening digest cadence — crontab vs. a second launchd agent vs. piggybacking on
tickat :00/:30 boundaries. - Subscription-renewal detection heuristic — Gmail alone vs. also parsing Apple/Stripe email patterns explicitly.
- Multi-device delivery (Apple Watch complication, push) — out of scope for now.