
Axiom Health
- 501 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-health is an Axiom Claude Code skill that orchestrates parallel Swift project auditors for memory, security, accessibility, performance, and framework-specific checks on Apple apps.
About
axiom-health (axiom-health-check) is part of CharlesWiltgen/Axiom—a toolkit with 260 skills, 41 agents, and 15 commands for Apple OS development. The health-check orchestrator runs six always-on auditors (memory, security-privacy, accessibility, swift-performance, modernization, codable) plus up to 17 conditional auditors triggered by signals like import SwiftUI, async/await, Core Data, or CloudKit. It supports full-project globs or diff-scoped audits via a DIFF SCOPE block, deduplicates findings by file:line, and writes scratch/health-check-{date}.md reports. Invoke axiom-health when you need a comprehensive Swift project audit, /axiom:health-check, or a PR-scoped scan before release.
- Environment sanity checks
- Subsystem status review
- Regression signal detection
- Baseline comparison prompts
- Escalation when unhealthy
Axiom Health by the numbers
- 501 all-time installs (skills.sh)
- Ranked #88 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-healthAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 501 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you audit a Swift iOS project health?
Run structured health checks on Apple apps and dev environments to spot regressions, misconfiguration, and degraded subsystems before they become user-visible outages.
Who is it for?
iOS and Swift developers using Axiom who want parallel domain audits across memory, accessibility, concurrency, and framework-specific risks.
Skip if: Android, React Native, or backend-only codebases—axiom-health targets Swift Apple app projects and Axiom auditor agents.
When should I use this skill?
User requests full project health check, /axiom:health-check, or comprehensive Swift audit across memory, security, and accessibility
What you get
Unified health-check markdown report, per-domain auditor files, and severity-ranked findings table
- Unified health-check report
- Per-domain auditor scratch files
- Severity summary table
By the numbers
- Runs 6 always-on auditors plus up to 17 conditional framework-specific auditors
- Axiom toolkit includes 260 skills, 41 agents, and 15 commands
- Excludes 10 path patterns including Pods, DerivedData, and *Tests.swift
Files
HealthKit and WorkoutKit
You MUST use this skill for ANY HealthKit or WorkoutKit development including authorization, data queries, background delivery, workout sessions, planned workouts, wellbeing APIs (State of Mind), Medications, and Health Records.
Quick Reference
| Symptom / Task | Reference |
|---|---|
HealthKit framework model, HKHealthStore, data types, sample vs characteristic data | See skills/fundamentals.md |
Capability setup, requestAuthorization, purpose strings, read-asymmetry, privacy | See skills/authorization-and-privacy.md |
HKSampleQuery, Swift Concurrency query APIs, HKStatisticsCollectionQuery, sample writes | See skills/queries.md |
Anchored queries, observer queries, HKDeletedObject, background-delivery entitlement | See skills/sync-and-background.md |
HKWorkoutSession, HKLiveWorkoutBuilder, recovery, multi-device, iOS/iPadOS/watchOS workout tracking | See skills/workouts.md |
Workout zones (heart-rate/power effort bands), live + retrospective OS27 | See skills/workouts.md |
| WorkoutKit custom/planned workouts, scheduling, swimming workouts, previewing | See skills/workoutkit.md |
State of Mind, Medications API, symptom logging, menopausal state, wellbeing APIs OS27 | See skills/wellbeing-and-medications.md |
| Health Records (FHIR), Mobility Health App, motion-based health | See skills/clinical-and-mobility.md |
Cross-Suite Routes
These topics overlap with HealthKit/WorkoutKit but live in separate suites:
watchOS presentation
- Watch-specific workout presentation (Always On, Smart Stack), complication surfaces → See axiom-watchos
- Workout app structure on Apple Watch → See axiom-watchos (
skills/platform-basics.md)
Concurrency
- Swift 6 concurrency, actors, Sendable → See axiom-concurrency
- HealthKit queries bridging to Swift Concurrency →
skills/queries.mdis the canonical example
SwiftUI
- Charts rendering for health data → See axiom-swiftui
@Observableview models for health data → See axiom-swiftui
Data and sync
- General cross-platform sync patterns (CloudKit, GRDB) → See axiom-data
- HealthKit anchored/observer queries as a generalizable sync mechanism →
skills/sync-and-background.md
Security and privacy
- Keychain storage, encryption, data-protection entitlements → See axiom-security
Conflict Resolution
axiom-health vs axiom-watchos: For workout apps on Apple Watch: 1. Use axiom-health for HKWorkoutSession lifecycle, HKLiveWorkoutBuilder, recovery APIs, multi-device mirroring — these are HealthKit concepts, not watch concepts 2. Use axiom-watchos for watch-specific presentation: Always On display, Smart Stack placement, watchOS background mode coordination 3. Both apply for a watch-native workout app — start with axiom-health for session lifecycle, then axiom-watchos for presentation
axiom-health vs axiom-concurrency: For HealthKit query patterns: 1. Use axiom-health for which query type to choose and HealthKit-specific gotchas 2. Use axiom-concurrency for general Swift 6 actor isolation rules that apply to query callbacks
axiom-health vs axiom-data: For change-tracked data synchronization: 1. Use axiom-health for HKAnchoredObjectQuery, HKObserverQuery, HKDeletedObject 2. Use axiom-data for application-level data sync across devices (CloudKit, GRDB, SwiftData)
Decision Tree
digraph health {
start [label="HealthKit / workout task" shape=ellipse];
what [label="What area?" shape=diamond];
start -> what;
what -> "skills/fundamentals.md" [label="framework basics, data types"];
what -> "skills/authorization-and-privacy.md" [label="permissions, purpose strings, privacy"];
what -> "skills/queries.md" [label="reading data, rollups, writes"];
what -> "skills/sync-and-background.md" [label="change tracking, background delivery"];
what -> "skills/workouts.md" [label="HKWorkoutSession, live workouts"];
what -> "skills/workoutkit.md" [label="planned/custom workouts"];
what -> "skills/wellbeing-and-medications.md" [label="State of Mind, Medications"];
what -> "skills/clinical-and-mobility.md" [label="Health Records, Mobility"];
what -> "axiom-watchos" [label="watch-specific presentation"];
what -> "axiom-concurrency" [label="general actor isolation rules"];
what -> "axiom-swiftui" [label="Charts, data visualization"];
}Resources
WWDC: 2019-218, 2020-10182, 2020-10184, 2020-10664, 2021-10009, 2021-10287, 2022-10005, 2023-10016, 2023-10023, 2024-10084, 2024-10109, 2025-321, 2025-322
Docs: /healthkit, /healthkit/about-the-healthkit-framework, /healthkit/authorizing-access-to-health-data, /healthkit/protecting-user-privacy, /healthkit/reading-data-from-healthkit, /healthkit/running-queries-with-swift-concurrency, /healthkit/executing-anchored-object-queries, /healthkit/executing-observer-queries, /healthkit/hkworkoutsession, /healthkit/hklivewobuilder, /workoutkit, /healthkit/accessing-health-records
Skills: axiom-watchos, axiom-concurrency, axiom-swiftui, axiom-data, axiom-security
HealthKit Authorization and Privacy
When to Use This Skill
Use when:
- Implementing the first authorization request for any HealthKit feature
- Investigating "my Health tab is empty for some users" (this is usually the read-asymmetry, not a bug)
- Deciding between
requestAuthorizationandgetRequestStatusForAuthorization - Handling
HKAuthorizationStatus.notDeterminedvs.sharingDeniedvs.sharingAuthorized - Writing purpose strings for
NSHealthShareUsageDescription/NSHealthUpdateUsageDescription - Adding clinical records, vision prescriptions, or other per-object-authorization types
- Preparing for App Store submission with Health data
Related Skills
- Use
fundamentals.mdfor the HealthKit data model andHKHealthStoresetup - Use
queries.mdfor how queries behave after authorization - Use
axiom-shippingfor App Store submission concerns around Clinical Records and privacy
The One Thing You Must Internalize
HealthKit does not tell you whether read access was granted or denied. This is a deliberate privacy feature, not an API oversight:
"To prevent possible information leaks, an app isn't aware when the user denies permission to read data. From the app's point of view, no data of that type exists." — Apple, Protecting User Privacy
Every other rule in this skill follows from that one. If a denied read returned a distinct error, an app could infer the user has data (and therefore a condition, habit, or workout) just from the denial pattern. Returning "no matching samples" makes denial indistinguishable from "no data exists," closing the leak.
HKAuthorizationStatus Is Write-Only
public enum HKAuthorizationStatus {
case notDetermined // User has not yet chosen.
case sharingDenied // User has explicitly denied WRITE access.
case sharingAuthorized // User has explicitly granted WRITE access.
}Every case name contains sharing. That word is literal — it refers to write (share to store) state only. There is no enum case for read status.
authorizationStatus(for:) returns one of the three cases above:
func authorizationStatus(for type: HKObjectType) -> HKAuthorizationStatus"checks the authorization status for saving data to the HealthKit store" — Apple, authorizationStatus(for:) docsSo authorizationStatus(for: stepCount) tells you nothing about whether you can read steps. If you want to know whether data is actually available, you must run a query and see what comes back.
getRequestStatusForAuthorization Is a Sheet Gate
func statusForAuthorizationRequest(
toShare typesToShare: Set<HKSampleType>,
read typesToRead: Set<HKObjectType>
) async throws -> HKAuthorizationRequestStatus
public enum HKAuthorizationRequestStatus {
case unknown // Error occurred.
case shouldRequest // At least one type is still .notDetermined; sheet would appear.
case unnecessary // All types have been previously requested.
}Use this when you want to avoid showing the request UI if it would be a no-op. It tells you whether the sheet would appear, not whether permissions were granted.
Required Info.plist Keys — Or The App Crashes
"You must set the usage keys, or your app will crash when you request authorization." — Apple, requestAuthorization docs| Key | Required when |
|---|---|
NSHealthShareUsageDescription | typesToRead is non-empty |
NSHealthUpdateUsageDescription | typesToShare is non-empty |
NSHealthClinicalHealthRecordsShareUsageDescription | You access clinical records (FHIR) |
NSHealthRequiredReadAuthorizationTypeIdentifiers | You require specific clinical record types to be readable |
Write strings that explain the feature, not the framework. "Share activity with friends" beats "This app reads your step count."
Capability and Entitlement Setup
1. Xcode → target → Signing & Capabilities → + Capability → HealthKit 2. Toggle Clinical Health Records only if you actually access FHIR data. App Review rejects apps that enable it without using it. 3. Toggle Background Delivery only if you register observer queries with background delivery enabled (see sync-and-background.md).
Entitlements added:
com.apple.developer.healthkitcom.apple.developer.healthkit.access
If HealthKit is optional for your app, remove the healthkit entry from UIRequiredDeviceCapabilities in Info.plist so non-HealthKit devices can still install. The healthkit capability entry is not used on watchOS.
Canonical Request Flow
import HealthKit
@MainActor
final class HealthAuth {
let store = HKHealthStore()
func requestStepCountAccess() async throws {
// 1. Gate on device availability — macOS, old iPadOS, or unsupported devices return false.
guard HKHealthStore.isHealthDataAvailable() else {
throw AuthError.notAvailable
}
let toRead: Set<HKObjectType> = [HKQuantityType(.stepCount)]
let toWrite: Set<HKSampleType> = []
// 2. Optionally skip the sheet if it wouldn't appear anyway.
let status = try await store.statusForAuthorizationRequest(
toShare: toWrite,
read: toRead
)
if status == .unnecessary {
return
}
// 3. Request. This may or may not show the sheet depending on prior choices.
// The `async` variant throws only on system errors, NOT on user denial.
try await store.requestAuthorization(toShare: toWrite, read: toRead)
// 4. DO NOT check authorizationStatus(for:) here and expect a read answer.
// Run a query and treat an empty result as "no permission OR no data" —
// they're indistinguishable by design.
}
enum AuthError: Error { case notAvailable }
}Success Is Not Consent
"Success in this case does not mean that the user has granted permission to these data types. It just means that you successfully requested authorization." — WWDC 2020-10664
The async requestAuthorization throws only on system errors (missing usage key, device unavailable). It does not throw on user denial. The completion-handler variant returns (success: true, error: nil) even when the user taps "Don't Allow."
This means:
- Never treat a successful
requestAuthorizationreturn as "I can read data now." - Never show "authorization granted" UI based on the request's return value.
- Always validate actual access by attempting the operation. For writes, check the thrown error. For reads, run a query and accept that empty results are valid.
The Four Rules of When to Request
From WWDC 2020-10664:
1. Request in context. Ask when the user is in the flow that needs the data — not at launch. The onboarding walkthrough is also a fine place for a single upfront request. 2. Request every time you intend to interact. Users change permissions in Settings and the Health app outside your app. HealthKit is the source of truth, not your cache. 3. Request only what the feature needs. Over 100 data types exist; batching them feels like a privacy violation to users. 4. Never treat success as granted. (See previous section.)
Background Reads May Fail
"your app may not be able to read data from the store when it runs in the background." — Apple, Protecting User Privacy
Writes still work in the background via temporary caching. Reads do not reliably work when the device is locked. Design background workflows so that read failures are acceptable — see sync-and-background.md for observer queries + background delivery that handle this correctly.
Guest User Session Trap
"An app's permissions don't change when an app runs in a Guest User session. Therefore,authorizationStatus(for:)returns.sharingAuthorizedif the owner previously granted authorization to write the data, even though the app can't write it during a Guest User session." — Apple,authorizationStatus(for:)docs
Relevant on iPad. Do not use authorizationStatus(for:) as a pre-flight gate for writes; attempt the write and handle the error.
Per-Object Read Authorization (Vision Prescriptions)
A small class of types uses per-object authorization — the user picks specific records, not a type-wide permission. Currently applies to HKVisionPrescriptionType (iOS 16+):
store.requestPerObjectReadAuthorization(
for: HKObjectType.visionPrescriptionType(),
predicate: myPredicate
) { success, error in
// "success" means the request was delivered, not that the user approved any record.
// Users select specific prescriptions to share.
}This is the only authorization path that always shows a sheet (per WWDC 2022-10005: "Doing so will always display an authorization prompt in your app with a list of all the prescriptions that match your predicate").
App Store Privacy
- The Privacy Manifest (
PrivacyInfo.xcprivacy) does not currently require HealthKit-specific declarations in Apple's published manifest documentation. Check App Store Connect's data-collection questionnaire at submission — it asks separately about health and fitness data categories. - Apps must not "use information gained through the use of the HealthKit framework for advertising or similar services" or "sell information gained through HealthKit to advertising platforms, data brokers, or information resellers." These are hard App Review rejection triggers.
- A privacy policy is required for any HealthKit-reading app, and Apple specifically expects alignment with PHR or HIPAA-style disclosures.
Common Mistakes
| Mistake | Reality |
|---|---|
Reading authorizationStatus(for:) and assuming it reflects read access | It's write-only. There is no API that reflects read access. Period. |
Showing "authorization granted" UI after a successful requestAuthorization return | Success means the request was delivered, not approved. Users may have tapped "Don't Allow." |
| Re-presenting the authorization sheet aggressively when the Health tab looks empty | The sheet only appears once per type. Re-calling requestAuthorization when status is .notDetermined is fine, but after denial the sheet will not reappear — you are silently no-oping. |
| Treating empty query results as a bug | By design, denied reads and genuinely-empty reads are indistinguishable. Empty is a valid state. Show an empty-state UI, not an error. |
| Requesting a large batch of types on launch | Users see a sheet with 40 toggles and either deny everything or drop out of onboarding. Request in context. |
Forgetting NSHealthUpdateUsageDescription | App crashes the first time requestAuthorization is called with a non-empty toShare. Same for NSHealthShareUsageDescription and toRead. |
Relying on authorizationStatus(for:) on iPad (Guest mode) | Returns the owner's status even in Guest sessions where writes fail. Attempt the write and handle the error. |
| Enabling Clinical Health Records capability "just in case" | App Review rejects apps that enable it without using it. |
| Using reads as an access-granted signal for billing or paywall gating | You cannot know if the user denied reads vs has no data. Both look identical. Use write access as the signal if you need proof of permission. |
Pressure-Resistant Decision Tree
digraph auth {
q1 [label="Feature needs HealthKit data?" shape=diamond];
q2 [label="Device supports HealthKit?" shape=diamond];
q3 [label="Feature writes, reads, or both?" shape=diamond];
run_query [label="Run the query\nEmpty result = valid state\nShow empty UI"];
attempt_write [label="Attempt the write\nCheck thrown error\nAuth status is unreliable"];
request [label="requestAuthorization\n(async, throws only on system errors)"];
degrade [label="Degrade gracefully\nHealthKit not available" shape=box];
check_avail [label="HKHealthStore.isHealthDataAvailable()"];
q1 -> q2 [label="yes"];
q2 -> check_avail;
check_avail -> degrade [label="false"];
check_avail -> q3 [label="true"];
q3 -> request [label="either"];
request -> run_query [label="for reads"];
request -> attempt_write [label="for writes"];
}Pressure Scenario — "My Health tab is empty for some users"
Real case, high frequency. The team sees analytics showing that some users open the Steps tab and see zero data. The instinctive fix:
- Check
authorizationStatus(for: stepCount)— returns.sharingAuthorizedfor the users in question. - Conclude "permission is granted, so the data should be there — it's a bug."
- Add a re-auth prompt on every launch, panic-implement data-refetching, or file a radar.
All wrong. The status returned .sharingAuthorized because the user granted write access (maybe a workout they logged once). Read access was denied silently. The query correctly returned empty. The UI correctly showed empty.
Correct fix: 1. Change the empty UI to explain what the state could mean: "No step data available. If this isn't right, check permissions in the Health app > Sources." 2. Do not re-prompt. The sheet will not reappear after denial. 3. Do not rely on authorizationStatus(for:) as a read signal — ever.
Resources
WWDC: 2020-10664, 2022-10005
Docs: /healthkit/authorizing-access-to-health-data, /healthkit/protecting-user-privacy, /healthkit/setting-up-healthkit, /healthkit/hkauthorizationstatus, /healthkit/hkauthorizationrequeststatus, /healthkit/hkhealthstore/requestauthorization(toshare:read:), /healthkit/hkhealthstore/statusforauthorizationrequest(toshare:read:), /healthkit/hkhealthstore/authorizationstatus(for:)
Skills: axiom-health (fundamentals, queries, sync-and-background), axiom-shipping
Clinical Records and Mobility
When to Use This Skill
Use when:
- Accessing electronic health records (allergies, conditions, immunizations, lab results, medications, procedures, vital signs, coverage) via HealthKit
- Parsing FHIR resources (
HKFHIRResource) from provider data - Reading mobility metrics — walking speed, step length, asymmetry, Apple Walking Steadiness, six-minute walk test
- Implementing recovery- or rehabilitation-focused features that track mobility trends
- Building a health-records-reading app that must satisfy App Store privacy requirements
Related Skills
- Use
fundamentals.mdfor HealthKit basics - Use
authorization-and-privacy.md— clinical records have a separate authorization sheet and an extra Info.plist key - Use
queries.mdfor reading standard samples; clinical records use the same query APIs withHKSampleQuery+ cast toHKClinicalRecord - Use
sync-and-background.mdforHKObserverQueryon walking-steadiness events (proactive alerts)
Two Distinct Domains
This skill covers two independent features that share the suite because they're specialized and smaller:
1. Health Records — read-only access to clinical data (FHIR) from connected healthcare providers. Distinct authorization, capability, and Info.plist key from the rest of HealthKit. 2. Mobility — passive, system-generated quantity types that measure gait and walking health. Read-only (the system collects these).
Health Records (Clinical)
Platform: iOS 12+, iPadOS 12+, Mac Catalyst 13+, macOS 13+, visionOS 1+
Clinical Type Identifiers
| Identifier | Covers |
|---|---|
allergyRecord | Allergic or intolerant reactions |
conditionRecord | Conditions, problems, diagnoses |
immunizationRecord | Vaccine administration |
labResultRecord | Lab results |
medicationRecord | Medications |
procedureRecord | Procedures |
vitalSignRecord | Vital signs (note: singular "Sign") |
clinicalNoteRecord | Clinical notes (iOS 16+) |
coverageRecord | Insurance coverage (iOS 14+) |
Note the .vitalSignRecord spelling — .vitalSignsRecord (plural) does not compile.
Construct types via HKObjectType.clinicalType(forIdentifier:):
guard let allergyType = HKObjectType.clinicalType(forIdentifier: .allergyRecord),
let conditionType = HKObjectType.clinicalType(forIdentifier: .conditionRecord) else {
fatalError("Clinical types should always construct")
}HKClinicalRecord — Shape
A sample whose value is a FHIR resource:
clinicalType: HKClinicalType— the categorydisplayName: String— the name as shown in the Health appfhirResource: HKFHIRResource?— the actual data
Critical gotcha: HKClinicalRecord.startDate / endDate reflect the download timestamp to the device, not the clinical event date. To display "when did this happen," parse the FHIR JSON for recordedDate, performedDateTime, onsetDateTime, etc., depending on resource type.
HKFHIRResource — Parsing the Data
func parse(resource: HKFHIRResource) throws -> [String: Any]? {
try JSONSerialization.jsonObject(with: resource.data, options: []) as? [String: Any]
}Properties:
resourceType: HKFHIRResourceType—.condition,.observation,.medicationRequest, etc.fhirVersion: HKFHIRVersion— DSTU2 or R4 (varies by provider)identifier: String— FHIR resource IDsourceURL: URL?— origin URLdata: Data— JSON payload
Capability Setup (Two Gotchas)
Both must be set — the standard HealthKit setup is insufficient for clinical records:
1. Xcode capability: Enable HealthKit, then check the Clinical Health Records checkbox inside the HealthKit capability. 2. Info.plist key: NSHealthClinicalHealthRecordsShareUsageDescription — separate from NSHealthShareUsageDescription. Both keys are required if you read both clinical and non-clinical data.
App Review enforces:
- A valid Privacy Policy URL in App Store Connect — Apple displays this on the clinical-records permission sheet.
- "App Review may reject inappropriate use of clinical records" — don't enable the capability speculatively.
Authorization (Read-Only)
Clinical records cannot be written by your app:
store.requestAuthorization(toShare: nil, read: [allergyType, conditionType]) { success, error in
// Even with "success", the user may have granted access to no records.
// Run a query and handle empty results as a valid state.
}The permission sheet for clinical types surfaces connected provider accounts and is distinct from the standard HealthKit sheet. The user can grant access per provider connection.
Reading Clinical Records
Use the same query APIs as any other sample type, but cast the result:
let descriptor = HKSampleQueryDescriptor(
predicates: [HKSamplePredicate.clinicalRecord(type: allergyType, predicate: nil)],
sortDescriptors: []
)
let records: [HKClinicalRecord] = try await descriptor.result(for: store)
for record in records {
print(record.displayName)
if let resource = record.fhirResource {
// Parse resource.data for clinical-event details.
}
}FHIR Parsing Realities
- Parse defensively. The same logical resource (e.g., a
Condition) can arrive in DSTU2 or R4, with different JSON shapes. InspectfhirVersionand branch. - Don't assume fields are present. Clinical data is sparse. Missing fields are normal, not errors.
- Normalize dates from FHIR, not `HKSample`.
HKClinicalRecord.startDateis download time; always pull the clinical event date from the FHIR payload.
Mobility
Apple Watch and iPhone collect a suite of system-generated walking and mobility metrics passively. Your app reads them; you cannot write them.
Core Mobility Quantity Types
| Identifier | iOS | Unit | What it measures |
|---|---|---|---|
walkingSpeed | 14+ / watchOS 7+ | HKUnit.meter().unitDivided(by: .second()) | Average speed when walking steadily over flat ground |
walkingStepLength | 14+ / watchOS 7+ | .meter() | Average step length |
walkingDoubleSupportPercentage | 14+ / watchOS 7+ | .percent() | Time with both feet on the ground (typical 20–40%) |
walkingAsymmetryPercentage | 14+ / watchOS 7+ | .percent() | Steps where one foot moves differently from the other |
appleWalkingSteadiness | 15+ / watchOS 8+ | .percent() (0.0–1.0) | Gait stability score; sampled ~weekly |
sixMinuteWalkTestDistance | 14+ / watchOS 7+ | .meter() | Estimated six-minute walk distance (capped at 500 m) |
stairAscentSpeed | 14+ / watchOS 7+ | m/s | Speed climbing stairs |
stairDescentSpeed | 14+ / watchOS 7+ | m/s | Speed descending stairs |
Unit gotcha: Apple Walking Steadiness is .percent() but values are in [0.0, 1.0], not [0, 100]. Multiply by 100 only for display.
Wheelchair Mode Suppresses Walking Metrics
If the user has wheelchair mode enabled in Health → Health Profile, walking and stair metrics return empty. Your app must treat empty results as "not applicable for this user," not "this user has no mobility issues."
Walking Steadiness Classification
func classify(for quantity: HKQuantity) -> HKAppleWalkingSteadinessClassification? {
try? HKAppleWalkingSteadinessClassification(for: quantity)
}Three cases: .ok, .low, .veryLow. Each carries minimum and maximum properties exposing the band thresholds.
Pair with HKCategoryType(.appleWalkingSteadinessEvent) and an HKObserverQuery to proactively notify the user when gait degrades — see sync-and-background.md for the observer pattern.
Six-Minute Walk Recalibration
After surgery, injury, or major medical events, walking estimates can drift. Users can reset them:
let type = HKSampleType.quantityType(forIdentifier: .sixMinuteWalkTestDistance)!
if type.allowsRecalibrationForEstimates {
try await store.recalibrateEstimates(sampleType: type, date: surgeryDate)
}Requires a separate entitlement: com.apple.developer.healthkit.recalibrate-estimates.
Important user-facing caveats (from WWDC 2021-10287):
"This method does not affect estimates that are already present in HealthKit at the time of use, so it's important to recalibrate as soon as possible after a surgery."
"After recalibration, it could take up to 14 days to rebuild enough activity history to make a confident estimate."
Surface a 14-day warm-up message in your UI — don't present stale or uncertain data as authoritative during the warm-up window.
Mobility App Setup Prerequisites
- Walking metrics require the user to set height in the Health app (required for accurate walking-speed estimation).
- Apple Watch Series 3 or later, worn ≥8 hours/day, ≥3 days/week, sustained ≥4 weeks.
- Verify the user has at least two weeks of consistent
walkingSpeedsamples before displaying derived trends — per WWDC 2021-10287, that's the threshold Apple suggests for data confidence.
Core Motion Is a Different Framework
Apple has two motion-telemetry stacks and they do not bridge:
| Core Motion | HealthKit mobility | |
|---|---|---|
| API surface | CMMotionManager, CMPedometer, CMFallDetectionManager | HKQuantityType.quantityType(forIdentifier:) with mobility identifiers |
| Latency | Real-time (Hz range) | Days (validated, processed metrics) |
| Persistence | In-memory streams (some history for CMPedometer) | Health database (user-portable) |
| Authorization | Motion & Fitness permission | HealthKit share/read |
| Use case | Live games, rep counters, instant step feedback | Trend analysis, clinical export, gait/steadiness |
Rule of thumb: do not reimplement HealthKit mobility metrics from raw Core Motion data. The system-generated metrics encode Apple's validated thresholds (waist-carry detection, flat-ground gating, walking-steadily detection) that a custom pipeline cannot easily replicate.
Axiom does not yet cover Core Motion as its own suite — it's parked in future-suites-parking-lot memory for a future Core Motion suite.
Common Mistakes
| Mistake | Fix |
|---|---|
Using .vitalSignsRecord (plural) | Correct symbol is .vitalSignRecord (singular). |
Treating HKClinicalRecord.startDate as the clinical event date | It's the download timestamp. Parse the FHIR JSON for the real date. |
Forgetting NSHealthClinicalHealthRecordsShareUsageDescription | Without this key, authorization for clinical types fails silently. It's separate from NSHealthShareUsageDescription. |
| Enabling Clinical Health Records capability "just in case" | App Review rejects unused capability. Enable only when you actually read clinical data. |
| Treating empty mobility queries as "user has no problems" | Wheelchair mode suppresses walking metrics; the user may simply not generate this data. Render an honest empty state. |
Reading appleWalkingSteadiness as 0–100 | It's [0.0, 1.0]. Multiply by 100 for display only. |
| Skipping the Privacy Policy URL for clinical apps | App Store review rejects without one. It's displayed on the permission sheet. |
Assuming HKFHIRResource.data follows a single schema | DSTU2 and R4 have different shapes. Check fhirVersion and parse defensively. |
| Displaying six-minute walk estimates during the 14-day recalibration warm-up | Not reliable. Explain the recalibration window in the UI or hide the metric. |
| Hand-rolling gait analysis from Core Motion | Re-implementing the validated HealthKit mobility metrics is a research project, not a feature. Use HealthKit mobility types. |
Resources
WWDC: 2018-229, 2021-10287
Docs: /healthkit/accessing-health-records, /healthkit/hkclinicaltype, /healthkit/hkclinicaltypeidentifier, /healthkit/hkclinicalrecord, /healthkit/hkfhirresource, /healthkit/hkfhirresourcetype, /healthkit/creating-a-mobility-health-app, /healthkit/hkquantitytypeidentifier/walkingspeed, /healthkit/hkquantitytypeidentifier/applewalkingsteadiness, /healthkit/hkquantitytypeidentifier/sixminutewalktestdistance, /healthkit/hkapplewalkingsteadinessclassification, /healthkit/hkhealthstore/recalibrateestimates(sampletype:date:completion:)
Skills: axiom-health (fundamentals, authorization-and-privacy, queries, sync-and-background)
HealthKit Fundamentals
When to Use This Skill
Use when:
- Starting a HealthKit feature and need the framework mental model
- Deciding between characteristic data and sample data for a new type
- Confused about
HKQuantitySamplevsHKCumulativeQuantitySamplevsHKDiscreteQuantitySample - Figuring out which platforms support HealthKit read/write
- Setting up
HKHealthStorecorrectly for the first time - Debugging completion-handler threading issues
Related Skills
- Use
authorization-and-privacy.mdfor the full authorization flow, purpose strings, and read/write permission model - Use
queries.mdfor reading data, statistics rollups, and writing samples - Use
sync-and-background.mdfor anchored queries, observer queries, and background delivery - Use
workouts.mdforHKWorkoutSessionlifecycle andHKLiveWorkoutBuilder - Use
clinical-and-mobility.mdfor Health Records (FHIR) and mobility-specific data - Use
axiom-concurrencyfor general Swift 6 actor isolation rules that apply to HealthKit completion handlers
Core Concepts
HealthKit is a central repository. Apps read from it and contribute to it. The system handles cross-device sync between iPhone, Apple Watch, iPad (iPadOS 17+), and visionOS automatically — your app never deals with sync.
Three properties shape every HealthKit API:
1. Authorization-gated per type — read and write are requested separately for each data type (no "all-or-nothing" permission). 2. Shared store with third-party contributors — your reads return data from any app the user has authorized, not just yours. 3. All HealthKit objects are immutable — once saved, samples are deleted-and-replaced rather than edited.
HKHealthStore — The Gateway
HKHealthStore is the single entry point for everything: authorization, reads, writes, background delivery, workout sessions.
One instance per app. Create it at launch, reuse for the app's lifetime:
"You only need to create one instance and reuse it across the lifecycle of your application." — WWDC 2020-10664
import HealthKit
@MainActor
final class HealthStore {
static let shared = HealthStore()
let store = HKHealthStore()
private init() {}
}HKHealthStore conforms to Sendable, so reuse across actors is safe.
Gate on device availability first. HealthKit compiles on all Apple platforms but is read/write-capable only on iOS, iPadOS 17+, watchOS, and visionOS. Check isHealthDataAvailable() before touching the store:
guard HKHealthStore.isHealthDataAvailable() else {
// HealthKit not usable on this device — degrade gracefully
return
}Data Type Hierarchy
Two root branches below HKObjectType:
digraph hkobjecttype {
HKObjectType [shape=box];
HKCharacteristicType [shape=box];
HKSampleType [shape=box];
HKQuantityType [shape=box];
HKCategoryType [shape=box];
HKCorrelationType [shape=box];
HKWorkoutType [shape=box];
HKSeriesType [shape=box];
HKClinicalType [shape=box];
HKAudiogramSampleType [shape=box];
HKElectrocardiogramType [shape=box];
HKActivitySummaryType [shape=box];
HKObjectType -> HKCharacteristicType;
HKObjectType -> HKSampleType;
HKSampleType -> HKQuantityType;
HKSampleType -> HKCategoryType;
HKSampleType -> HKCorrelationType;
HKSampleType -> HKWorkoutType;
HKSampleType -> HKSeriesType;
HKSampleType -> HKClinicalType;
HKSampleType -> HKAudiogramSampleType;
HKSampleType -> HKElectrocardiogramType;
HKSampleType -> HKActivitySummaryType;
}Characteristic vs sample — the most important distinction.
| Aspect | Characteristic | Sample |
|---|---|---|
| Shape | Single value, static per user | Time-windowed event with value(s) |
| Examples | birthday, biological sex, blood type, wheelchair use | step count, heart rate, workout, sleep stage |
| Access | synchronous getters on HKHealthStore | queries (HKSampleQuery, statistics, anchored, observer) |
| Authorization | read-only | read and write are separate permissions |
Sample Kinds
Every sample carries a type, a time window, a value, optional metadata, and provenance (device + source app).
| Kind | Value shape | Canonical example |
|---|---|---|
Quantity (HKQuantitySample — abstract since iOS 13) | Numeric + unit | 105 kcal active energy, 628 m walking distance |
Quantity cumulative (HKCumulativeQuantitySample) | Sum (steps, distance, calories) | 8,342 steps between 09:00 and 17:00 |
Quantity discrete (HKDiscreteQuantitySample) | Avg / min / max / most-recent (heart rate, body mass) | 142 bpm at 10:15 during a workout |
| Category | Value from predefined enum, no unit | .asleepREM sleep-analysis sample |
| Correlation | Groups multiple subsamples | Blood pressure (systolic + diastolic) |
| Workout | Aggregates multiple values and units over an activity | A 5 km run with distance + energy + HR |
| Series | Compact storage for high-frequency streams | Heartbeat series (timestamps only), quantity series (many quantities sharing metadata) |
| Clinical | FHIR records | Lab results, prescriptions, immunizations — see clinical-and-mobility.md |
Why quantity samples split into cumulative vs discrete (iOS 13+). HKQuantitySample is an abstract base class — concrete instances are always HKCumulativeQuantitySample or HKDiscreteQuantitySample. Existing code that handles instances as HKQuantitySample still compiles (the abstract base is preserved for source compatibility), but you cast to the concrete subclass to access the summary properties:
- Cumulative types expose
sum. Steps, distance, calories. - Discrete types expose
average,minimum,maximum,mostRecentQuantity. Heart rate, body mass, audio exposure.
Aggregation styles (relevant when you see these in statistics queries):
| Style | Applies to | What average means |
|---|---|---|
.cumulative | Summable types | Sum |
.discreteArithmetic | Most discrete types | Simple arithmetic mean |
.discreteTemporallyWeighted | Heart rate | Time-weighted average (older readings matter less) |
.discreteEquivalentContinuousLevel | Audio exposure | Continuous-level average per acoustics convention |
Canonical Setup Pattern
import HealthKit
@MainActor
final class HealthSession {
let store = HKHealthStore()
func bootstrap() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthError.notAvailable
}
// Reads can include characteristics (non-sample, static data).
let toRead: Set<HKObjectType> = [
HKQuantityType(.stepCount),
HKQuantityType(.heartRate),
]
// Writes are samples only — characteristics are read-only.
let toWrite: Set<HKSampleType> = [
HKWorkoutType.workoutType(),
]
try await store.requestAuthorization(toShare: toWrite, read: toRead)
}
}
enum HealthError: Error { case notAvailable }Required `Info.plist` keys:
| Key | When required |
|---|---|
NSHealthShareUsageDescription | Any read access |
NSHealthUpdateUsageDescription | Any write access |
Without these, the authorization call crashes at runtime. Full authorization discipline lives in authorization-and-privacy.md.
Platform Availability
HealthKit's own documentation uses the language "Full HealthKit stores" (can read and write) vs "Limited support" (can compile, cannot read or write):
| Platform | HealthKit store |
|---|---|
| iOS 8+ | Full |
| iPadOS 17+ | Full |
| iPadOS 16 and earlier | Limited — compiles, cannot read or write |
| watchOS 2+ | Full (old Apple Watch data is periodically purged) |
| visionOS 1+ | Full |
| macOS 13+ | Limited — compiles, cannot read or write |
| Mac Catalyst 13+ | Limited — runs on a Mac, which has no Health store; isHealthDataAvailable() returns false (matches macOS, not iPadOS rules) |
Always check isHealthDataAvailable() at runtime — it is the definitive signal for the current device.
Threading and Concurrency
"All the HealthKit API's completion handlers execute on private background queues. You typically dispatch this data back to the main queue before updating your user interface." — Apple framework docs
Two rules this implies:
1. Do not touch UI from completion handlers. Use @MainActor to hop back. Modern Swift Concurrency variants (iOS 15.4+) handle this correctly if you await from a @MainActor context. 2. Long-running queries must be stopped. HKStatisticsCollectionQuery and HKObserverQuery with update handlers keep running until you call healthStore.stop(query) — or, for descriptor-based async variants, break out of the AsyncSequence loop.
See queries.md for the async descriptor pattern and sync-and-background.md for observer and anchored query lifecycles.
Common Mistakes
| Mistake | Fix |
|---|---|
Assuming "success" in requestAuthorization means "user said yes" | It only means the request was delivered. Check actual data access, not the completion status. See authorization-and-privacy.md. |
| Requesting authorization for "all" data types up front | Request only what the feature needs. Over 100 data types exist; a giant sheet feels like a privacy violation. |
Creating a new HKHealthStore per view model | Reuse one store. Multiple instances work but waste resources and complicate lifecycle. |
| Running on macOS and expecting data | Full store only on iOS, iPadOS 17+, watchOS, visionOS. Gate on isHealthDataAvailable() first. |
| Casting a quantity sample to the wrong concrete subclass | Heart rate is discrete (average/min/max/mostRecentQuantity); steps/distance/calories are cumulative (sum). Casting heart rate to HKCumulativeQuantitySample with as? silently yields nil → a wrong 0 that looks like "no data"; with as! it crashes the first time real data exists. Match the subclass to the type's aggregation style, or read aggregates via a statistics query. |
| Hand-summing quantity samples for a daily total | iPhone + Apple Watch both record system types like steps/distance/energy, so a raw HKSampleQuery returns overlapping samples and .reduce(+) double-counts the same activity (the store is shared — Core Concept 2). HKStatisticsQuery / HKStatisticsQueryDescriptor with .cumulativeSum applies the system's source-prioritization for these auto-recorded types so the same activity isn't counted twice; scope to one source with a predicate when you need it. See queries.md. |
Forgetting an Info.plist usage key | The crash happens at requestAuthorization(toShare:read:) — not later at save or read. Including a write type without NSHealthUpdateUsageDescription (or a read type without NSHealthShareUsageDescription) throws an NSException the moment you request authorization. Both keys are required if you do both operations. |
| Updating UI from a completion handler without hopping to main | Completion handlers run on private background queues. Use await MainActor.run or call from an @MainActor context. |
Resources
WWDC: 2019-218, 2020-10664, 2020-10182, 2022-10005
Docs: /healthkit, /healthkit/about-the-healthkit-framework, /healthkit/data-types, /healthkit/hkhealthstore, /healthkit/hkobjecttype, /healthkit/hksampletype
Skills: axiom-health (authorization-and-privacy, queries, sync-and-background, workouts, clinical-and-mobility), axiom-concurrency
HealthKit Queries and Sample Writes
When to Use This Skill
Use when:
- Reading data from HealthKit for a one-shot display (today's steps, most recent heart rate)
- Computing daily, hourly, or weekly rollups for charts
- Writing new
HKQuantitySample,HKCategorySample, orHKWorkoutsamples to the store - Choosing between
HKSampleQuery,HKStatisticsQuery,HKStatisticsCollectionQuery, and descriptor variants - Modernizing callback-based query code to Swift Concurrency descriptors
Out of Scope — Use Different Skills
- Change tracking over time, background delivery, observer queries, anchored queries →
sync-and-background.md. Those are long-running queries with anchor persistence and wake-on-change semantics. - Workout session lifecycle (`HKWorkoutSession`, `HKLiveWorkoutBuilder`) →
workouts.md. - Authorization prerequisites →
authorization-and-privacy.md.
Related Skills
- Use
fundamentals.mdfor the HKObjectType hierarchy and quantity-vs-category distinction - Use
axiom-concurrencyfor Swift 6 actor isolation patterns that affect query handlers - Use
axiom-swiftuifor renderingHKStatisticsCollectionresults as charts
The Query Decision
HealthKit has nine query types. For most reads, you need exactly one of three:
digraph query_decision {
q1 [label="What do you need?" shape=diamond];
q2 [label="Single aggregate\n(sum, avg, min, max)?" shape=diamond];
q3 [label="Multiple intervals\n(rollups for a chart)?" shape=diamond];
raw [label="HKSampleQuery\nRaw samples with predicate + sort + limit"];
stats [label="HKStatisticsQuery\nOne aggregate over a window"];
collection [label="HKStatisticsCollectionQuery\nOne aggregate per interval"];
other [label="See sync-and-background.md\nor series skills"];
q1 -> q2 [label="aggregated values"];
q1 -> raw [label="raw samples"];
q1 -> other [label="change notifications"];
q2 -> q3 [label="yes, with rollups"];
q2 -> stats [label="no, single value"];
q3 -> collection [label="yes"];
}Rule of thumb: charts with x-axis intervals need HKStatisticsCollectionQuery. A single "total steps today" metric uses HKStatisticsQuery. Reading a table of raw heart rate samples uses HKSampleQuery.
Prefer Swift Concurrency Descriptors
The callback-based query classes (HKSampleQuery, HKStatisticsQuery, HKStatisticsCollectionQuery) still work, but since iOS 15.4 / watchOS 8.5 / macOS 13.0, the descriptor variants are the preferred API. They conform to HKAsyncQuery (and, for streaming variants, HKAsyncSequenceQuery).
| Classic (callback) | Descriptor (async) | Output |
|---|---|---|
HKSampleQuery | HKSampleQueryDescriptor<Sample> | [Sample] |
HKStatisticsQuery | HKStatisticsQueryDescriptor | HKStatistics? |
HKStatisticsCollectionQuery | HKStatisticsCollectionQueryDescriptor | Result (wraps HKStatisticsCollection) |
Why descriptors win:
async throwsresult APIs — no completion-handler callback pyramids.- Automatic cleanup — one-shot descriptors need no
healthStore.stop(query). - Generic typing (
HKSampleQueryDescriptor<HKQuantitySample>) catches wrong-type bugs at compile time. Sendableconformance suits Swift 6 strict concurrency.
Canonical Patterns
Read raw samples (most recent 100 heart rate readings)
import HealthKit
@MainActor
final class HeartRateFeed {
let store = HKHealthStore()
func recentSamples() async throws -> [HKQuantitySample] {
let predicate = HKSamplePredicate<HKQuantitySample>.quantitySample(
type: HKQuantityType(.heartRate),
predicate: nil
)
let descriptor = HKSampleQueryDescriptor(
predicates: [predicate],
sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)],
limit: 100
)
return try await descriptor.result(for: store)
}
}Single aggregate (total steps today)
func stepsToday() async throws -> Double {
let startOfDay = Calendar.current.startOfDay(for: .now)
let nsPredicate = HKQuery.predicateForSamples(withStart: startOfDay, end: nil)
let predicate = HKSamplePredicate<HKQuantitySample>.quantitySample(
type: HKQuantityType(.stepCount),
predicate: nsPredicate
)
let descriptor = HKStatisticsQueryDescriptor(
predicate: predicate,
options: .cumulativeSum
)
let statistics = try await descriptor.result(for: store)
return statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0
}Rollups for a chart (daily steps, last 30 days)
func dailySteps(days: Int) async throws -> [(date: Date, steps: Double)] {
let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: .now)
let start = calendar.date(byAdding: .day, value: -days, to: anchorDate)!
let nsPredicate = HKQuery.predicateForSamples(withStart: start, end: nil)
let predicate = HKSamplePredicate<HKQuantitySample>.quantitySample(
type: HKQuantityType(.stepCount),
predicate: nsPredicate
)
let descriptor = HKStatisticsCollectionQueryDescriptor(
predicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: DateComponents(day: 1)
)
let result = try await descriptor.result(for: store)
var points: [(Date, Double)] = []
result.enumerateStatistics(from: start, to: anchorDate) { stats, _ in
let sum = stats.sumQuantity()?.doubleValue(for: .count()) ?? 0
points.append((stats.startDate, sum))
}
return points
}HKStatisticsOptions — The Option Rules
| Option | Use for |
|---|---|
.cumulativeSum | Cumulative types (step count, distance, active energy) |
.discreteAverage | Discrete types' mean (heart rate, body mass) |
.discreteMin / .discreteMax | Discrete types' bounds |
.mostRecent | Latest reading (replaces the deprecated .discreteMostRecent) |
.duration | Total time covered by samples (workout rollups) |
.separateBySource | Report values per contributing device/app |
Hard rule:
"You cannot combine a discrete option with a cumulative option. You can, however, combine multiple discrete options together to perform multiple calculations." — HKStatisticsOptionsSo [.discreteAverage, .discreteMin, .discreteMax] is valid; [.cumulativeSum, .discreteAverage] is not.
Hard rule for collection queries:
"You can only use statistics collection queries with quantity samples. If you want to calculate statistics over workouts or correlation samples, you must perform the appropriate query and process the data yourself." — HKStatisticsCollectionQueryIf you need a "weekly workout count," run an HKSampleQueryDescriptor<HKWorkout> and bucket in Swift.
Statistics Result Shapes
HKStatistics (single interval's aggregate)
Access the option you requested; other accessors return nil:
stats.sumQuantity() // present when .cumulativeSum was requested
stats.averageQuantity() // present when .discreteAverage was requested
stats.minimumQuantity() // .discreteMin
stats.maximumQuantity() // .discreteMax
stats.mostRecentQuantity() // .mostRecent
stats.duration() // .duration
stats.sources // [HKSource]?, contributors
stats.sumQuantity(for: source) // source-specific variant when .separateBySource setHKStatisticsCollection (multiple intervals)
collection.statistics() // [HKStatistics] — populated intervals only
collection.statistics(for: date) // HKStatistics? at that instant
collection.enumerateStatistics(from:to:with:) // preferred — fills gaps correctly
collection.sources() // Set<HKSource>enumerateStatistics(from:to:) is usually what you want — it produces one HKStatistics per interval in range, including intervals with zero samples (so your chart axis has continuous points).
Writing Samples
func recordBodyMass(kg: Double) async throws {
let quantity = HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: kg)
let sample = HKQuantitySample(
type: HKQuantityType(.bodyMass),
quantity: quantity,
start: .now,
end: .now,
metadata: [HKMetadataKeyWasUserEntered: true]
)
try await store.save(sample)
}Save rules:
save(_:)andsave(_:withCompletion:)both accept a singleHKObjector an[HKObject]. Batch when saving many — one transaction is much faster.- Prefer minute-or-less granularity for active data. Apple: "Avoid samples 24+ hours long. Workouts benefit from minute-or-less granularity; daily counts work well at hourly intervals."
- Samples are immutable once saved. To correct a value, delete the old sample and save a new one.
- Writes throw on authorization errors. Don't trust
authorizationStatus(for:)— attempt the save and handle the error (seeauthorization-and-privacy.md).
Threading
"All queries run on an anonymous background queue." — Apple, Reading data from HealthKit
Callback-based query handlers run on private background queues. The async descriptor methods inherit the caller's actor context — if you call try await descriptor.result(for: store) from a @MainActor function, you stay on main. This is one more reason to prefer descriptors.
Performance Caveat
"People may have a large quantity of data saved to the HealthKit store. Querying for all samples of a given data type can become very expensive, both in terms of memory usage and processing time." — Apple, Running Queries with Swift Concurrency
Always set a limit: or a date-range predicate. Avoid HKObjectQueryNoLimit except when you know the scope is bounded (for example, samples written by your own app in a known window).
For bulk processing of all historical samples, use HKAnchoredObjectQueryDescriptor with pagination (see sync-and-background.md) — not an unlimited sample query.
Common Mistakes
| Mistake | Fix |
|---|---|
Using HKStatisticsCollectionQuery with workout samples | Statistics collection queries only work on HKQuantitySample. Query workouts with HKSampleQueryDescriptor<HKWorkout> and bucket in Swift. |
Combining .cumulativeSum with .discreteAverage | Not allowed. Pick one family. |
Requesting .discreteAverage but calling stats.sumQuantity() | Returns nil. Aggregate accessors return nil unless the matching option bit was requested. |
Running an unbounded HKSampleQueryDescriptor without a limit | Can return thousands of samples and blow memory. Always set limit: or predicate by date. |
| Treating an empty result as an error | Empty can mean denied reads (by design — see authorization-and-privacy.md) or genuinely no data. Render an honest empty state. |
Forgetting to call stop on a callback-based collection query with statisticsUpdateHandler set | Long-running callback queries leak until stopped. Prefer the async results(for:) streaming descriptor and cancel via Task.cancel(). |
Saving samples without HKMetadataKeyWasUserEntered when appropriate | Apple uses this metadata for Journal-app suggestions in iOS 17.2+ and for source distinction. Set it for manually-entered data. |
Querying without a start/end predicate and hoping limit: saves you | limit: caps the returned array, but the system still scans the matching range. Always narrow with date predicates for efficiency. |
Resources
WWDC: 2020-10664, 2022-10005
Docs: /healthkit/reading-data-from-healthkit, /healthkit/queries, /healthkit/running-queries-with-swift-concurrency, /healthkit/hksamplequerydescriptor, /healthkit/hkstatisticsquerydescriptor, /healthkit/hkstatisticscollectionquerydescriptor, /healthkit/hkstatisticsoptions, /healthkit/hkstatistics, /healthkit/hkstatisticscollection, /healthkit/hksamplepredicate, /healthkit/saving-data-to-healthkit
Skills: axiom-health (fundamentals, authorization-and-privacy, sync-and-background, workouts), axiom-concurrency, axiom-swiftui
HealthKit Sync and Background Delivery
When to Use This Skill
Use when:
- Reading from HealthKit across app launches without re-reading the entire history every time
- Responding to HealthKit changes in the background (no polling)
- Syncing HealthKit data to a server without creating duplicates
- Handling sample deletions correctly — not just additions
- Adding the
com.apple.developer.healthkit.background-deliveryentitlement - Deciding between
HKObserverQuery,HKAnchoredObjectQuery, and their descriptor variants
Related Skills
- Use
fundamentals.mdforHKHealthStoreand the sample type system - Use
authorization-and-privacy.mdbefore adding any read workflow — background reads fail if the user has not authorized - Use
queries.mdfor one-shot reads and statistics - Use
axiom-concurrencyfor Swift 6 actor isolation around background callbacks
The One Thing You Must Internalize
Re-reading the whole HealthKit store on every launch is wrong. Apple's anchored-object design exists precisely so you never do that:
"Persisting the anchor allows us to only retrieve the changes in HealthKit since the last query." — WWDC 2020-10184
Three reasons this matters:
1. Battery — a daily full re-read of thousands of samples is orders of magnitude more expensive than a delta query with a persisted anchor. 2. Correctness — without anchors, you cannot see deletions. A sample the user removed in the Health app will silently stay in your local store forever. 3. Duplicates — without sync identifiers, repeated writes create duplicate samples; anchored queries return them all, and your UI double-counts.
Fix all three by adopting the anchor + deletion handling + sync identifier pattern below.
The Sync Architecture
Three APIs that work together:
digraph sync {
user [label="User adds/deletes sample\n(from your app or another)" shape=ellipse];
observer [label="HKObserverQuery\nwakes app on change"];
anchored [label="HKAnchoredObjectQuery\nreturns diff since last anchor"];
app [label="Your app processes delta\n(adds, deletions)" shape=box];
store [label="Your local store /\nserver backend" shape=box];
user -> observer [label="change"];
observer -> anchored [label="run against persisted anchor"];
anchored -> app [label="(samples, deletions, newAnchor)"];
app -> store [label="apply delta"];
app -> anchored [label="persist newAnchor"];
}Roles:
- Observer query — the signal. Fires when any sample of a type changes. Carries no payload — "you have new data, go look."
- Anchored query — the payload. Run after the observer fires. Returns everything added or deleted since the anchor you provide, plus a new anchor to persist.
- Sync identifiers — the de-duplicator. Metadata keys that let you re-save a sample idempotently without creating duplicates.
Anchor Persistence
HKQueryAnchor is a point-in-time token. Persist it between launches with NSKeyedArchiver:
import HealthKit
func persist(anchor: HKQueryAnchor) throws {
let data = try NSKeyedArchiver.archivedData(
withRootObject: anchor,
requiringSecureCoding: true
)
UserDefaults.standard.set(data, forKey: "healthkit.anchor.stepCount")
}
func loadAnchor() -> HKQueryAnchor? {
guard let data = UserDefaults.standard.data(forKey: "healthkit.anchor.stepCount") else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(
ofClass: HKQueryAnchor.self,
from: data
)
}Persist per type (stepCount, heartRate, etc.) — anchors are type-specific. Store them wherever survives app termination (UserDefaults, SwiftData, Core Data — size is tiny).
First launch: pass nil. HealthKit returns all matching samples. Save the returned anchor.
Subsequent launches: pass the persisted anchor. HealthKit returns only diffs (adds + deletes). Save the new anchor.
HKAnchoredObjectQuery — Signature and Behavior
Classic callback form
class HKAnchoredObjectQuery : HKQuery
init(
type: HKSampleType,
predicate: NSPredicate?,
anchor: HKQueryAnchor?,
limit: Int,
resultsHandler: @escaping @Sendable (
HKAnchoredObjectQuery,
[HKSample]?,
[HKDeletedObject]?,
HKQueryAnchor?,
(any Error)?
) -> Void
)The fourth parameter — HKQueryAnchor? — is the new anchor to persist.
Important: resultsHandler fires exactly once with the initial batch. To get continuous updates without running a new query on every change, set updateHandler after creation — it fires for each subsequent change and delivers the same tuple.
Modern descriptor form (iOS 15.4+, watchOS 8.5+)
struct HKAnchoredObjectQueryDescriptor<Sample> where Sample : HKSample
// One-shot delta fetch:
func result(for store: HKHealthStore) async throws -> Result
// Long-running stream of deltas:
func results(for store: HKHealthStore) -> Results // AsyncSequenceThe descriptor conforms to both HKAsyncQuery (one-shot) and HKAsyncSequenceQuery (streaming). Choose deliberately:
result(for:)— use from within an observer query's handler, or at startup, for a one-off delta batch.results(for:)— use for a persistent foreground reader that streams deltas until cancelled. Cancel viaTask.cancel().
Canonical delta read
@MainActor
final class StepSync {
let store = HKHealthStore()
func fetchDelta() async throws {
let anchor = loadAnchor()
let predicate = HKSamplePredicate<HKQuantitySample>.quantitySample(
type: HKQuantityType(.stepCount),
predicate: nil
)
let descriptor = HKAnchoredObjectQueryDescriptor(
predicates: [predicate],
anchor: anchor
)
let result = try await descriptor.result(for: store)
apply(additions: result.addedSamples, deletions: result.deletedObjects)
try persist(anchor: result.newAnchor)
}
private func apply(additions: [HKQuantitySample], deletions: [HKDeletedObject]) {
// Upsert additions in local store, remove deletions by UUID.
}
}HKDeletedObject — Do Not Ignore Deletions
class HKDeletedObject
var uuid: UUID
var metadata: [String : Any]?Semantics:
- The
uuidmatches the deleted sample's UUID. Use it to remove the sample from your local mirror. metadatacarries the sync identifier of the original sample (see below) — so if you're syncing to a server, you can delete by sync identifier without needing to keep a UUID↔syncID map.
Critical warning (verbatim from Apple):
"Deleted objects are temporary; the system may remove them from the HealthKit store at any time."
In practice, this means: if your app is offline for weeks, you may miss deletions that happened during that time. The anchor-based model tolerates this (you still see the additions you need), but if strict sync correctness matters, fall back to a full resync after a long absence.
Observer queries alone do not deliver deletions. Only anchored queries include HKDeletedObject instances in their results. An observer-only architecture silently accumulates tombstones in your local store.
HKObserverQuery — The Wake-Up Signal
class HKObserverQuery : HKQuery
init(
sampleType: HKSampleType,
predicate: NSPredicate?,
updateHandler: @escaping @Sendable (
HKObserverQuery,
HKObserverQueryCompletionHandler,
(any Error)?
) -> Void
)
typealias HKObserverQueryCompletionHandler = () -> VoidObserver queries "monitor the HealthKit store and alert you to any changes to matching samples." They carry no payload — the handler fires with only a completion token.
The handler contract:
1. The handler receives a HKObserverQueryCompletionHandler closure. 2. You must call that closure when you have finished processing the change (run your anchored query, persist data, whatever). 3. HealthKit will then re-suspend your app.
Three-strikes rule: if your handler fails to call the completion handler three times in a row, HealthKit disables background delivery for your app. You then need to call enableBackgroundDelivery again. Always call completion, even on error paths.
Simulator limitation: observer-query background delivery does not work on the Simulator. Test background sync on device.
Background Delivery
Background delivery lets your app wake and process changes while the user is not actively in the app.
Entitlement
Add com.apple.developer.healthkit.background-delivery (Boolean, true) to your entitlements file. Without this, enableBackgroundDelivery fails with an authorization-denied error — not a runtime crash, just a silent no-op.
In Xcode: Signing & Capabilities → HealthKit → check Background Delivery.
API
func enableBackgroundDelivery(
for type: HKObjectType,
frequency: HKUpdateFrequency,
withCompletion completion: @escaping @Sendable (Bool, (any Error)?) -> Void
)
func enableBackgroundDelivery(
for type: HKObjectType,
frequency: HKUpdateFrequency
) async throws
func disableBackgroundDelivery(
for type: HKObjectType,
withCompletion completion: @escaping @Sendable (Bool, (any Error)?) -> Void
)
public enum HKUpdateFrequency {
case immediate
case hourly
case daily
case weekly
}`HKCorrelationType` is not supported for background delivery — observe the child types instead (e.g., systolic and diastolic blood pressure separately rather than the correlation).
`.immediate` on watchOS has additional restrictions — it's only honored for heart rate, audio exposure, and a handful of other fitness types. Most types are silently capped at hourly. Don't rely on .immediate unless you've verified it for your specific type on watchOS.
Lifecycle
Register observer queries in application(_:didFinishLaunchingWithOptions:) (or the SwiftUI @main app struct's init). Not in a view's onAppear.
// In App.init or AppDelegate:
func registerBackgroundSync() async {
let type = HKQuantityType(.stepCount)
let observer = HKObserverQuery(sampleType: type, predicate: nil) { _, completionHandler, error in
Task {
defer { completionHandler() } // ALWAYS call this, even on error
guard error == nil else { return }
try? await StepSync.shared.fetchDelta()
}
}
store.execute(observer)
do {
try await store.enableBackgroundDelivery(for: type, frequency: .immediate)
} catch {
// Entitlement missing, authorization denied, or correlation type — log and degrade.
}
}Sync Identifiers — The De-Duplication Discipline
Metadata keys that make HealthKit itself responsible for de-duplication:
| Key | Type | Purpose |
|---|---|---|
HKMetadataKeySyncIdentifier | String | Stable identifier for a logical sample |
HKMetadataKeySyncVersion | NSNumber | Monotonically increasing version |
The conflict rule (verbatim from Apple):
"the new object replaces any matching objects (existing objects with a matching HKMetadataKeySyncIdentifier value) with a lower sync version."
So if you save a sample with syncIdentifier = "user-123-weekly-rollup-2026-W17" and syncVersion = 1, then later save the same identifier with version 2, HealthKit drops version 1 and keeps version 2. No duplicates, no manual reconciliation.
How to use:
func saveIdempotent(sample original: HKQuantitySample, syncID: String, version: Int) async throws {
var metadata = original.metadata ?? [:]
metadata[HKMetadataKeySyncIdentifier] = syncID
metadata[HKMetadataKeySyncVersion] = version as NSNumber
let sample = HKQuantitySample(
type: original.quantityType,
quantity: original.quantity,
start: original.startDate,
end: original.endDate,
metadata: metadata
)
try await store.save(sample)
}Design the identifier as a stable key derived from your backend's ID — not from the HealthKit UUID, which you won't know in advance. Good pattern: "<feature>-<userID>-<entityID>".
What Goes Where
A common confusion is where each piece lives. Here is the decision map:
| If you need... | Use |
|---|---|
| One-off read of current data | HKSampleQueryDescriptor / HKStatisticsQueryDescriptor (see queries.md) |
| Persistent foreground stream of changes | HKAnchoredObjectQueryDescriptor.results(for:) in a Task |
| Delta fetch on demand or in background wake-up | HKAnchoredObjectQueryDescriptor.result(for:) |
| Be notified of changes without running a query yet | HKObserverQuery + background delivery |
| De-duplicate server-originated writes | HKMetadataKeySyncIdentifier + HKMetadataKeySyncVersion |
| See what was deleted | HKDeletedObject from an anchored query |
Common Mistakes
| Mistake | Consequence / Fix |
|---|---|
| Re-reading the entire store on every launch | Battery drain + you never see deletions + duplicates compound. Use anchored queries with persisted HKQueryAnchor. |
| Observer query without anchored query | Observer carries no payload. You know something changed but not what. Pair them. |
| Anchored query without a persisted anchor | Same as re-reading the whole store. Persist the returned anchor. |
Observer query in a view's onAppear | Query only runs while the view is on screen. Register in didFinishLaunchingWithOptions or app init. |
Forgetting to call HKObserverQueryCompletionHandler | Three misses and HealthKit disables your background delivery. Always call completion, even in error paths (use defer). |
Missing com.apple.developer.healthkit.background-delivery entitlement | enableBackgroundDelivery returns an error and silently no-ops. The observer still fires when app is open; background wake-up does not happen. |
Ignoring [HKDeletedObject] | Your local mirror accumulates tombstones. A user who deletes a sample in Health will still see it in your app forever. |
| Re-saving the same logical sample without sync identifiers | Creates duplicates. Add HKMetadataKeySyncIdentifier and HKMetadataKeySyncVersion. |
Using .immediate frequency on watchOS for arbitrary types | Silently capped at hourly for most types. Test behavior for your specific type or use .hourly and document. |
| Testing background delivery on Simulator | Not supported. Test on device. |
Background-delivering HKCorrelationType | Not supported. Observe the child types instead (systolic + diastolic, not blood-pressure correlation). |
Pressure Scenario — "Reports say our app's battery usage is too high"
Real case. A developer ships a HealthKit-reading app with a simple read-all-steps-on-launch design. Users report fast battery drain. Deadline pressure says ship a fix tonight. Tempting "fixes":
- "Just limit the read to last 24 hours." Ships. Battery improves. But now you miss deletions older than 24 hours, and every launch still does a full range read. You've masked the problem.
- "Run the read less often." Ships. Battery improves. But now your UI shows stale data, and changes from the Health app can lag by hours.
- "Use background delivery." Better. But without anchors, every wake still re-reads everything. And without deletion handling, you accumulate tombstones indefinitely.
Correct fix (takes roughly a day, not an hour):
1. Introduce HKQueryAnchor persistence for each type you sync. 2. Replace the startup full-read with HKAnchoredObjectQueryDescriptor.result(for:) using the persisted anchor. 3. Register an HKObserverQuery in app init, enable background-delivery with .hourly frequency, and re-use the same delta-fetch logic in the handler. 4. Process [HKDeletedObject] to remove samples from your local mirror. 5. If you also write data, tag writes with HKMetadataKeySyncIdentifier + version for idempotency.
This is the architecture Apple designed. The "quick fix" paths compound tech debt you'll pay for later.
Resources
WWDC: 2020-10184
Docs: /healthkit/executing-anchored-object-queries, /healthkit/executing-observer-queries, /healthkit/hkanchoredobjectquery, /healthkit/hkobserverquery, /healthkit/hkdeletedobject, /healthkit/hkanchoredobjectquerydescriptor, /healthkit/hkqueryanchor, /healthkit/hkupdatefrequency, /healthkit/hkmetadatakeysyncidentifier, /healthkit/hkmetadatakeysyncversion, /healthkit/hkhealthstore/enablebackgrounddelivery(for:frequency:withcompletion:), /bundleresources/entitlements/com.apple.developer.healthkit.background-delivery
Skills: axiom-health (fundamentals, authorization-and-privacy, queries, workouts), axiom-concurrency, axiom-data
Wellbeing and Medications
When to Use This Skill
Use when:
- Reading or writing State of Mind samples (mood and emotion logging, iOS 18+)
- Integrating the HealthKit Medications API (iOS 26+) — concepts, tracked medications, dose events
- Logging symptoms associated with medications
- Understanding the per-object authorization model for medications (different from every other HealthKit type)
Related Skills
- Use
fundamentals.mdfor the HealthKit data model - Use
authorization-and-privacy.mdfor authorization discipline — and read the per-object section in this skill, which differs from the norm - Use
queries.mdfor one-shot sample reads - Use
sync-and-background.mdfor anchored queries, the recommended pattern for State of Mind and medication dose events
Why Both Live Here
State of Mind (mental wellbeing) and the Medications API are high-salience categories. Both can reveal mental-health, reproductive-health, HIV, or oncology diagnoses. Apple groups them together under "sensitive health data," and both demand extra care in authorization, UI, and privacy disclosures.
State of Mind
Platform: iOS 18+, iPadOS 18+, macOS 15+, visionOS 2+, watchOS 11+
HKStateOfMind is a sample class capturing a user's emotional state at a point in time. Four orthogonal inputs:
| Field | Type | Values |
|---|---|---|
kind | HKStateOfMind.Kind | .momentaryEmotion (seconds to minutes) or .dailyMood (hours to days) |
valence | Double | Continuous -1.0 (very unpleasant) to +1.0 (very pleasant) |
labels | [HKStateOfMind.Label] | 38 emotion labels (happy, anxious, grateful, etc.) — multiple allowed |
associations | [HKStateOfMind.Association] | 18 life-area tags (work, family, fitness, etc.) — multiple allowed |
A derived valenceClassification: HKStateOfMind.ValenceClassification bucket (7 cases from veryUnpleasant to veryPleasant) is available via init(valence:).
Recording a sample
import HealthKit
let sample = HKStateOfMind(
date: .now,
kind: .momentaryEmotion,
valence: 0.6,
labels: [.happy, .grateful],
associations: [.family, .selfCare]
)
try await store.save(sample)Reading State of Mind
Use HKSamplePredicate.stateOfMind(_:) with compound predicates over the wellbeing-specific helpers:
let dateRange = HKQuery.predicateForSamples(
withStart: start, end: .now
)
let associationPredicate = HKQuery.predicateForStatesOfMind(with: .work)
let labelPredicate = HKQuery.predicateForStatesOfMind(with: .stressed)
let compound = NSCompoundPredicate(andPredicateWithSubpredicates: [
dateRange, associationPredicate, labelPredicate
])
let descriptor = HKSampleQueryDescriptor(
predicates: [HKSamplePredicate.stateOfMind(compound)],
sortDescriptors: []
)
let results: [HKStateOfMind] = try await descriptor.result(for: store)Aggregating Valence Correctly
Common bug: naively averaging valence across a mix of negative and positive values gives a misleading "average mood." Apple's canonical pattern (WWDC 2024-10109) shifts the range to [0, 2] first:
let adjusted = results.map { $0.valence + 1.0 } // [0, 2]
let totalAdjusted = adjusted.reduce(0.0, +)
let averageAdjusted = totalAdjusted / Double(adjusted.count)
let percent = Int(100.0 * averageAdjusted / 2.0) // 0..100Without the shift, one +0.8 day and one –0.8 day average to 0 (neutral), misrepresenting two emotionally-intense days as flat.
SwiftUI Authorization Modifier
HealthKitUI provides a declarative request modifier tied to a trigger:
import HealthKitUI
struct MoodView: View {
@State private var triggerAuth = false
let store = HKHealthStore()
var body: some View {
Button("Start mood logging") { triggerAuth = true }
.healthDataAccessRequest(
store: store,
shareTypes: [HKSampleType.stateOfMindType()],
readTypes: [HKSampleType.stateOfMindType()],
trigger: triggerAuth
) { result in
// Handle Result<Bool, Error>
}
}
}Medications API
Platform: iOS 26+, iPadOS 26+, macOS 26+, visionOS 26+, watchOS 26+
The Health app has had medication tracking since iOS 15, but the public Medications API is iOS 26 and later only. Prior-OS apps cannot read or write medication data.
Three types form the model:
| Type | Role | Sample? |
|---|---|---|
HKMedicationConcept | Conceptual medication identity (name, form, clinical codes like RxNorm) | Not a sample |
HKUserAnnotatedMedication | The user's tracked medication — wraps a concept with nickname, schedule, archive state | Not a sample |
HKMedicationDoseEvent | A single logged dose — taken, skipped, snoozed | Yes (HKSample subclass) |
HKMedicationConcept
Identity of a medication, with clinical codings (e.g., RxNorm code 105929 is piroxicam):
identifier: HKHealthConceptIdentifier— unique identifier (a typed identifier, not aString)displayText: String— user-facing namegeneralForm: HKMedicationGeneralForm— tablet, capsule, cream, injection, inhaler, etc.relatedCodings: Set<HKClinicalCoding>— FHIR-style codings for interop (aSet, not an array)
HKClinicalCoding has system, version, code properties. The supported coding systems aren't exhaustively documented; RxNorm is confirmed.
HKUserAnnotatedMedication
A medication the user is tracking. Queried via HKUserAnnotatedMedicationQueryDescriptor:
let descriptor = HKUserAnnotatedMedicationQueryDescriptor(predicate: nil, limit: nil) // limit is Int?; nil = no limit
let meds: [HKUserAnnotatedMedication] = try await descriptor.result(for: store)
for med in meds where !med.isArchived {
// Active medication; nickname, concept, etc.
}Key properties:
medication: HKMedicationConcept— the tracked conceptnickname: String?— user-set labelhasSchedule: Bool— user configured times in the Health appisArchived: Bool— user marked as "no longer taking"
Users configure schedules in the Health app. The system handles notifications and dose logging. Third-party apps observe, they don't drive.
HKMedicationDoseEvent
A sample recording a single dose:
// Dose-event filters are `HKQuery` class methods. The medication filter takes the
// concept's `HKHealthConceptIdentifier` (concept.identifier), and the status filter
// takes an `HKMedicationDoseEvent.LogStatus`.
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
HKQuery.predicateForSamples(withStart: startOfDay, end: .now),
HKQuery.predicateForMedicationDoseEvent(medicationConceptIdentifier: concept.identifier),
HKQuery.predicateForMedicationDoseEvent(status: .taken)
])
// There is no typed `HKSamplePredicate.medicationDoseEvent` factory — use the generic
// `.sample(type:predicate:)` with the dose-event sample type, then cast the results.
let descriptor = HKSampleQueryDescriptor(
predicates: [.sample(type: HKObjectType.medicationDoseEventType(), predicate: predicate)],
sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)],
limit: 1
)
let doses = try await descriptor.result(for: store)
.compactMap { $0 as? HKMedicationDoseEvent }Key properties:
medicationConceptIdentifier: HKHealthConceptIdentifier(a typed identifier, not aString)logStatus: HKMedicationDoseEvent.LogStatus—.taken,.skipped,.snoozed,.notInteracted,.notLogged,.notificationNotSentscheduleType: HKMedicationDoseEvent.ScheduleType— scheduled vs. "as needed"scheduledDate: Date?andscheduledDoseQuantity: Double?doseQuantity: Double?— actual amount taken (aDoublepaired withunitbelow, not anHKQuantity)unit: HKUnit
Per-Object Authorization (Medications-Specific)
This is the biggest departure from normal HealthKit. Medications do not use the familiar per-type authorization sheet. Instead, the user authorizes your app medication-by-medication, inside the Health app.
Consequences:
- You cannot request medication access via the normal
requestAuthorizationsheet — it will not appear. - When a user adds a new medication in Health, Apple presents a per-app toggle inline. Your app is not notified; on next query, the new medication just appears.
- You cannot know which medications the user has but denied access to. From your app's point of view, denied medications simply do not exist.
- Use
HKObjectType.userAnnotatedMedicationType().requiresPerObjectAuthorization()to branch if needed (requiresPerObjectAuthorizationis anHKObjectTypeinstance method; get the type via theHKObjectType.userAnnotatedMedicationType()factory, not a bareHKUserAnnotatedMedicationType()init).
This is the same privacy-protective design as HealthKit reads broadly — denials are invisible — but scoped per medication instead of per type.
Linking Medications to Symptoms (No Built-in API)
There is no framework-level API for "this symptom was caused by that medication." Apple's sample app maintains a client-side dictionary keyed by RxNorm code to map medications to relevant symptoms:
let symptomMap: [String: [SymptomModel]] = [
"105929": [ // Piroxicam
SymptomModel(name: "Headache", categoryID: .headache),
SymptomModel(name: "Nausea", categoryID: .nausea),
SymptomModel(name: "Diarrhea", categoryID: .diarrhea),
],
// ...
]Symptoms themselves are ordinary HKCategorySample:
enum SymptomIntensity: Int {
case none = 0, mild, moderate, severe, extreme
}
let sample = HKCategorySample(
type: HKCategoryType(.headache),
value: SymptomIntensity.moderate.rawValue,
start: .now,
end: .now
)
try await store.save(sample)Reproductive Health — Menopausal State OS27
HealthKit adds a menopausal-state category (HKCategoryTypeIdentifierMenopausalState, all platforms 27) for cycle-tracking and reproductive-health apps. Like State of Mind and Medications, this is sensitive data — apply the same authorization care described at the top of this file and in authorization-and-privacy.md.
@available(anyAppleOS 27, *)
func logMenopausalState(_ store: HKHealthStore) async throws {
let sample = HKCategorySample(
type: HKCategoryType(.menopausalState),
value: HKCategoryValueMenopausalState.perimenopause.rawValue,
start: .now,
end: .now
)
try await store.save(sample)
}The three values are .menopause, .perimenopause, and .none. HKCategoryValueMenopausalState conforms to HKCategoryValuePredicateProviding, so you can filter category queries by value directly. (Note: the HKCategoryValueVaginalBleeding category is iOS 18, not new in 27 — don't gate it on OS27.)
Info.plist
Both State of Mind and Medications require the same keys as any HealthKit feature:
NSHealthShareUsageDescriptionNSHealthUpdateUsageDescription
Write purpose strings that honestly describe why the app needs mental-health or medication data. These categories are the most likely to trigger user denial or App Review scrutiny.
Common Mistakes
| Mistake | Fix |
|---|---|
| Averaging raw valence across a mix of negative and positive days | Shift to [0, 2] by valence + 1.0 before averaging, then rescale to [0, 100]. |
Trying to request medication access via requestAuthorization | Medications use per-object authorization managed inside the Health app. The normal sheet does nothing for medication types. |
| Expecting a framework API linking symptoms to medications | There isn't one. Apple's sample uses an RxNorm → symptom-list dictionary client-side. |
| Using the Medications API on iOS 25 or earlier | API is iOS 26+. Check with @available(iOS 26.0, *). |
| Assuming the Health app "one mood per day" rule reflects the framework | Daily mood samples can be saved multiple times per day via the API. The Health app UI shows one, but your data model can differ. |
Requesting every HKStateOfMind.Label and .Association up front | Request the minimum set for your feature. Broad requests feel invasive for mental-health data. |
| Displaying raw valence numbers to users | Users understand emotional language, not -0.2 to 0.8. Map to the 7-bucket ValenceClassification or emoji. |
| Failing to treat denied medication reads as "no data" | Per privacy design, denials are invisible; empty queries must render as empty states, not errors. |
Resources
WWDC: 2024-10109, 2025-321
Docs: /healthkit/hkstateofmind, /healthkit/hkmedicationconcept, /healthkit/hkuserannotatedmedication, /healthkit/hkmedicationdoseevent, /healthkit/hkclinicalcoding, /healthkit/hkuserannotatedmedicationquerydescriptor, /healthkit/logging-symptoms-associated-with-a-medication, /healthkit/visualizing-healthkit-state-of-mind-in-visionos, /healthkit/recording-and-querying-menopausal-state, /healthkitui/healthdataaccessrequest(store:sharetypes:readtypes:trigger:completion:)
Skills: axiom-health (fundamentals, authorization-and-privacy, queries, sync-and-background)
WorkoutKit
When to Use This Skill
Use when:
- Creating custom or planned workouts for the Apple Watch Workout app
- Scheduling workouts to run on the user's watch at specific times
- Building intervals, warmups, cooldowns, and pacer workouts
- Authoring swimming workouts (pool distance + time goals, stroke-aware)
- Previewing a workout from within your app before the user runs it
Not This Skill
- Live workout tracking inside your own app →
workouts.md(HKWorkoutSession+HKLiveWorkoutBuilder) - Reading completed workouts from HealthKit →
queries.md
Related Skills
- Use
workouts.mdfor live session tracking inside your app - Use
authorization-and-privacy.mdfor HealthKit permissions that cover WorkoutKit results - Use
axiom-watchosfor Smart Stack placement of workout widgets
WorkoutKit vs HealthKit Workouts
| WorkoutKit | HKWorkoutSession | |
|---|---|---|
| Purpose | Compose and schedule workouts | Track live workouts |
| Executes in | Apple Watch Workout app | Your own app |
| Produces | HKWorkout result (written to HealthKit) | HKWorkout result (saved by your app) |
| Scope | Structured intervals, pacer workouts, pool swims, scheduling | Real-time sensor collection |
They're complementary: you can author a WorkoutKit plan for the user to run in the Workout app, then later query the resulting HKWorkout from HealthKit to display a summary in your app.
Platform Availability
All core WorkoutKit types: iOS 17.0+, iPadOS 17.0+, Mac Catalyst 18.0+, macOS 15.0+, watchOS 10.0+.
Swimming additions (WWDC 2024-10084): iOS 18+ / watchOS 11+.
Unlike HKLiveWorkoutBuilder, WorkoutKit was cross-platform from day one — your iOS app can compose and schedule watch workouts directly without a companion watch app.
Composing a Custom Workout
A CustomWorkout is three phases: optional warmup → repeatable interval blocks → optional cooldown.
import WorkoutKit
import HealthKit
let warmup = WorkoutStep(
goal: .time(5, .minutes),
alert: nil,
displayName: "Easy jog"
)
let work = IntervalStep(
.work,
step: WorkoutStep(goal: .distance(400, .meters))
)
let recover = IntervalStep(
.recovery,
step: WorkoutStep(goal: .time(90, .seconds))
)
let block = IntervalBlock(steps: [work, recover], iterations: 6)
let cooldown = WorkoutStep(
goal: .time(5, .minutes),
displayName: "Easy jog"
)
let workout = CustomWorkout(
activity: .running,
location: .outdoor,
displayName: "6×400m",
warmup: warmup,
blocks: [block],
cooldown: cooldown
)Goals
WorkoutGoal — what finishes a step:
| Goal | Meaning |
|---|---|
.open | No automatic completion; user ends step manually |
.time(_:_:) | Finish after a duration |
.distance(_:_:) | Finish after a distance |
.energy(_:_:) | Finish after kilocalories burned |
.poolSwimDistanceWithTime(_:_:) | iOS 18+: finish only when both distance and time are met |
The pool-swim goal is specifically for structured pool workouts where the user's pool length is set at runtime — the watch scales distances to actual laps.
Alerts
Alerts trigger during a step to nudge the user back to target. Nine concrete alert types:
| Alert | Use |
|---|---|
HeartRateRangeAlert, HeartRateZoneAlert | Keep HR in a band or zone |
PowerRangeAlert, PowerThresholdAlert, PowerZoneAlert | Running or cycling power |
CadenceRangeAlert, CadenceThresholdAlert | Steps/revs per minute |
SpeedRangeAlert, SpeedThresholdAlert | Pace by speed (named "Speed" in the shipping API) |
// HeartRateRangeAlert takes a Measurement<UnitFrequency> range — NOT an Int range
// plus an HKUnit. Beats-per-minute is `WorkoutAlertMetric.countPerMinute`.
let alert = HeartRateRangeAlert(
target: Measurement(value: 140, unit: WorkoutAlertMetric.countPerMinute)
... Measurement(value: 160, unit: WorkoutAlertMetric.countPerMinute)
)
// Or the factory (unit defaults to .countPerMinute):
// let alert = WorkoutAlert.heartRate(140...160)
let step = WorkoutStep(
goal: .distance(5, .kilometers),
alert: alert,
displayName: "Tempo"
)Other metric alerts follow the same shape — a Measurement<UnitFrequency> range for the range types, or the WorkoutAlert.cadence(_:unit:) / .speed(_:unit:) / .power(_:unit:) factories (unit is Foundation UnitFrequency / UnitSpeed / UnitPower, not HKUnit).
Check WorkoutAlert.supports(activity:location:) before attaching — not every alert works with every activity (e.g., power alerts are meaningless for swimming).
Other Workout Shapes
For simpler compositions, use these built-in types instead of CustomWorkout:
// One goal, no intervals
SingleGoalWorkout(
activity: .cycling,
location: .outdoor,
goal: .distance(50, .kilometers)
)
// Pacer: watch paces you against a reference time
PacerWorkout(
activity: .running,
location: .outdoor,
distance: 5.0.kilometers,
time: 22.0.minutes
)
// Triathlon: contiguous activities
SwimBikeRunWorkout(
activities: [...],
displayName: "Sprint triathlon"
)Scheduling Workouts to the Watch
WorkoutScheduler.shared schedules plans to appear in the Workout app at a future time.
Authorization
// authorizationState is `get async` — it requires `await`.
var state = await WorkoutScheduler.shared.authorizationState
if state == .notDetermined {
// requestAuthorization() is async and RETURNS the new state — it does NOT throw.
state = await WorkoutScheduler.shared.requestAuthorization()
}
switch state {
case .authorized:
// Proceed.
break
case .denied, .restricted:
// Degrade — tell the user how to enable in Settings.
break
default:
break
}Authorization is separate from HealthKit authorization. A user can grant HealthKit reads but deny WorkoutKit scheduling, or vice versa.
Schedule a workout
// isSupported is a static member on the type — NOT on `.shared`.
guard WorkoutScheduler.isSupported else { return }
let plan = WorkoutPlan(.custom(workout))
// schedule(_:at:) takes DateComponents (NOT a Date) — the watch resolves it in
// the user's calendar/time zone. Include hour/minute so it lands at a real time.
let when = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: .now.addingTimeInterval(3600) // ~1 hour from now
)
// schedule(_:at:) is async-only — it does NOT throw. No `try`.
await WorkoutScheduler.shared.schedule(plan, at: when)Schedule rules
- Max 15 scheduled workouts at a time (WWDC 2023-10016).
- Schedules must be within ±7 days of now.
- Listing:
await WorkoutScheduler.shared.scheduledWorkouts(returns[ScheduledWorkoutPlan]). - Removing:
await WorkoutScheduler.shared.remove(plan, at: components)(sameDateComponentsyou scheduled with) orremoveAllWorkouts(). - Marking a scheduled workout complete (e.g., user did it in another way):
markComplete(_:at:).
Previewing / Opening in Workout App
To open a plan directly in the Workout app without scheduling:
let plan = WorkoutPlan(.custom(workout))
try await plan.openInWorkoutApp()This is the current shipping way to preview or hand off a plan for immediate execution.
Swimming Workouts
iOS 18 / watchOS 11 added first-class pool swimming:
let warmup = WorkoutStep(goal: .time(3, .minutes), displayName: "Easy")
let interval = IntervalStep(
.work,
step: WorkoutStep(
// Takes two Measurement values — NOT (Double, Unit, Double, Unit).
goal: .poolSwimDistanceWithTime(
Measurement(value: 100, unit: .meters),
Measurement(value: 2, unit: .minutes)
),
displayName: "100 @ 2:00"
)
)
let block = IntervalBlock(steps: [interval], iterations: 8)
let workout = CustomWorkout(
activity: .swimming,
location: .indoor,
displayName: "8×100 @ 2:00",
warmup: warmup,
blocks: [block]
)The .poolSwimDistanceWithTime goal is unique to swimming — it advances only when the user has covered the distance and the time has elapsed, giving coach-style "swim 100m, arrive at the 2-minute mark" semantics.
The user's pool length is configured when they start the workout; the watch converts your distance goals to actual laps at runtime.
Common Mistakes
| Mistake | Fix |
|---|---|
Mixing up WorkoutSession (HealthKit) and WorkoutPlan (WorkoutKit) | Sessions track live in your app; plans are authored and scheduled to run in the Workout app. Different APIs, different use cases. |
Scheduling without checking WorkoutScheduler.isSupported | isSupported is a static member (not on .shared); it returns false on devices where WorkoutKit scheduling is not available. Guard on it before scheduling — schedule(_:at:) is async-only and does not throw, so it won't surface unsupported state for you. |
| Scheduling more than 15 workouts | Older plans silently fall off. Track your scheduled count and remove stale plans before scheduling new ones. |
| Scheduling beyond ±7 days | The scheduler rejects dates outside the window. Schedule closer to the time and re-schedule as needed. |
| Attaching an alert to an incompatible activity | WorkoutAlert.supports(activity:location:) returns false; runtime behavior is undefined. Check before attaching. |
| Using the term "pace alert" in code | The shipping API uses Speed, not Pace. SpeedRangeAlert, SpeedThresholdAlert. |
| Assuming WWDC 2023 sample code matches the shipping API | Early WWDC samples used BlockStep, WarmupStep, CustomWorkoutComposition — these are superseded. Use IntervalStep, WorkoutStep, CustomWorkout. |
| Expecting WorkoutKit to collect sensor data into a builder | It doesn't. Only HKLiveWorkoutBuilder does live collection. The Workout app handles WorkoutKit plans. |
| Forgetting WorkoutKit authorization is separate from HealthKit | Two separate permissions. Requesting HealthKit doesn't imply WorkoutKit. requestAuthorization() is async and returns WorkoutScheduler.AuthorizationState — it does not throw. |
Passing a Date to schedule(_:at:) / remove(_:at:) | Both take DateComponents, not Date. Build it with Calendar.current.dateComponents([.year,.month,.day,.hour,.minute], from:) so the watch resolves the local day/time. |
Resources
WWDC: 2023-10016, 2024-10084
Docs: /workoutkit, /workoutkit/customizing-workouts-with-workoutkit, /workoutkit/customworkout, /workoutkit/singlegoalworkout, /workoutkit/pacerworkout, /workoutkit/swimbikerunworkout, /workoutkit/workoutplan, /workoutkit/workoutstep, /workoutkit/intervalblock, /workoutkit/intervalstep, /workoutkit/workoutgoal, /workoutkit/workoutalert, /workoutkit/workoutscheduler
Skills: axiom-health (workouts, authorization-and-privacy, queries), axiom-watchos
HealthKit Workouts
When to Use This Skill
Use when:
- Building a workout tracking app on watchOS, iOS, or iPadOS
- Deciding between a live workout session (
HKWorkoutSession) and just logging a finishedHKWorkout - Implementing workout pause/resume, multi-activity (triathlon-style), or mirroring between watch and iPhone
- Handling workout recovery after an app or process termination
- Adopting iOS 26's new ability to originate workout sessions from iPhone (previously watch-only)
- Implementing Always On support for workout views
- Querying or reading historical
HKWorkoutandHKWorkoutActivitydata - Reading or tracking workout zones — heart-rate / cycling-power effort bands — live or retrospectively (
OS27)
Related Skills
- Use
fundamentals.mdfor the HKObjectType hierarchy andHKHealthStoresetup - Use
authorization-and-privacy.mdbefore adding workout write access (workouts require write authorization) - Use
workoutkit.mdfor planned/scheduled custom workouts — distinct from live sessions - Use
axiom-watchos(skills/platform-basics.md) for watch-specific presentation concerns (Always On, Smart Stack, background mode) - Use
axiom-concurrencyfor actor isolation around session delegates
Two Distinct Workouts APIs
| API | Purpose | Notes |
|---|---|---|
HKWorkoutSession + HKLiveWorkoutBuilder | Live in-progress workout — collects data from sensors, persists an HKWorkout at the end | Covered here |
HKWorkoutBuilder (non-live) + finishWorkout | Logging a historical workout (e.g., from a server or manual entry) | The HKWorkout.init(...) convenience initializers are deprecated (iOS 17 / watchOS 10) — build retrospective workouts with a non-live HKWorkoutBuilder (add(_:) samples → finishWorkout), not the old initializer + save. See queries.md |
| WorkoutKit | Planned or scheduled custom workouts that the Workout app executes | Covered in workoutkit.md |
This skill covers the first path — live sessions with sensor collection.
Platform Availability — Read Carefully
| Class | Platform |
|---|---|
HKWorkoutSession | iOS 17+, iPadOS 17+, watchOS 2+ |
HKLiveWorkoutBuilder, HKLiveWorkoutDataSource, HKLiveWorkoutBuilderDelegate | watchOS 5+; iOS 26+, iPadOS 26+ |
HKWorkoutSession.startMirroringToCompanionDevice | watchOS 10+ only |
HKHealthStore.recoverActiveWorkoutSession | iOS 26+, iPadOS 26+, watchOS 5+ (unavailable on Mac Catalyst, macOS, visionOS) |
Workout zones — stored (HKWorkoutZoneGroup, HKWorkout.zoneGroupsByType) | all platforms 27 (anyAppleOS 27) |
Workout zones — live (HKLiveWorkoutZoneUpdate, didUpdateWorkoutZone) | iOS 27+, watchOS 27+ (not macOS/visionOS) |
The critical new-in-2025 change: iPhone could receive a mirrored workout session since iOS 17, but iOS 26 is the first release where iPhone can originate a session and drive a local HKLiveWorkoutBuilder. Before iOS 26, iPhone workout tracking meant calling HKWorkout(init:) retrospectively — no live sensor collection.
The Session State Machine
digraph workout_state {
notStarted [shape=ellipse];
prepared [shape=ellipse];
running [shape=ellipse];
paused [shape=ellipse];
stopped [shape=ellipse];
ended [shape=doublecircle];
notStarted -> prepared [label="prepare()"];
prepared -> running [label="startActivity(with:)"];
running -> paused [label="pause()"];
paused -> running [label="resume()"];
running -> stopped [label="stopActivity(with:)"];
paused -> stopped [label="stopActivity(with:)"];
stopped -> ended [label="end()"];
}The six states (HKWorkoutSessionState):
| State | Meaning |
|---|---|
.notStarted | Session created, not yet prepared |
.prepared | Readied for fast start (sensors warm) |
.running | Activity tracking live |
.paused | Paused, ready to resume |
.stopped | Activity halted but session not yet ended — builder still needs to finish |
.ended | Terminal; session is fully closed |
`.stopped` is not `.ended`. This is the single most common bug. stopActivity(with:) transitions to .stopped — the workout sample has NOT been saved yet. You must run the end sequence below before calling session.end().
The End Sequence (Do This Exactly)
// 1. In the UI event that finishes the workout:
session.stopActivity(with: .now)
// 2. In the session delegate:
func workoutSession(
_ session: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
guard toState == .stopped, let builder = self.builder else { return }
Task {
try await builder.endCollection(at: date)
let finishedWorkout = try await builder.finishWorkout()
session.end()
// finishedWorkout is now saved as an HKWorkout in the store.
}
}If you call session.end() before builder.endCollection(at:) + builder.finishWorkout(), the workout is not persisted. There is no "save-on-end" convenience.
Canonical Session Setup
import HealthKit
@MainActor
final class WorkoutController: NSObject {
let store = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
func startRun() throws {
let config = HKWorkoutConfiguration()
config.activityType = .running
config.locationType = .outdoor
let session = try HKWorkoutSession(healthStore: store, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(
healthStore: store,
workoutConfiguration: config
)
session.delegate = self
builder.delegate = self
session.startActivity(with: .now)
Task { try await builder.beginCollection(at: .now) }
self.session = session
self.builder = builder
}
}
extension WorkoutController: HKWorkoutSessionDelegate {
nonisolated func workoutSession(
_ session: HKWorkoutSession,
didFailWithError error: Error
) {
// Log and degrade — HKWorkoutSessionError.anotherWorkoutSessionStarted is a common one.
}
nonisolated func workoutSession(
_ session: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
// Handle .stopped transition here for end sequence.
}
}
extension WorkoutController: HKLiveWorkoutBuilderDelegate {
nonisolated func workoutBuilder(
_ builder: HKLiveWorkoutBuilder,
didCollectDataOf types: Set<HKSampleType>
) {
// For each type in `types`, call builder.statistics(for: type) to update UI.
}
nonisolated func workoutBuilderDidCollectEvent(_ builder: HKLiveWorkoutBuilder) {
// Fires when a pause/resume/lap event is appended.
}
}Delegate method isolation. HealthKit calls these on its own queue — mark them nonisolated and hop to @MainActor inside when updating UI. See axiom-concurrency for the full pattern.
HKLiveWorkoutDataSource — What Data Is Collected
Data sources tell the builder which quantity types to auto-collect from sensors. Default collection depends on the activity type in the configuration (running gets heart rate + distance + energy; swimming adds stroke count and SWOLF).
let source = HKLiveWorkoutDataSource(
healthStore: store,
workoutConfiguration: config
)
// Add a type the default set doesn't include:
source.enableCollection(for: HKQuantityType(.runningPower), predicate: nil)
// Remove a default if you don't need it:
source.disableCollection(for: HKQuantityType(.basalEnergyBurned))For multi-activity workouts, update the data source's typesToCollect when switching activities — swimming and cycling collect different types, and leaving stale types wastes sensor time.
Workout Zones OS27
HealthKit models workout zones natively — the effort bands (heart-rate zones, cycling-power zones) that classify intensity during a workout. Before 27 you computed and stored time-in-zone yourself; now HealthKit tracks it live and attaches a retrospective breakdown to every finished HKWorkout.
A breakdown is built from four Sendable value types. Stored zone data reads on every platform (anyAppleOS 27); only live tracking is iOS27/watchOS27 (not macOS/visionOS):
| Type | What it holds |
|---|---|
HKWorkoutZone | One zone: index + optional minimum/maximum HKQuantity (the first zone is unbounded below, the last unbounded above, so one bound is nil) |
HKWorkoutZoneConfiguration | The zone set for one quantity type: zones (contiguous, non-overlapping, 3–9 zones), quantityType, source (.system/.user/.app), configurationType (.automatic/.manual/.custom) |
HKWorkoutZoneDuration | Time in one zone: zone + duration |
HKWorkoutZoneGroup | A workout's full breakdown for one type: configuration + zoneDurations |
Retrospective zones on a finished workout
HKWorkout and HKWorkoutActivity expose zone groups keyed by quantity type:
@available(anyAppleOS 27, *)
func heartRateZones(_ workout: HKWorkout) {
guard let group = workout.zoneGroupsByType?[HKQuantityType(.heartRate)] else { return }
let zoneCount = group.configuration.zones.count
let durations = group.zoneDurations.map(\.duration) // ordered by threshold
// ... drive a post-workout chart
}Use HKWorkoutActivity.zoneGroup(for:) for per-leg zones in a multi-activity (triathlon) workout.
Live zone changes
Implement the new optional HKLiveWorkoutBuilderDelegate method. It fires only when the current zone changes, not on every sample:
@available(iOS 27, watchOS 27, *)
func workoutBuilder(
_ builder: HKLiveWorkoutBuilder,
didUpdateWorkoutZone zoneUpdate: HKLiveWorkoutZoneUpdate
) {
guard let group = zoneUpdate.zoneGroup else { return }
let currentIndex = zoneUpdate.currentZoneDuration?.zone.index
// zoneUpdate.previousZoneDuration and .lastSampleProcessedDate are also available
}Preferred vs. custom zones
Zones come from the user's Health settings (auto-calculated from age/resting HR or manually set, synced across devices). Read the resolved configuration first; only supply your own if none exists:
@available(iOS 27, watchOS 27, *)
func configureZones(_ builder: HKLiveWorkoutBuilder) async throws {
let heartRate = HKQuantityType(.heartRate)
if try await builder.zoneConfiguration(for: heartRate) == nil {
let bpm = HKUnit.count().unitDivided(by: .minute())
let boundaries = defaultHeartRateZoneThresholds.map {
HKQuantity(unit: bpm, doubleValue: $0)
}
let config = try HKWorkoutZoneConfiguration(
quantityType: heartRate,
zoneBoundaries: boundaries
)
// MUST be set before beginCollection:
try await builder.setCustomZoneConfiguration(config, for: heartRate)
}
try await builder.beginCollection(at: Date())
}To read the user's preferred zones outside a builder (e.g. to preview them), use HKHealthStore.preferredWorkoutZoneConfiguration(for:).
Custom-zone gotchas:
- Set before `beginCollection`. Calling
setCustomZoneConfigurationafter collection starts is too late for that workout. - HealthKit does not persist custom zones. They are scoped to a single workout — to reuse them, your app saves and re-applies them itself.
- Supported quantity types are heart rate and cycling power (functional-threshold-power based; defaults to 6 zones).
- Time-in-zone isn't comparable across configurations. A 5-zone and an 8-zone workout bucket effort differently — re-bucket raw samples if you need to compare across workouts.
Recovery — Different Entry Points Per Platform
Workout sessions survive app termination. If iOS force-quits your app or the app crashes mid-session, the workout continues recording on the watch's sensors, and you can reconnect to it on next launch.
watchOS recovery
class ExtensionDelegate: NSObject, WKExtensionDelegate {
func handleActiveWorkoutRecovery() {
Task {
do {
let session = try await store.recoverActiveWorkoutSession()
// Reattach delegate, update UI.
} catch {
// No active session to recover.
}
}
}
}iOS 26+ recovery
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
if options.shouldHandleActiveWorkoutRecovery {
// Configure a scene that calls recoverActiveWorkoutSession on launch.
}
return UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
}
}On both platforms, recoverActiveWorkoutSession throws if no session is active. Always wrap in do/try/catch and degrade.
Multi-Device Mirroring
As of watchOS 10 + iOS 17, a watch-originated session can mirror to the paired iPhone so that a companion app on iPhone shows live metrics.
// On watchOS (the primary):
try await session.startMirroringToCompanionDevice()
// On iOS (receives):
// Register HKWorkoutSessionMirroringStartHandler on the HKHealthStore.
store.workoutSessionMirroringStartHandler = { session in
// A watch workout just started mirroring. Attach UI.
}Constraints:
- Handler fires on a background queue; hop to
@MainActorfor UI. - The handler may fire multiple times during a single workout — every time the iPhone reconnects (user picks up phone, network hiccup). Handle re-entry idempotently.
- 10-second launch budget: if the iPhone app is backgrounded, iOS wakes it for up to 10 seconds to receive the mirror-start event. If your app doesn't register state fast enough, the mirror is missed. Register
workoutSessionMirroringStartHandlerinapplication(_:didFinishLaunchingWithOptions:), not later. - Use
session.sendToRemoteWorkoutSession(data:)for custom cross-device messaging during the workout (e.g., chat, coach signals).
Multi-Activity Workouts (Triathlons)
For workouts where activity type changes (swim → bike → run), use multi-activity mode.
// Initial configuration sets the container:
let container = HKWorkoutConfiguration()
container.activityType = .swimBikeRun
let session = try HKWorkoutSession(healthStore: store, configuration: container)
session.startActivity(with: .now)
// Begin each sub-activity:
let swim = HKWorkoutConfiguration()
swim.activityType = .swimming
swim.swimmingLocationType = .openWater
session.beginNewActivity(configuration: swim, date: .now, metadata: nil)
// Later, transition:
session.beginNewActivity(configuration: cycleConfig, date: .now, metadata: nil)
// HealthKit ends the previous activity automatically.Activities cannot overlap in time and need not be contiguous — insert an activity of type .transition between the sports to capture transition metrics.
Per-activity statistics are available via HKWorkoutActivity.statistics(for:) on the saved workout.
Always On Considerations (watchOS)
When the watch locks mid-workout, the workout view must continue to display. Apple mandates a 1 Hz maximum refresh rate in the low-power state — one update per second, nothing finer.
struct WorkoutView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(duration.formatted())
}
}
}Branch on TimelineView's mode == .lowFrequency to show a simpler view during locked state. Avoid animation during low-frequency updates — the system won't render it anyway.
Info.plist and Entitlements
Session-capable apps require:
- Xcode capability: HealthKit (adds
com.apple.developer.healthkit) NSHealthShareUsageDescriptionandNSHealthUpdateUsageDescriptionin Info.plist- For watchOS: add
WKBackgroundModeswithworkout-processingtoInfo.plist - For iOS 26 session origination: background mode key is documented in Apple's "Building a workout app for iPhone and iPad" sample (verify against the latest sample before shipping — this key is new enough that multiple names have circulated)
Mirroring iPhone app: must include a corresponding watchOS app target in the same bundle.
Querying Workouts After the Fact
Workouts are just HKSample subtypes — read them with a sample query:
let predicate = HKSamplePredicate<HKWorkout>.workout(
NSCompoundPredicate(andPredicateWithSubpredicates: [
HKQuery.predicateForWorkouts(with: .running),
HKQuery.predicateForSamples(withStart: thirtyDaysAgo, end: nil)
])
)
let descriptor = HKSampleQueryDescriptor(
predicates: [predicate],
sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)],
limit: 50
)
let runs: [HKWorkout] = try await descriptor.result(for: store)For per-activity statistics, iterate workout.workoutActivities — each HKWorkoutActivity carries its own type + duration + stats.
Common Mistakes
| Mistake | Fix |
|---|---|
Calling session.end() without first running endCollection + finishWorkout | The workout is not saved. Always: stopActivity → wait for .stopped → endCollection → finishWorkout → end. |
Treating .stopped as the terminal state | It's not. .ended is terminal. .stopped means "activity halted but builder still needs to finish." |
| Not implementing recovery | After app termination, the session keeps running on the watch. Without recoverActiveWorkoutSession, users see "you don't have an active workout" even though their heart rate is still being logged. |
| Assuming iOS can originate sessions before iOS 26 | Pre-26, iPhone can only mirror a watch-initiated session. It cannot drive a local HKLiveWorkoutBuilder. |
Registering workoutSessionMirroringStartHandler in a view's onAppear | The iPhone gets 10 seconds after wake to receive the mirror event. Register in app-launch code. |
Using @MainActor delegate callbacks directly | HealthKit calls these on its own queue. Mark methods nonisolated and hop to @MainActor internally. |
Collecting all quantity types "just in case" with HKLiveWorkoutDataSource | Only enable types you actually display or save. Extra types waste battery. |
| Full-screen animation during Always On | The 1 Hz refresh cap means your animation won't render smoothly anyway. Use a simplified view in the .lowFrequency timeline branch. |
| Creating multiple concurrent sessions | HKWorkoutSessionError.anotherWorkoutSessionStarted fires and ends your session. Enforce one-at-a-time at your UI layer. |
Missing workout-processing background mode on watch | Session runs only while app is foreground. Add WKBackgroundModes → workout-processing. |
Hand-building an HKWorkout(activityType:start:end:) to "save" a live session | Those convenience initializers are deprecated (iOS 17 / watchOS 10) and produce an empty shell — no samples, route, or totals. Only the live builder's endCollection + finishWorkout persists collected data; for retrospective non-sensor logging use a non-live HKWorkoutBuilder. |
| Writing retrospective workouts and also running live sessions for the same activity | You get duplicates. Choose one path per activity. |
Calling setCustomZoneConfiguration after beginCollection (OS27) | Too late — it won't apply to that workout. Set custom zones before beginCollection(at:). |
Expecting HealthKit to remember your custom zones (OS27) | It doesn't persist app-set zones; they live for one workout. Save and re-apply them yourself. |
Comparing raw time-in-zone across workouts with different zone counts (OS27) | A 5-zone vs 8-zone configuration buckets effort differently. Re-bucket raw samples before comparing. |
Resources
WWDC: 2021-10009, 2022-10005, 2023-10023, 2025-322, 2026-207
Docs: /healthkit/workouts-and-activity-rings, /healthkit/running-workout-sessions, /healthkit/hkworkoutsession, /healthkit/hkliveworkoutbuilder, /healthkit/hkliveworkoutbuilderdelegate, /healthkit/hkliveworkoutdatasource, /healthkit/hkworkoutconfiguration, /healthkit/hkworkout, /healthkit/hkworkoutactivity, /healthkit/hkhealthstore/recoveractiveworkoutsession(completion:), /healthkit/build-a-workout-app-for-apple-watch, /healthkit/building-a-workout-app-for-iphone-and-ipad, /healthkit/accessing-workout-zone-data, /healthkit/hkworkoutzonegroup, /healthkit/hkworkoutzoneconfiguration
Skills: axiom-health (fundamentals, authorization-and-privacy, queries, workoutkit), axiom-watchos, axiom-concurrency
Related skills
How it compares
Use axiom-health for orchestrated multi-auditor Swift scans; invoke a single Axiom auditor directly when the issue is already narrowed to memory, accessibility, or concurrency alone.
FAQ
What does axiom-health check in a Swift project?
axiom-health always runs memory, security-privacy, accessibility, swift-performance, modernization, and codable auditors. Conditional auditors activate on signals like SwiftUI imports, async/await, Core Data models, CloudKit, or Liquid Glass APIs.
Can axiom-health audit only changed Swift files?
axiom-health accepts a DIFF SCOPE block listing changed Swift files versus a base ref. Diff-scoped mode forwards that list to every auditor, skips full-project globs, and writes scratch/health-check-diff-{date}.md.