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.
npx -y skills add kaustin923/agent-fitness-coach --skill healthkit-importAssembled 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.ziporexport.xmlin 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.csvis missing or has fewer than 7 of the last 28 days filled (## Maintenance calories (TDEE)inreference/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 toathlete/plan.jsonandathlete/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:
- On the iPhone, open the Health app.
- Tap the profile picture (or initials) at the top right.
- Scroll down and tap Export All Health Data, then confirm. Preparing takes a few minutes.
- Share the resulting
export.zipto this machine (AirDrop, Files, iCloud Drive) and drop it inathlete/health/raw/— everything underathlete/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 attribute | Feeds | Unit handling |
|---|---|---|
HKQuantityTypeIdentifierActiveEnergyBurned | active_kcal | Cal/kcal are both kcal |
HKQuantityTypeIdentifierStepCount | steps | count |
HKQuantityTypeIdentifierDistanceWalkingRunning | distance_m | km ×1000, mi ×1609.34 |
HKQuantityTypeIdentifierAppleExerciseTime | exercise_min | min |
HKCategoryTypeIdentifierAppleStandHour (value HKCategoryValueAppleStandHourStood) | stand_hours | count distinct stood hours |
HKQuantityTypeIdentifierBodyMass | weight_kg | lb ÷ 2.20462; last sample of the day wins |
<Workout workoutActivityType="HKWorkoutActivityType..."> | workouts.csv | see 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.
startDateis 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
WorkoutStatisticschild elements, not the oldtotalEnergyBurned/totalDistanceattributes (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
sourceNameand 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):
HKWorkoutActivityTypeRunning→running,Walking→walking,Cycling→cycling,Swimming→swimming,TraditionalStrengthTrainingandFunctionalStrengthTraining→strength,Yoga→yoga,CrossTraining→cross_training, anything else→other. - Id: use
HKExternalUUIDmetadata when present, else a deterministichk-<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 ofstart;source= the rawsourceName(Stravarows 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 gradinginreference/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.xmlwhole — not into memory (useiterparse+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 inreference/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 fromathlete/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.gitignorealready excludesathlete/**; 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.