●2.2553.0 — Claude Desktop and Cowork both shipped over the weekend. Connector and extension tools no longer fail when an optional argument is left out●COMPACT — The Messages API gained a compaction parameter. Behind the compact-2026-09-04 beta header, you can now compress a conversation on your own schedule●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, sixteen days away. Move your fleet past 1.15200.0 before switching orgPluginSettings to the array form●GH.LINK — Cowork shows the GitHub integration as authenticated, yet there is no way to use it: it never registers as a connector●NEW — You are paying for Pro but no charges appear in the Console. Here is where to look●KB5129195 — September Windows updates that stopped Cowork from reaching files are fixed by Microsoft KB5129195. No Claude Desktop update is needed●2.2553.0 — Claude Desktop and Cowork both shipped over the weekend. Connector and extension tools no longer fail when an optional argument is left out●COMPACT — The Messages API gained a compaction parameter. Behind the compact-2026-09-04 beta header, you can now compress a conversation on your own schedule●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, sixteen days away. Move your fleet past 1.15200.0 before switching orgPluginSettings to the array form●GH.LINK — Cowork shows the GitHub integration as authenticated, yet there is no way to use it: it never registers as a connector●NEW — You are paying for Pro but no charges appear in the Console. Here is where to look●KB5129195 — September Windows updates that stopped Cowork from reaching files are fixed by Microsoft KB5129195. No Claude Desktop update is needed
Building iOS 18 Widgets & App Intents with Claude Code: A Complete Implementation Guide
Accelerate WidgetKit and App Intents development with Claude Code. A hands-on guide covering interactive widgets, Live Activities, and Siri integration—with working code and real-world pitfalls.
The first wall you hit when building iOS 18 widgets is almost always the same: the timeline isn't updating. You set up the refresh schedule, rebuild the app, and the home screen just... stays the same. WidgetKit is notoriously hard to debug in the simulator alone—you end up chasing device logs, toggling background app refresh, and narrowing down the cause one hypothesis at a time.
Since integrating Claude Code into this process, my debugging cycles have shortened considerably. I paste the error log directly, ask "what's wrong with this timeline setup?", and get back answers that account for WidgetKit-specific constraints I might not have checked. This guide documents the practical knowledge I accumulated while implementing iOS 18 widget features with Claude Code as a coding partner—complete with working Swift code, pitfalls I actually hit, and the prompt patterns that helped me move faster.
WidgetKit Foundations and What Changed in iOS 18
The Timeline Model
Everything in WidgetKit revolves around the TimelineProvider. You give the system a sequence of TimelineEntry values—each paired with a date—and it renders the right entry at the right time. iOS 17 added interactive widgets (buttons, toggles), and iOS 18 expanded the Dynamic Island layout options and improved multi-widget coordination.
Understanding the three provider methods is essential before anything else works correctly:
import WidgetKitimport SwiftUI// The data unit your widget displays at each point in timestruct TaskEntry: TimelineEntry { let date: Date let taskCount: Int let nextDeadline: Date? let completionRate: Double let highPriorityTitles: [String]}struct TaskTimelineProvider: TimelineProvider { typealias Entry = TaskEntry // Shown immediately while the real data loads — keep it fast func placeholder(in context: Context) -> TaskEntry { TaskEntry( date: Date(), taskCount: 3, nextDeadline: Calendar.current.date(byAdding: .hour, value: 2, to: Date()), completionRate: 0.6, highPriorityTitles: ["Write report", "Review PR"] ) } // Used in the widget gallery — also needs to be fast // ⚠️ Slow snapshots make the gallery feel broken; reviewers see this too func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { let entry = TaskEntry( date: Date(), taskCount: 5, nextDeadline: Calendar.current.date(byAdding: .hour, value: 1, to: Date()), completionRate: 0.8, highPriorityTitles: ["Write report", "Review PR", "Ship release"] ) completion(entry) } // The real timeline — called when system needs fresh data func getTimeline(in context: Context, completion: @escaping (Timeline<TaskEntry>) -> Void) { var entries: [TaskEntry] = [] let now = Date() // ⚠️ UserDefaults.standard is NOT accessible from a widget extension // Always use App Group shared storage let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared") let taskCount = sharedDefaults?.integer(forKey: "taskCount") ?? 0 let completionRate = sharedDefaults?.double(forKey: "completionRate") ?? 0.0 let titles = sharedDefaults?.stringArray(forKey: "highPriorityTitles") ?? [] // Generate 15-minute interval entries for the next hour for minuteOffset in stride(from: 0, to: 60, by: 15) { guard let entryDate = Calendar.current.date( byAdding: .minute, value: minuteOffset, to: now ) else { continue } entries.append(TaskEntry( date: entryDate, taskCount: taskCount, nextDeadline: sharedDefaults?.object(forKey: "nextDeadline") as? Date, completionRate: completionRate, highPriorityTitles: titles )) } // Timeline refresh policies: // .atEnd — system calls getTimeline again after the last entry (default choice) // .after(date) — refresh at a specific future time (good for deadline-driven content) // .never — only refresh when you call WidgetCenter.shared.reloadTimelines manually let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) }}
When I gave this to Claude Code and asked for a "debugging checklist for when the timeline doesn't update," the response systematically covered App Group misconfiguration, missing Background App Refresh entitlement, iOS's per-widget throttle (roughly 40–70 refreshes per hour depending on device state), and the easily overlooked fact that getTimeline may not fire at all in Low Power Mode.
Interactive Widgets — Making Button Taps Work
iOS 17+ lets you add Button(intent:) and Toggle(isOn:intent:) directly to widget views. The action handler is an AppIntent, not a closure—this is what enables the system to run the intent in a sandboxed process without launching the full app.
import AppIntentsimport WidgetKit// The intent that runs when the user taps the widget buttonstruct CompleteTaskIntent: AppIntent { static var title: LocalizedStringResource = "Complete Task" static var description = IntentDescription("Marks a task as done from the widget") @Parameter(title: "Task ID") var taskId: String func perform() async throws -> some IntentResult { let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared") var completedIds = sharedDefaults?.stringArray(forKey: "completedTaskIds") ?? [] guard !completedIds.contains(taskId) else { return .result() // Already completed — idempotent } completedIds.append(taskId) sharedDefaults?.set(completedIds, forKey: "completedTaskIds") // ⚠️ You don't need to call WidgetCenter.shared.reloadTimelines here. // When an AppIntent is invoked from a widget, the system reloads that // widget's timeline automatically once perform() returns. Calling it // explicitly queues a second render pass, which is what causes the // brief flicker people report. What you *do* need is for the write to // shared storage to finish before perform() returns — see the section // on ordering below. return .result() }}// Widget view with interactive controlsstruct TaskWidgetView: View { let entry: TaskEntry var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Today's Tasks") .font(.caption) .foregroundStyle(.secondary) Spacer() Text("\(entry.taskCount)") .font(.title2.bold()) } ProgressView(value: entry.completionRate) .tint(entry.completionRate >= 0.8 ? .green : .orange) if let title = entry.highPriorityTitles.first { HStack { Text(title) .font(.caption2) .lineLimit(1) Spacer() // Interactive button — links to AppIntent, not a closure Button(intent: CompleteTaskIntent(taskId: "top-priority")) { Image(systemName: "checkmark.circle") .foregroundStyle(.green) } .buttonStyle(.plain) } } } .padding() // ⚠️ This modifier is required in iOS 17+ for interactive widgets to work // Missing it can silently disable button interactions .containerBackground(.fill.tertiary, for: .widget) }}
The most common question Claude Code gets about interactive widgets is: "The button tap works, but the UI doesn't update immediately." The answer involves explaining that WidgetCenter.shared.reloadTimelines is asynchronous and the system schedules the reload rather than performing it instantly. For cases where immediate visual feedback matters, an Optimistic UI pattern works well—Claude Code can generate the full implementation if you describe the desired behavior.
Live Activities — Dynamic Island and Lock Screen Updates
Defining the Activity Structure
Live Activities use ActivityKit to display real-time updates in the Dynamic Island and on the lock screen. They're ideal for anything that has a clear start, updates over time, and ends: delivery tracking, workout sessions, long builds, sports scores, cooking timers.
The key design decision is separating static data (set at start, never changes) from dynamic content state (updated throughout the activity):
import ActivityKitimport SwiftUIstruct BuildTaskAttributes: ActivityAttributes { // ContentState: changes during the activity life cycle public struct ContentState: Codable, Hashable { var progress: Double // 0.0 to 1.0 var currentStep: String var elapsedSeconds: Int var isComplete: Bool var errorMessage: String? // nil when everything is fine } // Static data: fixed at the time Activity.request is called let taskName: String let startedAt: Date let estimatedDurationSeconds: Int}// Starting a Live Activityfunc startBuildActivity(taskName: String, estimatedDuration: Int) -> Activity<BuildTaskAttributes>? { // Check authorization — user can disable Live Activities in Settings guard ActivityAuthorizationInfo().areActivitiesEnabled else { print("Live Activities are disabled. Ask user to enable in Settings → Notifications → Live Activities.") return nil } let attributes = BuildTaskAttributes( taskName: taskName, startedAt: Date(), estimatedDurationSeconds: estimatedDuration ) let initialState = BuildTaskAttributes.ContentState( progress: 0.0, currentStep: "Initializing", elapsedSeconds: 0, isComplete: false, errorMessage: nil ) // staleDate: past this time, the system may visually indicate stale data let content = ActivityContent( state: initialState, staleDate: Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ) do { // pushType: .token enables remote updates via APNs (useful for server-driven activities) // pushType: nil for purely local updates let activity = try Activity.request( attributes: attributes, content: content, pushType: .token ) print("Live Activity started with ID: \(activity.id)") return activity } catch { // ActivityKitError code 4 is one of the most common: // usually NSSupportsLiveActivities missing or wrong target print("Failed to start Live Activity: \(error)") return nil }}// Updating a running Live Activityfunc updateBuildActivity( _ activity: Activity<BuildTaskAttributes>, step: String, progress: Double, error: String? = nil) async { let elapsed = Int(Date().timeIntervalSince(activity.attributes.startedAt)) let updatedState = BuildTaskAttributes.ContentState( progress: min(progress, 1.0), currentStep: step, elapsedSeconds: elapsed, isComplete: progress >= 1.0, errorMessage: error ) await activity.update( ActivityContent( state: updatedState, staleDate: Calendar.current.date(byAdding: .minute, value: 5, to: Date()) ) )}// Ending a Live Activityfunc endBuildActivity(_ activity: Activity<BuildTaskAttributes>, success: Bool) async { let finalState = BuildTaskAttributes.ContentState( progress: 1.0, currentStep: success ? "Complete" : "Failed", elapsedSeconds: Int(Date().timeIntervalSince(activity.attributes.startedAt)), isComplete: true, errorMessage: success ? nil : "Build failed — check Xcode logs" ) // .immediate: dismiss the Live Activity right away // .default: keep it visible for a few seconds after ending // .after(date): keep it visible until a specific time (good for results users want to read) await activity.end( ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .after(Date().addingTimeInterval(30)) )}
Live Activity UI for Dynamic Island
import ActivityKitimport SwiftUIimport WidgetKitstruct BuildActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: BuildTaskAttributes.self) { context in // Lock screen / StandBy view LockScreenLiveActivityView(context: context) } dynamicIsland: { context in DynamicIsland { // Expanded view (user long-presses) DynamicIslandExpandedRegion(.leading) { Label(context.attributes.taskName, systemImage: "hammer.fill") .font(.caption) .lineLimit(1) } DynamicIslandExpandedRegion(.trailing) { Text(String(format: "%.0f%%", context.state.progress * 100)) .font(.caption.monospacedDigit()) .foregroundStyle(context.state.isComplete ? .green : .primary) } DynamicIslandExpandedRegion(.bottom) { VStack(spacing: 4) { ProgressView(value: context.state.progress) .tint(context.state.errorMessage != nil ? .red : .orange) if let error = context.state.errorMessage { Text(error) .font(.caption2) .foregroundStyle(.red) } else { Text(context.state.currentStep) .font(.caption2) .foregroundStyle(.secondary) } } } } compactLeading: { // Compact left — shown when another app uses the island Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hammer.fill") .foregroundStyle(context.state.isComplete ? .green : .orange) } compactTrailing: { // Compact right Text(String(format: "%.0f%%", context.state.progress * 100)) .font(.caption2.monospacedDigit()) } minimal: { // Minimal — shown when two activities compete for the island ProgressView(value: context.state.progress) .progressViewStyle(.circular) .tint(context.state.isComplete ? .green : .orange) } } }}struct LockScreenLiveActivityView: View { let context: ActivityViewContext<BuildTaskAttributes> var body: some View { HStack { VStack(alignment: .leading) { Text(context.attributes.taskName) .font(.headline) .lineLimit(1) Text(context.state.currentStep) .font(.caption) .foregroundStyle(.secondary) } Spacer() VStack(alignment: .trailing) { Text(String(format: "%.0f%%", context.state.progress * 100)) .font(.title3.bold().monospacedDigit()) Text("\(context.state.elapsedSeconds / 60)m elapsed") .font(.caption2) .foregroundStyle(.tertiary) } } .padding() .activityBackgroundTint(.black.opacity(0.6)) }}
dismissalPolicy decides how the activity disappears
Calling end() on a Live Activity doesn't remove it right away. How long it lingers on the Lock Screen depends on the ActivityUIDismissalPolicy you pass. Leaving this at the default is what led to "the build finished ages ago, why is this still here?"
// Show the final state, then let the system clear it (up to four hours)await activity.end( ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .default)// Remove it the instant the work ends — good for cancellations and errorsawait activity.end( ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)// Let the completed state sit for a fixed window, then clear itawait activity.end( ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .after(.now.addingTimeInterval(60)))
The heuristic is straightforward: use .after when the user needs to see the outcome, .immediate when they don't..default suits activities someone might want to revisit later; applied to a build or a download it just occupies the notification area longer than anyone wants.
It's easy to conflate this with staleDate, but they do different jobs. staleDate marks the point after which the displayed information should be treated as out of date. How long the activity itself lives is dismissalPolicy's decision.
Debugging Live Activities with Claude Code
Live Activity error messages are frustratingly vague. ActivityKitError(code: 4) is among the most common, and the code alone gives little away. Sending a structured report to Claude Code narrows the search quickly:
My Live Activity fails to start:
Error: ActivityKitError(code: 4)
- iOS 18.3.1, Xcode 16.2, deployment target iOS 17.0+
- NSSupportsLiveActivities: YES in main app Info.plist
- App Group configured and working (UserDefaults reads fine)
- Tested on physical device, not simulator
What should I check next, in order of likelihood?
From experience, the most useful responses cover: (1) the main app target vs Extension target distinction for NSSupportsLiveActivities, (2) App Group identifier mismatches that can pass casual inspection but differ by a single character, (3) the fact that certain features simply don't run in the simulator and require a device.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦How to design per-content refresh intervals so multiple widgets do not exhaust the shared timeline reload budget
✦An ImageIO downsampling implementation that keeps Widget Extensions under the ~30MB memory ceiling (measured: 6MB source down to ~2MB)
✦A safe App Group pattern that shares image references instead of binaries, wired into Crashlytics and Firebase operations
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
App Intents gives Siri, Spotlight, Shortcuts, and interactive widgets a unified API. Getting the design right upfront saves significant refactoring—especially the distinction between intents that open the app (openAppWhenRun: true) and those that run silently in the background.
import AppIntents// A simple informational intent — runs without opening the appstruct ShowTodayTasksIntent: AppIntent { static var title: LocalizedStringResource = "Show Today's Tasks" static var description = IntentDescription( "Summarizes your incomplete tasks for today", categoryName: "Task Management" ) static var openAppWhenRun: Bool = false @MainActor func perform() async throws -> some IntentResult & ReturnsValue<String> { let tasks = await TaskRepository.shared.fetchToday() guard !tasks.isEmpty else { return .result(value: "All done for today — great work.") } let titles = tasks.prefix(3).map(\.title).joined(separator: ", ") let suffix = tasks.count > 3 ? " and \(tasks.count - 3) more" : "" return .result(value: "\(tasks.count) tasks remaining: \(titles)\(suffix)") }}// A mutating intent with parameters and a visual confirmationstruct AddTaskIntent: AppIntent { static var title: LocalizedStringResource = "Add Task" static var description = IntentDescription("Creates a new task") @Parameter(title: "Task name", requestValueDialog: "What's the task called?") var taskTitle: String @Parameter(title: "Due date") var deadline: Date? @Parameter(title: "Priority", default: .medium) var priority: TaskPriority func perform() async throws -> some IntentResult & ShowsSnippetView { guard !taskTitle.trimmingCharacters(in: .whitespaces).isEmpty else { throw AppIntentError.notSupported // Siri will ask again } let task = Task(title: taskTitle, deadline: deadline, priority: priority) try await TaskRepository.shared.add(task) // Reload related widgets WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result { VStack(spacing: 6) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle) .foregroundStyle(.green) Text("Added \"\(taskTitle)\"") .font(.headline) if let d = deadline { Text("Due \(d.formatted(date: .abbreviated, time: .shortened))") .font(.caption) .foregroundStyle(.secondary) } } .padding() } }}// AppEnum — Siri can parse spoken values that map to these casesenum TaskPriority: String, AppEnum { case high, medium, low static var typeDisplayRepresentation: TypeDisplayRepresentation = "Priority" static var caseDisplayRepresentations: [TaskPriority: DisplayRepresentation] = [ .high: DisplayRepresentation(title: "High", image: .init(systemName: "exclamationmark.3")), .medium: DisplayRepresentation(title: "Medium", image: .init(systemName: "minus")), .low: DisplayRepresentation(title: "Low", image: .init(systemName: "arrow.down")) ]}
Registering App Shortcuts for Siri
App Shortcuts tell Siri which intents to offer proactively. The phrasing rules are strict—miss them and your shortcuts won't appear in the Shortcuts gallery at all.
struct MyAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: ShowTodayTasksIntent(), phrases: [ // At least one phrase must contain \(.applicationName) // to appear in the Siri Shortcuts gallery "\(.applicationName) what's on my list today", "\(.applicationName) show today's tasks", "What's left to do in \(.applicationName)" ], shortTitle: "Today's Tasks", systemImageName: "checklist" ) AppShortcut( intent: AddTaskIntent(), phrases: [ "Add \(.title) to \(.applicationName)", "New task in \(.applicationName)", "\(.applicationName) add task \(.title)" ], shortTitle: "Add Task", systemImageName: "plus.circle.fill" ) } // Called by the system to update the shortcut list // Trigger this when your data changes significantly static func updateAppShortcutParameters() async { await AppShortcut.updateAppShortcutParameters() }}
A useful Claude Code prompt here: "Review my AppShortcutsProvider for common configuration mistakes that would prevent shortcuts from appearing in the gallery." The response reliably flags missing \(.applicationName) in phrases, duplicate phrase patterns across intents, and the need to call updateAppShortcutParameters() after data changes.
The Tap That Undoes Itself: Ordering Inside perform()
The first report I got after shipping interactive widgets wasn't a crash or a layout bug. It was: "I tap the checkbox, it fills in for a moment, then it pops back." Nothing in the logs.
The cause was a misreading of the widget update model. When an AppIntent is invoked from a widget, the system reloads that widget's timeline the moment perform() returns. Which means the shared state has to already be new by the time perform() returns. If you hand the write off to a detached Task { }, or return without awaiting an async save, the system reads the old value back and renders it. To the user, the tap simply undid itself.
Finish the write before you return
struct CompleteTaskIntent: AppIntent { static var title: LocalizedStringResource = "Complete Task" @Parameter(title: "Task ID") var taskId: String func perform() async throws -> some IntentResult { // ❌ Fire-and-forget: the system re-reads stale state // Task { try? await TaskRepository.shared.complete(taskId) } // return .result() // ✅ Await the persistence layer before returning try await TaskRepository.shared.complete(taskId) // Mirror it into shared storage, still before returning let defaults = UserDefaults(suiteName: "group.com.yourapp.shared") var completedIds = defaults?.stringArray(forKey: "completedTaskIds") ?? [] if !completedIds.contains(taskId) { completedIds.append(taskId) defaults?.set(completedIds, forKey: "completedTaskIds") } return .result() }}
Writes to UserDefaults are synchronous, so this rarely bites you there. The moment Core Data, SwiftData, or file I/O enters the path, the ordering problem surfaces. In my case the save had been factored into a shared repository layer, and one await had gone missing along the way.
Show the user that a reload is in flight
Even with correct ordering, the reload takes a few hundred milliseconds. invalidatableContent() is what fills that gap.
struct TaskRowView: View { let task: Task var body: some View { HStack { Button(intent: CompleteTaskIntent(taskId: task.id)) { Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") } .buttonStyle(.plain) Text(task.title) // Tells the system this content is about to change, so it // renders just this portion dimmed during the reload instead // of leaving the whole widget looking frozen. .invalidatableContent() } }}
Toggle(isOn:intent:) is more forgiving: the system flips the control's appearance first, then runs perform(). That optimistic update is built in, so if an action can be modeled as a toggle, prefer a toggle — it simply feels better. A Button, by contrast, will read as unresponsive for those few hundred milliseconds unless you place invalidatableContent() yourself.
When you bring this to Claude Code, paste more than the symptom. Include what perform() awaits. Describe only the visible behavior and you'll usually get containerBackground and App Group suggestions first, which sent me down a longer path than necessary.
Configurable widgets now live in App Intents
Since iOS 17, the user-facing widget configuration (long press → Edit Widget) is defined with App Intents rather than a SiriKit .intentdefinition file. Migrating a project that still used the old file was the single largest rewrite in this area.
import AppIntentsimport WidgetKit// Replaces the old .intentdefinition configurationstruct SelectCategoryIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Category to Display" static var description = IntentDescription("Filters which tasks appear in the widget.") @Parameter(title: "Category", default: .all) var category: TaskCategoryAppEnum}// Provider that receives the user's configurationstruct ConfigurableProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> TaskEntry { TaskEntry(date: .now, taskCount: 3, nextDeadline: nil, completionRate: 0.5) } func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> TaskEntry { await loadEntry(category: configuration.category) } func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<TaskEntry> { let entry = await loadEntry(category: configuration.category) // The system calls timeline() again immediately when the user changes // the configuration, so there's no need for a short refresh interval here. return Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(1800))) } private func loadEntry(category: TaskCategoryAppEnum) async -> TaskEntry { TaskEntry(date: .now, taskCount: 0, nextDeadline: nil, completionRate: 0) }}struct ConfigurableTaskWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "ConfigurableTaskWidget", intent: SelectCategoryIntent.self, provider: ConfigurableProvider() ) { entry in TaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Tasks") .supportedFamilies([.systemSmall, .systemMedium]) }}
The easy thing to miss during migration: replacing an IntentConfiguration widget with AppIntentConfiguration does not carry over the settings of widgets users already placed on their home screen. Keeping the same kind doesn't help — the configuration reverts to defaults. If you can, ship the new widget under a new kind alongside the old one and let them coexist for a release or two.
Five Common Mistakes and How to Catch Them
① App Group Not Shared Between Targets
This is the single most common root cause when widget data appears stale or empty. It often passes visual inspection because both targets appear to have App Groups enabled—but the identifiers differ by one character.
// ❌ Widget can't read this — different UserDefaults domainUserDefaults.standard.set(tasks, forKey: "tasks")// ✅ Both app and widget extension use the same suite namelet suite = "group.com.yourcompany.yourapp"UserDefaults(suiteName: suite)!.set(tasks, forKey: "tasks")// After writing new data, notify WidgetKitWidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
How to confirm the configuration is correct: open the .entitlements file for each target and compare the com.apple.security.application-groups arrays manually. Claude Code can do this comparison if you paste both files.
② containerBackground Missing — Interactions Silently Disabled
In iOS 17+, the root widget view requires .containerBackground. Without it, the system may render the widget but silently ignore interactive events like button taps. The widget appears fine; tapping it does nothing.
// ❌ Interactive events may be ignoredstruct MyWidgetView: View { var body: some View { VStack { /* content */ } }}// ✅ Required in iOS 17+struct MyWidgetView: View { var body: some View { VStack { /* content */ } .containerBackground(.fill.tertiary, for: .widget) }}
③ Siri Shortcuts Not Appearing in Gallery
// ❌ No phrase includes \(.applicationName) — gallery will skip thisAppShortcut( intent: AddTaskIntent(), phrases: ["Add a task", "New task: \(.title)"])// ✅ At least one phrase must reference the app nameAppShortcut( intent: AddTaskIntent(), phrases: [ "Add \(.title) to \(.applicationName)", // ← required format "New task in \(.applicationName): \(.title)" ])
④ NSSupportsLiveActivities On Wrong Target
The plist key must be in the main application target's Info.plist. Adding it only to the Widget Extension is a common mistake when the widget was created first.
<!-- ✅ Main app Info.plist --><key>NSSupportsLiveActivities</key><true/><!-- Widget Extension may also need this entry in some configurations --><key>NSSupportsLiveActivities</key><true/>
⑤ getSnapshot Takes Too Long
The widget gallery and the App Store reviewer's device both call getSnapshot. If it takes several seconds, the gallery shows a loading state and reviewers may interpret it as a broken widget. Keep getSnapshot synchronous or near-instant—read from a cache rather than making a network request.
// ❌ Network call in getSnapshot — may time out or appear brokenfunc getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { Task { let data = try? await APIClient.fetchTasks() // ← do NOT do this here completion(TaskEntry(taskCount: data?.count ?? 0, /* ... */)) }}// ✅ Read from cached App Group data insteadfunc getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) { let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared") let count = sharedDefaults?.integer(forKey: "taskCount") ?? 0 completion(TaskEntry(taskCount: count, /* ... */))}
Setting Up CLAUDE.md for Widget Projects
Documenting your widget project's constraints in CLAUDE.md means Claude Code starts every session with the right context. You won't need to re-explain App Group identifiers, iOS version requirements, or shared data keys each time.
## Widget Extension### App Group- Identifier: group.com.yourcompany.yourapp- Shared UserDefaults suite: UserDefaults(suiteName: "group.com.yourcompany.yourapp")- Data keys: taskCount (Int), completionRate (Double), nextDeadline (Date?), completedTaskIds ([String]), highPriorityTitles ([String])### Widget Targets- TaskWidget: static widget, Small + Medium sizes- BuildWidget: Live Activity with Dynamic Island support### Hard Constraints- No network calls in getSnapshot or getTimeline- UserDefaults.standard is forbidden — always use App Group- .containerBackground(.fill.tertiary, for: .widget) is required on root view (iOS 17+)- Interactive widgets require iOS 17+ minimum deployment target- getSnapshot must complete in under 1 second### Refresh Triggers- After data change in main app: WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")- Background fetch: BGAppRefreshTask → update App Group → reloadTimelines- Avoid: WidgetCenter.shared.reloadAllTimelines() unless all widgets need refresh### Known Issues- Interactive button taps have ~1-2s visual lag after perform() completes (system behavior)- Live Activities don't run fully in simulator — use physical device for testing
With this context always available, Claude Code will suggest App Group reads instead of UserDefaults.standard, propose .atEnd vs .after policies based on your content type, and flag potential issues before they become device bugs.
App Store Review Checklist for Widget Features
Ask Claude Code to "generate an App Store review checklist for my widget implementation" and you get a structured list worth running through before every submission. The items that catch developers off guard most often:
First-run behavior: Does the widget display meaningful content immediately, without requiring the user to open the app first? A widget stuck on "Set Up Widget" will fail review.
All size variants: Test Small, Medium, and Large if you support them. Reviewers check all declared sizes. A layout that breaks at Small is a common rejection.
Widget tap destinations: Every tap should navigate to a relevant screen in the app. A widget tap that opens the app's root screen regardless of context is a red flag. Use widgetURL for single-tap or Link for tappable regions.
Screenshot metadata: Widget screenshots in App Store Connect are required if your app includes widgets. They must accurately reflect what users see—not a mockup.
getSnapshot speed: As noted above, slow snapshots affect both the gallery UX and what reviewers observe during testing. Profile this on a physical device before submission.
Claude Code is particularly good at reviewing the live code for these issues. A prompt like "scan this widget implementation for App Store rejection risks" will catch most of them before you submit.
What the Official Docs Don't Tell You: Lessons from Production
WidgetKit's documentation is solid as an API reference, but it doesn't describe what happens when you run an app with several widgets over the long term. Here are the out-of-docs pitfalls I hit while adding widgets to a wallpaper app I maintain as a solo developer, and then growing the number of widget kinds it offers.
Think of timeline reloads as a daily budget, not an hourly quota
The docs often phrase the limit as "40–70 reloads per hour," but in practice it behaves more like a per-app reload budget that gets divided across all your widgets. If you ship several widget kinds, they compete for that same budget.
My wallpaper app originally refreshed all three widgets (clock, calendar, and "today's pick") on a 15-minute cadence. By the afternoon the budget was exhausted, and widgets would freeze for the rest of the day. Crashlytics showed no crashes—just a steady trickle of one-star reviews complaining that the widget "doesn't update." That's a hard bug to diagnose precisely because nothing crashes.
The fix was to set refresh frequency based on whether the display actually needs to change.
enum WidgetKind { case clock, dailyWallpaper, calendar }// ❌ A uniform 15-minute cadence wastes the budget// let timeline = Timeline(entries: entries, policy: .atEnd)// ✅ Vary the interval by the nature of the contentfunc makeTimeline(for kind: WidgetKind, entry: WallpaperEntry) -> Timeline<WallpaperEntry> { let now = Date() switch kind { case .clock: // Text(date, style: .time) lets the system update the clock itself, // so there's no need to slice the timeline — regenerate hourly. return Timeline(entries: [entry], policy: .after(now.addingTimeInterval(3600))) case .dailyWallpaper: // "Today's pick" only needs to flip at the day boundary let nextMidnight = Calendar.current.nextDate( after: now, matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime )! return Timeline(entries: [entry], policy: .after(nextMidnight)) case .calendar: // The calendar widget only changes when the date rolls over let nextMidnight = Calendar.current.nextDate( after: now, matching: DateComponents(hour: 0, minute: 0), matchingPolicy: .nextTime )! return Timeline(entries: [entry], policy: .after(nextMidnight)) }}
The key insight is that Text(date, style: .time) lets the system redraw the time on its own, so you never need to slice the timeline minute by minute. Generating an entry every minute just burns the budget for nothing. After this change, the afternoon "frozen widget" reports dropped to essentially zero.
Widget Memory Limits and Image Optimization (Measured in a Wallpaper App)
A wallpaper-specific gotcha: a Widget Extension has a much tighter memory ceiling than the host app. The docs don't print a number, but in my testing the Widget Extension ceiling sits around 30MB, and exceeding it leaves the widget blank (stuck on the placeholder). Handing a full-size wallpaper straight to the widget (a 6MB JPEG decodes into a multi-tens-of-MB bitmap) blows past that ceiling easily.
The fix is to downsample with ImageIO before the image ever reaches the widget.
import UIKitimport ImageIO// Downsample an image to the widget's display size.// Reading a full-size image with UIImage(contentsOfFile:) expands the// decoded bitmap in memory and overruns the ~30MB widget limit.func downsampledImage(at url: URL, pointSize: CGSize, scale: CGFloat) -> UIImage? { let sourceOpts = [kCGImageSourceShouldCache: false] as CFDictionary guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOpts) else { return nil } let maxDimension = max(pointSize.width, pointSize.height) * scale let downsampleOpts = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, // decode right here kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxDimension ] as CFDictionary guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOpts) else { return nil } return UIImage(cgImage: cgImage)}
As a concrete measurement, my Medium widget (roughly 360×170 pt, about 1080×510 px at @3x) took a 6MB source image (about a 48MB decoded bitmap) down to roughly 2MB after downsampling. That left comfortable headroom under the ceiling and the blank-widget reports stopped.
When you ask Claude Code about this, it explains why ImageIO's thumbnail API is the right tool—because UIImage(named:) and UIImage(contentsOfFile:) expand the decoded bitmap into memory. For isolating image-related crashes, the symbolication automation I describe in Automating iOS Crashlytics Triage with Claude Code was a real help.
Share App Group data small, and by reference
Related to memory limits: avoid putting large data (image binaries and the like) directly into the App Group UserDefaults or shared container. It's safer to have the widget receive only a file name or identifier and downsample the file itself when needed.
// ❌ Storing image binaries in UserDefaults (expands in memory on every read)// sharedDefaults.set(imageData, forKey: "wallpaperImage") // several MB each time// ✅ Share only the file name inside the shared containerlet containerURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared")!let imageURL = containerURL.appendingPathComponent("today.jpg")let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")!sharedDefaults.set(imageURL.lastPathComponent, forKey: "wallpaperFileName")// On the widget side, read it via downsampledImage(at:)
This design pairs well with caching Firebase-delivered wallpapers locally for display; I've written up the delivery-pipeline side in Claude Code × Firebase Development Automation Guide.
Starting Point: The Four Steps That Must Work First
Before building the widget UI, confirm these four infrastructure steps are solid. Getting them wrong causes failures that are hard to debug after the fact:
Main target → Signing & Capabilities → App Groups → add group.com.yourapp.shared
Widget Extension target → enable the same App Group identifier
After any data update: WidgetCenter.shared.reloadTimelines(ofKind: "YourWidgetKind")
If you can write a value from the main app and read it from the widget extension timeline provider, the foundation is correct. Everything else—interactive buttons, Live Activities, Siri intents—builds on top of this.
Write these four constraints into your CLAUDE.md under a ## Widget Extension section. From that point forward, Claude Code treats them as given context for every widget-related question in the project, and you can focus the conversation on the actual implementation rather than re-explaining the setup.
iOS widget capabilities continue to grow with each release. The patterns here—TimelineProvider, AppIntent, ActivityAttributes—are the stable core that new features build on. Getting comfortable with them now means each year's additions slot in without requiring a rewrite.
Reusable Claude Code Prompt Patterns for Widget Development
Once you have the CLAUDE.md foundation in place, the following prompt patterns have consistently produced useful results across multiple widget projects. Keep them handy.
For debugging update failures:
My widget timeline isn't refreshing after a data change.
- App Group: configured (reads work in a unit test)
- WidgetCenter.shared.reloadTimelines called after every write
- Background App Refresh: enabled in Settings
- Device: iPhone 15 Pro, iOS 18.3.1
The widget updates eventually (minutes later) but not promptly.
What should I check for the delay?
This prompt framing—state what you've already verified, then ask specifically about the delay—produces more targeted responses than "my widget doesn't update."
For intent design review:
I have these three AppIntents in my project:
[paste your intent structs]
Review them for:
1. Parameters that Siri might struggle to parse
2. Missing error handling in perform()
3. Whether openAppWhenRun is set appropriately for each
4. Any patterns that might fail App Store review
For performance profiling context:
My getTimeline implementation takes about 800ms on an iPhone 12.
It reads from Core Data using a FetchRequest.
Is this acceptable, or should I switch to App Group UserDefaults for widget reads?
What's the typical threshold before iOS throttles widget refresh?
For App Store preparation:
Before I submit this widget update, review the implementation for:
- Behaviors that would cause rejection
- Widget gallery UX issues (slow snapshot, broken size variants)
- Any missing Info.plist keys for the features I'm using
The consistent thread across all of these is specificity—stating what you've already tried and what environment you're working in. Claude Code's widget knowledge is broad enough that vague questions tend to produce generic checklists rather than targeted answers.
Building Incrementally: A Suggested Implementation Order
If you're adding widgets to an existing app for the first time, this sequence minimizes the chance of hitting multiple problems at once:
Week 1 — Foundation: Set up App Group, implement a simple static widget that reads from shared UserDefaults. Verify data flows correctly from app to widget on a physical device.
Week 2 — Timeline: Add proper timeline logic with appropriate refresh policy. Test what happens when the app is backgrounded, in Low Power Mode, and when the device is on battery vs. plugged in.
Week 3 — Interactive: Migrate one user action (like completing an item) to an AppIntent. Wire it to a widget button. Test the visual feedback timing and consider whether Optimistic UI is needed.
Week 4 — Siri / Shortcuts: Register AppShortcuts. Test with actual Siri voice commands on device—the simulator is unreliable for this. Verify shortcuts appear in the Shortcuts gallery.
Week 5 — Live Activities (if applicable): Implement ActivityKit for any time-bounded actions in your app. Test the Dynamic Island compact and expanded layouts; they're rendered differently than SwiftUI previews suggest.
Before submission: Run through the App Store review checklist above. Check all widget size variants on real hardware. Profile getSnapshot timing. Confirm all App Group identifiers match exactly.
Using Claude Code throughout this sequence, with the CLAUDE.md constraints always present, keeps each phase focused. You're not re-explaining the project setup with each question—Claude Code already knows the App Group identifier, the widget kinds, and which iOS version features are available.
Looking back, almost nothing that cost me real time was Swift syntax or SwiftUI layout. It was process boundaries: data crossing an App Group, the extension's narrow memory ceiling, and a timeline reload the system controls rather than you. Re-read your widget code with those three in mind and a surprising number of unexplained bugs start to explain themselves.
If you're staring at a widget that won't update right now, compare the App Group identifier character by character first. That's where the largest single block of my time went.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.