Skip to content
AF
Health Connect API

PlannedExerciseSessionRecord: training plans in Health Connect

Last verified October 4, 2026 · 6 min read

PlannedExerciseSessionRecord is Health Connect's training-plan record, which Google's reference describes as "a planned exercise session, also commonly referred to as a training plan." A plan holds blocks, each block repeats a list of steps, and each step has an exercise phase, a completion goal and performance targets such as a heart-rate range; its start and end may be in the future. The workout app links its ExerciseSessionRecord back by setting plannedExerciseSessionId, and Health Connect fills in the plan's completedExerciseSessionId automatically. The record requires FEATURE_PLANNED_EXERCISE and has no supported aggregations.

Covered here:Health Connect

Health Connect can carry a workout before it happens. A coaching app writes the plan, a watch or workout app reads it and records the session, and the coaching app reads the result back to see whether the targets were hit. The record type behind this is PlannedExerciseSessionRecord. This page covers its structure, the permissions each side needs, and how a finished session links back to its plan, according to Google's training-plans guide and the Jetpack reference.

What Google says it is#

The reference, where the class is listed as added in 1.1.0: "Captures a planned exercise session, also commonly referred to as a training plan. Each record contains a start time, end time, an exercise type and a list of [PlannedExerciseBlock] which describe the details of the planned session. The start and end times may be in the future."

It also says: "Requires androidx.health.connect.client.HealthConnectFeatures.FEATURE_PLANNED_EXERCISE." Google's guide opens with the check:

val plansSupported = healthConnectClient.features.getFeatureStatus(
    HealthConnectFeatures.FEATURE_PLANNED_EXERCISE
) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE

The flag is the constant 3, added in 1.1.0; the general pattern is on the getFeatureStatus page. Google's guide states "There are no supported aggregations for this data type."

The structure: session, blocks, steps#

LevelClassFields (reference)
SessionPlannedExerciseSessionRecordstartTime, endTime and zone offsets, exerciseType, blocks, title, notes, metadata, completedExerciseSessionId, hasExplicitTime
BlockPlannedExerciseBlockrepetitions: Int, steps: List<PlannedExerciseStep>, description: String?
StepPlannedExerciseStepexerciseType, exercisePhase, completionGoal, performanceTargets, description

A block is "a series of PlannedExerciseSteps"; Google's guide explains that blocks exist "to support repetition of different groups of steps (for example, do a sequence of arm curls, burpies, and crunches five times in a row)." A step is "A single step within an PlannedExerciseBlock e.g. 8x 60kg barbell squats."

Each step has a phase, one of EXERCISE_PHASE_UNKNOWN (0), EXERCISE_PHASE_WARMUP (1), EXERCISE_PHASE_REST (2), EXERCISE_PHASE_ACTIVE (3), EXERCISE_PHASE_COOLDOWN (4) and EXERCISE_PHASE_RECOVERY (5), which the reference calls "Lower intensity, active exercise."

A step's completionGoal is "The goal that must be completed to finish this step", an ExerciseCompletionGoal subclass: ActiveCaloriesBurnedGoal, DistanceAndDurationGoal, DistanceGoal, DurationGoal, ManualCompletion, RepetitionsGoal, StepsGoal, TotalCaloriesBurnedGoal or UnknownGoal.

Its performanceTargets are "Performance related targets that should be met during this step", each an ExercisePerformanceTarget: AmrapTarget, CadenceTarget, HeartRateTarget, PowerTarget, RateOfPerceivedExertionTarget, SpeedTarget, UnknownTarget or WeightTarget. For cadence the reference notes "The value may be interpreted as RPM for e.g. cycling activities, or as steps per minute for e.g. walking/running activities."

Two ways to set the time#

The record has two constructors. One takes startTime, startZoneOffset, endTime and endZoneOffset like other interval records; for the offsets, "If null is provided, this will default to the current system timezone." The other takes a startDate: LocalDate and a duration: Duration, for plans that name a day but no time: "The start time will be implicitly generated from a local time at noon in the system default timezone on the day specified by startDate." The reference also lists a hasExplicitTime property, with no description.

Writing a plan#

This follows Google's training-plans example: a run with three laps and a heart-rate target between 90 and 110 bpm.

val plan = PlannedExerciseSessionRecord(
    startTime = start,
    startZoneOffset = null,
    endTime = start.plus(Duration.ofMinutes(90)),
    endZoneOffset = null,
    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
    blocks = listOf(
        PlannedExerciseBlock(
            repetitions = 1,
            steps = listOf(
                PlannedExerciseStep(
                    exerciseType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
                    exercisePhase = PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
                    completionGoal = ExerciseCompletionGoal.RepetitionsGoal(repetitions = 3),
                    performanceTargets = listOf(
                        ExercisePerformanceTarget.HeartRateTarget(minHeartRate = 90.0, maxHeartRate = 110.0)
                    )
                )
            ),
            description = "Three laps around the lake"
        )
    ),
    title = "Run at lake",
    notes = null,
    metadata = Metadata.activelyRecorded(device = Device(type = Device.TYPE_PHONE))
)
val planId = healthConnectClient.insertRecords(listOf(plan)).recordIdsList.firstOrNull()

Note the two kinds of exercise type: the session takes an ExerciseSessionRecord constant, while Google's sample fills the step's exerciseType with an ExerciseSegment constant. Every record needs factory-made metadata, explained on the Metadata page, and the returned ID is what the workout app will point back to.

Linking the finished session to the plan#

The workout app records the session as an ordinary ExerciseSessionRecord and sets plannedExerciseSessionId to the plan's ID. In Google's example, "the ID would be determined by the user selecting a planned exercise session from a list of existing sessions."

val session = ExerciseSessionRecord(
    startTime = actualStart, startZoneOffset = ZoneOffset.UTC,
    endTime = actualEnd, endZoneOffset = ZoneOffset.UTC,
    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
    title = "Run at lake",
    plannedExerciseSessionId = planId,
    metadata = Metadata.activelyRecorded(device = Device(type = Device.TYPE_PHONE))
)
healthConnectClient.insertRecords(listOf(session))

The link back is automatic. Google's guide lists, among the fields of a planned session, "Completed exercise session ID — This is written automatically after an exercise session related to this planned exercise session is completed." On the record that is completedExerciseSessionId, "The exercise session that completed this planned session." So the plan app can go from a session to its plan through plannedExerciseSessionId, or from a plan to its session through completedExerciseSessionId.

Evaluating targets#

Google's last step reads recent sessions, follows plannedExerciseSessionId to the plan with readRecord, and aggregates heart rate over the session's time range. A corrected version of that check:

suspend fun heartRateRange(client: HealthConnectClient, exercise: ExerciseSessionRecord) {
    val planId = exercise.plannedExerciseSessionId ?: return
    val planned = client.readRecord(PlannedExerciseSessionRecord::class, planId).record

    val hr = client.aggregate(
        AggregateRequest(
            metrics = setOf(HeartRateRecord.BPM_MIN, HeartRateRecord.BPM_MAX),
            timeRangeFilter = TimeRangeFilter.between(exercise.startTime, exercise.endTime)
        )
    )
    val minBpm = hr[HeartRateRecord.BPM_MIN] ?: return
    val maxBpm = hr[HeartRateRecord.BPM_MAX] ?: return
    // your code: compare with planned.blocks -> steps -> performanceTargets
}

Then walk planned.blocks, each block's steps, and each step's performanceTargets, comparing a HeartRateTarget's minHeartRate and maxHeartRate with the measured range. Aggregates are covered on the aggregate page.

Who needs which permission#

Planned exercise has its own pair, android.permission.health.READ_PLANNED_EXERCISE and android.permission.health.WRITE_PLANNED_EXERCISE, requested as HealthPermission.getReadPermission(PlannedExerciseSessionRecord::class) and getWritePermission(...). Google notes that "often the app that creates training plans and evaluates performance against plans isn't the same as the app that consumes training plans and writes actual exercise data", and splits its heart-rate example like this:

Training plan appWorkout app
WRITE_PLANNED_EXERCISEREAD_PLANNED_EXERCISE
READ_EXERCISEWRITE_EXERCISE
READ_EXERCISE_ROUTESWRITE_EXERCISE_ROUTE
READ_HEART_RATEWRITE_HEART_RATE

Google's key point on the same table: "Write permission also provides you read access to records you have written using that permission. It isn't always necessary to request both read and write permission for a data type." The route rows follow the consent rules on the exercise route page.

Pitfalls#

  • Google's evaluation sample reads metrics it never requested. It builds the AggregateRequest with only HeartRateRecord.BPM_AVG, then reads BPM_MAX and BPM_MIN. AggregationResult.get is documented to return "the value of the metric, or null if not set", so request every metric you read, as in the sketch above.
  • Skipping the feature check. The record "Requires" FEATURE_PLANNED_EXERCISE. Hide plan screens when it is unavailable.
  • Null zone offsets mean the device's current zone. The constructor docs say a null offset defaults to the current system timezone, so a plan written while the phone is set to another zone picks up that zone. Pass explicit offsets when you know the zone the user plans in.
  • A date-only plan lands at noon. The LocalDate constructor places the start at noon in the system default timezone. Show such plans as "on this day", not "at 12:00".
  • No aggregates on plans. Google lists none. Totals come from the performed session's data types, not from the plan.
  • Trying to write the completed ID yourself. completedExerciseSessionId is not a constructor parameter, and Google says it is "written automatically". Set plannedExerciseSessionId on the session instead.

The data type itself, with its permissions and fields, is listed on the PlannedExerciseSessionRecord data page.

Frequently asked questions

How do I link a completed workout to a Health Connect training plan?
Set plannedExerciseSessionId on the ExerciseSessionRecord you write to the ID of the PlannedExerciseSessionRecord, which insertRecords returns when the plan is written. Google's training-plans guide says the plan's completed exercise session ID is then written automatically, and the reference exposes it as completedExerciseSessionId on the planned record.
What is the difference between a PlannedExerciseBlock and a PlannedExerciseStep?
A step is one exercise instruction, such as Google's example of eight 60 kg barbell squats, with a phase, a completion goal and optional performance targets. A block groups steps and repeats them a set number of times; Google's guide gives the example of a sequence of arm curls, burpees and crunches done five times in a row. A planned session holds a list of blocks.
Which permissions does a training plan app need compared with a workout app in Health Connect?
Google's guide splits them by role. In its heart-rate example, the app that writes plans and evaluates them needs WRITE_PLANNED_EXERCISE plus read access to exercise, exercise routes and heart rate, while the workout app needs READ_PLANNED_EXERCISE plus write access to exercise, exercise routes and heart rate. Google adds that write permission also gives read access to records your app wrote.
What time does a Health Connect training plan get when created with a LocalDate?
Noon on that day. Google's reference says the PlannedExerciseSessionRecord constructor that takes a startDate and a duration generates the start time from a local time at noon in the system default timezone, and the end time by adding the duration. Use the constructor with explicit start and end instants when the plan has a fixed time.

All 58 questions in Health Connect API →

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed October 4, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All health connect api · by AIFitnessAPI