Skip to content
AF
Watch Apps

CustomWorkout, IntervalBlock and WorkoutScheduler in Swift

Last verified October 3, 2026 · 5 min read

A WorkoutKit interval session is built bottom-up: WorkoutStep and IntervalStep each carry a WorkoutGoal and an optional WorkoutAlert, IntervalBlock repeats an array of interval steps a given number of iterations, and CustomWorkout(activity:location:displayName:warmup:blocks:cooldown:) assembles warmup, blocks and cooldown. You wrap the result in WorkoutPlan(.custom(workout)) and either hand it to the Workout app or pass it to WorkoutScheduler.shared, whose schedule(_:at:) takes DateComponents. Apple's metadata lists these types from iOS 17.0 and watchOS 10.0, with macOS 15.0 and Mac Catalyst 18.0 on the individual symbols, and lists openInWorkoutApp() for watchOS only. Check CustomWorkout.supportsGoal and supportsAlert before you build a plan, and read WorkoutScheduler.maxAllowedScheduledWorkoutCount at runtime rather than hard-coding a limit.

Covered here:HealthKit

This page is the code. The product question (whether a coaching program belongs in Apple's Workout app at all, and how to sequence preview, scheduling and your own watch app) is answered in WorkoutKit scheduled workouts. Everything below is the WorkoutKit API surface as Apple's reference documents it, read on 2026-10-03.

The type tree, from the leaves up#

Apple's reference describes each piece in one line, and together they read like a grammar for an interval session:

TypeApple's descriptionHow you create it
WorkoutGoal"A value that specifies the goal for a workout"Cases including .open, .distance(_:_:), .energy(_:_:), .time(_:_:)
WorkoutAlert"An alert that notifies the user of significant events during a workout"Concrete types such as HeartRateZoneAlert(zone:)
WorkoutStep"A step in a workout"init(goal:alert:), both parameters defaulted
IntervalStep"An interval that represents a work or recovery step in a workout"init(_:goal:alert:) or init(_:step:) with .work or .recovery
IntervalBlock"Blocks of work and recovery steps that repeat in a custom workout"init(steps:iterations:)
CustomWorkout"A workout that includes a repeating series of work and recovery steps"init(activity:location:displayName:warmup:blocks:cooldown:)

The goal is what ends a step. Apple's parameter note on WorkoutStep says so directly: the goal is "A goal that determines when the step ends." A step with .open has no end condition of its own. The goal cases carry Foundation units: .distance takes a UnitLength, .time takes a UnitDuration.

IntervalStep.Purpose has two cases, .work and .recovery. Apple documents no third value, so a coaching model that distinguishes "easy", "steady" and "rest" has to map onto those two plus the goal and alert you attach.

A minimal interval workout#

This uses only initializers and cases from Apple's reference. Six repeats of 400 metres with a heart-rate zone alert, 90 seconds of recovery, a timed warmup and cooldown:

import WorkoutKit
import HealthKit

let work = IntervalStep(.work,
                        goal: .distance(400, .meters),
                        alert: HeartRateZoneAlert(zone: 4))
let recovery = IntervalStep(.recovery, goal: .time(90, .seconds))

let repeats = IntervalBlock(steps: [work, recovery], iterations: 6)

let workout = CustomWorkout(activity: .running,
                            location: .outdoor,
                            displayName: "6 x 400 m",
                            warmup: WorkoutStep(goal: .time(10, .minutes)),
                            blocks: [repeats],
                            cooldown: WorkoutStep(goal: .time(5, .minutes)))

let plan = WorkoutPlan(.custom(workout))

activity is an HKWorkoutActivityType and location an HKWorkoutSessionLocationType, the same HealthKit enums a workout session uses; the full activity list is on HealthKit workout activity types. WorkoutPlan(_:id:) defaults id to a fresh UUID(). Pass your own when the plan has an identity in your backend, so you can find it again among scheduled workouts.

Check support before you build#

CustomWorkout exposes three static checks: supportsActivity(_:), supportsGoal(_:activity:location:) and supportsAlert(_:activity:location:). Apple describes supportsGoal as returning "a Boolean value that indicates whether the system supports the specified goal for the given activity type and location". Run them when you translate your own program format into WorkoutKit types, not after a user taps schedule:

let ok = CustomWorkout.supportsGoal(.distance(400, .meters),
                                    activity: .running,
                                    location: .outdoor)
    && CustomWorkout.supportsAlert(HeartRateZoneAlert(zone: 4),
                                   activity: .running,
                                   location: .outdoor)

Our judgement: treat a false here as "this session cannot be represented faithfully" and keep it in your own app, rather than silently dropping the alert and scheduling something the coach did not write.

Previewing and opening#

There are two documented ways to show a plan before the user runs it. On iPhone, SwiftUI's workoutPreview(_:isPresented:) modifier "Presents a preview of the workout contents as a modal sheet"; Apple lists it from iOS 17.0 and watchOS 11.0. On the watch, WorkoutPlan.openInWorkoutApp() "Opens the workout in Workout on Apple Watch". It is declared async throws, and its availability metadata names watchOS 10.0 and no iOS version, so do not plan an iPhone button around it.

Scheduling and managing the schedule#

WorkoutScheduler is a final class with a shared instance. Everything that touches the schedule is async:

let scheduler = WorkoutScheduler.shared

guard WorkoutScheduler.isSupported else { return }

if await scheduler.authorizationState != .authorized {
    let state = await scheduler.requestAuthorization()
    guard state == .authorized else { return }
}

var when = DateComponents()
when.year = 2026; when.month = 10; when.day = 6
when.hour = 7; when.minute = 0

await scheduler.schedule(plan, at: when)

let upcoming = await scheduler.scheduledWorkouts   // [ScheduledWorkoutPlan]

Details from the reference that shape real code:

  • requestAuthorization() returns a WorkoutScheduler.AuthorizationState: .authorized, .denied, .notDetermined or .restricted. Unlike HealthKit read access, this state is reported, so you can show an honest "scheduling is off" screen.
  • schedule(_:at:) takes DateComponents, not a Date, and is async without throws.
  • scheduledWorkouts is "An array of all the workouts scheduled by your app", each a ScheduledWorkoutPlan carrying plan, date and complete.
  • maxAllowedScheduledWorkoutCount is a static Int described as "The maximum number of workouts your app can schedule." Apple does not print the number on the page, so read it at runtime and schedule a rolling window, not a whole season.
  • markComplete(_:at:), remove(_:at:) and removeAllWorkouts() maintain the schedule. remove takes the plan and the same DateComponents, which is a reason to persist exactly what you scheduled.

Apple's WorkoutKit sample describes the round trip: a planned workout "appears at the top of the Workout list on the watch", and after it is run and the sample's list is refreshed, "A green checkmark appears next to the completed workout." How the completion is surfaced to your code beyond the complete property is not spelled out on the reference pages we read, so verify against the sample before you build on it.

Availability, symbol by symbol#

The framework root lists iOS, iPadOS and Mac Catalyst 17.0 with watchOS 10.0. The individual types used above (CustomWorkout, WorkoutPlan, WorkoutScheduler, IntervalBlock and the rest) also list macOS 15.0, and their Mac Catalyst line reads 18.0 rather than 17.0. If you ship a Catalyst build, take the higher number from the symbol pages, not the framework page.

Where the live session lives#

A scheduled composition runs in Apple's Workout app, not in yours. If you need your own in-session screens, the live side is the HKWorkoutSession lifecycle, and the architecture around it is the anatomy of a watchOS workout app.

Frequently asked questions

Does WorkoutScheduler.schedule take a Date or DateComponents?
DateComponents. Apple declares it as schedule(_ workout: WorkoutPlan, at: DateComponents) async, and describes the parameter as date components that represent the workout's starting time. The same type appears in ScheduledWorkoutPlan.date and in remove(_:at:) and markComplete(_:at:), which both take the plan plus the DateComponents it was scheduled at. Persist exactly what you scheduled so you can remove or complete it later without reconstructing the components from a Date and getting a time zone or field mismatch.
How many workouts can one app schedule with WorkoutKit?
Apple exposes the limit as WorkoutScheduler.maxAllowedScheduledWorkoutCount, a static Int described as the maximum number of workouts your app can schedule, but the reference page does not state the number. Read it at runtime instead of hard-coding one. Our recommendation is to schedule a rolling window of upcoming sessions from your backend's plan and top it up, rather than trying to push an entire training block, so a plan change touches only a few scheduled entries.
Can an iPhone app call openInWorkoutApp on a WorkoutPlan?
Apple's availability metadata for WorkoutPlan.openInWorkoutApp() lists watchOS 10.0 and no iOS version, and describes it as opening the workout in Workout on Apple Watch. On iPhone the documented preview path is SwiftUI's workoutPreview(_:isPresented:) modifier, listed from iOS 17.0, which presents the workout contents as a modal sheet, and the documented delivery path is WorkoutScheduler. Design the iPhone flow around preview and scheduling, and keep the open-now action in your watch app.
What are the two purposes an IntervalStep can have?
Apple's IntervalStep.Purpose enum has two cases: work, described as a work step, and recovery, described as a recovery step. Each IntervalStep pairs one purpose with a WorkoutStep, either through init(_:step:) or the convenience init(_:goal:alert:). If your coaching model has more step kinds than that, express the difference through the goal and alert you attach, and check each with CustomWorkout.supportsGoal and supportsAlert before scheduling.

All 55 questions in Watch Apps →

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 3, 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 watch apps · by AIFitnessAPI