① Collect
Pulse Provider System
How Pulse plugs data sources in: the platform/provider split, the BasePullProvider contract, link types, scheduled pulls, and normalisation to StandardPulseData.
Platforms and providers
Section titled “Platforms and providers”Health data reaches Mirobody through Pulse, which is split in two layers. A platform owns a family of sources plus the machinery they share; a provider is one concrete source inside a platform. Two platforms are registered at startup: theta is the pluggable one and owns every provider on disk, and apple is the built-in on-device batch importer, which accepts no plugins.
/api/v1/pulse/* surface users and vendors call BasePullProvider subclasses — one directory per source, loaded at startup The provider base class
Section titled “The provider base class”A theta provider subclasses BasePullProvider. The base class already implements credential storage, unlinking, timezone resolution and the per-user pull loop, so a subclass only supplies what is specific to its source.
Only two methods must be implemented: save_raw_data_to_db (keep the raw payload) and is_data_already_processed (idempotency). The rest have working defaults, or raise a clear error naming the method you were supposed to write.
Provider metadata
Section titled “Provider metadata”info returns a ProviderInfo. It is pure metadata: listing every provider costs no network call and no credentials, which is what makes GET /api/v1/pulse/providers cheap.
ProviderInfo field | Meaning |
|---|---|
slug | Unique identifier, e.g. theta_garmin. |
name · description · logo | What the client shows. |
supported · status | Whether the source is offered, and its availability. |
auth_type | A LinkType, which decides the connection code path. |
platform | The platform the provider belongs to. |
connect_info_fields | The form to collect, when the source needs credentials rather than OAuth. |
connect_info_fields is how a provider declares a form instead of hard-coding one in the UI. Each entry is a ConnectInfoField with field_name, field_type (string / number / select / password), required, label, and optional placeholder, default_value, options. The PostgreSQL provider uses five of them to ask for host, port, database, username and password. |
status is one of available · connected · disconnected · reconnect · error · maintenance. A provider declares available; the router overwrites it per user from what is actually linked.
Link types
Section titled “Link types”auth_type decides which flow a connection takes. The enum carries eleven values, but only a few are live in the shipped providers:
LinkType | Flow | Used by |
|---|---|---|
OAUTH1 | Browser redirect, then GET /api/v1/pulse/{platform}/{provider}/callback with oauth_token + oauth_verifier | theta_garmin |
OAUTH2 | Browser redirect, then the same callback with code + state | theta_whoop, theta_oura |
PASSWORD | Direct link with username + password — no browser | — |
CUSTOMIZED | Direct link with a connect_info object matching connect_info_fields | theta_pgsql |
NONE | No connection step at all | apple_health |
A PASSWORD or CUSTOMIZED provider gets validated and stored in one request, while an OAuth provider returns a link_web_url first and completes on the callback. Both end in the same place: credentials saved encrypted.
Scheduled pulls
Section titled “Scheduled pulls”A source that has to be polled gets a scheduled task — the provider declares whether it wants one. The cadence is per-slug, and each task takes a distributed lock, so several server instances can run the same schedule without pulling twice:
| Slug | Execution interval | Lock duration |
|---|---|---|
theta_oura | 5 minutes | 4 minutes |
theta_whoop | 24 hours | 23.5 hours |
theta_renpho | 24 hours | 23.5 hours |
theta_vital | 6 hours | 5.5 hours |
theta_cgm | 1 hour | 30 minutes |
| anything else | 1 hour | 30 minutes |
When a task fires, the provider loads every linked user’s credentials, fetches from the vendor per user, skips whatever it recognises as already processed, and hands the rest to the write path.
Normalizing to StandardPulseData
Section titled “Normalizing to StandardPulseData”Whether a payload arrived by webhook or by scheduled pull, every provider is bound by the same constraint: convert your source’s shape into StandardPulseData.
save_raw_data_to_db format_data_v2 Identity and timezone are resolved before formatting, so the format_data_v2 you write is pure: it maps fields and does no I/O. Each entry of healthData is a StandardPulseRecord:
StandardPulseRecord field | Meaning |
|---|---|
source | Where the reading came from, e.g. vital.garmin. |
type | The registered indicator name, not the vendor’s field name. |
timestamp | Milliseconds. startTime / endTime carry a period instead of a point. |
value · unit | The reading itself, and the unit as the source reported it. |
timezone | Defaults to UTC. |
source_id · task_id | Optional provenance, used for idempotency and tracing. |
type must be a registered indicator name rather than the source’s own field name — that is what makes readings from a Garmin watch and an Oura ring comparable. See Health Indicators for the registry, and Data Flow for what happens to the records afterwards. |
Provider discovery
Section titled “Provider discovery”Providers are loaded from disk at startup, so adding one is adding a directory — no registry edit, no rebuild. The directories scanned come from the PROVIDER_DIRS config key, which ships as:
PROVIDER_DIRS: - mirobody/pulse/providers - providersInside each directory the loader matches mirobody_*/provider_*.py, imports each match, looks for a class that subclasses BasePullProvider, then calls create_provider(config) on it and registers whatever comes back.
Four rules fall out of that, and breaking any one of them makes a provider silently absent:
mirobody_<slug>/ — the glob only matches this prefix. Keep your own providers in the root providers/ directory so upgrades don’t touch them.
provider_<something>.py — a differently named module in the same directory is never imported.
A name ending in Provider, subclassing BasePullProvider. The first match in the module wins.
create_provider(config) returning None is the supported way to stay disabled — that is how a provider with no credentials configured drops out.
Next steps
Section titled “Next steps”The sources that actually ship, and what each one needs
Configure credentials, connect an account, watch data arrive
A full implementation read end to end
Write your own against this contract
The providers that ship live in mirobody/pulse/providers/.