A small, vendor-neutral "wake detection" webhook bus. It subscribes to a health
data provider (v1: Google Health API, for Fitbit devices), infers when its person
actually woke up, and fans out a clean, signed user.awake event to every
subscriber. It is not built for any one consumer — OpenClaw is simply one subscriber
among many (Home Assistant, n8n, a raw URL, your own script…).
The bus states a fact ("this person woke at 07:03") and broadcasts it. Each subscriber decides its own behavior (OpenClaw runs a morning routine; a lamp turns on; a script logs it). The bus knows nothing about any subscriber's purpose.
You cannot point Google Health's webhook straight at OpenClaw. Three hard blockers:
- Semantics — Google's webhook fires on
operation: UPSERTmeaning "sleep data changed," which also fires for naps, mid-night syncs, and after-the-fact edits. It is not a "you woke up" event. Something must fetch the session and infer the wake. - Payload mismatch — Google sends
{operation, healthUserId, dataType, intervals}; OpenClaw expects its own{action, goal}/{text, mode}shapes. - Handshake — Google requires an endpoint-ownership verification challenge (POST with
auth →
200, POST without →401/403) and a204ack with up-to-7-day retries. OpenClaw's endpoint won't perform that.
This service is the translator that closes the gap — and it does so generically, so any consumer (OpenClaw, Home Assistant, n8n, a raw URL) can subscribe.
you wake
│ (Fitbit device → phone → Fitbit/Google cloud sync)
▼
Google Health cloud ──HTTP POST──▶ ADAPTER (/webhook) "sleep data changed (UPSERT)"
│
│ 1. ack 204 immediately
│ 2. fetch sleep session (Google Health API)
│ 3. apply wake-inference rule
│ 4. dedup against state (bun:sqlite)
▼
ADAPTER ──fan out signed `user.awake`──▶ ALL SUBSCRIBERS
├─▶ OpenClaw → runs workflow
├─▶ Home Assistant → lamp on
└─▶ any URL → its own behavior
Latency floor is Fitbit's phone→cloud sync (we add ~0). Opening the Fitbit app forces an instant sync; background sync is ~15 min. Documented as a setup note, not solvable in code.
[ Source adapter ] ──▶ [ Core: normalize → wake-inference → dedup/state ] ──▶ [ Subscriber fan-out ]
GoogleHealthSource provider-agnostic WakeEvent (user.awake) for each subscriber:
(v1) POST signed event
+ FitbitWebSource (later) ├─ generic (default)
+ HealthConnectSource (later) └─ OpenClaw preset
- Source interface — yields normalized
SleepSessions via one or both capabilities:webhook(push: handshake + parse notification + fetch) andpoll(pull: timedrun()). A config-driven registry (src/sources/registry.ts) selects the active source by name; adding a provider is a one-line registration + a file, with no core changes. v1 shipsGoogleHealthSource(webhook). The server mounts/webhookonly for push sources; a scheduler drives poll sources. - Core — stateless inference + stateful dedup. Provider-agnostic.
- Subscribers — a configured list of consumer endpoints. The core fans the
WakeEventout to all of them, each independently signed and retried. A subscriber is{ url, secret, preset }wherepresetdefaults togeneric(raw signed event) oropenclaw(thin translation to OpenClaw's/hooks/wake). OpenClaw is just one entry. Nothing about host/provider/consumer is hardcoded; the bus has no knowledge of any subscriber's purpose. (A dynamic/subscriptionsregister/unregister API is a clean later addition; v1 is config-defined.)
- Bun + Hono (HTTP routes:
/webhook,/healthz,/test/replay) bun:sqlitefor dedup state + OAuth tokens- Shipped as a Docker image; 12-factor config (env + optional file). No ingress baked in — operator fronts it with Cloudflare Tunnel / reverse proxy / VPS / serverless as they like.
On each UPSERT webhook: ack 204, fetch the affected sleep session, then:
fire user.awake IF
session.isMainSleep # ignore naps
AND session.end.date == today (local tz) # ignore edits to prior days
AND window.start <= session.end.time <= window.end # configurable, e.g. 04:00–11:00
AND session.durationMin >= minDurationMin # weak guard, e.g. 180
AND ( not firedToday
OR session.end > lastFiredEnd + supersedeGapMin ) # heals split-night, re-fires once
THEN
emit immediately
record { firedToday=true, lastFiredEnd=session.end }
Design intent (locked priorities): never miss a wakeup; fire as fast as possible.
- No upper freshness gate → a late-syncing webhook still fires (slightly late) instead of being rejected → never miss.
- Fire on first qualifying arrival → fastest the data can physically be acted on.
- Morning-window lower bound is the primary guard against a too-early/false fire from a Fitbit "first-part" split-sleep log (Fitbit splits a sleep log if you're awake >1h mid-night and the device syncs during the gap; logs <1h apart are stitched). A first-part log ending at e.g. 04:00 falls below the window and won't fire.
- Supersede re-fire self-heals the rare split-night where a later main-sleep ends well after one we already fired on.
All thresholds (window, minDurationMin, supersedeGapMin, timezone) are config.
Neutral, signed, consumer-agnostic. Delivered to every sink.
{
"event": "user.awake",
"wokeAt": "2026-06-14T07:03:00+02:00",
"user": "<healthUserId>",
"source": "google-health",
"session": { "start": "2026-06-13T23:52:00+02:00", "end": "2026-06-14T07:03:00+02:00", "durationMin": 431 }
}The same event is broadcast to every subscriber. Each subscriber receives it independently.
- Signing:
X-Wake-Signature: sha256=<hmac>over the raw body using that subscriber's shared secret, so each consumer can verify authenticity. genericpreset (default): posts the raw signed event above to the subscriber's URL. This is the vendor-neutral contract anything can consume.openclawpreset: translates the event into a factual nudge that lets OpenClaw decide, e.g.POST /hooks/wake { "text": "You woke at 07:03 (slept 7h11m).", "mode": "now" }. Opt-in alt mapping:create_flow { goal: "<operator-configured>" }. Behavior lives in the subscriber, never in the bus.
oauth_tokens(user, access_token, refresh_token, expires_at)— refreshed automatically.wake_state(user, fired_date, last_fired_end)— once-per-day dedup + supersede tracking.delivery(subscriber, event_id, status, attempts)— per-subscriber fan-out delivery + retry tracking (one slow/failing subscriber must not block the others).- Keyed by
healthUserId→ single-user today, multi-user-ready without schema change.
- Create a Google Cloud project, enable the Health API, configure the OAuth consent screen.
- Publish the app to "In Production" (still unverified, 100-user cap — fine for personal use). This is required so refresh tokens don't expire after 7 days (the "Testing" default).
- Run a one-time local authorization flow to mint the refresh token (stored in sqlite).
- Create the Google Health sleep webhook subscription pointing at the adapter's public
/webhookURL; the adapter answers the verification challenge automatically.
Scope: googlehealth.sleep.readonly.
- Additional sources: Fitbit Web API (turns down ~Sept 2026), Health Connect on-device bridge.
- Additional sinks: Home Assistant, n8n, generic fan-out to multiple URLs.
- Multi-tenant onboarding / dashboard / hosted SaaS.
user.asleep/ other lifecycle events.
| Decision | Resolution |
|---|---|
| Scope | Open-source, self-hostable, vendor-neutral (you are first user) |
| v1 breadth | Minimal Google Health→OpenClaw path with clean pluggable interfaces |
| Data source | Google Health API (solo access confirmed; publish OAuth to Production for long-lived tokens) |
| Wake rule | isMainSleep + end.date==today + morning window + min-duration + once/day |
| Reliability | No upper freshness gate (never miss); fire on first arrival (fastest); supersede re-fire heals split-nights |
| Hosting | Deployment-agnostic portable service (Docker); no ingress baked in |
| Stack | Bun + Hono + bun:sqlite |
| Output | Fan-out: same neutral signed user.awake event broadcast to all subscribers; OpenClaw is one preset among many; each subscriber decides its own behavior |