Skip to main content
The three shipped pull providers are OAuth clients of their vendor. Nothing in this repo can talk to Garmin, Oura or Whoop until you hold credentials from that vendor’s developer programme — they are issued per application, and cannot be bundled with an open-source release. This page is the path from “installed” to “pulling data”. To prove the provider mechanism works before dealing with any vendor, skip to Verify: a provider that declines for missing credentials still says so in the boot log, which is the mechanism working. The settings live in config.devices.yaml (named by the INCLUDE list at the top of config.yaml), not in config.yaml itself: a deployment that never connects a wearable never sees them. That file ships with empty credentials and each vendor’s endpoint defaults already filled in; put your credentials there, or in your config.{env}.yaml overlay, which overrides it (the YAML blocks below work in either).
Apple Health is not in this list, and cannot be. HealthKit is readable only from a signed iOS app, on-device, after per-type user consent — there is no web OAuth flow and no server-to-server API. This server receives Apple data (/apple/health, /apple/statistics, /apple/cda) from a client that already has it. See collect/providers/apple/.

Before you start

A publicly reachable HTTPS URL. Every vendor redirects the user’s browser back to your server after they approve, and none of them accept localhost or plain HTTP for a registered redirect. For local development, tunnel:
Use that hostname everywhere below, and set it as MCP_PUBLIC_URL in config.{env}.yaml so the rest of the server agrees about its own address. The callback route already exists. It is served at:
platform is always theta for these three. The slugs are theta_garmin, theta_oura, theta_whoop — the full slug, not the bare vendor name; the handler looks the provider up by exactly that string (ProviderPlatform.get_provider). So the redirect URL you register with the vendor, and the one you put in config, are the same string, and it looks like:

Oura

OAuth 2.0. Register at cloud.ouraring.com/oauth/applications. Set the redirect URI to the callback above with theta_oura.

Whoop

OAuth 2.0. Register at developer.whoop.com and set the redirect URI with theta_whoop.
Optional, all with working defaults — set only to pin a different environment: WHOOP_AUTH_URL, WHOOP_TOKEN_URL, WHOOP_API_BASE_URL, WHOOP_SCOPES, WHOOP_REQUEST_TIMEOUT, WHOOP_CONCURRENT_REQUESTS, WHOOP_MAX_DETAIL_RECORDS.

Garmin

OAuth 1.0a, not 2.0 — the flow is request-token → user authorises → oauth_verifier → access-token, and the callback handler branches on it (LinkType.OAUTH1). Apply through the Garmin Connect Developer Program; approval is a manual process and is usually the long pole.
Optional, with defaults: GARMIN_AUTH_URL, GARMIN_TOKEN_URL, GARMIN_ACCESS_TOKEN_URL, GARMIN_API_BASE_URL, OAUTH_TEMP_TTL_SECONDS.
Secrets are encrypted at rest automatically: any key whose name contains _SECRET, _KEY, _TOKEN, _PASSWORD … is encrypted with CONFIG_ENCRYPTION_KEY from .env the first time the server reads it. Paste the plaintext once; the file rewrites itself.

Verify

1. The provider starts. Restart and read the boot log:
The line that means credentials are missing, not code is broken:
Both are INFO. A Failed to load provider … at WARNING is a different problem: that is an import error, and a regression test covers it. 2. It is offered to users.
3. Link an account. POST /api/v1/pulse/user/providers/link (authenticated) returns the vendor authorisation URL; open it, approve, and the vendor sends the browser to your callback. On success the user’s linked providers appear in:
Unlink with POST /api/v1/pulse/user/providers/unlink. 4. Data arrives either on the pull schedule the provider registers at startup, or via webhook: POST /api/v1/pulse/{platform}/{provider}/webhook, which is the endpoint you give the vendor for push notifications. Webhooks are off (404) until you set COLLECT_WEBHOOK_SECRET; then register the URL with ?secret=<value>, or send the value in X-Webhook-Secret. A push names its person by the vendor’s own user id, which is matched through the linked account.

Troubleshooting

loaded 0 providers and no other line. Every provider declined. Check the key names against this page — a typo in OURA_CLIENT_ID looks identical to not configuring it, because create_provider returns None either way. Vendor rejects the redirect URI. It must match what you registered byte for byte, including scheme, host, path and the absence of a trailing slash. The most common miss is the slug: theta_oura, not oura. Callback returns “provider not available”. The provider declined at startup, so the platform has nothing registered under that slug. Fix the credentials first; the callback is downstream of registration. It worked, then stopped after an hour. Access tokens expire and are refreshed through refresh_access_token; if a refresh token was never stored, the vendor consent needs the offline/refresh scope. Re-link once with the right scope configured.

Writing your own

The provider contract is one directory: mirobody_<slug>/provider_<slug>.py, exporting a BasePullProvider subclass with create_provider(config) returning None when unconfigured. mirobody_whoop/ is the OAuth2 reference; mirobody_oura/ is the same shape with a different vendor. Full guide: provider-guide.md. Providers outside the package go in PROVIDER_DIRS; those are loaded by file location, so use absolute imports in them.