A widget extension is not the writer
Interactive widgets run App Intents. That does not mean the widget extension should own the mutation. On RuleCaddie- and Veto-shaped surfaces we share a small App Group store for timeline reads, then keep writes in the main app. When the intent package is linked by both targets, the system picks a process by heuristics — and heuristics are happy to run a favorite or acknowledge button inside the extension. That is the wrong process for a store you intentionally kept read-only on the Home Screen side.
The widget on screen is not a running process
WidgetKit archives a view and renders it later. Your SwiftUI code is not sitting there waiting for a tap. Button(intent:) and Toggle(isOn:intent:) are the interactive surface because the system needs something it can execute on your behalf after the snapshot is already painted. The intent is the action. The extension is only one possible place that action might run.
That distinction matters as soon as the button changes durable state. Marking a photo favorite, acknowledging a veto round, or starring a ruling tip is a write. Reloading the timeline afterward is a read of whatever the writer left behind. Collapsing those into "the widget did it" is how you end up with two processes both believing they own the same file.
Shared packages invite the wrong process
The tidy architecture is a Swift package of intents and entities imported by the main app and the WidgetKit extension. Apple's travel-tracking sample does exactly that. Once the same AppIntent type is visible to more than one target, the system has to choose which process answers a request. Heuristics prefer the already-running app when it is warm, and an extension when it is not. That is a convenience rule, not a data-ownership rule.
We treat the widget extension like a reader. It opens the App Group container, builds a TimelineProvider entry, and leaves. Writes go through the main app so there is one serializer for CloudKit, SwiftData, or whatever backs the product. If a Home Screen favorite button can still land in the extension, the read-only boundary was theater. The button worked in the demo because the store happened to be empty of concurrent writers that day.
Pin the writer with ExecutionTargets
ExecutionTargets is the override for that heuristic. Declare which processes are allowed to run the intent: main app, App Intents extension, WidgetKit extension, or a combination. A mutation that needs the writer gets .main. A display-only configuration intent that only shapes the next timeline can stay on .widgetKitExtension. A toggle that must work whether or not the app is warm can list both main and an App Intents extension — still not the widget process if that process cannot write.
The checklist we use when adding a widget button: name the durable write, name the process that already owns that write path, set allowedExecutionTargets to that process, then verify the extension still builds a correct timeline from the App Group after the intent finishes. If the button only opens a deep link, refuse the interactive intent and keep the default widget tap. Interactive for its own sake is how you invent a second writer.
Prove it with the app cold
Force-quit the app. Tap the widget control. Confirm the intent ran in the main app (or the App Intents extension you named), the shared store updated, and WidgetKit's next timeline entry reflects the change without a second write from the widget process. Repeat with the app already foregrounded so you catch the warm-heuristic path too.
This sits next to the reload-budget note, not on top of it. Budget honesty answers how often the snapshot refreshes. ExecutionTargets answers which binary is allowed to mutate the truth underneath that snapshot. LongRunningIntent answers how long a mutation may run once the right process has it. Three different clocks, three different failure modes — do not fix a process bug with a longer timeout.
Questions
- Why not let the widget extension write to the App Group?
- Two processes writing the same store race under time pressure. Keep the extension read-only, serialize writes in the main app (or a dedicated App Intents extension), and reload timelines from the file the writer already finished.
- When should an intent stay on the WidgetKit extension?
- When it only configures or refreshes presentation — no durable product write. If the tap changes user data, pin allowedExecutionTargets to the process that already owns that write path.
- How is this different from LongRunningIntent?
- LongRunningIntent extends how long work may run and requires progress updates. ExecutionTargets chooses which process runs the intent at all. A write that lands in the wrong process fails before duration matters.
Sources
- WWDC26 — Discover new capabilities in the App Intents framework — ExecutionTargets and process selection for shared intent packages.
- WWDC26 — WidgetKit foundations — Archived widget views; Button/Toggle take App Intents.
- LongRunningIntent — Apple Developer Documentation — Duration and progress are a separate concern from process targeting.