
Axiom Watchos
- 495 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-watchos is an agent skill that scaffolds watchOS targets, complications, workout sessions, and WatchConnectivity flows for developers who extend an iPhone app to Apple Watch.
About
axiom-watchos is a Mobile Development skill in charleswiltgen/axiom that guides agents through adding Apple Watch support to an existing iOS project. The skill covers watchOS target setup, complication definitions, workout session wiring, and WatchConnectivity message flows between iPhone and Watch. Developers reach for axiom-watchos when a companion Watch app, glanceable complications, or fitness session tracking must ship alongside an iPhone build without hand-rolling every Xcode target and entitlement. The workflow assumes SwiftUI or WatchKit patterns on watchOS paired with an iPhone host app. Catalog data shows 293 installs. Use it when Watch extension scaffolding blocks progress on an otherwise complete iOS feature.
- watchOS target and extension setup
- Complications and glanceable UI patterns
- WatchConnectivity messaging patterns
- Workout and health session hooks
- Battery-conscious UI constraints
Axiom Watchos by the numbers
- 495 all-time installs (skills.sh)
- Ranked #304 of 1,039 Mobile Development 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-watchosAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 495 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you add watchOS complications to an iPhone app?
Scaffold watchOS targets, complications, workout sessions, and WatchConnectivity flows when extending an iPhone app to Apple Watch.
Who is it for?
iOS developers shipping a Watch companion with complications, workouts, or phone-to-watch data sync on an existing Swift codebase.
Skip if: Developers building Android Wear apps, server-only APIs, or greenfield iOS apps with no Watch extension requirement.
When should I use this skill?
User asks to add Apple Watch support, watchOS complications, workout tracking on Watch, or WatchConnectivity between iPhone and Watch.
What you get
watchOS target configuration, complication definitions, workout session setup, and WatchConnectivity sync code ready to build in Xcode.
- watchOS target configuration
- WatchConnectivity handlers
- Complication and workout session stubs
By the numbers
- 293 catalog installs in Skillselion
- Covers 4 watchOS surface areas: targets, complications, workouts, and WatchConnectivity
Files
watchOS Development
You MUST use this skill for ANY watchOS-specific development including app structure, independent apps, Watch Connectivity, complications and Smart Stack widgets, controls, Live Activities on watch, background tasks, and ClockKit migration.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| App structure, independent apps, watchOS 26 submission requirements | See skills/platform-basics.md |
| watchOS HIG, glanceable UX, navigation model | See skills/design-for-watchos.md |
| Smart Stack widgets, complications, ClockKit→WidgetKit, RelevanceKit | See skills/smart-stack-and-complications.md |
| Controls on watch surfaces, Live Activities on watch | See skills/controls-and-live-activities.md |
| Watch Connectivity (WCSession), paired-device data transfer, Family Setup | See skills/watch-connectivity.md |
| Background tasks, freshness scheduling, TN3135 networking limits | See skills/background-and-networking.md |
BGTaskScheduler migration, deprecated WK background scheduling OS27 | See skills/background-and-networking.md |
Foundation Models / Private Cloud Compute on the watch OS27 | See skills/platform-basics.md |
| WatchKit→SwiftUI migration, ClockKit→WidgetKit migration | See skills/modernization.md |
Cross-Suite Routes
These topics overlap with watchOS development but live in separate suites:
SwiftUI (shared iOS/watchOS/macOS)
- View state, data flow, @Observable → See axiom-swiftui
- Navigation basics (NavigationStack) → See axiom-swiftui
- Layout, animations → See axiom-swiftui
Design
- General HIG, Liquid Glass, SF Symbols, typography → See axiom-design
Accessibility
- General VoiceOver, Dynamic Type, WCAG → See axiom-accessibility
- watchOS-specific (VoiceOver rotor on Digital Crown, AssistiveTouch, Double Tap) → See axiom-accessibility (
skills/watchos-a11y.md)
Health and workouts
- HealthKit,
HKWorkoutSession,HKLiveWorkoutBuilder, WorkoutKit → See axiom-health - Workout recovery, multi-device coordination → See axiom-health (
skills/workouts.md)
iOS-side widgets and App Intents
- iOS/iPadOS widgets, configuration intents, App Intents → See axiom-integration
- Live Activities on iPhone (initiation + ActivityKit) → See axiom-integration
Concurrency
- Swift 6 concurrency, actors, Sendable → See axiom-concurrency
New-on-watch frameworks (27 releases)
- Foundation Models depth (sessions, @Generable, tools, PCC) → See axiom-ai; watch scoping is in
skills/platform-basics.md - Vision framework (new on watchOS 27) → See axiom-vision
- NowPlaying / MusicUnderstanding (new on watchOS 27) → See axiom-media
Conflict Resolution
axiom-watchos vs axiom-swiftui: When building a watchOS SwiftUI app: 1. Use axiom-watchos for watch-specific patterns: glanceable UI, constrained navigation, Digital Crown focus, Smart Stack placement 2. Use axiom-swiftui for cross-platform SwiftUI: state management, layout primitives, animations 3. Both may apply: A watchOS NavigationStack with complications needs axiom-watchos for complication surfaces and axiom-swiftui for NavigationStack basics
axiom-watchos vs axiom-integration: For widgets and Live Activities: 1. Use axiom-watchos for watch complications, Smart Stack placement, watch-side Live Activity presentation, RelevanceKit 2. Use axiom-integration for iOS/iPadOS widgets, core ActivityKit API, App Intents
axiom-watchos vs axiom-health: For workouts on Apple Watch: 1. Use axiom-watchos for watch-specific presentation: Always On display, Smart Stack placement, background mode coordination 2. Use axiom-health for HKWorkoutSession lifecycle, HKLiveWorkoutBuilder, recovery, multi-device mirroring
Decision Tree
digraph watchos {
start [label="watchOS development task" shape=ellipse];
what [label="What area?" shape=diamond];
start -> what;
what -> "skills/platform-basics.md" [label="app structure, independent apps, submission"];
what -> "skills/design-for-watchos.md" [label="watch HIG, glanceable UX"];
what -> "skills/smart-stack-and-complications.md" [label="complications, Smart Stack, RelevanceKit"];
what -> "skills/controls-and-live-activities.md" [label="controls, watch Live Activities"];
what -> "skills/watch-connectivity.md" [label="WCSession, paired-device transfer"];
what -> "skills/background-and-networking.md" [label="background tasks, BGTaskScheduler, networking limits"];
what -> "skills/platform-basics.md" [label="Foundation Models / PCC on watch"];
what -> "skills/modernization.md" [label="WatchKit/ClockKit migration"];
what -> "axiom-health" [label="workouts, HealthKit, WorkoutKit"];
what -> "axiom-swiftui" [label="general SwiftUI patterns"];
what -> "axiom-accessibility" [label="VoiceOver rotor, AssistiveTouch"];
what -> "axiom-integration" [label="iOS-side widgets, App Intents"];
}Resources
WWDC: 2021-10003, 2022-10133, 2023-10138, 2023-10029, 2023-10309, 2024-10098, 2024-10157, 2024-10205, 2025-334
Docs: /watchos-apps/building-a-watchos-app, /watchos-apps/creating-independent-watchos-apps, /watchconnectivity, /widgetkit/creating-accessory-widgets-and-watch-complications, /widgetkit/converting-a-clockkit-app, /relevancekit, /technotes/tn3135-low-level-networking-on-watchos, /technotes/tn3157-updating-your-watchos-project-for-swiftui-and-widgetkit
Skills: axiom-swiftui, axiom-design, axiom-accessibility, axiom-health, axiom-integration, axiom-concurrency, axiom-ai, axiom-vision, axiom-media
Background Tasks and Networking
When to Use This Skill
Use when:
- Picking between SwiftUI
.backgroundTask(_:action:)and a WatchKit app-delegatehandle(_:)implementation - Scheduling app refresh —
BGTaskScheduleron the 27 SDK, or legacyscheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:) - Migrating WatchKit background-refresh scheduling to BGTaskScheduler
- Deciding between URLSession configurations (default, ephemeral, background) on a Watch target
- Hitting
ENETDOWNwhen startingNWConnectionon watchOS (TN3135) - Handling
WKURLSessionRefreshBackgroundTaskorWKWatchConnectivityRefreshBackgroundTaskwake-ups - Debugging mystery
EXC_CRASH (SIGKILL)crashes after a background wake
Related Skills
- Use
platform-basics.mdfor the SwiftUI.backgroundTaskhook in theAppbody and delegate adoption - Use
watch-connectivity.mdforWCSessionqueued transfers and the background-task completion contract - Use
smart-stack-and-complications.mdfor widget push updates that replace some background-refresh flows on watchOS 26 - Use
axiom-networkingfor general URLSession patterns; this skill covers watchOS-specific constraints - Use
axiom-concurrencyforwithTaskCancellationHandlerpatterns and Swift concurrency semantics
Core Principle
URLSession over everything. Low-level networking is blocked unless you're an audio streamer, a CallKit VoIP app, or a tvOS companion. TN3135 is explicit — NWConnection, Network framework TCP/UDP, URLSessionStreamTask, URLSessionWebSocketTask, NWBrowser, NetService, BSD sockets: all blocked for "normal" apps, enforced from watchOS 9.
"If a normal app attempts to start an NWConnection, that connection will stay in the.waiting(_:)state with an error of ENETDOWN. Similarly, an NWPathMonitor will remain in the.unsatisfiedstate." — Apple, TN3135
The three exceptions:
| App type | Low-level window | Minimum version |
|---|---|---|
| Audio streaming (background audio session) | While actively streaming | watchOS 6 |
| VoIP + CallKit | While running a CallKit call | watchOS 9 |
| tvOS pairing (DeviceDiscoveryUI application service listener) | Persistent | watchOS 9, tvOS 16 |
If your app isn't in one of those lanes, route every byte through URLSession. The simulator happily runs low-level networking even when the device would refuse — test on real hardware before drawing conclusions.
URLSession Choice Matrix
| Session | Use when |
|---|---|
Default (URLSessionConfiguration.default) | Foreground and active; quick-latency requests; normal cookie/cache behavior |
Ephemeral (URLSessionConfiguration.ephemeral) | Foreground; no persistence (sensitive requests, private-browsing-style fetches) |
Background (URLSessionConfiguration.background(withIdentifier:)) | App may become inactive or terminate before the request finishes; guarantees eventual completion |
Background sessions come with a cost: the system may delay or defer them based on resource conditions. For foreground work, default or ephemeral is faster and more predictable.
Background Scheduling Moves to BGTaskScheduler OS27
The 27 SDK brings the BackgroundTasks framework to watchOS and deprecates WatchKit's scheduling methods. WKApplication.scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:) (and the WKExtension variant) is deprecated with replacement BGTaskScheduler.submitTaskRequest; scheduleSnapshotRefresh is deprecated outright — "Snapshots may no longer be manually scheduled." SwiftUI's bare .backgroundTask(.appRefresh) (the String? userInfo form below) is deprecated in favor of the identifier form.
SDK nuance: the headers annotate BGTaskScheduler and the core task types as watchos(26.0), but the 26.5 SDK marked them watch-unavailable — building requires the Xcode 27 SDK; deployment back to watchOS 26 then works.
import BackgroundTasks
// Schedule — one identifier per refresh flow (replaces userInfo dispatch)
let request = BGAppRefreshTaskRequest(identifier: "com.example.weather-refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try await BGTaskScheduler.shared.submitTaskRequest(request)
// Handle — identifier-based SwiftUI hooks
.backgroundTask(.appRefresh("com.example.weather-refresh")) {
await fetchWeather()
}
.backgroundTask(.processingTask("com.example.maintenance")) { // OS27
await runMaintenance()
}What changes, exactly:
| Legacy (deprecated in 27) | Replacement |
|---|---|
WKApplication.scheduleBackgroundRefresh(withPreferredDate:userInfo:...) | BGAppRefreshTaskRequest(identifier:) + earliestBeginDate + submitTaskRequest |
userInfo string dispatch in one handler | One identifier (and one .backgroundTask hook) per flow; identifiers must be listed in Info.plist BGTaskSchedulerPermittedIdentifiers |
Bare .backgroundTask(.appRefresh) receiving String? | .backgroundTask(.appRefresh("identifier")) |
scheduleSnapshotRefresh | Nothing — snapshots can no longer be manually scheduled |
BGTaskScheduler.submit(_:) (annotated watchOS 26) — deprecated in 27 "to capture all error conditions" | submitTaskRequest(_:) async (all BGTaskScheduler platforms) |
Also available on watch via the 27 SDK: BGProcessingTaskRequest (longer maintenance work; the header allows 1 pending refresh + 10 pending processing tasks), BGHealthResearchTaskRequest, and — new at watchOS 27 — BGContinuedProcessingTaskRequest for user-visible continued work (no GPU resources on watch; not tvOS/visionOS). The new SwiftUI .processingTask(_ identifier:) hook is OS27 (not macOS/visionOS).
Delivery is unchanged: handle(_:) and the WKRefreshBackgroundTask types still exist for Watch Connectivity and URLSession wake-ups, and the budget realities below still apply.
Background Refresh — The Pre-27 SwiftUI Way
.backgroundTask(_:action:) on the App's scene is preferred over the delegate path below. The system marks the task complete when the closure returns, and you only handle the task types you care about. On 27, schedule with BGTaskScheduler and the identifier form above; the WKApplication scheduling and bare .appRefresh below are deprecated but keep working for existing apps.
import SwiftUI
@main
struct MyWatch_Watch_App: App {
var body: some Scene {
WindowGroup { ContentView() }
.backgroundTask(.appRefresh) { context in
await refreshData()
}
}
}Distinguishing refresh flows by userInfo (deprecated in 27)
Schedule with a specific userInfo string, then branch on it inside the handler — on 27, use one BGAppRefreshTaskRequest identifier per flow instead. userInfo must conform to both NSSecureCoding and NSObjectProtocol; a Swift String bridges cleanly via NSString:
// Scheduling
WKApplication.shared().scheduleBackgroundRefresh(
withPreferredDate: Date(timeIntervalSinceNow: 15 * 60),
userInfo: "WEATHER_UPDATE" as NSString
) { error in
if let error { /* handle */ }
}
// Handling — one handler, dispatch by the scheduled userInfo string.
// On watchOS the bare `.appRefresh` is BackgroundTask<String?, Void>: the
// closure receives the scheduled userInfo bridged to a String? DIRECTLY —
// it is NOT a context object (there is no `context.userInfo`), and NOT the
// SwiftUI identifier form `.appRefresh("id")` that iOS uses.
.backgroundTask(.appRefresh) { reason in // reason: String? == the scheduled userInfo
switch reason {
case "WEATHER_UPDATE":
await fetchWeather()
case "WIDGET_RELOAD":
await reloadWidgetData()
default:
await performDefaultRefresh()
}
}Cancellation handling on long tasks
The system cancels tasks before killing the app on budget exhaustion. Wrap in withTaskCancellationHandler:
.backgroundTask(.appRefresh) { _ in
await withTaskCancellationHandler {
// The main work
} onCancel: {
// Clean up, persist partial progress, release resources
}
}Background Refresh — WatchKit App Delegate
For apps that still use WKApplicationDelegate, implement handle(_:). This path is fully supported but more work: you receive every background task — yours, Watch Connectivity, URLSession background transfers — and must call setTaskCompletedWithSnapshot(_:) on each.
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
switch task {
case let t as WKApplicationRefreshBackgroundTask:
handleAppRefresh(t)
case let t as WKSnapshotRefreshBackgroundTask:
t.setTaskCompleted(
restoredDefaultState: false,
estimatedSnapshotExpiration: .distantFuture,
userInfo: nil
)
case let t as WKURLSessionRefreshBackgroundTask:
handleURLSessionRefresh(t) // save task; complete after delegate callbacks
case let t as WKWatchConnectivityRefreshBackgroundTask:
handleWatchConnectivity(t) // save task; complete via KVO (see watch-connectivity.md)
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}Every task must reach `setTaskCompletedWithSnapshot(_:)`. Missing this drains the budget and eventually crashes with EXC_CRASH (SIGKILL). For URLSession and Watch Connectivity tasks, complete after the session delegate callbacks fire, not inside handle(_:).
expirationHandler on WKRefreshBackgroundTask
task.expirationHandler = {
// Clean up; convert in-flight work to a background URLSession if possible.
}Apple recommends converting a synchronous download into a background URLSession from the expiration handler — the URL fetch keeps running after the app suspends.
Background URLSession — The Wake-Up Flow
When you start a background URLSession from the watch, the system delivers completion events through a WKURLSessionRefreshBackgroundTask. The shape is:
digraph bgurl {
schedule [label="App starts\nbackground URLSession" shape=box];
suspend [label="App suspends" shape=box];
wake [label="System wakes app\nfor download event" shape=box];
handle [label="handle(_:) receives\nWKURLSessionRefreshBackgroundTask\n— save the task" shape=box];
delegate [label="URLSessionDelegate callbacks fire\n(didFinishDownloadingTo, didCompleteWithError)" shape=box];
complete [label="setTaskCompletedWithSnapshot(false)\non saved task" shape=box];
schedule -> suspend -> wake -> handle -> delegate -> complete;
}1. In handle(_:), save the WKURLSessionRefreshBackgroundTask — don't complete it yet. 2. The system calls your URLSessionDelegate methods (urlSession(_:downloadTask:didFinishDownloadingTo:), then urlSession(_:task:didCompleteWithError:)). 3. Inside didCompleteWithError:, call setTaskCompletedWithSnapshot(false) on the saved task.
Move the downloaded file out of the temp location inside didFinishDownloadingTo: — the system deletes the temp file when the delegate returns.
Budget Reality
Apple is explicit about what gates background time:
- The system chooses. Every app has an allocation; the system chooses when to trigger tasks based on current conditions.
- Complication helps. Apps with a complication on the active watch face get higher background priority.
- Dock helps. Apps in the user's Dock get higher priority than apps that aren't.
- User activity throttles. Workouts, navigation, and other high-priority activities deprioritize your background work.
- Low battery throttles. Background tasks suspend when the battery is low even if your budget has room.
- Don't expect every task. Design a fallback: the app must still update when the user foregrounds it.
Schedule with a preferred date (earliestBeginDate on a BGAppRefreshTaskRequest, or legacy scheduleBackgroundRefresh(withPreferredDate:...)); expect the actual wake time to slip. Never promise the user a precise background-update schedule.
Fresh-Data Strategy
The briefing from watch-connectivity.md applies in reverse here — Watch Connectivity is an optimization, not the primary path:
| Scenario | Best primary path |
|---|---|
| Paired iPhone online | URLSession on the watch; system auto-routes through the iPhone via Bluetooth |
| LTE watch, iPhone asleep or out of range | URLSession over known Wi-Fi or cellular |
| CloudKit-backed data | CKSubscription + notifications (watchOS 6+) |
| Live refresh needed on-device | TimelineView for date/time redraws, a .backgroundTask app-refresh hook for data |
| Instant update from iPhone | Opportunistic WCSession.transferUserInfo + transferCurrentComplicationUserInfo |
| Widget refresh from server | APNs widget push updates (watchOS 26+, see smart-stack-and-complications.md) |
Test over all three network routes the watch uses — iPhone proxy via Bluetooth, known Wi-Fi, and cellular (Series 3+). Turn off both Wi-Fi and Bluetooth in the iPhone Settings app (not Control Center, which only disconnects) to force the watch to use Wi-Fi or LTE.
Watch-Specific Constraints
Use URLSession instead of Foundation convenience loaders. Apple explicitly calls out that synchronous byte-loading from URLs is unsupported on watchOS:
"Foundation has various APIs for synchronously creating a value using bytes loaded from a URL… Using these APIs with network URLs is not best practice on any Apple platform and is not supported by watchOS." — TN3135
Avoid Data(contentsOf: url), String(contentsOf: url), and similar. Use URLSession.shared.data(from:) or configure a session properly.
Debugging Checklist
| Symptom | Probable cause | Check |
|---|---|---|
NWConnection stays .waiting(_:) with ENETDOWN on device, works in simulator | Low-level networking blocked — TN3135 | Switch to URLSession; the simulator does not enforce the rule |
EXC_CRASH (SIGKILL) shortly after background wake | One or more WKRefreshBackgroundTasks never got setTaskCompletedWithSnapshot(_:) | Audit every branch in handle(_:); for async work, complete after all delegate callbacks fire |
| Background refresh never fires | No complication on active watch face; app not in Dock; low battery; high-priority user activity; on 27, identifier missing from BGTaskSchedulerPermittedIdentifiers or request never submitted | Add a complication; confirm Settings → Passcode → Allow Background App Refresh; verify the Info.plist identifier list; test with the device on charger |
| Download completes but data loss | urlSession(_:downloadTask:didFinishDownloadingTo:) didn't move the file before returning | Copy to a permanent location synchronously inside the delegate |
NWPathMonitor always .unsatisfied | Blocked path monitor on watchOS outside the three exceptions | Use URLSession error state instead of attempting to pre-check the path |
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Starting NWConnection from a non-audio, non-CallKit app | Connection stuck in .waiting; ENETDOWN errors | Switch to URLSession; low-level networking is blocked on watchOS by policy, not bug |
| Testing only in the simulator | Code works in simulator, fails on device | Always test networking on a real device; the simulator permits what the device doesn't |
Not saving WKURLSessionRefreshBackgroundTask / WKWatchConnectivityRefreshBackgroundTask before returning from handle(_:) | Tasks complete before URLSession delegate finishes; data loss; mystery crashes | Save the task; complete only after the session delegate finishes |
Using Data(contentsOf: url) on a network URL | Blocked / unsupported path | Use URLSession.shared.data(from:) |
Mixing SwiftUI .backgroundTask with a delegate's handle(_:) | Duplicate or dropped tasks; unpredictable completion | Pick one path — SwiftUI for new work, delegate only if the rest of the app already uses it |
| Expecting every scheduled refresh to fire | UI shows stale data even when the app "scheduled" a refresh | Design for missed refreshes; always refresh on foregrounding |
Not adding expirationHandler on long tasks | SIGKILL when the budget expires mid-task | Set expirationHandler (delegate path) or wrap in withTaskCancellationHandler (SwiftUI path) |
Using URLSession.shared for a background transfer | Download cancels when the app suspends | Use a dedicated URLSession with URLSessionConfiguration.background(withIdentifier:) |
| Toggling Bluetooth/Wi-Fi from iPhone Control Center thinking it isolates the watch | Control Center disconnects but doesn't fully disable; tests unreliable | Toggle from iPhone Settings app for a real test |
Migrating to BGTaskScheduler but keeping one handler with userInfo dispatch OS27 | Tasks fire but the dispatch string is gone — identifier form passes no payload | One identifier + one .backgroundTask(.appRefresh("id")) hook per flow; list each in BGTaskSchedulerPermittedIdentifiers |
Calling BGTaskScheduler in a project built with the 26.5 SDK | 'BGTaskScheduler' is unavailable in watchOS compile error | The watch surface ships in the Xcode 27 SDK (annotated back to watchOS 26) — build with Xcode 27 |
| Still scheduling snapshots on 27 | Deprecation warnings; no effect to rely on | Remove scheduleSnapshotRefresh — snapshots can no longer be manually scheduled |
Resources
WWDC: 2019-716 (audio streaming on watchOS 6)
Docs: /technotes/tn3135-low-level-networking-on-watchos, /watchkit/using-background-tasks, /watchos-apps/making-background-requests, /watchos-apps/keeping-your-watchos-app-s-content-up-to-date, /swiftui/scene/backgroundtask(_:action:), /backgroundtasks/bgtaskscheduler, /backgroundtasks/bgapprefreshtaskrequest, /watchkit/wkapplication/schedulebackgroundrefresh(withpreferreddate:userinfo:scheduledcompletion:), /watchkit/wkrefreshbackgroundtask, /watchkit/wkurlsessionrefreshbackgroundtask, /foundation/urlsession, /foundation/urlsessionconfiguration/background(withidentifier:)
Skills: axiom-watchos (platform-basics, watch-connectivity, smart-stack-and-complications), axiom-networking, axiom-concurrency
Controls and Live Activities on Apple Watch
When to Use This Skill
Use when:
- Building a control (button or toggle) that lands in Control Center, Smart Stack, or the Apple Watch Ultra Action button
- Deciding whether an iPhone-only control is enough or whether the Watch app needs its own
ControlWidget - Choosing between
StaticControlConfigurationandAppIntentControlConfiguration - Surfacing an existing iOS Live Activity on a paired Apple Watch (Dynamic Island → Smart Stack)
- Supporting Double Tap for the primary action on watchOS 11+
- Debugging why a control works in the gallery but doesn't fire on watch
Related Skills
- Use
smart-stack-and-complications.mdfor widget vs control decision-making and RelevanceKit - Use
platform-basics.mdfor app structure and Info.plist keys - Use
axiom-integrationfor ActivityKit setup on the iOS side and general App Intents patterns - Use
axiom-accessibility/skills/watchos-a11y.mdfor Double Tap accessibility interactions - Use
watch-connectivity.mdwhen the control's state needs to sync with a paired iPhone
Core Principle
Controls execute actions; widgets display information; Live Activities track bounded events. watchOS 26 brought the Smart Stack's three-surface unification — pick the right one by primary purpose:
| If the user wants to... | Use |
|---|---|
| Change a setting or toggle a device | Control (ControlWidgetToggle) |
| Trigger an action without opening the app | Control (ControlWidgetButton) |
| Open the app at a specific screen | Control with OpenIntent |
| Glance at info throughout the day | Widget (see smart-stack-and-complications.md) |
| Follow an event with start + end (flight, match, timer) | Live Activity |
Controls on Apple Watch (watchOS 26)
Controls arrived on Apple Watch in watchOS 26. People can place your controls in:
- Control Center on the watch
- Smart Stack alongside widgets and Live Activities
- Action button on Apple Watch Ultra
- Double Tap bound to the primary action (watchOS 11+)
Two ways a control reaches the watch
| Setup | Where action runs | Watch app required |
|---|---|---|
| Control ships in the iPhone app only | iPhone (wakes companion via relay) | No — iPhone-side control appears on the watch automatically |
| Control ships in the Watch app | Apple Watch | Yes |
"People can add the controls from your iPhone app to system spaces on Apple Watch, even if you don't have a Watch app." — Apple, What's new in watchOS 26
"When the control is tapped on the Apple Watch, the action is performed on the companion iPhone. Since the action is performed on iPhone, controls whose actions foreground the iPhone app will not appear on Apple Watch." — Apple, What's new in watchOS 26
Controls whose intent brings the iPhone app to the foreground — e.g., OpenIntent for "Open My App to the Timer screen" — are filtered out of watch placement. Use OpenIntent only on iPhone; use an AppIntent with no foregrounding on watch-visible controls.
Anatomy of a Control
Three pieces bolt together:
1. `ControlWidget` — your concrete control type, declared in the widget extension 2. `StaticControlConfiguration` or `AppIntentControlConfiguration` — the body structure; Static for non-configurable, AppIntent for configurable 3. `AppIntent` / `OpenIntent` / `SetValueIntent` — the action that runs when tapped
Add .displayName(_:) and .description(_:) so the controls gallery shows your control properly.
Control toggle (on/off with state)
struct TimerToggle: ControlWidget {
static let kind: String = "com.example.MyApp.TimerToggle"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Productivity Timer",
isOn: value,
action: ToggleTimerIntent(),
valueLabel: { isOn in
Label(isOn ? "Running" : "Stopped", systemImage: "timer")
}
)
}
.displayName("Productivity Timer")
.description("Start and stop a productivity timer.")
}
}
extension TimerToggle {
struct Provider: ControlValueProvider {
var previewValue: Bool { false }
func currentValue() async throws -> Bool {
TimerService.shared.isRunning
}
}
}
struct ToggleTimerIntent: SetValueIntent {
static var title: LocalizedStringResource = "Productivity Timer"
@Parameter(title: "Timer is running")
var value: Bool
func perform() async throws -> some IntentResult {
TimerService.shared.setRunning(value)
return .result()
}
}`value` is system-managed. Don't set it manually — the system populates it with the new desired state and your perform() mutates the underlying model to match.
Control button (fire-and-forget)
struct PerformActionButton: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.example.myApp.performActionButton"
) {
ControlWidgetButton(action: PerformAction()) {
Label("Perform Action", systemImage: "checkmark.circle")
}
}
.displayName("Perform Action")
.description("An example control that performs an action.")
}
}
struct PerformAction: AppIntent {
static let title: LocalizedStringResource = "Perform action"
func perform() async throws -> some IntentResult {
MyService.shared.doWork()
return .result()
}
}Configurable control (watchOS 26)
For controls where users pick the target — which timer, which room light, which beach — use AppIntentControlConfiguration + AppIntentControlValueProvider:
struct ConfigurableMeditationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: WidgetKinds.configurableMeditationControl,
provider: Provider()
) { value in
ControlWidgetToggle(
"Ocean Meditation",
isOn: value.isActive,
action: StartMeditationIntent(configuration: value.configuration),
valueLabel: { _ in Label("Meditate", systemImage: "leaf") }
)
}
.displayName("Ocean Meditation")
.description("Meditation with optional ocean sounds.")
.promptsForUserConfiguration()
}
}
extension ConfigurableMeditationControl {
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
Value(configuration: configuration, isActive: false)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
Value(
configuration: configuration,
isActive: MeditationService.shared.isActive(for: configuration)
)
}
}
struct Value {
let configuration: TimerConfiguration
let isActive: Bool
}
}.promptsForUserConfiguration() tells the system to surface configuration when the user adds the control.
Bundle All Controls in WidgetBundle
@main
struct MyControlsAndWidgetsBundle: WidgetBundle {
var body: some Widget {
PerformActionButton()
TimerToggle()
ConfigurableMeditationControl()
MyAppTimelineWidget()
}
}The order here is the order in the controls gallery — put the most useful first.
Opening the App from a Control (iPhone-only)
OpenIntent brings the app to the foreground:
struct LaunchAppIntent: OpenIntent {
static var title: LocalizedStringResource = "Launch App"
@Parameter(title: "Target")
var target: LaunchAppEnum
}
enum LaunchAppEnum: String, AppEnum {
case timer
case history
static var typeDisplayRepresentation =
TypeDisplayRepresentation("Productivity Timer's app screens")
static var caseDisplayRepresentations = [
LaunchAppEnum.timer: DisplayRepresentation("Timer"),
LaunchAppEnum.history: DisplayRepresentation("History")
]
}The intent's Target Membership must include both the app and the widget extension. If only the extension carries it, the system can't open the app.
Remember the watch filter. Controls whose action foregrounds the iPhone app don't appear on the watch — OpenIntent is iPhone-only territory.
Double Tap (watchOS 11+)
Double Tap binds to the primary action of the frontmost surface. On a control, that's the intent you wired to ControlWidgetToggle or ControlWidgetButton — no extra wiring required. On a regular view, use the handGestureShortcut(.primaryAction) modifier on the primary button to opt it into Double Tap.
The user can disable Double Tap globally; don't build UI that requires it. Make the primary action tappable as well as Double Tappable.
Live Activities on Apple Watch
Live Activities are authored once on iOS with ActivityKit + WidgetKit. Apple summarizes the watch hand-off:
"Live Activities from your iOS app automatically appear at the top of the Smart Stack on a connected Apple Watch." — Apple, developer.apple.com/watchos/
The watch doesn't start its own Live Activities. iPhone creates, updates, and ends; the watch displays. What you control:
- The Dynamic Island presentations (compact, minimal, expanded) you author for iOS also drive what surfaces on watch. Test each presentation on a paired device.
- The watch uses the minimal presentation by default when multiple Live Activities are active.
ActivityAttributes structure
One ActivityAttributes per activity kind — static data on the outer struct, dynamic data on ContentState:
import ActivityKit
struct OrderAttributes: ActivityAttributes {
struct ContentState: Codable & Hashable {
let estimatedArrival: Date
let driverName: String
let statusMessage: String
}
let orderID: String
let restaurantName: String
}Declare NSSupportsLiveActivities = YES in the iOS app's Info.plist. Add the widget extension with "Include Live Activity" checked at creation.
ActivityConfiguration — the view layer
struct OrderLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OrderAttributes.self) { context in
// Lock Screen / watch Smart Stack presentation
OrderLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) { /* ... */ }
DynamicIslandExpandedRegion(.trailing) { /* ... */ }
DynamicIslandExpandedRegion(.center) { /* ... */ }
DynamicIslandExpandedRegion(.bottom) { /* ... */ }
} compactLeading: {
Image(systemName: "bag")
} compactTrailing: {
Text(context.state.estimatedArrival, style: .timer)
} minimal: {
Text(context.state.estimatedArrival, style: .relative)
}
}
}
}Constraints that apply on watch too
| Constraint | Value |
|---|---|
| Maximum active duration | 8 hours (system auto-ends); Lock Screen persists up to 4 more hours (total 12) |
| Max payload (static + dynamic combined) | 4 KB |
| Network / location access from the Live Activity view | None — update via ActivityKit or APNs |
| Image resolution limit | Must be ≤ presentation size; oversize images fail to start the activity |
The 4 KB cap bites fast when using strings like ETA descriptions — keep payloads tight, fetch details from the companion app when needed.
Updating from a push (APNs)
Live Activities can receive dedicated APNs push tokens. See Starting and updating Live Activities with ActivityKit push notifications for the full server payload shape. The shared rule with widgets: APNs pushes propagate to the watch without any Watch Connectivity plumbing.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Using OpenIntent on a control you expect to appear on watch | Control missing from the watch controls gallery | Watch-visible controls cannot foreground the iPhone app — use a non-Open AppIntent and only surface the OpenIntent variant on iPhone |
Manually setting value on a SetValueIntent in perform() | Control state drifts from system expectation; jitter between taps | Treat value as read-only input; mutate your model to match |
Reading/writing control state through an in-process .shared singleton | Toggle drifts or "does nothing"; the control's state never matches the app | The control runs in the widget extension's process — its singleton is not the app's. Persist state in an App Group (UserDefaults(suiteName:) / shared store), and after a perform() mutation call ControlCenter.shared.reloadControls(ofKind:) so the control re-renders. (The examples above use bare .shared for brevity.) |
| App Intent target membership only on the widget extension | Control appears but intent fails silently | Add the intent file to both app and extension target memberships |
| Shipping a Live Activity without testing the minimal presentation | Watch Smart Stack card is illegible or truncated | The watch uses minimal most often — design and test minimal first, not last |
| Live Activity image assets sized for Dynamic Island leading slot (too large for minimal) | Live Activity fails to start | Provide presentation-sized assets; leading-slot image can't exceed the target presentation bounds |
| Relying on network or location access inside a Live Activity view | Views render stale data or crash | Live Activities can't access network/location — update via ActivityKit API or APNs |
| Static + dynamic payload > 4 KB | Update rejected; state frozen | Trim strings and numeric precision; fetch rich data from the app on tap |
Forgetting NSSupportsLiveActivities in Info.plist | Activity.request(attributes:...) throws; Live Activities never appear | Add the key (set YES) on the iOS app target |
| Assuming Double Tap is always available | Primary action missing for users who disabled Double Tap or own older hardware | Always keep a tappable equivalent; Double Tap is an accelerator, not the only path |
Putting controls in a separate WidgetBundle from the existing widgets | Controls don't appear in gallery | Bundle everything in one @main WidgetBundle |
Resources
WWDC: 2025-334, 2024-10157, 2024-10098, 2024-10205, 2023-10027, 2023-10194
Docs: /widgetkit/creating-controls-to-perform-actions-across-the-system, /widgetkit/controlwidget, /widgetkit/staticcontrolconfiguration, /widgetkit/appintentcontrolconfiguration, /widgetkit/controlwidgetbutton, /widgetkit/controlwidgettoggle, /widgetkit/controlvalueprovider, /widgetkit/appintentcontrolvalueprovider, /appintents/appintent, /appintents/openintent, /appintents/setvalueintent, /activitykit, /activitykit/activityattributes, /activitykit/activity, /activitykit/activityconfiguration, /activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
Skills: axiom-watchos (platform-basics, smart-stack-and-complications, watch-connectivity), axiom-integration, axiom-accessibility
Designing for watchOS
When to Use This Skill
Use when:
- Picking the right top-level navigation for a watchOS screen —
TabView(.verticalPage),NavigationSplitView, orNavigationStack - Placing toolbar buttons correctly (leading, trailing, bottom bar with up to 3 items)
- Adding full-color backgrounds that flow through navigation and tab transitions
- Designing for Always On — privacy, cadence, luminance, preview
- Reviewing a screen for glance-ability and vertical-scroll appropriateness
- Auditing a watchOS 10+ app for watchOS 26 Liquid Glass material consistency
Related Skills
- Use
platform-basics.mdfor app structure,@main, delegate adoption, andWKSupportsAlwaysOnDisplayInfo.plist key - Use
smart-stack-and-complications.mdfor widget layouts on the watch face and Smart Stack - Use
axiom-accessibilityfor general VoiceOver / Dynamic Type guidance; watchOS-specific a11y (rotor, AssistiveTouch, Double Tap) lives inaxiom-accessibility/skills/watchos-a11y.md - Use
axiom-swiftuifor cross-platform SwiftUI state, layout, and animation primitives
Core Principle
Glanceable first, vertical by default, fewest taps to the destination. watchOS 10 redesigned the OS around vertical scrolling with the Digital Crown and single-screen views. watchOS 26 layers Liquid Glass materials on top. Design for two seconds of attention — anything that needs a third tap probably belongs on iPhone.
Pick the Right Navigation Primitive
digraph nav {
start [label="What does this screen do?" shape=diamond];
tabs [label="TabView\n.tabViewStyle(.verticalPage)" shape=box];
split [label="NavigationSplitView" shape=box];
stack [label="NavigationStack" shape=box];
start -> tabs [label="2-5 sibling views, crown scrolls between"];
start -> split [label="Source list → detail (one at a time)"];
start -> stack [label="Hierarchical drill-down\nor more than 5 tabs"];
}| Primitive | Use when | Avoid when |
|---|---|---|
TabView(.verticalPage) | 2–5 peer views, mostly single-screen, Digital Crown is primary nav | More than 5 peers; deep hierarchy |
NavigationSplitView | Source list → detail pattern (weather locations, chat threads, world clock) | One-off detail; no natural list pivot |
NavigationStack | Arbitrary hierarchy with drill-down; richer than "list → detail" | Flat peer views (TabView is better) |
TabView — vertical paging
@Binding var selected: Item
var body: some View {
TabView(selection: $selected) {
ForEach(Item.allCases) { item in
Text("\(item.title) tab")
}
}
.tabViewStyle(.verticalPage)
}The system draws the page indicator beside the Digital Crown. Mixing single-screen pages with one long ScrollView works — place the long page last. The dot expands to show scroll position inside the long view.
NavigationSplitView — source + detail
The watchOS NavigationSplitView shows one column at a time, like an iPhone in portrait. Selecting a list row animates to the detail; tapping the list-icon back-button returns.
@Binding var selected: Item?
var body: some View {
NavigationSplitView {
List(selection: $selected) {
ForEach(Item.allCases, id: \.self) { item in
NavigationLink(item.rawValue.uppercased(), value: item)
}
}
.containerBackground(.green.gradient, for: .navigation)
.listStyle(.carousel)
} detail: {
DetailView(selected: $selected)
}
}API rule that catches everyone once. List unwraps selected and matches its id. TabView doesn't — it compares the raw value to each child's tag. When a NavigationSplitView's detail is itself a TabView, wrap tags in Optional(item) so the types line up.
NavigationStack — hierarchy
@State var stack = [Int]()
var body: some View {
NavigationStack(path: $stack) {
Text("Main page")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: 2) {
Image(systemName: "chevron.right")
}
}
}
.navigationDestination(for: Int.self) { value in
Text("Second page")
}
}
}Keep the stack shallow. Use a large title on the root view, no title on any view where a back button is present.
Toolbar Placement
The toolbar is the only durable place to put buttons that survive scrolling. Placement rules:
| Placement | Capacity | Behavior |
|---|---|---|
.topBarLeading | 1 button | System may auto-insert back/list icon; place your own only when that slot is free |
.topBarTrailing | 1 button | Most common placement for a single action |
.bottomBar | Up to 3 buttons | Make the center button prominent with .controlSize(.large) + a capsule tint for a primary action |
.primaryAction | 1 button | Inline in scrolling view; hidden until user scrolls up; re-discovery is free |
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { /* action */ } label: { Image(systemName: "suit.heart") }
}
ToolbarItem(placement: .topBarTrailing) {
Button { /* action */ } label: { Image(systemName: "suit.club") }
}
ToolbarItemGroup(placement: .bottomBar) {
Button { /* action */ } label: { Image(systemName: "suit.diamond") }
Button { /* action */ } label: { Image(systemName: "star") }
.controlSize(.large)
.background(.red, in: Capsule())
Button { /* action */ } label: { Image(systemName: "suit.spade") }
}
}Full-Color Backgrounds
containerBackground(_:for:) paints the color behind the navigation bar, toolbar, and safe-area — without it, colors stop at the content bounds. The modifier supports gradients natively:
.containerBackground(.blue.gradient, for: .tabView)
.containerBackground(.green.gradient, for: .navigation)Use color to communicate, not decorate:
- Branding — instantly recognizable first frame
- Emotion — calming blue (Sleep), urgent orange (Timer complete)
- Spatial sense — Fitness uses black for the home, then red/green/blue for Move/Exercise/Stand
- Information at a glance — World Clock's solar gradients convey time of day without reading a number
- State transitions — Timer flips black → orange when done
Normal views use .background(alignment:content:). Views inside NavigationSplitView, NavigationStack, or TabView need containerBackground(_:for:) because the container owns the chrome area.
Matched Geometry Between Pages
Making a shared element flow between tabs creates a sense of place. Use matchedGeometryEffect with isSource pinned to the currently-visible page:
NavigationStack {
TabView(selection: $pageNumber) {
VStack {
Image(systemName: "books.vertical.fill")
.matchedGeometryEffect(
id: bookIcon, in: library,
properties: .frame,
isSource: pageNumber == 0)
Text("Books")
}
.tag(0)
VStack { BookList() }.tag(1)
}
.tabViewStyle(.verticalPage)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Image(systemName: "books.vertical.fill")
.matchedGeometryEffect(
id: bookIcon, in: library,
properties: .frame,
isSource: pageNumber != 0)
}
}
}The system animates the icon between positions in sync with the Digital Crown.
Material Vibrancy and Hierarchy
Lean on system materials — don't hand-roll blurs or transparency.
- System auto-adds a vibrant fill behind buttons and list cells
- Sheets and full-screen covers get a full-screen thin material automatically (lets the covered view show through as a place marker)
- Navigation bar gets a blur behind it
- Text foreground styles
.primary,.secondary,.tertiary,.quaternarygive a four-step hierarchy that stays legible over any background
Text(item.title)
.font(.headline)
.foregroundStyle(.primary)
Text(item.subtitle)
.foregroundStyle(.secondary)For prominent buttons, use .buttonStyle(.borderedProminent). That handles tint, material, and sizing correctly on Liquid Glass.
watchOS 26 specific. Toolbar and control styles were refreshed. Apps built for watchOS 10 and later pick up the new look automatically. Custom styles need an audit — verify legibility on the new materials. See modernization.md for the migration pattern.
Always On — Design for Two Brightness Levels
Always On is enabled by default for apps compiled on watchOS 8+. The system dims the display, updates at a reduced cadence, and keeps controls tappable so a tap wakes the app.
Frontmost vs background rules
| State | Display behavior | Who controls timing |
|---|---|---|
| Active (interacting) | Full brightness, full update rate | User |
| Frontmost-inactive (wrist down, app still shown) | Dimmed; default cadence reduced | User: Settings → General → Wake Screen → Return to Clock (max 1 hour custom per app) |
| Background with session (workout, audio) | Dimmed; reduced update frequency; app continues running | App (while session active) |
| Background suspended | Screen off unless another app is frontmost | — |
Respond to the two environments you care about
@Environment(\.isLuminanceReduced) private var isLuminanceReduced
@Environment(\.redactionReasons) private var redactionReasons
Text("Hello!")
.opacity(isLuminanceReduced ? 0.5 : 1.0)
if !redactionReasons.contains(.privacy) {
Text("Balance: \(balance)")
}Privacy-sensitive fields — blur automatically
The privacySensitive() modifier opts a view into auto-blur whenever redactionReasons contains .privacy:
Text("Account Number:")
.font(.headline)
Text(accountNumber)
.privacySensitive()Always blur highly sensitive data — balances, account numbers, health readings. Default to showing information that may or may not be sensitive (messages, appointments); users can disable Always On per-app if they prefer.
Match update cadence with TimelineView
TimelineView(PeriodicTimelineSchedule(from: Date(), by: 1.0/60.0)) { context in
switch context.cadence {
case .live: /* up to 60 updates/sec */
case .seconds: /* ~1 update/sec */
case .minutes: /* ~1 update/min */
@unknown default: fatalError()
}
}For non-timeline views, switch on scenePhase:
@Environment(\.scenePhase) private var scenePhase
var body: some View {
if scenePhase == .active {
// animations and subsecond updates
} else {
// low-frequency representation — static icon, nearest-second time
}
}Preview both appearances in Xcode
#Preview("Active") { ContentView() }
#Preview("Always On") {
ContentView()
.environment(\.isLuminanceReduced, true)
.environment(\.redactionReasons, [.privacy])
}Xcode previews do not auto-dim. To see the actual dim effect, run on a device or simulator with Toggle Always On.
Opt-out — rare and usually wrong
Set WKSupportsAlwaysOnDisplay = false in the Watch target's Info.plist only when the app has a legal or contractual reason to never display in the Always On state. This excludes Apple Watch SE and Series 4 and earlier by default — they never show Always On regardless.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Using NavigationView in new code | Deprecation warnings; broken path binding; missing toolbar placements | Use NavigationStack with a path: Binding<[T]> — see platform-basics.md |
More than 5 tabs in a TabView(.verticalPage) | Users lose orientation; page dots become unreadable | Use NavigationStack or NavigationSplitView for more than 5 peer views |
Adding a title to the detail view inside a NavigationSplitView | Back button loses space; detail feels boxed in | Omit the title; make the detail unmistakable at a glance |
Using .background(_:) for color inside a NavigationStack | Color stops at content bounds; nav bar stays system-default | Use containerBackground(_:for: .navigation) on the content view |
TabView and List both binding to the same selection without Optional wrapping | Tabs never activate on selection | List unwraps Optional<Selection>; TabView doesn't — wrap tags with Optional(item) |
| Leaving subsecond animations running during Always On | Battery drain; bouncing between cadences | Gate subsecond content on scenePhase == .active or TimelineView cadence .live |
| Showing raw balances or health readings during Always On | Information visible to casual observers; privacy complaint | .privacySensitive() on the specific text, or gate on redactionReasons.contains(.privacy) |
| Hand-rolled blur behind a custom nav bar | Looks right on watchOS 10 and breaks on watchOS 26 Liquid Glass | Use system toolbar; let the system provide the blur |
| Custom button styles that assume the old watchOS 9 material | Low-contrast buttons on watchOS 26 | Adopt .buttonStyle(.borderedProminent) or audit the style against Liquid Glass materials |
Resources
WWDC: 2023-10138, 2023-10031, 2022-10133, 2022-10051
Docs: /watchos-apps/creating-an-intuitive-and-effective-ui-in-watchos-10, /watchos-apps/designing-your-app-for-the-always-on-state, /swiftui/tabview, /swiftui/navigationsplitview, /swiftui/navigationstack, /swiftui/containerbackground, /swiftui/privacysensitive, /swiftui/matchedgeometryeffect, /swiftui/timelineview, /swiftui/scenephase, /design/human-interface-guidelines/designing-for-watchos
Skills: axiom-watchos (platform-basics, smart-stack-and-complications, modernization), axiom-swiftui, axiom-accessibility
Modernizing a watchOS Project
When to Use This Skill
Use when:
- Migrating a WatchKit storyboard /
WKExtensionDelegateproject to SwiftUI +WKApplicationDelegate - Converting a dual-target project (Watch App + WatchKit Extension) to a single-target app
- Replacing ClockKit complications with WidgetKit accessory widgets
- Adding an iOS companion to a watch-only project
- Planning an incremental migration when a full rewrite isn't viable
- Getting a legacy watchOS 6-era app ready for watchOS 26 submission (64-bit + SDK rule)
Related Skills
- Use
platform-basics.mdfor the target SwiftUI App /WKApplicationDelegateshape - Use
smart-stack-and-complications.mdfor the destination WidgetKit complication architecture - Use
controls-and-live-activities.mdfor watchOS 26 control surfaces that didn't exist in legacy projects - Use
design-for-watchos.mdfor the watchOS 10 navigation model to aim at after UIKit bridging - Use
axiom-swiftuifor general SwiftUI patterns - Use
axiom-shippingfor the April 2026 submission gate
Core Principle
Modernize deliberately, in four axes, on the way to watchOS 26. The targets, in order of leverage:
1. Dependent → independent app (the user expects it) 2. Dual-target → single-target (simplifies everything) 3. WatchKit storyboards → SwiftUI (cross-platform, modern) 4. ClockKit → WidgetKit complications (Smart Stack + shared code)
None of these are optional for new development on watchOS 10+. All four are achievable without a full rewrite.
Historical Timeline (Context)
TN3157's framing helps set expectations:
| Year | Milestone |
|---|---|
| 2015 | watchOS 1 launches with WatchKit + ClockKit |
| 2020 | watchOS 7 ships; WatchKit storyboards deprecated; SwiftUI is new baseline |
| 2022 | Xcode 14 removes storyboard template creation; introduces single-target watchOS apps |
| 2023 | watchOS 10 ships; ClockKit complications deprecated; WidgetKit replaces them; redesigned UI |
| 2026 | watchOS 26 SDK + 64-bit/ARM64 required for App Store submission (April 2026) |
"If you have an existing watchOS app, now is the time to get rid of the deprecated WatchKit storyboards and ClockKit complications, and adopt the modern features." — Apple, TN3157
Axis 1 — Dependent to Independent
If the watchOS app can't function when the iPhone isn't reachable, it's dependent. Apple's user-expectation line:
"Apple Watch users expect that the apps to just work, even when they don't have their iPhones with them." — Apple, TN3157
Switch to independent: project editor → Watch App target → General → Deployment Info → check "Supports Running Without iOS App Installation". Then:
- Move account creation to the watch (sign-in, authorization)
- Route data fetching through URLSession directly from the watch
- Demote
WCSessionusage from primary data path to opportunistic optimization (seewatch-connectivity.md) - Register for APNs directly on the watch; don't rely on iPhone push handoff
Full detail: platform-basics.md.
Axis 2 — Dual-Target to Single-Target
A dual-target app has a Watch App target and a WatchKit Extension target. Xcode's consolidation tool does most of the work:
1. Back up the project (full copy — rollback is otherwise painful) 2. Xcode → Editor → Validate Settings 3. Check "Project — Upgrade to a single-target watch app" 4. Perform Changes 5. If there are storyboards: re-point each interface controller's Class module to the watchOS app module (Identity inspector → Custom Class) 6. Delete the extension's Info.plist and other extension-only files 7. Clean up project-navigator groups
Xcode's tool performs the code-level swaps:
WKExtension→WKApplicationWKExtensionDelegate→WKApplicationDelegate
This swap is no longer optional cleanup: the 27 SDK formally deprecates WKExtension and WKExtensionDelegate for apps with a minimum deployment target of watchOS 9.2 or later (watchOS 27 release notes; the headers carry WK_DEPRECATED_WITH_REPLACEMENT(2.0, 9.2, "WKApplication")). Background-refresh scheduling is also deprecated in 27 in favor of BGTaskScheduler — see axiom-watchos (skills/background-and-networking.md).
- Merges Info.plist content (e.g., moves
CLKComplicationPrincipalClass+CLKComplicationSupportedFamiliesfrom the extension's plist to the app's) - Moves complication-controller code to the app target
Single-target minimum versions
| Requirement | Minimum |
|---|---|
| Single-target watchOS app | watchOS 7 |
| HealthKit authorization inheritance from companion iOS app | watchOS 9.2 |
If the app needs to support watchOS 9.1 or earlier and uses HealthKit, keep the dual-target configuration. The HealthKit-inheritance behavior requires watchOS 9.2+. Everyone else should migrate.
Axis 3 — WatchKit Storyboards to SwiftUI
SwiftUI is the only path forward — storyboards have been deprecated since 2020. Two viable migration strategies:
Strategy A — Full rewrite (recommended for smaller apps)
1. Add a @main SwiftUI App struct:
import SwiftUI
@main
struct MyWatchApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}2. If the old WKApplicationDelegate still needs to run (remote notifications, workout recovery, Now Playing), attach it:
@main
struct MyWatchApp: App {
@WKApplicationDelegateAdaptor var appDelegate: MyAppDelegate
var body: some Scene {
WindowGroup { RootView() }
}
}3. Rebuild each storyboard scene as a SwiftUI view. Use the patterns in design-for-watchos.md — NavigationStack, NavigationSplitView, TabView(.verticalPage) — rather than reimplementing the old paging model.
4. Delete the storyboard files after the last scene is migrated.
Strategy B — Incremental (for large apps)
Use WKHostingController to host SwiftUI views inside existing WatchKit interface controllers. Each screen can migrate independently:
class MySettingsController: WKHostingController<SettingsView> {
override var body: SettingsView {
SettingsView()
}
}Keep the storyboard, swap one scene at a time to a WKHostingController, and eventually delete the storyboard when every scene is SwiftUI.
If you also have WKExtensionDelegate
Migrate to WKApplicationDelegate as part of this step — it's a rename-and-move, not a rewrite. The two protocols have the same methods, and the app-delegate path integrates with SwiftUI via @WKApplicationDelegateAdaptor.
Axis 4 — ClockKit to WidgetKit Complications
The migration target is smart-stack-and-complications.md. This skill covers the transition mechanics.
The partial-migration trap
The single most important rule — from Apple, emphasized:
"As soon as your WidgetKit extension begins providing widget-based complications, the system disables your app's ClockKit complications. It no longer wakes your app to call your CLKComplicationDataSource object's methods to request timeline entries." — Apple, Migrating ClockKit complications to WidgetKit
There is no "both at once" state. The moment WidgetKit provides any complication, ClockKit stops running.
Rule: migrate every ClockKit complication to WidgetKit in a single release. A partial migration silently disables the remaining ClockKit complications on every user's device.
Migration steps
1. Add a Widget Extension target (watchOS tab → Widget Extension). Enable "Include Configuration App Intent" if the app supports multiple complication variants dynamically.
2. Build one WidgetKit widget for each existing ClockKit complication. Implement the three required TimelineProvider methods:
placeholder(in:)— returns a generic entry for redacted stategetSnapshot(in:completion:)— gate oncontext.isPreviewto show generic data in the picker; else return current live datagetTimeline(in:completion:)— returnsTimeline<Entry>with a reload policy
Example timeline entry:
struct CoffeeTrackerEntry: TimelineEntry {
let date: Date
let mgCaffeine: Double
let totalCups: Double
}3. Add `CLKComplicationWidgetMigrator` to the existing CLKComplicationDataSource:
extension ComplicationController: CLKComplicationWidgetMigrator {
func getWidgetConfiguration(
from complicationDescriptor: CLKComplicationDescriptor,
completionHandler: @escaping (CLKComplicationWidgetMigrationConfiguration?) -> Void
) {
// When the descriptor uses CLKDefaultComplicationIdentifier,
// ignore it and return the default widget kind.
// CLKComplicationWidgetMigrationConfiguration is an abstract base
// (init is NS_UNAVAILABLE) — construct a concrete subclass.
// Static widgets use CLKComplicationStaticWidgetMigrationConfiguration;
// intent-configured widgets use CLKComplicationIntentWidgetMigrationConfiguration.
let config = CLKComplicationStaticWidgetMigrationConfiguration(
kind: "com.example.app.coffee-caffeine",
extensionBundleIdentifier: widgetExtensionBundleID
)
completionHandler(config)
}
}When a user updates the app, watchOS uses the migrator to map existing ClockKit complications on watch faces to the new WidgetKit complications — preserving the user's watch-face customization.
4. Bundle multiple complications via WidgetBundle:
@main
struct ComplicationBundle: WidgetBundle {
var body: some Widget {
CaffeineComplication()
TotalCupsComplication()
CombinedComplication()
}
}5. Remove the ClockKit target and code once every complication is migrated. The migrator stays around for as long as the deployment target supports users who installed while ClockKit was live.
Budget changes from ClockKit to WidgetKit
| ClockKit budget | WidgetKit budget |
|---|---|
| Custom per-data-source negotiation with the system | Up to 75 timeline reloads per day per complication, weighted by visibility on an active watch face |
Complications on the active face tend toward the higher end (~75); hidden ones get fewer. Design timelines with enough entries that the 75/day budget gives adequate freshness without needing explicit reloads.
From Watch-Only to Watch+iOS Companion
Adding an iOS companion to a watch-only app is a one-way change — you can't roll back to watch-only after the iOS app ships:
1. Back up the project 2. Xcode → Add a new iOS app target 3. Signing & Capabilities → pick team, set bundle ID. Critical: the iOS bundle ID must be the prefix of the watchOS bundle ID (e.g., com.yourco.coffee vs com.yourco.coffee.watchkitapp) 4. General tab → Frameworks, Libraries, and Embedded Content → add the watchOS app as embedded content of the iOS app 5. Confirm the watchOS app's WKCompanionAppBundleIdentifier matches the iOS app's bundle ID 6. (Optional) Set WKRunsIndependentlyOfCompanionApp = NO in the watchOS Info.plist if Xcode should install the iOS app automatically during watch-app runs
The result is an independent watchOS app with a companion iOS app — which is the ideal modern shape (see platform-basics.md).
Migration Sequencing
digraph seq {
start [label="Audit current project" shape=ellipse];
arm [label="Standard Architectures ON\n(64-bit/ARM64)" shape=box];
single [label="Consolidate to\nsingle-target" shape=box];
swiftui [label="Storyboards → SwiftUI\n(full or incremental)" shape=box];
indep [label="Enable independent mode\n+ APNs direct" shape=box];
widget [label="ClockKit → WidgetKit\n(all complications in one release)" shape=box];
ship [label="Submit on watchOS 26 SDK" shape=ellipse];
start -> arm -> single -> swiftui -> indep -> widget -> ship;
}Order matters: fix the build architecture first (submission hard gate), then the project shape (so everything builds cleanly against a single target), then the UI (where the work is), then independence + complication migration (which share the widget-extension infrastructure), then submit.
When to Use the modernization-helper Agent
The Axiom modernization-helper agent (see /agents/modernization-helper.md) scans for deprecated APIs across the codebase — WKExtensionDelegate, NavigationView, CLKComplicationDataSource without a migrator, WKHostingController left after full migration, and similar. Run it after each axis to catch residue:
/axiom:modernizeCommon Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Migrating half of the ClockKit complications to WidgetKit | Remaining ClockKit complications silently stop updating on every user device | Migrate every complication in a single release; use CLKComplicationWidgetMigrator for watch-face customization preservation |
| Consolidating a HealthKit dual-target app to single-target on watchOS < 9.2 | HealthKit permissions no longer inherited from iOS companion; users get re-prompted | Keep dual-target if supporting watchOS 9.1 or earlier with HealthKit |
Skipping @WKApplicationDelegateAdaptor after migrating to SwiftUI App | Remote notifications and workout recovery stop working | Attach the delegate to the SwiftUI App; delete it only if the app truly doesn't need any delegate callback |
Building a new WidgetKit extension and not setting up CLKComplicationWidgetMigrator | User's watch-face customization disappears on update; users have to re-add complications | Always implement the migrator before shipping the WidgetKit replacement |
| iOS companion bundle ID not a prefix of watchOS bundle ID | Watch app won't install from the iOS app | Rename so iOS is com.yourco.app and watchOS is com.yourco.app.watchkitapp |
Keeping WKHostingController wrappers after every scene is SwiftUI | Storyboard infrastructure lingers and blocks full single-target consolidation | Once all scenes are SwiftUI, delete the storyboard, the hosting controllers, and the WatchKit extension target |
Relying on NavigationView after migration | Deprecation warnings; broken deep-link / state-restore paths | Use NavigationStack with path: Binding<[T]> — see design-for-watchos.md |
| Submitting a build on an older watchOS SDK after April 2026 | Rejection at App Store Connect | Audit WATCHOS_DEPLOYMENT_TARGET and SDK version; see axiom-shipping for the submission rules |
Forgetting to remove the WKWatchOnly = YES flag when adding an iOS companion | iOS app is built but watchOS target still claims watch-only distribution | Flip WKWatchOnly to NO on the Watch App target after adding an iOS companion |
Resources
WWDC: 2023-10029, 2022-10051, 2022-10050, 2020-10177
Docs: /technotes/tn3157-updating-your-watchos-project-for-swiftui-and-widgetkit, /widgetkit/converting-a-clockkit-app, /widgetkit/creating-accessory-widgets-and-watch-complications, /watchkit/wkapplication, /watchkit/wkapplicationdelegate, /swiftui/wkapplicationdelegateadaptor, /watchkit/wkhostingcontroller, /clockkit/clkcomplicationwidgetmigrator, /clockkit/clkcomplicationwidgetmigrationconfiguration, /clockkit/clkcomplicationstaticwidgetmigrationconfiguration, /clockkit/clkcomplicationintentwidgetmigrationconfiguration, /watchos-apps/creating-independent-watchos-apps
Skills: axiom-watchos (platform-basics, design-for-watchos, smart-stack-and-complications, controls-and-live-activities, watch-connectivity), axiom-swiftui, axiom-shipping
watchOS Platform Basics
When to Use This Skill
Use when:
- Starting a new watchOS app and need the project template, target structure, and
Info.plistkeys - Deciding between a watch-only app, a companion iOS app, or an independent app that ships in both forms
- Setting up the app entry point —
@main,App,WindowGroup,NavigationStack, delegate adoption - Preparing for the April 2026 watchOS 26 SDK and ARM64 submission deadlines
- Adding a
WKApplicationDelegateto handle workouts, Now Playing, extended runtime, or remote notifications - Wiring a custom notification long-look with
WKUserNotificationHostingController - Debugging types that behave differently on arm64 (
Float,Int, pointer math) - Adding Apple Intelligence / Foundation Models (Private Cloud Compute only) to a watch app
OS27
Related Skills
- Use
design-for-watchos.mdfor watchOS HIG, navigation model, and glanceable UX - Use
watch-connectivity.mdwhen coordinating state with a paired iPhone app - Use
background-and-networking.mdforbackgroundTask(_:action:), URLSession background, and TN3135 networking limits - Use
smart-stack-and-complications.mdfor WidgetKit complications, Smart Stack widgets, and RelevanceKit - Use
controls-and-live-activities.mdfor controls that land in Control Center and the Smart Stack - Use
modernization.mdfor WatchKit → SwiftUI and ClockKit → WidgetKit migration - Use
axiom-shippingfor App Store Connect submission specifics beyond the watchOS SDK gate - Use
axiom-healthwhen the app records workouts with HealthKit; Smart Stack suggests workout apps from routine - Use
axiom-aifor Foundation Models depth (sessions, @Generable, tools, PCC); this skill covers only watch scoping
Core Principle
Ship a SwiftUI-first, independent, 64-bit app built with the watchOS 26 SDK. That is the supported path as of watchOS 26 (April 2026 submission rule). Every other path — WatchKit storyboards, 32-bit builds, companion-only apps — is either deprecated or blocked at submission.
Submission Requirements (watchOS 26, April 2026)
Both rules are already announced by Apple:
| Rule | Effective | Details |
|---|---|---|
| 64-bit / ARM64 support required | April 2026 | Apple news, July 22, 2025. Use Xcode's default Standard Architectures build setting. |
| Built with watchOS 26 SDK or later | April 28, 2026 | Apple news, February 3, 2026. Same rule applies across iOS/iPadOS/tvOS/visionOS 26 SDKs. |
"Apple Watch Series 9 and later, and Apple Watch Ultra 2 now use the arm64 architecture on watchOS 26." — Apple, What's new in watchOS 26
"Xcode has supported building Apple Watch apps for the arm64 architecture since Xcode 14… If you're already building with standard architectures, you're already building for arm64." — Apple, What's new in watchOS 26
What to verify before submission
- Build setting on every Watch target is
Standard Architectures, not a locked legacy setting - Audit
Float,Int, and pointer-based math — behavior differs on arm64 vs armv7k - Run on a device (Apple Watch Series 9, Series 10, Ultra 2) in addition to the simulator; the simulator always uses arm64 on Apple Silicon and can hide device-only issues
Project Structure — Three Models
Apple ships one Xcode template with three valid configurations. Pick the right one once; switching later means editing deployment info and resigning.
| Model | Template | WKRunsIndependentlyOfCompanionApp | When to use |
|---|---|---|---|
| Watch-only | "Watch-only App" | Auto (no companion exists) | Wrist-first apps with no iPhone surface |
| Paired companion | "App" + watchOS target added to iOS project | false | Existing iPhone app where the watch is a remote UI |
| Independent + companion | Same as above, with box checked | true | Paired-or-alone — the safe default for new work |
"Independent + companion" is the recommended default. It gives users the choice, works in Family Setup, and matches the Smart Stack workflow where people add controls from an iPhone app onto a Watch without having a Watch app installed.
"Independent watchOS apps can't rely on the Watch Connectivity framework to transfer data or files from a companion iOS app." — Apple, Creating independent watchOS apps
What an independent app must handle itself:
- Account creation and sign-in on the watch
- System permission prompts on the watch
- Data downloads over the network — no Watch Connectivity fallback
- Push notification registration, including complication pushes
Enable independence on an existing target: project editor → Watch App target → General → Deployment Info → check "Supports Running Without iOS App Installation".
Canonical App Entry Point
SwiftUI App protocol, no storyboard, no WatchKit Extension. Every new watchOS app should start from this shape:
import SwiftUI
@main
struct MyWatch_Watch_App: App {
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
}
}
}
}The @main attribute marks the entry point — an app has exactly one. The WindowGroup wraps a NavigationStack that provides the stack + title area. SwiftUI automatically composes scenes into a compound scene. (NavigationView is deprecated since watchOS 9 — use NavigationStack.)
Why SwiftUI over WatchKit
"On watchOS, SwiftUI gives you considerably more freedom, power, and control than user interfaces laid out and designed in a storyboard. For example, List has a number of features that aren't supported by WKInterfaceTable, such as the platter style, swipe actions, and row reordering." — Apple, Building a watchOS app
Start new projects on SwiftUI. Use modernization.md if an existing WatchKit app needs a migration plan.
Adding Notification Scenes
Every notification category that needs a custom long-look gets a WKNotificationScene in the App's body:
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
}
}
WKNotificationScene(controller: NotificationController.self, category: "myCategory")
}The controller subclasses WKUserNotificationHostingController and drives a SwiftUI view with the notification's content:
import SwiftUI
import UserNotifications
class NotificationController: WKUserNotificationHostingController<NotificationLongLook> {
var content: UNNotificationContent!
var date: Date!
override var body: NotificationLongLook {
NotificationLongLook(content: content, date: date)
}
override class var isInteractive: Bool { true }
override func didReceive(_ notification: UNNotification) {
content = notification.request.content
date = notification.date
}
}isInteractive decides whether the system shows action buttons. didReceive(_:) is the only hand-off point from UNNotification to your SwiftUI state.
SwiftUI Event Handling — What It Covers
For most lifecycle work, SwiftUI's environment values and view modifiers replace the old app-delegate callbacks:
| Need | SwiftUI hook |
|---|---|
| Foreground / background / inactive transitions | @Environment(\.scenePhase) + .onChange(of: scenePhase) |
Handoff / NSUserActivity | .onContinueUserActivity(_:perform:) |
| Background refresh, snapshot, URLSession background delivery | .backgroundTask(_:action:) |
Reach for an App delegate only when SwiftUI can't cover the need — the list is specific.
When You Still Need an App Delegate
Adopt WKApplicationDelegate and wire it with @WKApplicationDelegateAdaptor:
import SwiftUI
import WatchKit
@main
struct MyWatch_Watch_App: App {
@WKApplicationDelegateAdaptor var appDelegate: MyAppDelegate
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
}
}
WKNotificationScene(controller: NotificationController.self, category: "myCategory")
}
}The delegate is the only path for these events — SwiftUI doesn't expose them:
applicationDidFinishLaunching()(when truly needed — most apps don't need it)userInfodictionaries from handoff or complications- Remote Now Playing activity
- Workout configurations and recovery (crash-resilient workout continuation)
- Extended runtime sessions
- Registration of remote notifications (APNs device token)
If none of those apply, skip the delegate entirely. Do not add an empty delegate "just in case" — it's one more thing to break on arm64.
Info.plist Keys That Matter
The Xcode template sets these; know what they mean when reviewing an existing project:
| Key | Purpose |
|---|---|
WKWatchKitApp (Bool) | Marks this bundle as a watchOS app |
WKAppBundleIdentifier | Bundle ID of the watchOS app |
WKCompanionAppBundleIdentifier | Paired iOS app bundle ID (companion configs only) |
WKExtensionDelegateClassName | Legacy WatchKit Extension delegate class name — SwiftUI apps usually don't need it |
WKRunsIndependentlyOfCompanionApp (Bool) | Set true for independent or independent+companion apps |
WKWatchOnly (Bool) | Set true for watch-only apps with no iOS target |
WKRunsIndependentlyOfCompanionApp = YES + WKWatchOnly = NO is the "independent + companion" shape. WKWatchOnly = YES is the watch-only shape.
Apple Intelligence on watchOS OS27
Foundation Models reaches watchOS in 27 — via Private Cloud Compute only. The on-device SystemLanguageModel is explicitly watch-unavailable; LanguageModelSession is watchOS 27.0 and runs against PrivateCloudComputeLanguageModel. That makes every watch FM feature network-dependent: request the PCC entitlement, gate on availability, watch the quota, and design a non-AI fallback.
import FoundationModels
let model = PrivateCloudComputeLanguageModel()
switch model.availability {
case .available:
let session = LanguageModelSession(model: model)
// prompt as usual — see axiom-ai for session patterns
case .unavailable(let reason):
showNonAIFallback(reason) // .deviceNotEligible or .systemNotReady
// (network failures surface as request-time errors)
}
// Quota is real: PCC requests are budgeted per app
let usage = model.quotaUsage // .status, optional limitIncreaseSuggestion + resetDate
let tokens = try? await model.contextSize // async throwing; context window sizeWatch-relevant facts (verified against the watchOS 27 SDK headers):
| Fact | Detail |
|---|---|
| PCC only | SystemLanguageModel is @available(watchOS, unavailable) — there is no on-device text model on watch |
| Entitlement | PCC is entitlement-gated — without the Private Cloud Compute entitlement the model reports unavailable (see axiom-ai) |
| Quota | quotaUsage.status plus optional limitIncreaseSuggestion / resetDate |
| Context | contextSize is an async throwing property; supportedLanguages / supportsLocale(_:) for locale gating |
| Vision tools | BarcodeReaderTool is watchOS 27; OCRTool is watch-unavailable (_Vision_FoundationModels overlay) |
| Beta caveats | Per the watchOS 27 beta release notes: PCC might not work in simulators (test on a physical device); @Generable on enums fails to compile for watchOS; PrivateCloudComputeLanguageModel is greedy-decoding-only |
Full Foundation Models guidance (sessions, @Generable, tools, PCC depth) lives in axiom-ai (skills/foundation-models-ref.md), "Private Cloud Compute" section. This section covers only the watch-specific scoping.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Leaving the target on armv7k after enabling Standard Architectures via project-level settings | App Store submission rejected starting April 2026; crashes on device because the build links the wrong binary | Confirm Standard Architectures is set on every Watch target individually, not just at project level; rebuild |
| Treating the simulator as sufficient arm64 testing | Device crashes that never reproduce in the simulator — the simulator always uses arm64 on Apple Silicon and masks armv7k-only defects in legacy code | Test on a physical Apple Watch Series 9 / 10 / Ultra 2 running watchOS 26 before submission |
Forcing pointer arithmetic through Int casts | Misaligned reads, intermittent crashes on device | Audit Float, Int, and pointer-based math per Apple's arm64 guidance; use typed pointer APIs |
| Starting a new app as "Watch App with Companion iOS App" without checking "Supports Running Without iOS App Installation" | App fails Family Setup; won't install on a watch whose paired iPhone lacks the iPhone app | Check the box at project creation — independent+companion is the safe default |
Building an independent app that still relies on WCSession.transferFile as the primary data path | No data on Family Setup watches or unpaired watches; silent sync failures | Use URLSession + auth token directly from the watch; reserve Watch Connectivity for optimization when a paired iPhone is online |
Empty WKApplicationDelegate adopted "just in case" | Nothing breaks immediately, but obscures whether the app needs delegate callbacks and costs one more build-target dependency | Remove the delegate until a specific event (workout recovery, remote notifications, Now Playing) actually requires it |
| Custom toolbar/control styles that haven't been audited against Liquid Glass | Inconsistent appearance against watchOS 26 system style; hard-to-read elements on new materials | Run the app on watchOS 26 and verify every custom style, or drop the custom styling and adopt the new defaults |
| Assuming WWDC 2025-334's "Controls on Apple Watch" story means you must build a Watch app | Time spent building a Watch target for an action that works fine as an iPhone-side control surfaced on the watch | Controls from iPhone apps appear on Apple Watch even without a Watch app — see controls-and-live-activities.md |
Resources
WWDC: 2025-334, 2025-219, 2024-10205, 2023-10138, 2022-10133, 2026-241
Docs: /watchos-apps/building_a_watchos_app, /watchos-apps/setting-up-a-watchos-project, /watchos-apps/creating-independent-watchos-apps, /swiftui/app, /swiftui/scene, /swiftui/windowgroup, /watchkit/wkapplicationdelegate, /swiftui/wkapplicationdelegateadaptor, /swiftui/wknotificationscene, /watchkit/wkusernotificationhostingcontroller, /foundationmodels/privatecloudcomputelanguagemodel
Skills: axiom-watchos (design-for-watchos, watch-connectivity, background-and-networking, smart-stack-and-complications, controls-and-live-activities, modernization), axiom-shipping, axiom-health, axiom-ai
Smart Stack and Complications
When to Use This Skill
Use when:
- Building watch complications —
accessoryCircular,accessoryRectangular,accessoryInline,accessoryCorner, orAccessoryWidgetGroup - Deciding between a timeline widget and the new watchOS 26 relevant widget for Smart Stack placement
- Adopting RelevanceKit's
RelevantContext(the watchOS 26.date(interval:kind:)/.location(category:)additions) together with WidgetKit's existingWidgetRelevance/WidgetRelevanceAttribute - Making a widget or control configurable from the watch face or Smart Stack
- Adding APNs push updates to watch widgets (new in watchOS 26)
- Deduplicating cards when both a timeline and a relevant widget render the same event
- Planning a ClockKit → WidgetKit complication migration
Related Skills
- Use
platform-basics.mdfor overall app structure and Info.plist keys - Use
controls-and-live-activities.mdfor controls (which share Smart Stack real estate) and Live Activities on watch - Use
modernization.mdfor the ClockKit → WidgetKit migration checklist — this skill covers the target architecture - Use
watch-connectivity.mdfortransferCurrentComplicationUserInfoas a wake-on-change signal - Use
background-and-networking.mdfor widget timeline refresh strategies - Use
axiom-integrationfor general widget / App Intents patterns shared with iOS
Core Principle
Complications, widgets, Live Activities, and controls all share the Smart Stack on watchOS 26. Pick the right surface by primary purpose, not by habit. Then use RelevanceKit so the system shows your content when it matters.
"The Smart Stack now supports Controls, Widgets, and Live Activities. With so many ways to show content in the Smart Stack, it can be hard to decide which one to choose. It's helpful to consider the primary purpose." — Apple, What's new in watchOS 26
| Primary purpose | Surface |
|---|---|
| Perform a quick action (change setting, trigger a device) | Control — see controls-and-live-activities.md |
| Display info throughout the day (weather, upcoming event) | Widget (timeline or relevant) |
| Event with a clear start and end (flight, sports match) | Live Activity |
Complication Surfaces
Four watchOS complication families, plus one grouping view:
| Widget family | Placement | Typical content |
|---|---|---|
accessoryCircular | Corner, sub-dial, Modular Compact slot | One metric (steps, battery, next event time) |
accessoryRectangular | Modular large, Infograph rectangular slot | Two-to-three lines of text with optional icon |
accessoryInline | Inline band above/below watch face | Single line, system-tinted |
accessoryCorner | Corner of Infograph face only | Curved text + gauge |
AccessoryWidgetGroup | Wraps three circular views with a shared label | Bundled multi-metric complication |
Use the standard WidgetKit pattern — StaticConfiguration or AppIntentConfiguration, a TimelineProvider / AppIntentTimelineProvider, and a SwiftUI view. Apple's important migration rule:
"As soon as you offer a widget-based complication, the system stops calling ClockKit APIs." — Apple, Creating accessory widgets and watch complications
Offer a WidgetKit complication for every ClockKit complication you currently ship, in a single release — a partial migration silently breaks the ClockKit ones.
Smart Stack Basics
The Smart Stack surfaces widgets contextually — rotate the Digital Crown above the watch face and the system shows what it predicts is relevant. Widgets compete for placement; the system picks using signals you provide.
Two signal paths:
1. Timeline widget + `RelevanceConfiguration` — you compute timeline entries as usual, and supply an associated RelevanceConfiguration that hints when each entry matters. 2. Relevant widget (watchOS 26) — a new configuration type that generates entries on demand when a RelevantContext matches. Multiple views can appear simultaneously.
The relevant widget is the better tool when multiple instances of the same widget might be useful at the same time (three overlapping calendar events, two upcoming flights, four scheduled reminders). The timeline widget is still right for steady content (hourly weather, step count).
RelevanceKit (watchOS 26)
RelevanceKit tells the system when a widget matters. Contexts cover date, sleep schedule, fitness state, location — and on watchOS 26, points of interest by MapKit category.
Two frameworks, don't conflate them. Only RelevantContext lives in RelevanceKit, and only its .date(interval:kind:) / .location(category:) overloads are new in watchOS 26.0. The wrapper types WidgetRelevance<Configuration> and WidgetRelevanceAttribute<Configuration> are WidgetKit types that have shipped since iOS 18 / macOS 15 / watchOS 11 (visionOS 26; tvOS unavailable) — they are not new in watchOS 26 and are not part of RelevanceKit. You build the WidgetKit WidgetRelevance wrapper around the watchOS-26 RelevantContext cases.
RelevantContext types
.date(interval:kind:)— happening now or soon.location(category:)— at a specific MapKit point-of-interest type- plus sleep-schedule, fitness, and other built-in contexts
Location-based widget relevance
func relevance() async -> WidgetRelevance<Void> {
guard let context = RelevantContext.location(category: .beach) else {
return WidgetRelevance<Void>([])
}
return WidgetRelevance([WidgetRelevanceAttribute(context: context)])
}RelevantContext.location(category:) returns nil if the category isn't supported — guard, don't force-unwrap.
Relevant Widgets — the watchOS 26 Pattern
Think of a relevant widget as a multi-card version of a timeline widget. Three roles:
| Type | Role | Analog in timeline widgets |
|---|---|---|
RelevanceEntry | Data for one card | TimelineEntry |
RelevanceEntriesProvider | Builds entries + declares when the widget is relevant | TimelineProvider / AppIntentTimelineProvider |
RelevanceConfiguration | Glues provider + view into a Widget body | StaticConfiguration / AppIntentConfiguration |
Full example — beach events calendar
// 1. Relevance provider — tells the system when the widget is relevant
struct BeachEventRelevanceProvider: RelevanceEntriesProvider {
let store: BeachEventStore
func relevance() async -> WidgetRelevance<BeachEventConfigurationIntent> {
let events = store.upcomingEvents()
let attributes = events.map { event in
WidgetRelevanceAttribute(
configuration: BeachEventConfigurationIntent(event: event),
context: .date(interval: event.dateInterval, kind: .default)
)
}
return WidgetRelevance(attributes)
}
func entry(
configuration: BeachEventConfigurationIntent,
context: Context
) async throws -> BeachEventRelevanceEntry {
if context.isPreview {
return .previewEntry
}
return BeachEventRelevanceEntry(event: configuration.event)
}
func placeholder(context: Context) -> BeachEventRelevanceEntry {
.placeholderEntry
}
}
// 2. The widget
struct BeachEventWidget: Widget {
private let store = BeachEventStore.shared
var body: some WidgetConfiguration {
RelevanceConfiguration(
kind: "BeachEventWidget",
provider: BeachEventRelevanceProvider(store: store)
) { entry in
BeachWidgetView(entry: entry)
}
.configurationDisplayName("Beach Events")
.description("Events at the beach")
}
}The pattern:
1. relevance() returns a WidgetRelevance describing each card's WidgetRelevanceAttribute (configuration + context). 2. entry(configuration:context:) receives a single attribute's configuration and returns the entry for that card. Check context.isPreview for previews. 3. placeholder(context:) returns a skeleton entry while data loads. 4. RelevanceConfiguration ties the provider to a view closure.
Deduplicating Timeline + Relevant Widgets
If a user has a timeline widget in their Smart Stack and your relevant widget would match, the system may show two cards for the same event. Associate them so the system replaces the timeline widget with the relevant cards when relevance applies:
struct BeachEventWidget: Widget {
var body: some WidgetConfiguration {
RelevanceConfiguration(kind: "BeachEventWidget", provider: provider) { entry in
BeachWidgetView(entry: entry)
}
.associatedKind(WidgetKinds.beachEventsTimeline)
}
}associatedKind(_:) hands the system the timeline widget's kind string; when the relevant widget has cards to show, the timeline card steps aside.
Configurable Widgets (watchOS 26)
Starting in watchOS 26, users can customize widgets and controls on the watch face and Smart Stack the same way they do on iOS. Declare your widget as configurable by returning an empty recommendations array:
struct BeachWidgetProvider: AppIntentTimelineProvider {
func recommendations() -> [AppIntentRecommendation<BeachConfigurationIntent>] {
if #available(watchOS 26, *) {
// Empty array signals the widget is user-configurable
return []
} else {
// Pre-watchOS 26: return actual preconfigured options
return recommendedBeaches
}
}
}Controls are configurable via AppIntentControlConfiguration + AppIntentControlValueProvider:
struct ConfigurableMeditationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: WidgetKinds.configurableMeditationControl,
provider: Provider()
) { value in
// Provide the control's content using `value`
}
.displayName("Ocean Meditation")
.description("Meditation with optional ocean sounds.")
.promptsForUserConfiguration()
}
}
extension ConfigurableMeditationControl {
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
// Value shown in the add sheet
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
// Live value for this configuration
}
}
}Workout-App Suggestions
Apple-made feature you get for free if you do it right:
"If your Watch app uses HealthKit to record workouts, it may be suggested in the Smart Stack based on a person's routine." — Apple, What's new in watchOS 26
Requirements:
- Specify the correct
HKWorkoutActivityTypeon each workout session - Record accurate start and end times — not approximate, not front-loaded
- Attach location data via
HKWorkoutRouteBuilderwhen applicable
The system uses that data to predict when to suggest launching the app.
Widget Push Updates via APNs (watchOS 26)
"Beginning in watchOS 26, you can send push updates to widgets using APNs. Widget push updates are supported for all widgets on all Apple platforms that support WidgetKit." — Apple, What's new in watchOS 26
Push updates are the right tool when data changes unpredictably (score change, incoming message, status flip). See What's new in widgets (WWDC25). The rule in watch-connectivity.md still applies — Watch Connectivity complication transfers are a 50/day budget; push widgets via APNs when frequency matters.
Previewing Relevant Widgets
Three preview levels, each for a different development stage:
// 1. View-only preview — layout check across sizes
#Preview("Entries") {
BeachEventWidget()
} relevanceEntries: {
BeachEventRelevanceEntry.previewShorebirds
BeachEventRelevanceEntry.previewMeditation
}
// 2. Provider + relevance — verify entry generation
#Preview("Provider and Relevance") {
BeachEventWidget()
} relevanceProvider: {
BeachEventRelevanceProvider(store: .preview)
} relevance: {
let configurations: [BeachEventConfigurationIntent] = [
.previewSurfing,
.previewMeditation,
.previewWalk
]
let attributes = configurations.map {
WidgetRelevanceAttribute(
configuration: $0,
context: .date($0.event.startDate, kind: .default)
)
}
return WidgetRelevance(attributes)
}
// 3. Full provider preview — final pass
#Preview("Provider") {
BeachEventWidget()
} relevanceProvider: {
BeachEventRelevanceProvider(store: .preview)
}ClockKit Migration — Summary Only
ClockKit complications still work on watchOS 8 and earlier. From watchOS 9 onward, the target is WidgetKit. The single migration rule to know in this skill: the moment any WidgetKit complication is offered, ClockKit callbacks stop firing. Plan to migrate every complication in the same release — partial migration silently disables ClockKit.
Full migration workflow, including parallel-support patterns for older watchOS versions, is in modernization.md.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Force-unwrapping RelevantContext.location(category:) | Crash when the category is unsupported on the device | guard let context = RelevantContext.location(category: ...) and return an empty WidgetRelevance |
| Offering a WidgetKit complication while still relying on ClockKit callbacks | ClockKit complication silently stops updating on devices that install the update | Migrate all complications in a single release; modernization.md has the full checklist |
Missing associatedKind(_:) on a relevant widget that overlaps a timeline widget | Two cards appear for the same event in the Smart Stack | Call .associatedKind(timelineWidgetKind) on the RelevanceConfiguration |
| Returning non-empty recommendations while intending the widget to be configurable on watchOS 26 | Users see preconfigured options instead of the configuration UI | Wrap in if #available(watchOS 26, *) and return [] for the configurable path |
Relying on transferCurrentComplicationUserInfo as the primary refresh path at high frequency | Silent throttling past 50/day; updates stop | Move high-frequency updates to APNs widget push (watchOS 26+); reserve the Watch Connectivity budget for user-visible-change moments |
Using accessoryCorner on non-Infograph faces | No card appears; budget wasted | accessoryCorner is Infograph-only; offer the other three accessory families too |
Shipping a relevant widget without context.isPreview handling | Preview sheet shows placeholder or real user data instead of the preview | Return a dedicated .previewEntry branch inside entry(configuration:context:) |
Returning stale or empty relevance() attributes | Widget never appears in the Smart Stack even when relevant | Populate WidgetRelevanceAttribute for every event that should surface, not just the next one |
Skipping HKWorkoutRouteBuilder for outdoor workouts | App not suggested in the Smart Stack for the user's routine | Attach route data on runs, walks, cycling; see axiom-health |
Resources
WWDC: 2025-334, 2025-278, 2023-10029, 2023-10309, 2023-10027, 2022-10050, 2022-10051
Docs: /widgetkit/creating-accessory-widgets-and-watch-complications, /widgetkit/converting-a-clockkit-app, /widgetkit/widgets-and-complications-collection, /widgetkit/accessorywidgetgroup, /widgetkit/relevanceconfiguration, /widgetkit/relevanceentry, /widgetkit/relevanceentriesprovider, /widgetkit/widgetrelevance, /widgetkit/widgetrelevanceattribute, /relevancekit, /relevancekit/relevantcontext, /widgetkit/appintentcontrolconfiguration, /widgetkit/appintentcontrolvalueprovider, /widgetkit/appintentrecommendation
Skills: axiom-watchos (platform-basics, controls-and-live-activities, modernization, watch-connectivity, background-and-networking), axiom-integration, axiom-health
Watch Connectivity
When to Use This Skill
Use when:
- Picking between
updateApplicationContext,transferUserInfo,transferFile,transferCurrentComplicationUserInfo, andsendMessagefor a data hand-off - Setting up
WCSessionon both sides (iOS + watchOS) with aWCSessionDelegate - Completing a
WKWatchConnectivityRefreshBackgroundTaskcorrectly so the app doesn't blow its background-time budget - Updating a complication from the companion iPhone app
- Handling Family Setup and independent-app scenarios where the paired iPhone isn't always available
- Debugging why data doesn't arrive, arrives late, or crashes the watchOS app on wake
Related Skills
- Use
platform-basics.mdfor independent-app configuration andWKRunsIndependentlyOfCompanionApp - Use
background-and-networking.mdfor URLSession background tasks and TN3135 networking limits — which often replace Watch Connectivity entirely - Use
smart-stack-and-complications.mdfor widget timeline reloads driven by incoming transfers - Use
axiom-networkingfor URLSession patterns when the watch fetches directly
Core Principle
Watch Connectivity is an opportunistic optimization, never the primary data path. Apple's own guidance:
"In watchOS 6 and later, users may not install the iOS companion for their independent watchOS apps… you can't rely on WatchConnectivity as your only means of updating the watchOS app. Instead, use the WatchConnectivity framework as an opportunistic optimization, rather than the primary means of supplying fresh data." — Apple, Keeping your watchOS content up to date
Design the app to fetch from the network or CloudKit. Layer Watch Connectivity on top when the paired iPhone happens to be available and reachable.
Session Activation
One session singleton per process, activated once at launch on both sides:
import WatchConnectivity
final class ConnectivityProvider: NSObject, WCSessionDelegate {
static let shared = ConnectivityProvider()
override init() {
super.init()
guard WCSession.isSupported() else { return }
WCSession.default.delegate = self
WCSession.default.activate()
}
func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) { /* handle */ }
// iOS-only callbacks (required on iOS for pairing with multiple watches):
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) { }
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate() // reactivate for next watch
}
#endif
}Set the delegate before activating. On iOS, implementing both sessionDidBecomeInactive(_:) and sessionDidDeactivate(_:) is required to support multiple paired watches.
Choose the Right Transfer Method
Five methods, five jobs. Pick by primary purpose:
| Method | Queued | Overwrites prior | Wakes receiver | Best for |
|---|---|---|---|---|
updateApplicationContext(_:) | No | Yes — new context replaces old | Next launch | State snapshots where only the latest matters (current song, settings, last sync time) |
transferUserInfo(_:) | Yes | No — FIFO | Next launch (background) | Events that all matter in order (new messages, appointments, score updates) |
transferCurrentComplicationUserInfo(_:) | Yes | No — FIFO | Immediately (50/day limit) | Complication refresh triggers — the only method that wakes the watch for complications |
transferFile(_:metadata:) | Yes | No — FIFO | On receipt (background) | File payloads (images, audio clips, large JSON) |
sendMessage(_:replyHandler:errorHandler:) | No | — | Only if both apps are reachable/active | Live request-response while both apps run |
Decision tree
digraph pick {
start [label="What kind of data?" shape=diamond];
context [label="updateApplicationContext" shape=box];
userinfo [label="transferUserInfo" shape=box];
comp [label="transferCurrentComplicationUserInfo" shape=box];
file [label="transferFile" shape=box];
message [label="sendMessage" shape=box];
start -> context [label="Only latest matters"];
start -> userinfo [label="Every event matters, in order"];
start -> comp [label="Complication refresh trigger"];
start -> file [label="File payload > a few KB"];
start -> message [label="Live while both apps active"];
}updateApplicationContext — latest-wins state
try WCSession.default.updateApplicationContext([
"lastSync": Date().timeIntervalSince1970,
"trackTitle": currentTrack.title,
])Receiver implements session(_:didReceiveApplicationContext:). If three updates are queued while the receiver sleeps, only the newest arrives on wake.
transferUserInfo — ordered queue
let transfer = WCSession.default.transferUserInfo([
"event": "new-message",
"id": messageID,
"text": messageText,
])Each call creates a WCSessionUserInfoTransfer. Check session.outstandingUserInfoTransfers to see what's still in flight; cancel a queued transfer with transfer.cancel() to avoid piling stale data on the receiver.
transferCurrentComplicationUserInfo — budgeted
if WCSession.default.isComplicationEnabled {
WCSession.default.transferCurrentComplicationUserInfo(payload)
}
let left = WCSession.default.remainingComplicationUserInfoTransfers // check budgetRate limit — 50 transfers per day per complication. Fixed in 27: the watchOS 27 release notes resolve "transferCurrentComplicationUserInfo does not work with complications built using WidgetKit on watchOS" (FB12819178) — on earlier releases this method did not work with WidgetKit complications. The receiver persists the payload (usually to shared UserDefaults via an App Group) and calls WidgetCenter.shared.reloadTimelines(ofKind:) so WidgetKit rebuilds the entries:
WidgetCenter.shared.getCurrentConfigurations { result in
if case .success(let list) = result {
for info in list {
WidgetCenter.shared.reloadTimelines(ofKind: info.kind)
}
}
}transferFile — background file transfer
let transfer = WCSession.default.transferFile(fileURL, metadata: ["kind": "image"])
// transfer.progress gives Progress for UIDelete the file after the transfer completes in session(_:didFinish:error:) — the file stays on disk until you remove it.
sendMessage — live only
WCSession.default.sendMessage(
["request": "nowPlaying"],
replyHandler: { reply in /* runs on background thread */ },
errorHandler: { error in /* WCSession not reachable or timed out */ }
)Requires isReachable == true on both sides. Reply handler must return quickly — the system times it out. On watchOS, sendMessage from the watch wakes a reachable companion iPhone app.
Always Complete Every Background Task
This is the single most common Watch Connectivity crash pattern. watchOS wakes the app for WKWatchConnectivityRefreshBackgroundTask to deliver queued transfers. If the app fails to call setTaskCompletedWithSnapshot(_:) on every task, the background-time budget drains — and the next time it runs out, the app crashes.
The correct shape: retain the tasks in an array, complete them when (a) your handler is done, (b) activationState settles to .activated, and (c) hasContentPending becomes false:
private var wcBackgroundTasks: [WKWatchConnectivityRefreshBackgroundTask] = []
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
if let wcTask = task as? WKWatchConnectivityRefreshBackgroundTask {
wcBackgroundTasks.append(wcTask)
} else {
task.setTaskCompletedWithSnapshot(false)
}
}
completeBackgroundTasks()
}
private var activationObs: NSKeyValueObservation?
private var pendingObs: NSKeyValueObservation?
func bootstrap() {
activationObs = WCSession.default.observe(\.activationState) { _, _ in
DispatchQueue.main.async { self.completeBackgroundTasks() }
}
pendingObs = WCSession.default.observe(\.hasContentPending) { _, _ in
DispatchQueue.main.async { self.completeBackgroundTasks() }
}
}
private func completeBackgroundTasks() {
guard WCSession.default.activationState == .activated,
!WCSession.default.hasContentPending else { return }
wcBackgroundTasks.forEach { $0.setTaskCompletedWithSnapshot(false) }
wcBackgroundTasks.removeAll()
}Reachability and Companion State
let s = WCSession.default
s.activationState // .notActivated / .inactive / .activated
s.isPaired // iOS only — is any watch paired
s.isWatchAppInstalled // iOS only — does the paired watch have the companion
s.isComplicationEnabled // is a complication on an active watch face
s.isReachable // both apps active and reachable right nowGuard every send against the right precondition. transferUserInfo works offline, but sendMessage fails if isReachable == false. Check isComplicationEnabled before spending one of the 50 daily complication transfers.
`isReachable` is a hint, not a delivery guarantee. It can read true while a sendMessage/sendMessageData still fails or never arrives — don't gate sends on it as proof of delivery. Always pass the errorHandler, and for data that must arrive use the queued/background APIs (transferUserInfo / updateApplicationContext / transferFile). For genuine real-time, low-latency needs when both devices share a network, a direct HTTP/SSE channel is a known escape hatch around Watch Connectivity's reliability limits.
App Group Required for Complication Updates
Watch Connectivity hands the payload to the watchOS app, but WidgetKit reads from the widget's own process. Share a container:
1. Enable App Groups on the watchOS app target and the widget target (same group identifier). 2. Write the incoming payload to UserDefaults(suiteName: "group.com.yourco.app") or to a file inside the shared container. 3. Call WidgetCenter.shared.reloadTimelines(ofKind:) so WidgetKit re-reads and regenerates timeline entries.
The widget's TimelineProvider reads the same shared storage when it generates entries.
Design for the Disconnected Watch
Independent apps ship without an iPhone companion in some configurations — or the companion isn't installed, or the phone is out of Bluetooth range. Watch Connectivity must degrade cleanly:
| Configuration | Behavior you must support |
|---|---|
| Independent app on Family Setup watch | No iPhone companion exists — Watch Connectivity never activates on the watch |
| Independent app, companion not installed | isWatchAppInstalled = false on the iPhone side; fall back to network/CloudKit |
| Paired watch, iPhone asleep / Bluetooth range exceeded | isReachable = false; queued transfers deliver on reconnection |
| LTE watch, iPhone off | isReachable = false; app should still fetch from the network directly |
Concrete pattern. Fetch primary data over URLSession or CloudKit. When WCSession.activationState == .activated and isReachable == true, opportunistically cache or refresh via Watch Connectivity. Never wait for a transfer to render core content.
Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Forgetting to setTaskCompletedWithSnapshot on every WKWatchConnectivityRefreshBackgroundTask | App crashes at random times after a Watch Connectivity wake; background time budget exhausted | Retain tasks in an array, complete them in handle(_:) and via KVO on activationState / hasContentPending |
Using sendMessage as the primary data sync | Updates fail silently when the companion app is suspended; reply timeout errors | Switch to transferUserInfo (queued, background-delivered) or updateApplicationContext (latest-wins) |
| Relying on Watch Connectivity as the only data path | Family Setup watches show empty; LTE watches away from iPhone show stale data | Fetch primary data over URLSession or CloudKit; use Watch Connectivity opportunistically |
Spamming transferCurrentComplicationUserInfo on every small change | Silent throttling past 50 transfers/day; complication stops updating | Batch updates; call remainingComplicationUserInfoTransfers to gate; fall back to widget push notifications (watchOS 26+) for higher frequency |
| Updating a widget without an App Group | Transfer arrives, but widget never shows new data | Enable App Groups on both targets; share via UserDefaults(suiteName:) or shared container file; call WidgetCenter.shared.reloadTimelines(ofKind:) |
Missing sessionDidBecomeInactive / sessionDidDeactivate on iOS | Second paired watch never receives data | Implement both on iOS; call WCSession.default.activate() in sessionDidDeactivate to reactivate |
Activating WCSession before assigning a delegate | Activation callback arrives before the delegate exists; events silently dropped | Assign the delegate, then activate — in that order |
Assuming sendMessage/sendMessageData errors fire at most once | Duplicate retries or duplicated side effects for a message that already succeeded | WC offers no exactly-once delivery and the error handler can fire more than once — tag each message with your own frame/message ID and dedupe (add an app-level ack when delivery must be confirmed) |
Overwriting applicationContext when ordered delivery is needed | Receiver misses events between wake intervals | Use transferUserInfo when every event matters, not updateApplicationContext |
Not deleting files after transferFile completes | Files accumulate on the sender's disk indefinitely | Remove the source file in session(_:didFinish:error:) |
Resources
WWDC: 2021-10003, 2018-218
Docs: /watchconnectivity, /watchconnectivity/wcsession, /watchconnectivity/wcsessiondelegate, /watchconnectivity/transferring-data-with-watch-connectivity, /watchos-apps/keeping-your-watchos-app-s-content-up-to-date, /widgetkit/widgetcenter, /watchkit/wkwatchconnectivityrefreshbackgroundtask
Skills: axiom-watchos (platform-basics, background-and-networking, smart-stack-and-complications), axiom-networking
Related skills
How it compares
Pick axiom-watchos over generic iOS skills when the task is specifically watchOS targets, complications, or WatchConnectivity—not general SwiftUI iPhone UI work.
FAQ
What does axiom-watchos scaffold for Apple Watch?
axiom-watchos scaffolds watchOS targets, complications, workout sessions, and WatchConnectivity flows when extending an iPhone app to Apple Watch. The skill targets developers adding a Watch companion without manually configuring every Xcode target, entitlement, and sync handler
When should developers use axiom-watchos?
Developers should use axiom-watchos when an iPhone app needs a Watch extension with complications, workout tracking, or phone-to-watch messaging. The skill fits existing iOS codebases where Watch support is the remaining build task rather than greenfield mobile projects.