App Intents in production: one intent layer for Shortcuts, Spotlight, and widgets
How to design App Intents as a reusable product boundary so Shortcuts, Spotlight, widgets, and app features share behavior without duplicating domain logic.
On this page
App Intents are easy to add badly.
A few files compile, the Shortcuts app shows the action, Spotlight finds an entity, the widget button mostly works, and everyone agrees the integration is done. Then a real user runs the same action from Siri while the app is closed, a widget taps the same button in a different process, and the feature starts behaving like three separate products that happen to share an icon.
The problem is usually not the framework. The problem is treating App Intents as surface glue: one implementation for Shortcuts, another for widgets, a third for the app, and a URL scheme in the corner pretending none of this was its fault.
A production App Intents layer should look more like a public API for the app. The app, widgets, Live Activities, Spotlight, Siri, and Shortcuts are all clients of the same product capabilities.
That shift matters. App Intents are no longer just a way to donate a few actions. They are how Apple Intelligence, Siri, Spotlight, widgets, controls, Live Activities, the Action button, and other hardware triggers understand what the app can do. If the contract is vague, every system surface inherits the vagueness.
1. Treat App Intents as a public API
An app intent is not a button handler.
It is a typed, discoverable operation that can run from places the app does not control:
- Siri
- Spotlight
- the Shortcuts app
- interactive widgets
- Live Activities
- Control Center controls
- Action button configurations
- system search and Apple Intelligence flows
That changes the design bar.
Inside the app, a button can rely on current navigation state, an authenticated session, an observable model, loaded relationships, or a view that happens to be on screen. An intent can run without any of that context. It needs enough information to resolve the target, perform the operation, report the result, and leave the app in a sane state.
So the first rule is simple:
Model product capabilities, not UI actions.
Good capability names sound like things a user wants done:
- add an item
- mark an item packed
- search snippets
- copy the latest clip
- start pack mode
- open a trip
- generate a packing list
Weak intent names usually describe where the action came from:
WidgetPrimaryButtonTappedShortcutPerformAddSiriMarkDoneHomeScreenQuickAction
Those are surface-specific handlers, not product capabilities. They multiply quickly because each new system surface needs another near-copy of the same logic.
If two surfaces mean the same user action, they should usually call the same intent or the same domain operation behind the intent.
2. Start with verbs and nouns
The useful design exercise is small:
- list the user-visible verbs
- list the entities those verbs operate on
- decide which entities are stable enough to expose outside the app
For a packing app, the nouns might be Trip and PackingItem. The verbs might be add, mark packed, check status, start packing, and generate a list.
For a snippet or clipboard app, the nouns might be Snippet, Clip, and a fixed set of library destinations. The verbs might be create, search, copy, paste, and open.
Do not expose the whole database because the framework makes it technically possible. Expose the model the system needs to understand.
An app entity should be a small, stable projection:
struct TripEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Trip")
static let defaultQuery = TripQuery()
let id: UUID
let name: String
let destination: String
var displayRepresentation: DisplayRepresentation {
destination.isEmpty
? DisplayRepresentation(title: "\(name)")
: DisplayRepresentation(title: "\(name)", subtitle: "\(destination)")
}
}Notice what is missing: no SwiftData object, no relationship graph, no mutable app state, no view model.
The entity is a boundary object. It carries an identifier and enough presentation data for the system to display the thing. The query resolves that identifier back to the real model when needed.
That separation keeps persistence changes from breaking Shortcuts, widgets, Spotlight indexing, and Siri resolution every time the app schema evolves.
3. Use stable identifiers before anything else
The fastest way to make App Intents unreliable is to expose identifiers that only make sense in one process, one device, or one launch cycle.
For local-only content, a persistent UUID is usually enough. For synced content, prefer the identifier that survives sync and restore. If the app has both a local primary key and a CloudKit or server identifier, do not make the system guess which one is canonical.
The 2026 release cycle pushes this even further: iOS 27, in beta as this publishes, adds SyncableEntity because Siri and system flows increasingly need content identity to survive across devices. The implementation detail is new, but the rule is old: system-facing identifiers should be stable, scoped, and documented.
The same rule applies to deletion and merge behavior:
- What happens when Siri resolves an entity that no longer exists?
- What happens when CloudKit imports a replacement row with the same logical identity?
- What happens when a widget has an old entity snapshot after sync?
- What happens when the same object appears on two devices with different local IDs?
A production query should fail closed and answer clearly:
struct TripQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [TripEntity] {
let context = try IntentStore.context()
return try context.fetch(FetchDescriptor<Trip>())
.filter { identifiers.contains($0.uuid) }
.map(TripEntity.init)
}
func suggestedEntities() async throws -> [TripEntity] {
let context = try IntentStore.context()
return try context.fetch(FetchDescriptor<Trip>())
.filter { !$0.isArchived }
.map(TripEntity.init)
}
}If the target cannot be found, return a useful failure. Do not mutate a fallback object because it happened to be nearby. “Close enough” is not a data-consistency strategy.
4. Put the intent layer in a shared, thin module
The intent types often need to be visible to more than the app target: widgets, Live Activities, controls, App Intents extensions, and sometimes share extensions.
That pushes the code toward a shared package.
The correct shared package is thin:
- intent declarations
- entity projections
- queries
- parameter types
- result types
- small bridges into the app's domain operations
It should not become a second application module with view models, navigation, feature flags, networking orchestration, and half of SwiftUI living inside it.
A good dependency shape looks like this:
App / Widget / Live Activity / Control
│
▼
PackerlyIntents / TypeDockIntents
│
▼
Domain operations + persistence boundaryThe intent module declares the system contract. The domain layer performs the real operation. The app and widgets consume the same contract.
There is one practical wrinkle: AppShortcutsProvider belongs in the app target because that is where the system discovers the app's shortcut definitions. The surrounding intent code can be shared, but shortcut-backed intents and target membership have historically been picky across Xcode and OS releases. Verify discovery from a real archive or device run, not only from package compilation.
This split keeps the system-facing contract reusable without turning the package into a junk drawer.
5. Keep perform() thin and boring
perform() is not the place to improvise product logic.
A good implementation does four things:
- create or resolve the execution context
- resolve parameters into real model objects
- call a domain operation
- report the result and refresh affected projections
For example:
struct MarkPackedIntent: AppIntent {
static let title: LocalizedStringResource = "Mark Item Packed"
@Parameter(title: "Item") var item: PackingItemEntity
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let context = try IntentStore.context()
guard let model = try PackingItemResolver.find(item.id, in: context) else {
return .result(dialog: "I couldn't find that item.")
}
try PackingOperations.markPacked(model, in: context)
WidgetProjection.rebuildAndReload(context: context)
return .result(dialog: "Packed \(model.name).")
}
}The details vary by app, but the shape should not.
Avoid putting these directly in perform():
- UI navigation assumptions
- inline network orchestration
- relationship traversal across half the object graph
- feature-flag branching for five different callers
- large string-formatting systems
- direct view-model mutation
- hidden dependency on the app already being open
The intent should behave when the app is cold, backgrounded, or not running at all. If that is impossible, the intent is not ready to be exposed as a system action.
6. Decide which process should run the intent
Process choice is where “it worked in the app” becomes an unreliable test.
A widget button, Live Activity button, Shortcut, and Siri invocation can execute in different contexts. A plain AppIntent may run outside the app's main process. That matters when the operation depends on app-owned state such as an active Live Activity controller, an in-memory coordinator, or a singleton that only exists after app launch.
The failure is usually quiet: the intent runs, the data mutation succeeds, and the visible surface does not update because the process never owned the object you expected it to update.
For interactive widgets and Live Activities, choose the protocol intentionally:
- use a plain
AppIntentwhen the operation is self-contained and safe to run in the extension or intent process - use
LiveActivityIntentwhen the action needs the app's process to update the app-owned Live Activity - use
ExecutionTargetswhen the system needs an explicit choice between the app, widget extension, or App Intents extension
The exact API matters less than the habit: write down which process owns the state the intent mutates.
If the intent needs the main app, make that explicit. If it can run independently, keep it independent. Do not let the system's process heuristic become your architecture by accident.
7. Separate mutations, answers, and navigation
Not every intent should do the same kind of work.
I usually split them into three groups.
Mutation intents
These change state:
- add an item
- mark packed
- copy a snippet
- paste the latest clip
- start a session
They need the strongest consistency rules. They should resolve the target, perform the smallest safe mutation, save explicitly, and refresh derived surfaces.
Answer intents
These return information without opening the app:
- what is left to pack?
- what is the packing status?
- which snippets match this query?
- is there anything to buy?
They should be fast, bounded, and useful without visual context. Return a short dialog or a typed value, not a paragraph that sounds like a dashboard fell into a text field.
Navigation intents
These open the app at a destination:
- open the trip
- start pack mode
- open search with a query
- open a fixed library destination
These should use the app's URL or routing layer as a transport detail. The intent is still the system-facing contract. The deep link is just how the app receives the navigation request.
struct SearchTypeDockIntent: AppIntent {
static let title: LocalizedStringResource = "Search TypeDock"
static let openAppWhenRun = true
@Parameter(title: "Query") var query: String
func perform() async throws -> some IntentResult & OpensIntent & ProvidesDialog {
.result(
opensIntent: OpenURLIntent(TypeDockRoute.searchURL(query: query)),
dialog: "Opened TypeDock."
)
}
}The important part is that the route is canonical and tested. If Spotlight, a quick action, a shortcut, and in-app navigation all mean “search for this query,” they should converge on the same route grammar.
8. Design parameters for Siri, not just for Shortcuts
The Shortcuts editor is forgiving. Siri is not.
A parameter that looks acceptable in a form can be awkward or impossible in a spoken phrase. This is where AppEntity and AppEnum earn their keep.
Prefer entities and enums when the user is choosing from known values:
- a trip
- a snippet collection
- a destination
- a fixed filter
- a repeatable mode
Use strings for free-form input:
- an item name
- a search query
- a title
- free text the user wants created
There is an important practical limitation: a spoken App Shortcut phrase can interpolate entity and enum parameters, but a free-form String query is not something you can reliably splice into the phrase. The Shortcuts app can still expose the parameter. Siri just should not pretend it is a clean spoken-slot filler.
Give the system useful summaries:
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$destination) in TypeDock")
}That is not decoration. It is how the action reads inside system UI. If the summary is vague, the feature feels unfinished before the user even runs it.
9. Make Spotlight indexing a lifecycle, not a launch afterthought
Indexing entities into Spotlight is easy to start and easy to leave stale.
If an entity is searchable, the app needs a rule for:
- when to add it
- when to update it
- when to remove it
- when to rebuild the index
- how to handle sync imports
- how to avoid indexing drafts, hidden rows, or deleted objects
IndexedEntity and CSSearchableIndex.indexAppEntities are useful, but they are not a data-sync plan.
A reasonable pattern is to rebuild projections after meaningful model mutations:
- save the model change
- rebuild widget or snapshot projections
- update the searchable entity index
- reload the affected widget timelines
- update the Live Activity if one is active
Do not index directly from every random call site. Put the indexing decision behind a domain projection function that knows what belongs in the index.
The goal is not “index everything.” The goal is that Spotlight tells the truth.
10. Treat side effects as permission boundaries
An intent that creates content is one thing. An intent that deletes, shares, sends, purchases, overwrites, or exposes private data is another.
Meaningful side effects need explicit product judgment:
- Should Siri confirm before running it?
- Can the action run from a locked device?
- Does it reveal private content in a dialog or snippet?
- Does it modify shared data someone else owns?
- Can it be undone?
- Does it expose clipboard content, payment state, location, health data, or account information?
The system can help with confirmations, especially when side effects are clear. But “the system asked” is not a product policy. The app still needs to decide which operations are safe in each context.
For sensitive reads, prefer opening the app over answering in the background. For sensitive mutations, prefer a confirmation or an app-owned flow over silent execution. A shortcut that corrupts data is still automation, just faster vandalism.
11. Log the contract, not every implementation detail
Intent bugs are hard to reproduce because the invocation context is part of the bug.
A useful log line should answer:
- which intent ran
- which surface invoked it, when known
- which entity IDs were supplied
- whether resolution succeeded
- whether the mutation succeeded
- how long execution took
- which process executed the intent
- what result or failure was returned
Do not log raw user content by default. Entity IDs, counts, durations, and outcome categories are usually enough.
For widgets and Live Activities, also log projection refreshes. A mutation can succeed while the visible surface remains stale. Those are two different failures and they should not be collapsed into “widget bug.”
If the app has analytics, treat intents as product surfaces:
- shortcut created
- intent run
- intent failed
- widget action used
- Spotlight entity opened
- navigation route opened from intent
That tells you which integrations are real features and which ones are decorative protocol conformance.
12. Test intent behavior, not just compilation
The compiler proves the types line up. It does not prove the action works from the places users will actually invoke it.
A useful test matrix has four levels.
Unit tests
Test the domain operation and parameter resolution:
- valid entity
- missing entity
- archived entity
- duplicate names
- empty result
- permission or state precondition
- save failure
- partial mutation failure
Query tests
Test EntityQuery behavior:
- identifiers resolve in order or with documented ordering
- suggested entities exclude archived and invalid rows
- deleted rows fail cleanly
- sync replacements resolve to the same logical identity
Route tests
For navigation intents, test that generated URLs round-trip through the app's router:
- fixed destination
- model destination
- query parameter encoding
- invalid or missing ID
- route opened before app state restoration
System-surface checks
Run the real integration manually or through automation where possible:
- Shortcuts editor
- Siri phrase
- Spotlight search
- widget button
- Live Activity button
- app cold launch
- app already running
- app after sync or data migration
Apple's AppIntentsTesting framework, introduced in the 2026 release cycle, is worth adopting as it matures, but do not wait for a test framework to validate the basics. Most production intent bugs are ordinary state, process, and persistence failures wearing a system-integration costume.
13. Version the intent layer like public API
Once users create shortcuts, the intent layer has external callers.
That means changes need compatibility discipline:
- Do not rename intents casually.
- Do not change parameter meaning without a migration plan.
- Do not change entity identity semantics after users have stored shortcuts.
- Do not remove phrases because the copy felt slightly better on Tuesday.
- Do not silently make a background answer open the app, or the reverse.
If the product behavior changes, add a new intent or parameter when possible and deprecate the old path deliberately.
Keep a short inventory in the repo:
Intent: MarkPackedIntent
Purpose: mark one packing item packed
Surfaces: widget, Live Activity, Shortcuts
Mutates: PackingItem.status
Refreshes: WidgetProjection, Live Activity
Identifier: PackingItemEntity.id / PackingItem.uuid
Requires app process: yes on iOS when Live Activity is activeThis looks boring. That is the point. Public API documentation should make the safe change obvious.
14. Adopt it in slices
A full App Intents architecture does not need to arrive in one release.
A sane rollout order:
- Pick one high-value mutation and one read intent.
- Define the entity and stable identifier first.
- Put the domain operation behind a testable boundary.
- Add the intent with a clear dialog result.
- Add one widget or Live Activity consumer.
- Add Spotlight indexing if the entity deserves search.
- Add App Shortcuts after the behavior is reliable.
- Add logging and system-surface verification.
- Only then expand to more verbs.
The trap is exposing twenty mediocre actions because the framework makes the list look impressive. Five reliable capabilities beat thirty half-tested ones every time.
App Intents work best when the app already knows what its product operations are. The framework then gives those operations reach: search, voice, widgets, controls, automation, and system intelligence can all call the same thing.
That is the real win. Not “we support Shortcuts.”
The win is that the app has one clear answer to “what can this product do?” — and every system surface hears the same answer.