Skill: nathanielweiner/openclaw-health (version 1.0.0)

--- name: openclaw-health-brief description: Generate a daily health brief from Oura, Whoop, and Withings. Unified re-auth script, local token persistence, Green/Yellow/Red morning summary. --- # OpenClaw Health Brief Daily health metrics from **Oura**, **WHOOP**, and **Withings** → normalized JSON + Markdown brief. ## Setup (3 steps) ### Step 1: Configure secrets **Option A: 1Password (recommended)** ```bash export OP_SERVICE_ACCOUNT_TOKEN="your-token" export OPENCLAW_1P_VAULT="Assistant" # or your vault name ``` Create items in your vault with these titles and fields: - `OpenClaw Whoop` → `client_id`, `client_secret`, `token`, `refresh_token` - `OpenClaw Oura` → `client_id`, `client_secret`, `token`, `refresh_token` - `OpenClaw Withings` → `client_id`, `client_secret`, `access_token`, `refresh_token`, `user_id` See `./docs/1PASSWORD_CONVENTIONS.md` for full field details. **Option B: Environment variables** ```bash # WHOOP export WHOOP_ACCESS_TOKEN="..." WHOOP_REFRESH_TOKEN="..." WHOOP_CLIENT_ID="..." WHOOP_CLIENT_SECRET="..." # Oura export OURA_PERSONAL_ACCESS_TOKEN="..." # or OAuth: OURA_REFRESH_TOKEN + OURA_CLIENT_ID + OURA_CLIENT_SECRET # Withings export WITHINGS_CLIENT_ID="..." WITHINGS_CLIENT_SECRET="..." WITHINGS_REFRESH_TOKEN="..." WITHINGS_USER_ID="..." ``` ### Step 2: Authorize providers ```bash python3 ./bin/health-reauth all ``` This opens your browser for each provider. Click authorize, and tokens are saved to both 1Password and `~/.openclaw/secrets/health_tokens.json` automatically. You can also re-auth individually: `python3 ./bin/health-reauth whoop` ### Step 3: Run your first brief ```bash ./bin/health-brief --date "$(date +%F)" --sources whoop,oura,withings --out "./out/daily_health_$(date +%F).json" ``` **That's it.** Token rotation is handled automatically — refreshed tokens persist to the local file so you don't need to re-auth again. ## Add to OpenClaw cron Wire it into your morning routine with an OpenClaw cron job: ```bash openclaw cron add \ --name "morning-health-brief" \ --schedule "0 8 * * *" \ --tz "America/New_York" \ --session-target isolated \ --message 'Run the health brief: source ~/.openclaw/secrets/gateway.env export OPENCLAW_1P_VAULT=YourVault ./bin/health-brief --date "$(date +%F)" --sources whoop,oura,withings --out "/tmp/daily_health_$(date +%F).json" Read the JSON output. Report only non-null metrics with a Green/Yellow/Red rating.' ``` The cron job runs as an isolated agent session — it executes the brief, reads the output, and delivers a formatted summary to your preferred channel. ## Smoke test (no creds needed) ```bash ./bin/smoke ``` Runs in sample mode, validates JSON schema. Good for checking the skill is installed correctly. ## Troubleshooting ### Check individual providers ```bash ./bin/whoop --date "$(date +%F)" ./bin/oura --date "$(date +%F)" ./bin/withings --date "$(date +%F)" ``` ### Common errors - `has_token: false` → credentials not found. Check 1Password item names or env vars. - `refresh_failed` → refresh token expired. Run `python3 ./bin/health-reauth <provider>` - `missing_credentials` → client_id/client_secret not set. ### Validate output JSON ```bash ./bin/validate-json --in ./out/daily_health_YYYY-MM-DD.json ``` ## References - `./docs/1PASSWORD_CONVENTIONS.md` — field naming for 1Password items - `./docs/OURA.md`, `./docs/WHOOP.md`, `./docs/WITHINGS.md` — provider API notes - `./docs/MORNING_BRIEF.md` — morning brief intent and format

path: README.md
content: # OpenClaw Health Skills

Daily health metrics from **WHOOP**, **Oura**, and **Withings** — normalized JSON + Markdown brief.

## What it does
- Fetches sleep, readiness, activity, HRV, weight, and body composition
- Normalizes into a stable JSON schema (provider differences abstracted away)
- Prints a concise Markdown brief (Green/Yellow/Red rating)
- Auto-persists rotated OAuth tokens so you only authorize once

## Quick start

### 0. Pick your providers

```bash
./bin/onboard
```

Interactive setup (~1 min) that asks:
- Which devices do you use? (WHOOP, Oura, Withings — pick any combo)
- How do you use them? (24/7, sleep-only, day-only, etc.)
- Source of truth for overlapping metrics (e.g. sleep from Oura or WHOOP?)

Writes a `config.json` so the brief knows what to pull and which source to trust.

### 1. Configure secrets

**Option A: 1Password (recommended)**
```bash
export OP_SERVICE_ACCOUNT_TOKEN="your-token"
export OPENCLAW_1P_VAULT="YourVault"   # your vault name
```

Create items in your vault (see `docs/1PASSWORD_CONVENTIONS.md` for field details):
- `OpenClaw Whoop` → `client_id`, `client_secret`, `token`, `refresh_token`
- `OpenClaw Oura` → `client_id`, `client_secret`, `token`, `refresh_token`
- `OpenClaw Withings` → `client_id`, `client_secret`, `access_token`, `refresh_token`, `user_id`

**Option B: Environment variables**
```bash
# WHOOP
export WHOOP_CLIENT_ID="..." WHOOP_CLIENT_SECRET="..." WHOOP_ACCESS_TOKEN="..." WHOOP_REFRESH_TOKEN="..."
# Oura
export OURA_CLIENT_ID="..." OURA_CLIENT_SECRET="..." OURA_PERSONAL_ACCESS_TOKEN="..."
# Withings
export WITHINGS_CLIENT_ID="..." WITHINGS_CLIENT_SECRET="..." WITHINGS_REFRESH_TOKEN="..." WITHINGS_USER_ID="..."
```

### 2. Authorize providers

```bash
python3 ./bin/health-reauth all
```

Opens your browser for each provider. Click authorize — tokens are saved to both 1Password and `~/.openclaw/secrets/health_tokens.json` automatically.

Re-auth individually if needed: `python3 ./bin/health-reauth whoop`

### 3. Run your first brief

```bash
./bin/health-brief --date "$(date +%F)" --sources whoop,oura,withings --out /tmp/daily_health.json
```

Outputs:
- Normalized JSON → `--out` path
- Markdown brief → stdout

**That's it.** Token rotation is handled automatically.

## Add to OpenClaw cron (daily morning brief)

Add a cron job so your health brief runs automatically each morning:

```bash
openclaw cron add \
  --name "morning-health-brief" \
  --schedule "0 8 * * *" \
  --tz "America/New_York" \
  --session-target isolated \
  --message 'Run the health brief and send results.

```bash
source ~/.openclaw/secrets/gateway.env
export OPENCLAW_1P_VAULT=YourVault
DATE="$(date +%F)"
cd /path/to/openclaw-health-skills
./bin/health-brief --date "$DATE" --sources whoop,oura,withings --out "/tmp/daily_health_${DATE}.json"
```

Read the output JSON. Only report metrics with non-null values. Present with a Green/Yellow/Red rating. Send a brief summary to the user.'
```

Or if you prefer to add it via the OpenClaw config directly, create an isolated `agentTurn` cron job that:
1. Runs `./bin/health-brief` with today's date
2. Reads the output JSON
3. Formats a brief with Green/Yellow/Red rating
4. Delivers via your preferred channel (Telegram, Discord, etc.)

See [OpenClaw cron docs](https://docs.openclaw.ai/automation/cron) for more options.

## Token persistence

Refreshed tokens persist to `~/.openclaw/secrets/health_tokens.json` (chmod 600). If a provider rotates your refresh token during a refresh, the new one is saved immediately. You should never need to re-auth unless you revoke access at the provider.

Override location: `OPENCLAW_LOCAL_SECRETS_PATH=/path/to/health_tokens.json`

## Troubleshooting

### Check individual providers
```bash
./bin/whoop --date "$(date +%F)"
./bin/oura --date "$(date +%F)"
./bin/withings --date "$(date +%F)"
```

### Common issues
| Symptom | Cause | Fix |
|---------|-------|-----|
| `has_token: false` | Credentials not found | Check 1Password item names or env vars |
| `refresh_failed` | Refresh token expired/revoked | `python3 ./bin/health-reauth <provider>` |
| `missing_credentials` | No client_id/client_secret | Add to 1Password or env vars |
| 403 on token exchange | Cloudflare blocking Python | `health-reauth` uses curl to bypass this |

### Validate output
```bash
python3 -m unittest discover -s tests -p 'test_*.py'
```

## Provider setup (one-time per provider)

Each provider requires a free developer account and an OAuth app. This takes ~5 minutes per provider.

### WHOOP

1. Go to [developer.whoop.com](https://developer.whoop.com) and create an account
2. Create a new OAuth application:
   - **Name:** anything (e.g. "OpenClaw Health")
   - **Redirect URI:** `http://127.0.0.1:8787/callback`
   - **Scopes:** enable all `read:*` scopes + `offline` (required for refresh tokens)
3. Copy your **Client ID** and **Client Secret**
4. Store in 1Password (item: `OpenClaw Whoop`, fields: `client_id`, `client_secret`) or export as env vars
5. Run `python3 ./bin/health-reauth whoop` — it opens your browser, you click authorize, done

### Oura

1. Go to [cloud.ouraring.com/v2/docs](https://cloud.ouraring.com/v2/docs) and sign in
2. Go to **My Applications** → create a new application:
   - **Name:** anything
   - **Redirect URI:** `http://localhost:8788/callback`
   - **Scopes:** `daily` and `personal` (required for sleep, readiness, activity)
3. Copy your **Client ID** and **Client Secret**
4. Store in 1Password (item: `OpenClaw Oura`, fields: `client_id`, `client_secret`) or export as env vars
5. Run `python3 ./bin/health-reauth oura`

> **Note:** Oura deprecated personal access tokens in Dec 2025. OAuth is now required.

### Withings

1. Go to [developer.withings.com](https://developer.withings.com) and create a developer account
2. Create a new application:
   - **Name:** anything
   - **Redirect URI:** `http://localhost:8791/callback`
   - **Scopes:** `user.metrics`, `user.activity`
3. Copy your **Client ID** and **Client Secret**
4. Store in 1Password (item: `OpenClaw Withings`, fields: `client_id`, `client_secret`) or export as env vars
5. Run `python3 ./bin/health-reauth withings`

### After setup

Once you've authorized all providers, you're done. The `health-reauth` script saves tokens to both 1Password and a local file. Token rotation is automatic — you shouldn't need to re-auth unless you revoke access at the provider.

For detailed per-provider API notes, see `docs/WHOOP.md`, `docs/OURA.md`, `docs/WITHINGS.md`, and `docs/1PASSWORD_CONVENTIONS.md`.

## Architecture

```
bin/                  CLI entrypoints (health-brief, health-reauth, per-provider)
connectors/           Provider-specific fetch + OAuth refresh logic
core/
  normalize/          Unified DailyHealth schema
  render/             Markdown brief renderer
  util/
    secrets.py        1Password + env var secret resolution
    local_secrets.py  Local file token persistence
docs/                 Per-provider API notes + 1Password field conventions
tests/                Unit tests
```


path: connectors/oura/__init__.py
content: import os
import json
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from typing import Any, Dict, Optional

from core.util.secrets import get_secret


OURA_API = "https://api.ouraring.com"


def _http_get_json(url: str, headers: Dict[str, str], timeout: int = 10) -> Dict[str, Any]:
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec - network blocked in tests
        data = resp.read()
    return json.loads(data.decode("utf-8"))


def _http_post_form_json(url: str, form: Dict[str, str], headers: Optional[Dict[str, str]] = None, timeout: int = 10) -> Dict[str, Any]:
    headers = headers or {}
    body = urllib.parse.urlencode(form).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded", **headers},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec - network blocked in tests
        data = resp.read()
    return json.loads(data.decode("utf-8"))


def _http_error_details(he: Exception, max_chars: int = 400) -> Dict[str, Any]:
    # Best-effort to capture status + small response snippet (never includes auth headers).
    details: Dict[str, Any] = {}
    code = getattr(he, "code", None)
    if isinstance(code, int):
        details["status"] = code
    try:
        body = getattr(he, "read", None)
        if callable(body):
            raw = body()
            if isinstance(raw, (bytes, bytearray)):
                txt = raw.decode("utf-8", errors="replace")
                details["body_snippet"] = txt[:max_chars]
    except Exception:
        pass
    return details


def _get_oura_tokens() -> Dict[str, Optional[str]]:
    return {
        "access_token": (
            get_secret("OpenClaw Oura", "token")
            or get_secret("OpenClaw Oura", "credential")
            or os.getenv("OURA_PERSONAL_ACCESS_TOKEN")
        ),
        "refresh_token": get_secret("OpenClaw Oura", "refresh_token") or os.getenv("OURA_REFRESH_TOKEN"),
        "client_id": get_secret("OpenClaw Oura", "client_id") or os.getenv("OURA_CLIENT_ID"),
        "client_secret": get_secret("OpenClaw Oura", "client_secret") or os.getenv("OURA_CLIENT_SECRET"),
    }


def _oura_refresh_access_token(refresh_token: str, client_id: str, client_secret: str) -> Dict[str, Any]:
    """Refresh Oura access token.

    Per Oura docs, client_id/client_secret can be supplied either as body params OR via HTTP Basic Auth.
    We prefer Basic Auth because it is the most widely compatible OAuth2 pattern.
    """

    url = f"{OURA_API}/oauth/token"
    try:
        # Basic auth: base64(client_id:client_secret)
        import base64

        b = f"{client_id}:{client_secret}".encode("utf-8")
        basic = base64.b64encode(b).decode("ascii")
        headers = {"Authorization": f"Basic {basic}"}

        resp = _http_post_form_json(
            url,
            {
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
            },
            headers=headers,
        )
        return {"ok": True, "access_token": resp.get("access_token"), "refresh_token": resp.get("refresh_token")}
    except urllib.error.HTTPError as he:  # type: ignore[attr-defined]
        return {"ok": False, "error": "refresh_http_error", **_http_error_details(he)}
    except Exception as e:
        return {"ok": False, "error": f"refresh_failed: {type(e).__name__}"}


def _parse_daily_sleep(obj: Dict[str, Any]) -> Dict[str, Any]:
    d = {
        "total_seconds": None,
        "score": None,
        "respiratory_rate": None,
        "resting_hr": None,
        "hrv_rmssd": None,
        "spo2": None,
    }
    if not obj:
        return d
    # Seconds
    for k in ["total_sleep_duration", "total_sleep_time", "duration"]:
        if isinstance(obj.get(k), (int, float)):
            d["total_seconds"] = int(obj[k])
            break
    # Score
    if isinstance(obj.get("score"), (int, float)):
        d["score"] = obj["score"]
    # Respiratory rate
    for k in ["average_breathing_rate", "avg_breathing_rate", "breath_average"]:
        v = obj.get(k)
        if isinstance(v, (int, float)):
            d["respiratory_rate"] = float(v)
            break
    # Resting HR
    for k in ["resting_heart_rate", "lowest_heart_rate", "average_heart_rate"]:
        v = obj.get(k)
        if isinstance(v, (int, float)):
            d["resting_hr"] = int(v)
            break
    # HRV RMSSD (ms)
    for k in ["average_rmssd", "rmssd", "hrv_rmssd_millis", "rmssd_millis", "average_hrv"]:
        v = obj.get(k)
        if isinstance(v, (int, float)):
            d["hrv_rmssd"] = float(v)
            break
    return d


def _safe_int(v: Any) -> Optional[int]:
    try:
        return int(v) if v is not None else None
    except Exception:
        return None


def fetch(date_str: str, live: bool = False) -> Dict[str, Any]:
    tokens = _get_oura_tokens()
    access_token = tokens.get("access_token")

    payload: Dict[str, Any] = {
        "source": "oura",
        "date": date_str,
        "has_token": bool(access_token),
        "sleep": {"total_seconds": None, "score": None},
        "readiness": {"score": None},
        "activity": {"steps": None, "calories": None, "minutes": None},
        "resting_hr": None,
        "hrv_rmssd": None,
        "respiratory_rate": None,
        "spo2": None,
    }

    if not access_token:
        # No credentials available. Do NOT fabricate sample values.
        payload["has_token"] = False
        payload["error"] = {
            "message": "missing_token",
            "hint": "Set OURA_PERSONAL_ACCESS_TOKEN (or configure 1Password item 'OpenClaw Oura').",
        }
        return payload

    if os.getenv("OPENCLAW_FORCE_SAMPLE") == "1":
        return payload

    try:
        _ = datetime.strptime(date_str, "%Y-%m-%d")
    except Exception:
        payload["error"] = {"message": "invalid_date", "detail": "Expected YYYY-MM-DD"}
        return payload

    def _attempt(access_tok: str) -> Dict[str, Any]:
        headers = {"Authorization": f"Bearer {access_tok}"}
        try:
            base = f"{OURA_API}/v2/usercollection"
            q = urllib.parse.urlencode({"start_date": date_str, "end_date": date_str})

            js_sleep = _http_get_json(f"{base}/daily_sleep?{q}", headers)
            sleep_list = js_sleep.get("data") or []
            sleep_obj = sleep_list[0] if sleep_list else {}
            sleep_parsed = _parse_daily_sleep(sleep_obj)
            payload["sleep"] = {"total_seconds": sleep_parsed["total_seconds"], "score": sleep_parsed["score"]}
            if sleep_parsed.get("resting_hr") is not None:
                payload["resting_hr"] = _safe_int(sleep_parsed["resting_hr"])
            if sleep_parsed.get("hrv_rmssd") is not None:
                payload["hrv_rmssd"] = sleep_parsed["hrv_rmssd"]
            if sleep_parsed.get("respiratory_rate") is not None:
                payload["respiratory_rate"] = sleep_parsed["respiratory_rate"]

            js_ready = _http_get_json(f"{base}/daily_readiness?{q}", headers)
            r_list = js_ready.get("data") or []
            r_obj = r_list[0] if r_list else {}
            r_score = r_obj.get("score") if isinstance(r_obj.get("score"), (int, float)) else None
            payload["readiness"] = {"score": r_score}

            js_act = _http_get_json(f"{base}/daily_activity?{q}", headers)
            a_list = js_act.get("data") or []
            a_obj = a_list[0] if a_list else {}
            steps = _safe_int(a_obj.get("steps"))

            calories = None
            for k in ["calories", "total_calories", "calories_burned", "active_calories"]:
                v = a_obj.get(k)
                if isinstance(v, (int, float)):
                    calories = int(v)
                    break

            minutes = None
            for k in ["equivalent_min", "active_time", "moderate_activity_time", "high_activity_time"]:
                v = a_obj.get(k)
                if isinstance(v, (int, float)):
                    minutes = int(v / 60) if v and v > 1000 else int(v)
                    break

            payload["activity"] = {"steps": steps, "calories": calories, "minutes": minutes}
            return payload

        except urllib.error.HTTPError as he:  # type: ignore[attr-defined]
            details = _http_error_details(he)
            return {"http_error": details.get("status", 0), "message": "http_error", **details}
        except Exception as e:
            return {"error": f"fetch_failed: {type(e).__name__}"}

    res = _attempt(access_token)  # type: ignore[arg-type]

    if isinstance(res, dict) and res.get("http_error") == 401:
        rt, cid, cs = tokens.get("refresh_token"), tokens.get("client_id"), tokens.get("client_secret")
        if rt and cid and cs:
            ref = _oura_refresh_access_token(rt, cid, cs)
            if ref.get("ok") and ref.get("access_token"):
                new_access = ref.get("access_token")
                res2 = _attempt(new_access)  # type: ignore[arg-type]
                if isinstance(res2, dict) and "http_error" not in res2 and "error" not in res2:
                    new_rt = ref.get("refresh_token")
                    if new_rt and new_rt != rt:
                        payload["note"] = "Oura refresh_token rotated. Update your 1Password item."
                    return payload
                else:
                    payload["error"] = {
                        "message": "fetch_failed_after_refresh",
                        "detail": {k: res2.get(k) for k in ["http_error", "status", "body_snippet", "message", "error"] if isinstance(res2, dict)},
                    }
                    return payload
            else:
                payload["error"] = {"message": "refresh_failed", "detail": {k: ref.get(k) for k in ["status", "body_snippet", "error"]}}
                return payload
        else:
            payload["error"] = {"message": "unauthorized", "detail": "Token expired; missing refresh credentials."}
            return payload

    if isinstance(res, dict) and ("http_error" in res or "error" in res):
        payload["error"] = {"message": res.get("message", "fetch_failed"), "detail": {k: res.get(k) for k in ["http_error", "status", "body_snippet", "error"]}}
        return payload

    return payload


def cli(argv=None):
    import argparse

    parser = argparse.ArgumentParser(description="Oura connector")
    parser.add_argument("--date", required=True, help="YYYY-MM-DD")
    parser.add_argument("--live", action="store_true", help="DEPRECATED: live is default when credentials are present")
    args = parser.parse_args(argv)

    datetime.strptime(args.date, "%Y-%m-%d")
    payload = fetch(args.date, live=args.live)
    print(json.dumps(payload, indent=2))


if __name__ == "__main__":
    cli()


path: connectors/whoop/__init__.py
content: import os
import json
import urllib.parse
import urllib.request
from datetime import datetime
from typing import Any, Dict, Optional

from core.util.secrets import get_secret, set_secret
from core.util.local_secrets import get_local_secret, set_local_secret

# WHOOP Developer API base URLs
WHOOP_API_V2 = "https://api.prod.whoop.com/developer/v2"
WHOOP_TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token"


def _http_get_json(url: str, headers: Dict[str, str], timeout: int = 10) -> Dict[str, Any]:
    # Some edge/WAF setups will block requests without a User-Agent.
    base_headers = {
        "Accept": "application/json",
        "User-Agent": "openclaw-health-skills/0.1 (+https://github.com/openclaw/openclaw-health-skills)",
    }
    req = urllib.request.Request(url, headers={**base_headers, **headers})
    with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec - network disabled in tests
        data = resp.read()
    return json.loads(data.decode("utf-8"))


def _http_post_form_json(url: str, form: Dict[str, str], headers: Optional[Dict[str, str]] = None, timeout: int = 10) -> Dict[str, Any]:
    headers = headers or {}
    body = urllib.parse.urlencode(form).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=body,
        headers={
            "Accept": "application/json",
            "User-Agent": "openclaw-health-skills/0.1 (+https://github.com/openclaw/openclaw-health-skills)",
            "Content-Type": "application/x-www-form-urlencoded",
            **headers,
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec - network disabled in tests
        data = resp.read()
    return json.loads(data.decode("utf-8"))


def _http_error_details(he: Exception, max_chars: int = 400) -> Dict[str, Any]:
    details: Dict[str, Any] = {}
    code = getattr(he, "code", None)
    if isinstance(code, int):
        details["status"] = code
    try:
        body = getattr(he, "read", None)
        if callable(body):
            raw = body()
            if isinstance(raw, (bytes, bytearray)):
                txt = raw.decode("utf-8", errors="replace")
                details["body_snippet"] = txt[:max_chars]
    except Exception:
        pass
    return details


def _s(v: Optional[str]) -> Optional[str]:
    return v.strip() if isinstance(v, str) else None


def _get_whoop_tokens() -> Dict[str, Optional[str]]:
    # WHOOP access token: use `token` only.
    access = _s(get_secret("OpenClaw Whoop", "token") or os.getenv("WHOOP_ACCESS_TOKEN"))

    refresh = _s(
        # Prefer local persisted refresh token (for automatic rotation without 1Password write access).
        get_local_secret("whoop", "refresh_token")
        or get_secret("OpenClaw Whoop", "refresh_token")
        # Back-compat: an early 1Password item used this label.
        or get_secret("OpenClaw Whoop", "refresh_password")
        or os.getenv("WHOOP_REFRESH_TOKEN")
    )

    return {
        "access_token": access,
        "refresh_token": refresh,
        "client_id": _s(get_secret("OpenClaw Whoop", "client_id") or os.getenv("WHOOP_CLIENT_ID")),
        "client_secret": _s(get_secret("OpenClaw Whoop", "client_secret") or os.getenv("WHOOP_CLIENT_SECRET")),
        "redirect_uri": _s(os.getenv("WHOOP_REDIRECT_URI") or os.getenv("WHOOP_REDIRECT_URL") or "http://127.0.0.1:8787/callback"),
    }


def _whoop_refresh_access_token(refresh_token: str, client_id: str, client_secret: str, redirect_uri: Optional[str]) -> Dict[str, Any]:
    # Refresh token flow.
    # Note: WHOOP can be picky about parameters. In practice, sending redirect_uri on refresh
    # may cause "invalid_request" if it doesn't exactly match what WHOOP expects.
    # So we omit redirect_uri here and keep the payload minimal.
    try:
        form = {
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
            "client_id": client_id,
            "client_secret": client_secret,
        }

        resp = _http_post_form_json(WHOOP_TOKEN_URL, form)
        return {
            "ok": True,
            "access_token": resp.get("access_token"),
            "refresh_token": resp.get("refresh_token"),
            "expires_in": resp.get("expires_in"),
        }
    except urllib.error.HTTPError as he:  # type: ignore[attr-defined]
        return {"ok": False, "error": "refresh_http_error", **_http_error_details(he)}
    except Exception as e:
        return {"ok": False, "error": f"refresh_failed: {type(e).__name__}"}


def _iso_window_for_date(date_str: str) -> Dict[str, str]:
    # WHOOP collection endpoints expect RFC3339 date-time strings.
    return {
        "start": f"{date_str}T00:00:00.000Z",
        "end": f"{date_str}T23:59:59.999Z",
    }


def fetch(date_str: str, live: bool = False, debug_raw: bool = False) -> Dict[str, Any]:
    toks = _get_whoop_tokens()
    access_token = toks.get("access_token")

    payload: Dict[str, Any] = {
        "source": "whoop",
        "date": date_str,
        "has_token": bool(access_token),
        "sleep": {"total_seconds": None, "score": None},
        "readiness": {"score": None},
        "activity": {"steps": None, "calories": None, "minutes": None},
        "resting_hr": None,
        "hrv_rmssd": None,
        "respiratory_rate": None,
        "spo2": None,
    }

    if not access_token:
        # No access token — try to refresh if we have refresh creds.
        rt, cid, cs = toks.get("refresh_token"), toks.get("client_id"), toks.get("client_secret")
        if rt and cid and cs:
            ref = _whoop_refresh_access_token(rt, cid, cs, toks.get("redirect_uri"))
            if ref.get("ok"):
                access_token = ref["access_token"]
                payload["has_token"] = True
                # Persist rotated tokens
                try:
                    from core.util.local_secrets import set_local_secret
                    set_local_secret("whoop", "access_token", access_token)
                    if ref.get("refresh_token"):
                        set_local_secret("whoop", "refresh_token", ref["refresh_token"])
                except Exception:
                    pass
            else:
                payload["has_token"] = False
                payload["error"] = {
                    "message": "refresh_failed",
                    "detail": ref,
                }
                return payload
        else:
            payload["has_token"] = False
            payload["error"] = {
                "message": "missing_token",
                "hint": "Set WHOOP_ACCESS_TOKEN (and optionally refresh creds) or configure 1Password item 'OpenClaw Whoop'.",
            }
            return payload

    if os.getenv("OPENCLAW_FORCE_SAMPLE") == "1":
        return payload

    def _attempt(tok: str) -> Dict[str, Any]:
        headers = {"Authorization": f"Bearer {tok}"}

        win = _iso_window_for_date(date_str)
        qs = urllib.parse.urlencode({"start": win["start"], "end": win["end"], "limit": "1"})

        # Cycle (v2)
        cyc = _http_get_json(f"{WHOOP_API_V2}/cycle?{qs}", headers)
        if debug_raw:
            payload.setdefault("debug_raw", {})["cycle_v2"] = cyc
        recs = cyc.get("records") or cyc.get("data") or []
        c0 = recs[0] if recs else {}

        # Activity calories: best-effort from cycle score kilojoules
        try:
            cscore = c0.get("score") or {}
            kj = cscore.get("kilojoule")
            if isinstance(kj, (int, float)):
                payload["activity"]["calories"] = int(float(kj) * 0.239005736)
        except Exception:
            pass

        # Recovery collection (v2)
        rec2 = _http_get_json(f"{WHOOP_API_V2}/recovery?{qs}", headers)
        if debug_raw:
            payload.setdefault("debug_raw", {})["recovery_v2"] = rec2
        items = rec2.get("records") or rec2.get("data") or []
        rec = items[0] if items else None
        if isinstance(rec, dict):
            score_obj = rec.get("score") or rec
            if isinstance(score_obj, dict):
                score = score_obj.get("recovery_score")
                if isinstance(score, (int, float)):
                    payload["readiness"]["score"] = int(score)
                rhr = score_obj.get("resting_heart_rate")
                if isinstance(rhr, (int, float)):
                    payload["resting_hr"] = int(rhr)
                hrv = score_obj.get("hrv_rmssd_milli")
                if isinstance(hrv, (int, float)):
                    payload["hrv_rmssd"] = float(hrv)
                spo2 = score_obj.get("spo2_percentage")
                if isinstance(spo2, (int, float)):
                    payload["spo2"] = float(spo2)

        # Sleep collection (v2)
        sl2 = _http_get_json(f"{WHOOP_API_V2}/activity/sleep?{qs}", headers)
        if debug_raw:
            payload.setdefault("debug_raw", {})["sleep_v2"] = sl2
        items = sl2.get("records") or sl2.get("data") or []
        sl = items[0] if items else None
        if isinstance(sl, dict):
            sc = sl.get("score") or sl.get("scores") or {}
            if isinstance(sc, dict):
                perf = sc.get("sleep_performance_percentage")
                if isinstance(perf, (int, float)):
                    payload["sleep"]["score"] = int(perf)
                resp = sc.get("respiratory_rate")
                if isinstance(resp, (int, float)):
                    payload["respiratory_rate"] = float(resp)
                stage = sc.get("stage_summary") or {}
                if isinstance(stage, dict):
                    tib = stage.get("total_in_bed_time_milli")
                    if isinstance(tib, (int, float)):
                        payload["sleep"]["total_seconds"] = int(tib / 1000)

        return payload

    try:
        return _attempt(access_token)
    except urllib.error.HTTPError as he:  # type: ignore[attr-defined]
        details = _http_error_details(he)
        if details.get("status") == 401:
            rt, cid, cs = toks.get("refresh_token"), toks.get("client_id"), toks.get("client_secret")
            if rt and cid and cs:
                ref = _whoop_refresh_access_token(rt, cid, cs, toks.get("redirect_uri"))
                if ref.get("ok") and ref.get("access_token"):
                    try:
                        res2 = _attempt(ref.get("access_token"))
                    except urllib.error.HTTPError as he2:  # type: ignore[attr-defined]
                        payload["error"] = {"message": "fetch_failed_after_refresh", "detail": {"error": "http_error", **_http_error_details(he2)}}
                        return payload
                    except Exception as e:
                        payload["error"] = {"message": "fetch_failed_after_refresh", "detail": f"{type(e).__name__}"}
                        return payload

                    new_rt = ref.get("refresh_token")
                    if new_rt and new_rt != rt:
                        # Persist rotated refresh token.
                        # 1) Always write to local secrets file (preferred for no-human-intervention).
                        local_ok = False
                        try:
                            local_ok = set_local_secret("whoop", "refresh_token", str(new_rt))
                        except Exception:
                            local_ok = False

                        # 2) Best-effort 1Password writeback (opt-in).
                        wrote_1p = False
                        try:
                            wrote_1p = set_secret("OpenClaw Whoop", "refresh_token", str(new_rt))
                        except Exception:
                            wrote_1p = False

                        if local_ok:
                            payload["note"] = "WHOOP refresh_token rotated. Saved locally."
                        else:
                            payload["note"] = "WHOOP refresh_token rotated. Local save failed; update your stored token."
                        if wrote_1p:
                            payload["note"] += " (Also updated 1Password.)"
                    return res2

                payload["error"] = {"message": "refresh_failed", "detail": {k: ref.get(k) for k in ["status", "body_snippet", "error"]}}
                return payload

            payload["error"] = {"message": "unauthorized", "detail": "WHOOP access token expired. Please re-auth.", **details}
            return payload

        payload["error"] = {"message": "http_error", **details}
        return payload
    except Exception as e:
        return {**payload, "error": {"message": f"fetch_failed: {type(e).__name__}"}}


def cli(argv=None):
    import argparse

    parser = argparse.ArgumentParser(description="Whoop connector")
    parser.add_argument("--date", required=True, help="YYYY-MM-DD")
    parser.add_argument("--live", action="store_true", help="DEPRECATED: live is default when credentials are present")
    parser.add_argument("--debug-raw", action="store_true", help="Include raw WHOOP API responses (for mapping/debugging)")
    args = parser.parse_args(argv)

    datetime.strptime(args.date, "%Y-%m-%d")

    payload = fetch(args.date, live=args.live, debug_raw=args.debug_raw)
    print(json.dumps(payload, indent=2))


if __name__ == "__main__":
    cli()


path: connectors/withings/__init__.py
content: import os
import json
import time
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Tuple

from core.util.secrets import get_secret, set_secret
from core.util.local_secrets import get_local_secret, set_local_secret

WITHINGS_API = "https://wbsapi.withings.net"


def _http_get_json(url: str, headers: Dict[str, str], timeout: int = 10) -> Dict[str, Any]:
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec
        data = resp.read()
    return json.loads(data.decode("utf-8"))


def _http_post_form_json(url: str, form: Dict[str, str], headers: Optional[Dict[str, str]] = None, timeout: int = 10) -> Dict[str, Any]:
    headers = headers or {}
    body = urllib.parse.urlencode(form).encode("utf-8")
    req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded", **headers}, method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:  # nosec
        data = resp.read()
    return json.loads(data.decode("utf-8"))


def _read_withings_secrets() -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
    # Prefer 1Password
    cid = get_secret("OpenClaw Withings", "client_id")
    csec = get_secret("OpenClaw Withings", "client_secret")
    rtok = get_local_secret("withings", "refresh_token") or get_secret("OpenClaw Withings", "refresh_token")
    uid = get_secret("OpenClaw Withings", "user_id")
    # Fallback env
    cid = cid or os.getenv("WITHINGS_CLIENT_ID")
    csec = csec or os.getenv("WITHINGS_CLIENT_SECRET")
    rtok = rtok or os.getenv("WITHINGS_REFRESH_TOKEN")
    uid = uid or os.getenv("WITHINGS_USER_ID")
    return cid, csec, rtok, uid


def _withings_refresh_access_token(cid: str, csec: str, rtok: str) -> Dict[str, Any]:
    url = f"{WITHINGS_API}/v2/oauth2"
    try:
        resp = _http_post_form_json(
            url,
            {
                "action": "requesttoken",
                "grant_type": "refresh_token",
                "client_id": cid,
                "client_secret": csec,
                "refresh_token": rtok,
            },
        )
        # Withings returns {status: 0, body: {...}}
        if isinstance(resp, dict) and resp.get("status") == 0:
            body = resp.get("body", {})
            return {
                "ok": True,
                "access_token": body.get("access_token"),
                "refresh_token": body.get("refresh_token"),
                "expires_in": body.get("expires_in"),
            }
        return {"ok": False, "error": f"status_{resp.get('status')}"}
    except Exception as e:
        return {"ok": False, "error": f"refresh_failed: {type(e).__name__}"}


def _epoch_range_for_day(date_str: str) -> Tuple[int, int]:
    dt = datetime.strptime(date_str, "%Y-%m-%d")
    start = int(time.mktime(dt.timetuple()))
    end = int(time.mktime((dt + timedelta(days=1)).timetuple()))
    return start, end


def fetch(date_str: str, live: bool = False) -> Dict[str, Any]:
    cid, csec, rtok, uid = _read_withings_secrets()
    has_creds = bool(cid and csec and rtok)

    payload: Dict[str, Any] = {
        "source": "withings",
        "date": date_str,
        "has_token": has_creds,
        "sleep": {"total_seconds": None, "score": None},
        "readiness": {"score": None},
        "activity": {"steps": None, "calories": None, "minutes": None},
        "resting_hr": None,
        "hrv_rmssd": None,
        "respiratory_rate": None,
        "spo2": None,
        "weight_kg": None,
        "body_fat_percent": None,
    }

    # If we don't have refresh creds, do NOT fabricate sample values.
    if not has_creds:
        payload["has_token"] = False
        payload["error"] = {
            "message": "missing_creds",
            "hint": "Set WITHINGS_CLIENT_ID/WITHINGS_CLIENT_SECRET/WITHINGS_REFRESH_TOKEN (or configure 1Password item 'OpenClaw Withings').",
        }
        return payload

    if os.getenv("OPENCLAW_FORCE_SAMPLE") == "1":
        return payload

    # Refresh to get an access token (Withings typically requires it)
    ref = _withings_refresh_access_token(cid or "", csec or "", rtok or "")
    if not ref.get("ok") or not ref.get("access_token"):
        payload["error"] = {"message": "refresh_failed", "hint": "Ensure client_id, client_secret, refresh_token are valid."}
        return payload
    access_token = ref.get("access_token")
    rotated_note = None
    new_rtok = ref.get("refresh_token")
    if new_rtok and rtok and new_rtok != rtok:
        local_ok = False
        try:
            local_ok = set_local_secret("withings", "refresh_token", str(new_rtok))
        except Exception:
            local_ok = False

        wrote_1p = False
        try:
            wrote_1p = set_secret("OpenClaw Withings", "refresh_token", str(new_rtok))
        except Exception:
            wrote_1p = False

        rotated_note = "Withings refresh_token rotated. "
        rotated_note += "Saved locally." if local_ok else "Local save failed; update stored token."
        if wrote_1p:
            rotated_note += " (Also updated 1Password.)"

    headers = {"Authorization": f"Bearer {access_token}"}

    # Sleep summary
    try:
        q = urllib.parse.urlencode({"action": "getsummary", "startdateymd": date_str, "enddateymd": date_str})
        js = _http_get_json(f"{WITHINGS_API}/v2/sleep?{q}", headers)
        if js.get("status") == 0:
            series = js.get("body", {}).get("series", [])
            entry = series[0] if series else {}
            data = entry.get("data", {})
            # total sleep duration may be in seconds under different keys
            total = None
            for k in ["totalsleepduration", "duration", "total_timeinbed"]:
                v = data.get(k)
                if isinstance(v, (int, float)):
                    total = int(v)
                    break
            score = data.get("score") if isinstance(data.get("score"), (int, float)) else None
            payload["sleep"] = {"total_seconds": total, "score": score}
    except Exception:
        pass

    # Activity (daily)
    try:
        q = urllib.parse.urlencode({"action": "getactivity", "startdateymd": date_str, "enddateymd": date_str})
        js = _http_get_json(f"{WITHINGS_API}/v2/measure?{q}", headers)
        if js.get("status") == 0:
            acts = js.get("body", {}).get("activities", [])
            a = acts[0] if acts else {}
            steps = a.get("steps") if isinstance(a.get("steps"), (int, float)) else None
            cals = a.get("calories") if isinstance(a.get("calories"), (int, float)) else None
            minutes = None
            for k in ["active", "duration", "totalactive"]:
                v = a.get(k)
                if isinstance(v, (int, float)):
                    minutes = int(v / 60) if v and v > 1000 else int(v)
                    break
            payload["activity"] = {"steps": steps, "calories": cals, "minutes": minutes}
    except Exception:
        pass

    # Weight, body fat %, BP
    try:
        start, end = _epoch_range_for_day(date_str)
        q = urllib.parse.urlencode({
            "action": "getmeas",
            "meastypes": ",".join(["1", "6", "10", "11"]),
            "startdate": str(start),
            "enddate": str(end),
        })
        js = _http_get_json(f"{WITHINGS_API}/measure?{q}", headers)
        if js.get("status") == 0:
            grps = js.get("body", {}).get("measuregrps", [])
            latest_by_type: Dict[int, Tuple[int, float]] = {}
            for g in grps:
                ts = g.get("date") or 0
                for m in g.get("measures", []) or []:
                    mtype = m.get("type")
                    val = m.get("value")
                    unit = m.get("unit")
                    if isinstance(mtype, int) and isinstance(val, (int, float)) and isinstance(unit, int):
                        value = float(val) * (10 ** unit)
                        prev = latest_by_type.get(mtype)
                        if prev is None or ts > prev[0]:
                            latest_by_type[mtype] = (ts, value)
            # Map types
            if 1 in latest_by_type:  # Weight kg
                payload["weight_kg"] = round(latest_by_type[1][1], 2)
            if 6 in latest_by_type:  # Fat ratio %
                payload["body_fat_percent"] = round(latest_by_type[6][1], 2)
            bp = {}
            if 10 in latest_by_type:
                bp["systolic"] = round(latest_by_type[10][1], 0)
            if 11 in latest_by_type:
                bp["diastolic"] = round(latest_by_type[11][1], 0)
            if bp:
                payload.setdefault("withings_extra", {})["bp"] = bp
    except Exception:
        pass

    if rotated_note:
        payload["note"] = rotated_note

    return payload


def cli(argv=None):
    import argparse

    parser = argparse.ArgumentParser(description="Withings connector")
    parser.add_argument("--date", required=True, help="YYYY-MM-DD")
    parser.add_argument("--live", action="store_true", help="DEPRECATED: live is default when credentials are present")
    args = parser.parse_args(argv)

    datetime.strptime(args.date, "%Y-%m-%d")
    payload = fetch(args.date, live=args.live)
    print(json.dumps(payload, indent=2))


if __name__ == "__main__":
    cli()


path: core/normalize/__init__.py
content: from typing import Any, Dict, Optional


def _pick_best(*values):
    for v in values:
        if v is not None:
            return v
    return None


def normalize(date_str: str, sources: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
    """Combine source payloads into a simplified normalized schema.

    Precedence order (first wins): whoop, oura, withings
    """
    order = ["whoop", "oura", "withings"]

    def s(name: str) -> Dict[str, Any]:
        return sources.get(name, {}) or {}

    sleep_total = _pick_best(
        s("whoop").get("sleep", {}).get("total_seconds"),
        s("oura").get("sleep", {}).get("total_seconds"),
        s("withings").get("sleep", {}).get("total_seconds"),
    )
    sleep_score = _pick_best(
        s("whoop").get("sleep", {}).get("score"),
        s("oura").get("sleep", {}).get("score"),
        s("withings").get("sleep", {}).get("score"),
    )

    steps = _pick_best(
        s("whoop").get("activity", {}).get("steps"),
        s("oura").get("activity", {}).get("steps"),
        s("withings").get("activity", {}).get("steps"),
    )
    calories = _pick_best(
        s("whoop").get("activity", {}).get("calories"),
        s("oura").get("activity", {}).get("calories"),
        s("withings").get("activity", {}).get("calories"),
    )
    minutes = _pick_best(
        s("whoop").get("activity", {}).get("minutes"),
        s("oura").get("activity", {}).get("minutes"),
        s("withings").get("activity", {}).get("minutes"),
    )

    resting_hr = _pick_best(
        s("whoop").get("resting_hr"),
        s("oura").get("resting_hr"),
        s("withings").get("resting_hr"),
    )
    hrv = _pick_best(
        s("whoop").get("hrv_rmssd"),
        s("oura").get("hrv_rmssd"),
        s("withings").get("hrv_rmssd"),
    )
    rr = _pick_best(
        s("whoop").get("respiratory_rate"),
        s("oura").get("respiratory_rate"),
    )
    spo2 = _pick_best(
        s("whoop").get("spo2"),
        s("oura").get("spo2"),
        s("withings").get("spo2"),
    )
    readiness = _pick_best(
        s("whoop").get("readiness", {}).get("score"),
        s("oura").get("readiness", {}).get("score"),
        s("withings").get("readiness", {}).get("score"),
    )
    weight = _pick_best(
        s("withings").get("weight_kg"),
    )
    body_fat = _pick_best(
        s("withings").get("body_fat_percent"),
    )

    norm = {
        "date": date_str,
        "sources": {k: v for k, v in sources.items() if v},
        "metrics": {
            "sleep": {"total_seconds": sleep_total, "score": sleep_score},
            "readiness": {"score": readiness},
            "activity": {"steps": steps, "calories": calories, "minutes": minutes},
            "resting_hr": resting_hr,
            "hrv_rmssd": hrv,
            "respiratory_rate": rr,
            "spo2": spo2,
            "weight_kg": weight,
            "body_fat_percent": body_fat,
        },
    }
    return norm



path: core/render/__init__.py
content: from typing import Any, Dict, Optional


def _fmt_maybe(value, suffix: str = "") -> str:
    return f"{value}{suffix}" if value is not None else "-"


def _round_maybe(value: Any, ndigits: int) -> Optional[float]:
    if isinstance(value, (int, float)):
        return round(float(value), ndigits)
    return None


def _fmt_sleep_duration(total_seconds: Any) -> str:
    if not isinstance(total_seconds, (int, float)):
        return "-"
    sec = int(total_seconds)
    h = sec // 3600
    m = (sec % 3600) // 60
    return f"{h}h{m:02d}m"


def to_markdown(norm: Dict[str, Any]) -> str:
    m = norm.get("metrics", {})
    sleep = m.get("sleep", {})
    lines = []
    lines.append(f"### Daily Health – {norm.get('date')}")
    lines.append("")

    # Sleep
    lines.append(f"- Sleep: {_fmt_sleep_duration(sleep.get('total_seconds'))} (score {_fmt_maybe(sleep.get('score'))})")

    # Readiness
    lines.append(f"- Readiness: {_fmt_maybe(m.get('readiness', {}).get('score'))}")

    # Activity
    act = m.get("activity", {})
    lines.append(
        f"- Activity: steps {_fmt_maybe(act.get('steps'))}, calories {_fmt_maybe(act.get('calories'))}, minutes {_fmt_maybe(act.get('minutes'))}"
    )

    # Biometrics (rounded)
    lines.append(f"- Resting HR: {_fmt_maybe(m.get('resting_hr'), ' bpm')}")
    lines.append(f"- HRV (RMSSD): {_fmt_maybe(_round_maybe(m.get('hrv_rmssd'), 1), ' ms')}")
    lines.append(f"- Respiratory Rate: {_fmt_maybe(_round_maybe(m.get('respiratory_rate'), 1), ' bpm')}")
    lines.append(f"- SpO2: {_fmt_maybe(_round_maybe(m.get('spo2'), 1), '%')}")

    # Body
    wkg = m.get("weight_kg")
    if isinstance(wkg, (int, float)):
        wlb = round(float(wkg) * 2.2046226218, 1)
        lines.append(f"- Weight: {wlb} lb ({round(float(wkg), 2)} kg)")
    else:
        lines.append(f"- Weight: {_fmt_maybe(wkg, ' kg')}")
    lines.append(f"- Body Fat: {_fmt_maybe(_round_maybe(m.get('body_fat_percent'), 1), '%')}")

    lines.append("")

    # Sources summary
    srcs = ", ".join(sorted(norm.get("sources", {}).keys())) or "-"
    lines.append(f"_sources: {srcs}_")
    return "\n".join(lines)



path: core/util/__init__.py
content: 


path: core/util/local_secrets.py
content: import json
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional


DEFAULT_PATH = os.path.expanduser("~/.openclaw/secrets/health_tokens.json")


def _path() -> Path:
    return Path(os.getenv("OPENCLAW_LOCAL_SECRETS_PATH") or DEFAULT_PATH)


def _read() -> Dict[str, Any]:
    p = _path()
    if not p.exists():
        return {}
    try:
        return json.loads(p.read_text()) if p.read_text().strip() else {}
    except Exception:
        return {}


def get_local_secret(namespace: str, key: str) -> Optional[str]:
    """Read a secret value from a local JSON file.

    Format:
      {
        "whoop": {"refresh_token": "...", "access_token": "..."},
        "withings": {"refresh_token": "..."}
      }

    Returns None if missing.
    """

    data = _read()
    ns = data.get(namespace) if isinstance(data, dict) else None
    if not isinstance(ns, dict):
        return None
    v = ns.get(key)
    return v if isinstance(v, str) and v.strip() else None


def set_local_secret(namespace: str, key: str, value: str) -> bool:
    """Atomically write a secret value to the local JSON file.

    This is intended for rotated refresh tokens when 1Password writeback is not available.

    Returns True on success.
    """

    try:
        p = _path()
        p.parent.mkdir(parents=True, exist_ok=True)
        data = _read()
        if not isinstance(data, dict):
            data = {}
        ns = data.get(namespace)
        if not isinstance(ns, dict):
            ns = {}
        ns[key] = value
        data[namespace] = ns

        # Atomic write
        tmp_dir = str(p.parent)
        fd, tmp_path = tempfile.mkstemp(prefix=p.name + ".", dir=tmp_dir)
        try:
            with os.fdopen(fd, "w") as f:
                json.dump(data, f, indent=2, sort_keys=True)
                f.write("\n")
            os.replace(tmp_path, p)
        finally:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass

        # Best-effort permissions hardening
        try:
            os.chmod(p, 0o600)
        except Exception:
            pass

        return True
    except Exception:
        return False


path: core/util/secrets.py
content: import json
import os
import subprocess
from typing import Optional


def set_secret(item_title: str, field_name: str, value: str) -> bool:
    """Best-effort secret writeback to 1Password.

    This is intentionally opt-in and designed for rotated refresh tokens.

    Enabled only when:
    - `OPENCLAW_1P_WRITEBACK=1`
    - `op` CLI is available

    Returns True on success, False otherwise.
    """

    if os.getenv("OPENCLAW_1P_WRITEBACK") != "1":
        return False

    if not _op_available():
        return False

    vault = os.getenv("OPENCLAW_1P_VAULT")

    # 1Password CLI supports editing fields by label.
    # We avoid printing secrets; callers should also avoid logging values.
    try:
        cmd = ["op", "item", "edit", item_title, f"{field_name}={value}"]
        if vault:
            cmd.extend(["--vault", vault])
        res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, text=True)
        return res.returncode == 0
    except Exception:
        return False


def _op_available() -> bool:
    try:
        subprocess.run(["op", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
        return True
    except Exception:
        return False


def get_secret(item_title: str, field_name: str) -> Optional[str]:
    """Best-effort secret fetch from 1Password via op. Returns None if unavailable.

    Notes:
    - Requires OP_SERVICE_ACCOUNT_TOKEN (or an op session) to be present in the environment.
    - Optionally set OPENCLAW_1P_VAULT to force a specific vault name.

    Implementation:
    - `op item get "<title>" --vault <vault> --format json`
    - Then search item.fields[] for a matching label.
    """

    if not _op_available():
        return None

    vault = os.getenv("OPENCLAW_1P_VAULT")

    try:
        cmd = ["op", "item", "get", item_title, "--format", "json"]
        if vault:
            cmd.extend(["--vault", vault])

        res = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            check=False,
            text=True,
        )
        if res.returncode != 0:
            return None

        item = json.loads(res.stdout)
        fields = item.get("fields", []) if isinstance(item, dict) else []

        want = field_name.strip().lower()
        for f in fields if isinstance(fields, list) else []:
            label = (f.get("label") or f.get("name") or "").strip().lower()
            if label == want:
                return f.get("value")

        return None
    except Exception:
        return None



path: docs/1PASSWORD_CONVENTIONS.md
content: 1Password Conventions

Use a single 1Password vault accessible to your OpenClaw automations. Each connector has one item. Prefer these exact titles and field names to simplify automation.

General
- Store tokens under the field name `token` when a single token is sufficient. For OAuth2 (e.g., Withings), store the long-lived refresh token under `refresh_token`.
- Add optional `user_id` or `account` fields if you manage multiple profiles.
- CLI access: this project reads via `op item get "<TITLE>" --fields json`.

Items
- OpenClaw Whoop
  - token: Whoop OAuth access token (Bearer)
  - refresh_token: Whoop OAuth refresh token (requires requesting the `offline` scope)
  - refresh_password: Back-compat field name used in an early item (treated as `refresh_token`)
  - client_id: Whoop OAuth client id (required for refresh)
  - client_secret: Whoop OAuth client secret (required for refresh)

- OpenClaw Oura
  - client_id: Oura OAuth client id
  - client_secret: Oura OAuth client secret
  - token: Oura OAuth access token (Bearer)
  - refresh_token: Oura OAuth refresh token

- OpenClaw Withings
  - client_id: Withings app client id
  - client_secret: Withings app client secret
  - refresh_token: Withings OAuth refresh token
  - user_id: Optional user id (integer)

Environment Variable Fallbacks
- WHOOP_ACCESS_TOKEN
- WHOOP_REFRESH_TOKEN
- WHOOP_CLIENT_ID
- WHOOP_CLIENT_SECRET
- OURA_PERSONAL_ACCESS_TOKEN
- WITHINGS_CLIENT_ID, WITHINGS_CLIENT_SECRET, WITHINGS_REFRESH_TOKEN, WITHINGS_USER_ID

Tips
- Rotate tokens periodically and update the item.
- Oura refresh tokens are rotation-sensitive (single-use): if you refresh and don’t save the *new* refresh_token, subsequent refresh attempts will fail.
- Service accounts require a vault filter for most `op` commands. Set `OPENCLAW_1P_VAULT` (e.g. `Assistant`) so this repo can pass `--vault` automatically.
- For local dev without `op`, set env vars in a `.env.local` (not committed) and export them in your shell.


path: docs/ASSUMPTIONS.md
content: Assumptions and Scope

- This initial version prioritizes a working local CLI with sane fallbacks over perfect coverage.
- Connectors attempt to read credentials from 1Password via the `op` CLI if present, else environment variables, else return null metrics with an error.
- When credentials exist, connectors attempt **live API calls by default**.
  - Set `OPENCLAW_FORCE_SAMPLE=1` to force sample output (useful for offline/test environments).
- Normalized schema is intentionally small and will evolve. Unknown or unavailable fields are left as `null`.
- Dates use local date strings `YYYY-MM-DD` and are interpreted as full-day windows.
- The output location defaults to the repo root for `daily_health.json`. You can pass `--out` to change it.


path: docs/MORNING_BRIEF.md
content: Morning Brief

Summary
- CLI: `./bin/health-brief --date YYYY-MM-DD [--out path] [--sources S1,S2]`
- Orchestrates connectors, normalizes data, writes `daily_health.json`, and prints a Markdown summary.

Sources: `whoop`, `oura`, `withings`.
Normalization Schema (simplified)
- date: string (YYYY-MM-DD)
- sources: { whoop?, oura?, withings? }
- metrics:
  - sleep: { total_seconds?, score? }
  - readiness: { score? }
  - activity: { steps?, calories?, minutes? }
  - resting_hr: number?
  - hrv_rmssd: number?
  - respiratory_rate: number?
  - spo2: number?
  - weight_kg: number?
  - body_fat_percent: number?

Examples
- `./bin/health-brief --date 2025-01-15`
- `./bin/health-brief --date 2025-01-15 --sources oura`
- `./bin/health-brief --date 2025-01-15 --out ./out/daily.json`

Notes
- If no credentials are available, connectors return null metrics and an error block. No data is fabricated.
- Add `--verbose` to see which sources contributed each metric.



path: docs/OURA.md
content: Oura Connector

Summary
- CLI: `./bin/oura --date YYYY-MM-DD`
- Reads OAuth credentials from 1Password item `OpenClaw Oura` or env.
- By default, if credentials are present, performs live API calls; otherwise falls back to samples.

Credentials
Personal access tokens were deprecated (Dec 2025). Use OAuth.

1) Create an Oura API application.
2) Register redirect URI (default used by this repo):
   - `http://127.0.0.1:8788/callback`
3) Run the helper (request the `daily` scope or the daily_* endpoints will 401):

```bash
./bin/oura-auth --client-id <OURA_CLIENT_ID> --client-secret <OURA_CLIENT_SECRET> --redirect-uri "http://localhost:8788/callback" --scope "daily personal"
```

4) Store in 1Password (your vault) under item `OpenClaw Oura` with fields:
- `client_id`
- `client_secret`
- `token` (access_token)
- `refresh_token`

Legacy env var support:
- `OURA_CLIENT_ID`, `OURA_CLIENT_SECRET`, `OURA_PERSONAL_ACCESS_TOKEN`, `OURA_REFRESH_TOKEN`

Behavior
- Live by default when credentials exist. Set `OPENCLAW_FORCE_SAMPLE=1` to force sample output (e.g., in offline/test envs).
- On 401, the connector attempts a refresh using `refresh_token + client_id + client_secret`.
- If a refresh rotates the refresh_token, the connector includes a non-sensitive note telling you to update your 1Password item (it never prints tokens).

Output
- JSON payload for the given date (consumed by `./bin/health-brief`).


path: docs/WHOOP.md
content: WHOOP Connector

Summary
- CLI: `./bin/whoop --date YYYY-MM-DD`
- Auth helper: `./bin/whoop-auth` (prints auth URL; can exchange code for tokens)
- Reads credentials from 1Password item **"OpenClaw Whoop"** (recommended) or env vars.
  - Access token: field `token` or env `WHOOP_ACCESS_TOKEN`
  - Refresh token: field `refresh_token` (back-compat: `refresh_password`) or env `WHOOP_REFRESH_TOKEN`
  - Client creds (for refresh): fields `client_id` / `client_secret` or env `WHOOP_CLIENT_ID` / `WHOOP_CLIENT_SECRET`
- If no access token is available, falls back to sample data.

Credentials
WHOOP uses OAuth 2.0 (authorization code flow).

1) In the WHOOP developer dashboard, create an OAuth app and set the redirect URI.
   - Default redirect URI used by this repo: `http://127.0.0.1:8787/callback`
2) Run the helper to get tokens (includes `offline` by default so refresh works for cron):
   - `./bin/whoop-auth --client-id <...> --client-secret <...>`
   - Open the printed URL, approve, and let it capture the `code`.
3) Store tokens in 1Password (your vault):
   - Item title: `OpenClaw Whoop`
   - Field `token`: `<access_token>`
   - Field `refresh_token`: `<refresh_token>` (requires requesting the `offline` scope)
   - Field `client_id`: `<WHOOP_CLIENT_ID>`
   - Field `client_secret`: `<WHOOP_CLIENT_SECRET>`

Alternate: set env var `WHOOP_ACCESS_TOKEN`.

Output
- Prints JSON payload for the given date and writes nothing by default.
- Intended for composition by `./bin/health-brief`.

Notes
- When an access token is present, the connector performs live WHOOP API calls.
- On HTTP 401, it will attempt an OAuth refresh **if** `refresh_token`, `client_id`, and `client_secret` are available.
- WHOOP refresh responses may rotate the refresh token; if that happens, the connector annotates the payload with a note so you can update 1Password.
- The `--live` flag is deprecated (live is the default when credentials are present).



path: docs/WITHINGS.md
content: Withings Connector

Summary
- CLI: `./bin/withings --date YYYY-MM-DD`
- Reads OAuth details from 1Password item `OpenClaw Withings` or env vars.
- By default, if credentials are present, performs live API calls; otherwise falls back to samples.

Credentials
Withings uses OAuth2. For automation, you maintain a refresh token.

1) Create a Withings developer app and set a redirect URI.
   - Default used here: `http://localhost:8791/callback`
2) Run the helper:

```bash
./bin/withings-auth --client-id <WITHINGS_CLIENT_ID> --client-secret <WITHINGS_CLIENT_SECRET>

# If your app redirect differs:
# ./bin/withings-auth --client-id <...> --client-secret <...> --redirect-uri "http://localhost:8888/callback"
```

3) Store in 1Password (your vault):
- Item: `OpenClaw Withings`
- Fields (labels must match exactly):
  - `client_id`
  - `client_secret`
  - `refresh_token`
  - `user_id` (optional)

Why client_id + client_secret?
- Withings refresh flows require refresh_token + client_id + client_secret to mint a new access token.

Env var fallbacks:
- `WITHINGS_CLIENT_ID`, `WITHINGS_CLIENT_SECRET`, `WITHINGS_REFRESH_TOKEN`, `WITHINGS_USER_ID`

Behavior
- Live by default when credentials exist. Set `OPENCLAW_FORCE_SAMPLE=1` to force sample output (e.g., offline/test envs).
- On each run, the connector refreshes the access token using the refresh token and client credentials.
- If a refresh rotates the refresh_token, the connector prints a non-sensitive note in the output telling you to update your 1Password item (it never prints tokens).

Output
- JSON payload for the given date (consumed by `./bin/health-brief`).


path: tests/test_schema.py
content: import os
import sys
import unittest
from datetime import datetime

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
    sys.path.insert(0, ROOT)

from connectors import whoop as whoop_mod
from connectors import oura as oura_mod
from connectors import withings as withings_mod
from core.normalize import normalize


class TestNormalization(unittest.TestCase):
    def test_normalized_schema_keys(self):
        today = datetime.now().strftime("%Y-%m-%d")
        sources = {
            "whoop": whoop_mod.fetch(today),
            "oura": oura_mod.fetch(today),
            "withings": withings_mod.fetch(today),
        }
        norm = normalize(today, sources)
        self.assertIn("date", norm)
        self.assertIn("sources", norm)
        self.assertIn("metrics", norm)
        m = norm["metrics"]
        # Required top-level fields exist
        for key in [
            "sleep",
            "readiness",
            "activity",
            "resting_hr",
            "hrv_rmssd",
            "respiratory_rate",
            "spo2",
            "weight_kg",
            "body_fat_percent",
        ]:
            self.assertIn(key, m)
        # Nested checks
        self.assertIn("total_seconds", m["sleep"])  # may be None but key exists
        self.assertIn("score", m["sleep"])  # may be None but key exists


if __name__ == "__main__":
    unittest.main()



Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)