agentsclimarketplace

Healthkit import

Skill kaustin923/agent-fitness-coach/.claude/skills/healthkit-import

Give this repo to Claude and it becomes your training coach: real periodized plans, Strava + Apple Health data, progress tracking and grading — files are the database, skills are the features, the agent is the app.

Install
npx -y skills add kaustin923/agent-fitness-coach --skill healthkit-import

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Parse an Apple Health export into the athlete's health ledger — daily activity totals in health/daily.csv, workouts in health/workouts.csv — then match imported workouts to planned sessions and unlock wearable-based calorie targets. Use when the athlete shares export.zip or export.xml, says "import my Apple Health data", trains with an Apple Watch without Strava, or when nutrition-setup needs real active-energy data for TDEE.

SKILL.md

14.2 KB, as published. Nobody here has run it

HealthKit import

Apple Health is the richest data source this coach can read: every workout from any app or watch, plus the daily active-energy numbers that turn calorie targets from population estimates into measured ones. There is no live API — the athlete exports their data as a file and you parse it. The export is huge (hundreds of MB to several GB of XML), so everything here runs through streaming python; never read export.xml into your context.

When to run

  • The athlete drops an export.zip or export.xml in the project, or asks to import Apple Health / Apple Watch data.
  • The athlete trains with an Apple Watch (or logs workouts in any iPhone app) and does not use Strava.
  • nutrition-setup wants a wearable-based maintenance estimate and athlete/health/daily.csv is missing or has fewer than 7 of the last 28 days filled (## Maintenance calories (TDEE) in reference/formulas.md).
  • A periodic refresh — roughly monthly, since exports are point-in-time snapshots.

Inputs

Files read and written:

  • Read: athlete/plan.json, athlete/log.json, athlete/profile.md (units, timezone).
  • Written: athlete/health/daily.csv, athlete/health/workouts.csv, plus matched-day updates to athlete/plan.json and athlete/log.json.

From the athlete: the export file, and two one-time choices — the import window (default: last 90 days, or back to plan start minus 35 days if that is earlier; offer full history on request) and whether to backfill athlete/weight-log.csv from body-mass samples.

No MCP tools required. If the athlete also uses Strava, run strava-sync separately; the dedupe rule below keeps the two sources from double-counting.

Procedure

Compute everything with python — never sum rows, derive weekdays, or convert units in your head.

1. Date ritual

Run date +%F for today. Build the weekday-to-date table for this week and next (per the COACH.md ritual) and pair every weekday you mention with its date.

2. Get the export

If the athlete does not have one yet, walk them through it:

  1. On the iPhone, open the Health app.
  2. Tap the profile picture (or initials) at the top right.
  3. Scroll down and tap Export All Health Data, then confirm. Preparing takes a few minutes.
  4. Share the resulting export.zip to this machine (AirDrop, Files, iCloud Drive) and drop it in athlete/health/raw/ — everything under athlete/ is gitignored, so raw health data never leaves the machine.

Worth mentioning while they wait: if they also use Strava, enabling Strava's relay (Strava app → Settings → Applications, Services, and Devices → Health → connect → "Send workouts to Health") makes Strava workouts arrive inside Apple Health with source name Strava — one export then carries everything, and it is the Strava-compliant channel for AI coaching in product contexts (see guides/02-getting-your-data.md).

Extract only the XML — the zip also contains GPX routes and a CDA file you do not need:

unzip -o "athlete/health/raw/export.zip" "apple_health_export/export.xml" -d "athlete/health/raw/"

3. Parse with streaming python

Write the parser to athlete/health/import_export.py (gitignored, rerunnable next month). Use xml.etree.ElementTree.iterparse and clear elements as you go — loading the tree whole will exhaust memory on real exports. The records you care about:

Element / type attributeFeedsUnit handling
HKQuantityTypeIdentifierActiveEnergyBurnedactive_kcalCal/kcal are both kcal
HKQuantityTypeIdentifierStepCountstepscount
HKQuantityTypeIdentifierDistanceWalkingRunningdistance_mkm ×1000, mi ×1609.34
HKQuantityTypeIdentifierAppleExerciseTimeexercise_minmin
HKCategoryTypeIdentifierAppleStandHour (value HKCategoryValueAppleStandHourStood)stand_hourscount distinct stood hours
HKQuantityTypeIdentifierBodyMassweight_kglb ÷ 2.20462; last sample of the day wins
<Workout workoutActivityType="HKWorkoutActivityType...">workouts.csvsee step 5

Parser core:

import sys
import xml.etree.ElementTree as ET
from collections import defaultdict

QTY = {
    "HKQuantityTypeIdentifierActiveEnergyBurned": "active_kcal",
    "HKQuantityTypeIdentifierStepCount": "steps",
    "HKQuantityTypeIdentifierDistanceWalkingRunning": "distance_m",
    "HKQuantityTypeIdentifierAppleExerciseTime": "exercise_min",
}
DIST_TO_M = {"km": 1000.0, "mi": 1609.34, "m": 1.0}

days = defaultdict(lambda: defaultdict(lambda: defaultdict(float)))  # day -> col -> source -> sum
stand, weight, workouts = defaultdict(set), {}, []

it = ET.iterparse(sys.argv[1], events=("start", "end"))
_, root = next(it)                                   # the <HealthData> element
for ev, el in it:
    if ev != "end" or el.tag not in ("Record", "Workout"):
        continue
    if el.tag == "Record":
        t, day, src = el.get("type"), el.get("startDate")[:10], el.get("sourceName", "")
        if t in QTY:
            v = float(el.get("value", 0))
            if QTY[t] == "distance_m":
                v *= DIST_TO_M.get(el.get("unit", "km"), 1.0)
            days[day][QTY[t]][src] += v
        elif t == "HKCategoryTypeIdentifierAppleStandHour" \
                and el.get("value") == "HKCategoryValueAppleStandHourStood":
            stand[day].add(el.get("startDate")[:13])
        elif t == "HKQuantityTypeIdentifierBodyMass":
            kg = float(el.get("value")) / (2.20462 if el.get("unit") == "lb" else 1.0)
            weight[day] = round(kg, 1)
    else:                                            # Workout — children already parsed
        w = {"hk_type": el.get("workoutActivityType"), "start": el.get("startDate"),
             "end": el.get("endDate"), "duration_min": round(float(el.get("duration", 0)), 1),
             "kcal": None, "distance_km": None, "source": el.get("sourceName", "")}
        for st in el.findall("WorkoutStatistics"):
            if st.get("type") == "HKQuantityTypeIdentifierActiveEnergyBurned":
                w["kcal"] = round(float(st.get("sum", 0)))
            elif (st.get("type") or "").startswith("HKQuantityTypeIdentifierDistance"):
                meters = float(st.get("sum", 0)) * DIST_TO_M.get(st.get("unit", "km"), 1.0)
                w["distance_km"] = round(meters / 1000, 2)
        if w["kcal"] is None and el.get("totalEnergyBurned"):          # pre-iOS-18 exports
            w["kcal"] = round(float(el.get("totalEnergyBurned")))
        if w["distance_km"] is None and el.get("totalDistance"):
            meters = float(el.get("totalDistance")) \
                * DIST_TO_M.get(el.get("totalDistanceUnit", "km"), 1.0)
            w["distance_km"] = round(meters / 1000, 2)
        for meta in el.findall("MetadataEntry"):
            if meta.get("key") == "HKExternalUUID":
                w["uuid"] = meta.get("value")
        workouts.append(w)
    root.clear()                                     # keep memory flat on multi-GB files

Two parsing rules that are not optional:

  • Local days. startDate is local wall-clock with an explicit offset (2026-06-14 07:31:02 -0500). Bucket by the first 10 characters — never convert to UTC, or evening activity lands on the next day.
  • Modern exports put workout energy and distance in WorkoutStatistics child elements, not the old totalEnergyBurned/totalDistance attributes (Apple deprecated those; on iOS 18+ they are empty). Read the children first and fall back to the attributes only for old exports — otherwise every workout imports with 0 kcal.

4. Write athlete/health/daily.csv

Header: date,active_kcal,steps,distance_m,exercise_min,stand_hours,weight_kg.

  • Deduplicate sources before summing. An iPhone and an Apple Watch both record steps and active energy; adding them double-counts. For each day and column, sum per sourceName and keep the single largest source's total (this approximates the priority-dedupe Apple's own queries do). Do not sum across sources.
  • stand_hours = the count of distinct stood hours; weight_kg = that day's last body-mass sample, blank if none.
  • Restrict to the agreed import window, round sensibly (kcal and steps to integers, distance to whole meters), upsert by date into the existing file, and keep it sorted by date.

5. Write athlete/health/workouts.csv

Header: id,date,start,end,duration_min,type,kcal,distance_km,source.

  • Filter: duration ≥ 60 seconds (excludes accidental starts), within the import window.
  • Type mapping (shared vocabulary with strava-sync): HKWorkoutActivityTypeRunningrunning, Walkingwalking, Cyclingcycling, Swimmingswimming, TraditionalStrengthTraining and FunctionalStrengthTrainingstrength, Yogayoga, CrossTrainingcross_training, anything else→other.
  • Id: use HKExternalUUID metadata when present, else a deterministic hk-<start as YYYYMMDDTHHMM>-<type> so re-imports upsert instead of duplicating.
  • Dedupe across sources: skip an imported workout if the file already has a row (any source — a Strava-synced row, or a previous import) whose start is within ±3 minutes with the same mapped type. One workout, one row; never dedupe by deleting existing rows.
  • date = local date of start; source = the raw sourceName (Strava rows here are relay workouts — that is expected and good).

6. Match workouts to the plan

If athlete/plan.json exists, match newly imported workouts to scheduled sessions using the scoring rubric in reference/tracking.md (## Activity matching): candidates share the same local day; type-keyword match scores 0.5, duration ratio ≥ 0.6 adds 0.25 × ratio, run distance ratio ≥ 0.6 adds 0.25 × ratio; normalize by the applicable factors and accept at ≥ 0.5. Greedy and one-to-one: each imported workout satisfies at most one plan row, each plan row at most one workout; seed the used-set with workouts already matched in earlier runs, and never touch days already completed.

On a match, update athlete/plan.json:

  • Run days: status: "completed", actual = { "distance_mi": <km × 0.621371, 1dp>, "pace": "M:SS", "source": "healthkit", "grade": ..., "activity_id": "<id>" }. Compute pace from duration and distance by rounding total seconds per mile before splitting into minutes and seconds (never :60), and grade against the blended expected pace per ## Run grading in reference/tracking.md.
  • Lift and cross-train days: status: "completed", actual = { "source": "healthkit", "activity_id": "<id>", "duration_min": <n> } (run fields omitted).
  • A workout on a Rest day matches nothing and marks nothing.

Mirror matched run days into athlete/log.json: done[date] = true and, if no Strava entry already occupies that date (Strava rows keep priority — they carry the linkable activity id), activities[date] = { "dist_mi": ..., "pace": ..., "name": ..., "id": "<id>", "source": "healthkit" }. If you added or changed any running entries, recompute the fitness block exactly as strava-sync does (## Current fitness in reference/tracking.md), reconstructing each run's seconds as paceSec(pace) × dist_mi.

7. Optional weight backfill

If the athlete opted in, upsert body-mass days into athlete/weight-log.csv (date,weight_kg,source with source healthkit, upsert by date, never overwrite a same-date manual entry). This feeds the weight-trend math in reference/formulas.md (## Weight trend math).

8. Check the TDEE bridge and report

Count days in the last 28 with a non-blank active_kcal in daily.csv. Per ## Maintenance calories (TDEE) in reference/formulas.md, 7 or more unlocks the wearable-based maintenance estimate (BMR + mean active kcal) instead of an activity multiplier. Include the count in the digest, and if the athlete's macros in athlete/macros.md were set with a multiplier, suggest a nutrition-setup recalculation.

Then print the import digest — compute first, narrate second:

Imported Apple Health export · 2026-07-06 (window 2026-04-07 → 2026-07-06)

Daily ledger: 90 days upserted · 84 with active energy · 12 weight entries
Workouts: 31 imported (24 running, 5 strength, 2 yoga) · 3 skipped as duplicates of Strava rows
Plan matches: 22 sessions confirmed · 20 runs graded (14 on target, 4 faster, 2 slower)

Wearable TDEE: 26 of the last 28 days have active energy — measured maintenance is available.

Rules

  • Never load export.xml whole — not into memory (use iterparse + root.clear()) and never into your own context. You read the CSVs you produce, not the XML.
  • Truthfulness guard: report counts only after the file writes happened this turn, and only counts you computed programmatically.
  • All matching, grading, and fitness math lives in reference/tracking.md; energy and weight-trend math live in reference/formulas.md. Cite them, do not restate them.
  • Storage units are metric (distance_m, distance_km, weight_kg); render to the athlete in their declared units from athlete/profile.md (miles = km × 0.621371).
  • Bucket everything by the athlete's local day; pair every weekday you mention with its date.
  • One imported workout satisfies one planned slot, ever. Re-running the import must be idempotent: upserts by date and id, dedupe before insert, completed days untouched.
  • Raw exports stay in athlete/health/raw/ and never get committed anywhere — they contain the athlete's entire medical-adjacent history. The .gitignore already excludes athlete/**; do not work around it.

Output

The import digest from step 8, in chat. Then suggest 2–3 next actions, for example:

  • Recalculate macros with nutrition-setup now that measured maintenance is available.
  • Refresh the dashboard so confirmed sessions and fitness numbers show up.
  • Set a monthly reminder to re-export — or, if they also use Strava, run strava-sync so future runs confirm automatically between exports.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.