
App Intents Expert Skill
- 8 installs
- 11 repo stars
- Updated February 8, 2026
- rudrankriyam/app-intents-agent-skill
Helps with ai & agent building tasks.
About
app-intents-expert-skill is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- app-intents-expert-skill
- AI & Agent Building
- AI-coding skill
App Intents Expert Skill by the numbers
- 8 all-time installs (skills.sh)
- Ranked #12,339 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rudrankriyam/app-intents-agent-skill --skill app-intents-expert-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 11 |
| Last updated | February 8, 2026 |
| Repository | rudrankriyam/app-intents-agent-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
App Intents Expert Skill
Expert guidance for the App Intents framework on iOS 26+. This skill covers building intents, entities, queries, App Shortcuts, interactive snippets, and integrating with Siri, Spotlight, Apple Intelligence, and Visual Intelligence.
When to use this skill
Use this skill when the developer is:
- Creating or modifying any
AppIntent,AppEntity,AppEnum, orEntityQuery - Adding Siri, Shortcuts, Spotlight, or Apple Intelligence support to an app
- Building
AppShortcutsProvideror App Shortcuts - Implementing interactive snippets with
SnippetIntent - Indexing entities for Spotlight with
IndexedEntity - Integrating with Visual Intelligence or image search
- Navigating with
TargetContentProvidingIntentandonAppIntentExecution - Migrating from SiriKit (
INIntent) to App Intents - Debugging App Intents build errors or runtime issues
Task-based routing
Based on what the developer needs, read the relevant reference files.
"I want to create my first App Intent"
1. Read references/intent-fundamentals.md 2. Then references/shortcuts-provider.md for discoverability
"I want my app's content searchable in Siri and Spotlight"
1. Read references/entities-and-queries.md 2. Then references/spotlight-indexing.md 3. Then references/siri-integration.md
"I want to integrate with Apple Intelligence and the new Siri"
1. Read references/apple-intelligence.md 2. Then references/siri-integration.md 3. Then references/intent-fundamentals.md if new to the framework
"I want to build interactive snippets"
1. Read references/interactive-snippets.md 2. Then references/intent-fundamentals.md if not familiar with intents
"I want to add Shortcuts and Siri phrases to my app"
1. Read references/shortcuts-provider.md 2. Then references/siri-integration.md
"I want to structure my app around App Intents"
1. Read references/intent-driven-architecture.md 2. Then references/entities-and-queries.md 3. Then references/intent-fundamentals.md
"I want to support Visual Intelligence / image search"
1. Read references/apple-intelligence.md — image search section 2. Then references/entities-and-queries.md
"I'm migrating from SiriKit"
1. Read references/migration-from-sirikit.md 2. Then references/intent-fundamentals.md
"I need to test my App Intents"
1. Read references/testing-intents.md
"I'm hitting build errors or runtime issues"
1. Read references/common-pitfalls.md
Quick-reference checklists
Minimum viable App Intent
- [ ] Create a struct conforming to
AppIntent - [ ] Add a
static let title: LocalizedStringResource - [ ] Implement
func perform() async throws -> some IntentResult - [ ] Add
@Parameterfor any inputs - [ ] Add a
ParameterSummaryfor all required parameters - [ ] Set
supportedModesif the intent should foreground the app
Minimum viable App Entity
- [ ] Create a struct conforming to
AppEntity - [ ] Add a persistent, unique
idproperty - [ ] Add
@Propertyor@ComputedPropertyfor each exposed property - [ ] Provide
typeDisplayRepresentationanddisplayRepresentation - [ ] Create an
EntityQuerywithentities(for:)method - [ ] Set
static let defaultQueryon the entity - [ ] Register data dependencies with
AppDependencyManager
Minimum viable App Shortcut
- [ ] Create an
AppShortcutsProviderwithstatic var appShortcuts - [ ] Create
AppShortcutinstances with intent, phrases, shortTitle, systemImageName - [ ] Every phrase must include
\(.applicationName) - [ ] At most one
@Parameterper phrase - [ ] Call
updateAppShortcutParameters()when suggested entities change
Making an entity Spotlight-searchable
- [ ] Conform entity to
IndexedEntity - [ ] Add
indexingKeyparameter to@Propertyattributes - [ ] Donate entities using the framework's indexing APIs
- [ ] Implement an
OpenIntentso tapping results navigates to the entity
Adding interactive snippets (iOS 26+)
- [ ] Create a struct conforming to
SnippetIntent - [ ] Mark all view-driving variables as
@Parameter - [ ] Return
.result(view:)fromperform() - [ ] Use
Button(intent:)orToggle(intent:)in the snippet view - [ ] Return
ShowsSnippetIntentfrom the parent intent - [ ] Do NOT mutate app state in the snippet intent's perform method
Key decision tree
Which protocol should my intent conform to?
Is it a basic action?
→ AppIntent
Does it open your app to show content?
→ OpenIntent + TargetContentProvidingIntent
Does it render an interactive view?
→ SnippetIntent
Should it support undo?
→ UndoableIntent
Does it match an Apple Intelligence domain?
→ Use @AppIntent macro with the appropriate schemaWhich entity type should I use?
Is the set of values fixed and known at compile time?
→ AppEnum
Is the set of values dynamic?
→ AppEntity
Do I need Spotlight indexing?
→ AppEntity + IndexedEntity
Do I need image search / Visual Intelligence?
→ AppEntity + Transferable + OpenIntentWhich query type should I use?
Can all entities fit in memory?
→ EnumerableEntityQuery
Do I need text-based search?
→ EntityStringQuery
Do I need filtering by properties?
→ EntityPropertyQuery
Do I only need lookup by ID?
→ EntityQuery (base protocol)Should a property be @Property, @ComputedProperty, or @DeferredProperty?
Is the value stored on the entity struct?
→ @Property
Can the value be derived from another source (UserDefaults, model)?
→ @ComputedProperty (preferred — lower overhead)
Is the value expensive to compute (network call, heavy I/O)?
→ @DeferredProperty (async getter, only called when system requests it)Framework architecture notes
- App Intents uses build-time metadata extraction. Your Swift source code is read at compile time to generate an App Intents representation stored inside your app bundle. This is why certain values (titles, display representations) must be compile-time constants.
- The system reads this metadata without running your app. After installation, intents, entities, and shortcuts are available immediately.
- Each target in your app is processed independently. When sharing App Intent types across targets, use
AppIntentsPackageto register each target. - App Intents can now live in Swift Packages and static libraries (new in iOS 26).
- Register dependencies with
AppDependencyManager.shared.add { }as early as possible in your app's lifecycle (typically in theApp.init()).
Apple Intelligence Integration
Integrating with Apple Intelligence through assistant schemas, the @AppIntent macro, on-screen entities, and Visual Intelligence image search.
Assistant schemas and the @AppIntent macro
Apple Intelligence uses assistant schemas to understand your app's intents in the context of specific domains. The @AppIntent macro (replacing AssistantIntent in iOS 26+) annotates your intent with a schema:
@AppIntent(.photos, .search)
struct SearchPhotosIntent: AppIntent {
static let title: LocalizedStringResource = "Search Photos"
var searchCriteria: PhotoSearchCriteria
func perform() async throws -> some IntentResult {
// Search photos using criteria
}
}Available domains
Apple Intelligence supports predefined schemas across domains including:
- Photos — search, edit, organize
- Mail — compose, search, organize
- Messages — send, search
- Books — open, search
- Browsers — open tabs, search
- Spreadsheets — create, open
- Word processors — create, open
- Presentations — create, open
- File management — open, search
- Journaling — create entries
Check AppIntentDomains in Apple's documentation for the full list of available domains and their schemas.
Semantic content search (iOS 26+)
The @AppIntent macro also supports Visual Intelligence schemas:
@AppIntent(.semanticContentSearch)
struct SearchByImageIntent: AppIntent {
static let title: LocalizedStringResource = "Search by Image"
var semanticContent: SemanticContentDescriptor
func perform() async throws -> some IntentResult {
// Process the semantic content and navigate to search
}
}The macro automatically marks the schema-required properties as intent parameters.
Visual Intelligence / Image Search (iOS 26+)
Allow users to search your app's content by pointing their camera at objects or selecting screenshots:
Step 1: Implement an IntentValueQuery
struct LandmarkImageQuery: IntentValueQuery {
@Dependency var modelData: ModelData
func values(for descriptor: SemanticContentDescriptor) async throws -> [LandmarkEntity] {
// Convert descriptor pixels to a recognizable format
guard let cgImage = descriptor.cgImage else { return [] }
// Search your data model using the image
let matches = try await modelData.searchLandmarks(matching: cgImage)
return matches.map(LandmarkEntity.init)
}
}Step 2: Implement an OpenIntent
Required for tapping on search results:
struct OpenLandmarkIntent: OpenIntent {
static let title: LocalizedStringResource = "Open Landmark"
@Parameter(title: "Landmark")
var target: LandmarkEntity
}Step 3: Support mixed result types with UnionValue
If your image search can return different entity types:
enum SearchResult: UnionValue {
case landmark(LandmarkEntity)
case collection(CollectionEntity)
}
struct ImageSearchQuery: IntentValueQuery {
func values(for descriptor: SemanticContentDescriptor) async throws -> [SearchResult] {
let landmarks = try await searchLandmarks(descriptor)
let collections = try await searchCollections(descriptor)
return landmarks.map { .landmark($0) } + collections.map { .collection($0) }
}
}Implement OpenIntent for each entity type in the union.
Image search best practices
- Return a few pages of results to increase the chance of a match
- Use your servers for larger dataset searches, but don't take too long
- Provide a "more results" option that opens your app's full search view
On-screen entities
Associate App Entities with visible content so Apple Intelligence (and ChatGPT) can understand what the user is looking at:
Step 1: Attach entity to a view
struct LandmarkDetailView: View {
let landmark: LandmarkEntity
var body: some View {
ScrollView { /* content */ }
.userActivity("com.myapp.viewLandmark") { activity in
activity.appEntityIdentifier = EntityIdentifier(landmark)
}
}
}Step 2: Make entity Transferable
ChatGPT needs data it can process. Conform your entity to Transferable with a supported representation:
extension LandmarkEntity: Transferable {
static var transferRepresentation: some TransferRepresentation {
// PDF for rich content
DataRepresentation(exportedContentType: .pdf) {
try $0.generatePDF()
}
// Plain text fallback
DataRepresentation(exportedContentType: .plainText) {
$0.textDescription.data(using: .utf8) ?? Data()
}
}
}Supported types for ChatGPT integration:
- PDF (
.pdf) - Plain text (
.plainText) - Rich text (
.rtf)
How it works
When a user asks Siri about something on screen (e.g., "Is this place near the ocean?"): 1. Siri identifies the on-screen entity via userActivity 2. Offers to send a screenshot or the full entity content to ChatGPT 3. ChatGPT processes the content and responds
PredictableIntent
Help the system learn user patterns and suggest intents proactively:
struct OpenLandmarkIntent: AppIntent, PredictableIntent {
static let title: LocalizedStringResource = "Open Landmark"
@Parameter var target: LandmarkEntity
static var predictionConfiguration: some IntentPredictionConfiguration {
IntentPrediction(parameters: (\.$target, .value(niagara))) {
DisplayRepresentation(title: "Open Niagara Falls")
}
IntentPrediction(parameters: (\.$target, .value(eiffel))) {
DisplayRepresentation(title: "Open Eiffel Tower")
}
}
}The system uses these predictions alongside user behavior to surface relevant suggestions.
Key rules
- `OpenIntent` is required for image search results to be tappable.
- `@AppIntent` macro replaces `AssistantIntent` in iOS 26+ — use the macro for new code.
- On-screen entities need `Transferable` to share content with ChatGPT.
- Don't block image search queries. Return results quickly, even if incomplete.
- Use `userActivity` on detail views to associate entities with visible content.
Common Pitfalls
Frequent errors, gotchas, and debugging techniques for App Intents development.
Build-time errors
"Expression is not a string literal"
Cause: title, typeDisplayRepresentation, or caseDisplayRepresentations is not a compile-time constant.
// BAD — computed property
static var title: LocalizedStringResource {
"My Intent \(someValue)" // Error
}
// GOOD — constant
static let title: LocalizedStringResource = "My Intent"App Intents metadata extraction happens at build time. Titles, type representations, and case representations must be constant values that can be evaluated without running your code.
"Type does not conform to AppEntity"
Checklist:
- [ ] Is the struct
Identifiablewith a stableid? - [ ] Does it have
typeDisplayRepresentation? - [ ] Does it have
displayRepresentation? - [ ] Does it have
static let defaultQuery? - [ ] Is the query type correctly implementing
EntityQuery?
"AppIntentsPackage not found" or "Type not indexed"
When sharing types across targets (app + extension + package):
// Every target with App Intents types needs a package
struct MyPackage: AppIntentsPackage { }
// Parent targets must include child packages
struct AppTargetPackage: AppIntentsPackage {
static let includedPackages: [any AppIntentsPackage.Type] = [
MyPackage.self
]
}Intent not appearing in Shortcuts
Checklist:
- [ ] Is the intent in a target that's installed on the device?
- [ ] Does the intent have a valid
title? - [ ] If using App Shortcuts, is there an
AppShortcutsProvider? - [ ] Clean build folder (Cmd+Shift+K) and rebuild
- [ ] Delete the app from the device and reinstall
Runtime errors
"Entity not found" when resolving parameters
The entity's entities(for:) query method returned an empty array for the requested ID.
Common causes:
- Entity ID changed (IDs must be persistent and stable)
- Data was deleted but the system still holds a reference
- Query doesn't have access to the data store (missing
@Dependency)
// Ensure entities(for:) always attempts to resolve
func entities(for identifiers: [LandmarkEntity.ID]) async throws -> [LandmarkEntity] {
// Don't filter out missing IDs silently — the system expects results
modelData.landmarks(for: identifiers).map(LandmarkEntity.init)
}Dependencies returning nil or wrong instance
Dependencies must be registered before any intent runs:
@main
struct MyApp: App {
init() {
// Register FIRST
AppDependencyManager.shared.add { ModelData() }
}
}If you register too late (e.g., in a view's onAppear), intents triggered before that point will crash.
Snippet intent crashes or shows blank
Checklist:
- [ ] All view-driving properties are marked
@Parameter - [ ]
perform()returns quickly (no long network calls) - [ ] View doesn't access unavailable state
- [ ] Entity parameters have valid queries that return fresh data
"The operation couldn't be completed"
Generic error often caused by:
- Intent
perform()throwing an unexpected error - Network request timing out inside perform
- Missing required parameter that wasn't resolved
Wrap operations in do/catch for better diagnostics:
func perform() async throws -> some IntentResult {
do {
try await riskyOperation()
} catch {
// Log the actual error
print("Intent failed: \(error)")
throw error
}
return .result()
}Design gotchas
Don't mutate state in SnippetIntent.perform()
Snippet intents are called multiple times during their lifecycle. Side effects will execute repeatedly:
// BAD — adds to favorites every time the snippet refreshes
struct MySnippet: SnippetIntent {
func perform() async throws -> some IntentResult & ShowsSnippetView {
store.addFavorite(item) // Called on every refresh!
return .result(view: MyView())
}
}
// GOOD — only reads state
struct MySnippet: SnippetIntent {
func perform() async throws -> some IntentResult & ShowsSnippetView {
let isFavorite = store.isFavorite(item)
return .result(view: MyView(isFavorite: isFavorite))
}
}Don't catch requestConfirmation cancellation
When the user cancels a confirmation, let the error propagate:
// BAD
func perform() async throws -> some IntentResult {
do {
try await requestConfirmation(dialog: "Are you sure?")
} catch {
return .result(dialog: "Cancelled") // Don't do this
}
// ...
}
// GOOD
func perform() async throws -> some IntentResult {
try await requestConfirmation(dialog: "Are you sure?")
// If we reach here, user confirmed
// ...
}Entity IDs must not change
If you change an entity's ID scheme, all existing references break:
// BAD — ID changes if database row is recreated
var id: UUID { UUID() } // New UUID every time!
// GOOD — stable, persistent ID
var id: Int { landmark.databaseID }@Parameter on optional vs required
Optional parameters don't prompt the user. Required parameters do:
@Parameter var name: String // Required — system will ask for value
@Parameter var name: String? // Optional — nil if not provided
@Parameter(default: "Default") var name: String // Required but has a defaultDebugging techniques
Print metadata extraction
Build and check the extracted metadata:
# In your app's derived data
find ~/Library/Developer/Xcode/DerivedData -name "*.appintentsmetadata" | head -5Check if intents are registered
In the Shortcuts app, search for your app. If intents don't appear: 1. Clean build folder 2. Delete the app 3. Rebuild and reinstall 4. Wait a few seconds for metadata extraction
Test in Shortcuts first
Before testing with Siri, always verify your intents work correctly in the Shortcuts app. Shortcuts provides more detailed error information.
Console logging
Filter Console.app for App Intents related logs:
subsystem:com.apple.appintentsPerformance gotchas
Don't make entities expensive to create
Entities are created frequently (in queries, parameter resolution, etc.). Keep initialization lightweight:
// BAD — network call during init
struct LandmarkEntity: AppEntity {
var id: Int
var crowdStatus: String // Set via network call in query
// ...
}
// GOOD — use @DeferredProperty for expensive data
struct LandmarkEntity: AppEntity {
var id: Int
@DeferredProperty
var crowdStatus: String {
get async { await NetworkService.shared.fetchCrowdStatus(for: id) }
}
}Don't return too many suggested entities
suggestedEntities() should return a focused list (10-20 items), not your entire database:
func suggestedEntities() async throws -> [LandmarkEntity] {
// GOOD — focused list
modelData.favoriteLandmarks.prefix(15).map(LandmarkEntity.init)
}Batch Spotlight donations
Don't donate entities one by one:
// BAD
for landmark in landmarks {
try await CSSearchableIndex.default().indexAppEntities([LandmarkEntity(landmark: landmark)])
}
// GOOD
let entities = landmarks.map(LandmarkEntity.init)
try await CSSearchableIndex.default().indexAppEntities(entities)Entities and Queries
Building AppEntity, AppEnum, and query types for dynamic data resolution across Siri, Shortcuts, Spotlight, and Apple Intelligence.
AppEnum — fixed set of values
Use AppEnum for types with a constant, known set of values:
enum NavigationOption: String, AppEnum {
case landmarks
case map
case collections
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Navigation Option"
static let caseDisplayRepresentations: [NavigationOption: DisplayRepresentation] = [
.landmarks: DisplayRepresentation(
title: "Landmarks",
image: .init(systemName: "building.columns")
),
.map: DisplayRepresentation(
title: "Map",
image: .init(systemName: "map")
),
.collections: DisplayRepresentation(
title: "Collections",
image: .init(systemName: "book.closed")
)
]
}Requirements:
- Must have a
Stringraw value typeDisplayRepresentation— describes the type as a wholecaseDisplayRepresentations— describes each case- All representations must be compile-time constants
AppEntity — dynamic values
Use AppEntity for values that are dynamic and resolved at runtime:
struct LandmarkEntity: AppEntity {
var id: Int { landmark.id }
@ComputedProperty
var name: String { landmark.name }
@ComputedProperty
var description: String { landmark.description }
let landmark: Landmark
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Landmark")
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
static let defaultQuery = LandmarkEntityQuery()
}Requirements:
- Must be
Identifiablewith a persistent, stable identifier - The system must be able to look up entities by their ID at any time
typeDisplayRepresentation— compile-time constant describing the typedisplayRepresentation— instance property describing each entitydefaultQuery— the query the system uses to resolve entities
Property types
@Property — stored on the entity
Standard properties exposed to Shortcuts and the system:
@Property(title: "Name")
var name: String@ComputedProperty (iOS 26+) — derived from source of truth
Avoids storing duplicate values. Reads from the actual data model:
@ComputedProperty
var name: String { landmark.name }
@ComputedProperty
var defaultPlace: String { UserDefaults.standard.string(forKey: "place") ?? "" }Prefer @ComputedProperty over @Property when the value can be derived from another source.
@DeferredProperty (iOS 26+) — expensive, loaded on demand
For values that require network calls or heavy computation. The async getter is only called when the system explicitly requests the value:
@DeferredProperty
var crowdStatus: String {
get async {
await networkService.fetchCrowdStatus(for: id)
}
}Decision guide
| Property type | When to use | Overhead |
|---|---|---|
@Property | Value is stored directly on the entity struct | Low |
@ComputedProperty | Value derived from another source (model, UserDefaults) | Low |
@DeferredProperty | Value requires async work (network, heavy I/O) | Lowest (lazy) |
Queries
Queries tell the system how to find and resolve entities.
EntityQuery (base)
The minimum — lookup entities by their IDs:
struct LandmarkEntityQuery: EntityQuery {
@Dependency
var modelData: ModelData
func entities(for identifiers: [LandmarkEntity.ID]) async throws -> [LandmarkEntity] {
modelData
.landmarks(for: identifiers)
.map(LandmarkEntity.init)
}
}Every query must implement `entities(for:)` — the system uses this to resolve entity references.
Suggested entities
Provide default entities shown when a parameter needs a value:
struct LandmarkEntityQuery: EntityQuery {
@Dependency var modelData: ModelData
func entities(for identifiers: [LandmarkEntity.ID]) async throws -> [LandmarkEntity] {
modelData.landmarks(for: identifiers).map(LandmarkEntity.init)
}
func suggestedEntities() async throws -> [LandmarkEntity] {
modelData.favoriteLandmarks.map(LandmarkEntity.init)
}
}Suggested entities are also used to generate parameterized App Shortcuts.
EnumerableEntityQuery
When all entities can fit in memory:
struct ColorEntityQuery: EnumerableEntityQuery {
func allEntities() async throws -> [ColorEntity] {
ColorEntity.allColors
}
func entities(for identifiers: [ColorEntity.ID]) async throws -> [ColorEntity] {
allEntities().filter { identifiers.contains($0.id) }
}
}The framework can derive more complex query behaviors from allEntities().
EntityStringQuery
Search entities by a string:
struct LandmarkEntityQuery: EntityStringQuery {
@Dependency var modelData: ModelData
func entities(for identifiers: [LandmarkEntity.ID]) async throws -> [LandmarkEntity] {
modelData.landmarks(for: identifiers).map(LandmarkEntity.init)
}
func entities(matching string: String) async throws -> [LandmarkEntity] {
modelData.landmarks
.filter { $0.name.localizedCaseInsensitiveContains(string) ||
$0.description.localizedCaseInsensitiveContains(string) }
.map(LandmarkEntity.init)
}
}EntityPropertyQuery
Filter entities by specific properties. Enables the "Find and Filter" action in Shortcuts:
struct LandmarkEntityQuery: EntityPropertyQuery {
static let properties = QueryProperties {
Property(\LandmarkEntity.$state) {
EqualToComparator { $0 }
ContainsComparator { $0 }
}
Property(\LandmarkEntity.$isFeatured) {
EqualToComparator { $0 }
}
}
static let sortingOptions = SortingOptions {
SortableBy(\LandmarkEntity.$name)
}
func entities(
matching comparators: [LandmarkEntityQuery.Comparator],
mode: ComparatorMode,
sortedBy: [Sort<LandmarkEntity>],
limit: Int?
) async throws -> [LandmarkEntity] {
// Apply comparators and sorting to your data source
}
func entities(for identifiers: [LandmarkEntity.ID]) async throws -> [LandmarkEntity] {
// ID-based lookup
}
}Transferable conformance
Make entities shareable with other apps and usable in more Shortcuts actions:
extension LandmarkEntity: Transferable {
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(exportedContentType: .image) {
return try $0.imageRepresentationData
}
}
}This enables:
- Using entity images as inputs to photo-related Shortcuts actions
- Sharing entity data between apps
- Providing content to ChatGPT via on-screen entity (PDF, plain text, rich text)
UnionValue — mixed entity types
Return different entity types from a single query:
enum SearchResult: UnionValue {
case landmark(LandmarkEntity)
case collection(CollectionEntity)
}Useful for Visual Intelligence image search where results may include multiple entity types.
Key rules
- Entity IDs must be persistent. The system stores references to entities by ID and resolves them later.
- Always implement `entities(for:)`. This is how the system looks up entities by their stored IDs.
- Use `@Dependency` for data access. Don't create new data stores inside queries.
- Display representations on types must be constant.
typeDisplayRepresentationis read at build time. - Display representations on instances can be dynamic.
displayRepresentationis computed at runtime. - Call `updateAppShortcutParameters()` after suggested entities change to regenerate parameterized App Shortcuts.
Intent-Driven Architecture
Structuring your app around App Intents to share logic between your UI, Siri, Shortcuts, and Spotlight.
Core principle
App Intents should not duplicate your app's business logic. Instead, they act as a thin bridge between system integrations and your existing code. The intent describes what to do; your data layer handles how.
Separating concerns
Bad: Business logic in the intent
// Don't do this — logic is trapped in the intent
struct AddToFavoritesIntent: AppIntent {
static let title: LocalizedStringResource = "Add to Favorites"
@Parameter var landmark: LandmarkEntity
func perform() async throws -> some IntentResult {
let defaults = UserDefaults.standard
var favorites = defaults.array(forKey: "favorites") as? [Int] ?? []
favorites.append(landmark.id)
defaults.set(favorites, forKey: "favorites")
return .result()
}
}Good: Intent delegates to shared logic
// Intent is a thin wrapper
struct AddToFavoritesIntent: AppIntent {
static let title: LocalizedStringResource = "Add to Favorites"
@Parameter var landmark: LandmarkEntity
@Dependency var store: FavoritesStore
func perform() async throws -> some IntentResult {
try await store.addFavorite(landmark.id)
return .result(dialog: "Added \(landmark.name) to favorites.")
}
}
// Same store is used by SwiftUI views
struct LandmarkDetailView: View {
@Environment(FavoritesStore.self) var store
let landmark: Landmark
var body: some View {
Button("Favorite") {
Task { try await store.addFavorite(landmark.id) }
}
}
}Entity as bridge type
Create entities as lightweight bridges to your data model, not as replacement models:
struct LandmarkEntity: AppEntity {
// Bridge to the real model
let landmark: Landmark
var id: Int { landmark.id }
@ComputedProperty
var name: String { landmark.name }
@ComputedProperty
var state: String { landmark.state }
static let defaultQuery = LandmarkEntityQuery()
// ...
}The entity wraps your model and exposes properties through @ComputedProperty. No data duplication.
Navigation with TargetContentProvidingIntent
Avoid putting navigation logic in intents. Use TargetContentProvidingIntent with onAppIntentExecution:
// Intent — no navigation code, no perform method needed
struct OpenLandmarkIntent: OpenIntent, TargetContentProvidingIntent {
static let title: LocalizedStringResource = "Open Landmark"
@Parameter(title: "Landmark", requestValueDialog: "Which landmark?")
var target: LandmarkEntity
}
// View handles its own navigation
struct LandmarksNavigationStack: View {
@State var path: [Landmark] = []
var body: some View {
NavigationStack(path: $path) {
LandmarkListView()
.navigationDestination(for: Landmark.self) { landmark in
LandmarkDetailView(landmark: landmark)
}
}
.onAppIntentExecution(OpenLandmarkIntent.self) { intent in
path.append(intent.target.landmark)
}
}
}Benefits:
- No
@Dependencyneeded for navigation - No
@MainActorannotation on the intent - Navigation logic stays in SwiftUI where it belongs
- The intent doesn't need a
perform()method at all
Scene control with handlesExternalEvents
Control which scene handles an intent:
// Intent has a content identifier
struct OpenLandmarkIntent: OpenIntent, TargetContentProvidingIntent {
var contentIdentifier: String { persistentIdentifier }
// ...
}
// Scene declares which intents it handles
WindowGroup {
LandmarkBrowserView()
}
.handlesExternalEvents(matching: ["OpenLandmarkIntent"])
// Or conditionally on views
LandmarkBrowserView()
.handlesExternalEvents(matching: isEditing ? [] : ["OpenLandmarkIntent"])The contentIdentifier defaults to the intent's persistentIdentifier (typically the struct name).
UIKit scene handling
For UIKit apps, use UISceneAppIntent or AppIntentSceneDelegate:
struct OpenLandmarkIntent: OpenIntent, UISceneAppIntent {
@Parameter var target: LandmarkEntity
func perform() async throws -> some IntentResult {
// Access scene via self.scene
}
}
// Or delegate-based
class SceneDelegate: UIResponder, UIWindowSceneDelegate, AppIntentSceneDelegate {
func handle(_ intent: OpenLandmarkIntent) async {
// Navigate to landmark
}
}Dependency injection
Register shared dependencies once, use everywhere:
@main
struct MyApp: App {
init() {
// Register all dependencies
AppDependencyManager.shared.add { ModelData() }
AppDependencyManager.shared.add { FavoritesStore() }
AppDependencyManager.shared.add { SearchEngine() }
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(ModelData.shared)
.environment(FavoritesStore.shared)
}
}
}Intents access dependencies with @Dependency:
struct FindLandmarkIntent: AppIntent {
@Dependency var modelData: ModelData
@Dependency var favorites: FavoritesStore
}Register dependencies as early as possible — ideally in App.init().
Sharing types across targets
When App Intents code lives in multiple targets (app + extension + package):
Step 1: Move shared types to a Swift Package
// In your package
public struct LandmarkEntity: AppEntity { /* ... */ }
public struct LandmarkEntityQuery: EntityQuery { /* ... */ }Step 2: Register each target with AppIntentsPackage
// In the Swift Package containing the entity
struct LandmarkIntentsPackage: AppIntentsPackage { }
// In your app target
struct AppIntentsPackage: AppIntentsPackage {
static let includedPackages: [any AppIntentsPackage.Type] = [
LandmarkIntentsPackage.self
]
}
// In your extension target
struct ExtensionIntentsPackage: AppIntentsPackage {
static let includedPackages: [any AppIntentsPackage.Type] = [
LandmarkIntentsPackage.self
]
}This ensures the App Intents runtime has proper access to all types across targets.
Architecture checklist
- [ ] Business logic lives in shared services, not in intents
- [ ] Entities are bridges to your data model, using
@ComputedProperty - [ ] Navigation is handled by
onAppIntentExecution, not intentperform() - [ ] Dependencies are registered in
App.init()viaAppDependencyManager - [ ] Shared types are in Swift Packages with
AppIntentsPackageregistration - [ ] Queries use
@Dependencyto access data, not direct instantiation
Intent Fundamentals
Core patterns for building App Intents: the AppIntent protocol, parameters, perform methods, results, dialog, and supported modes.
Creating a basic intent
Every intent is a struct conforming to AppIntent:
import AppIntents
struct OpenFavoritesIntent: AppIntent {
static let title: LocalizedStringResource = "Open Favorites"
func perform() async throws -> some IntentResult {
return .result()
}
}Requirements:
titlemust be a compile-time constantLocalizedStringResource(no computed properties or function calls)perform()isasync throwsand returnssome IntentResult
Parameters
Use @Parameter to declare inputs. Parameters can be required (default) or optional:
struct NavigateIntent: AppIntent {
static let title: LocalizedStringResource = "Navigate to Section"
@Parameter(title: "Section", requestValueDialog: "Which section?")
var section: NavigationOption
@Parameter(title: "Animated", default: true)
var animated: Bool
func perform() async throws -> some IntentResult {
Navigator.shared.navigate(to: section, animated: animated)
return .result()
}
}Supported parameter types:
- Swift primitives:
String,Int,Double,Bool,Date,URL AppEnum— fixed set of values known at compile timeAppEntity— dynamic values resolved at runtime via queriesIntentFile— file inputs- Arrays of any of the above
Parameter Summary
A human-readable sentence describing the intent and its parameters. Required for Spotlight actions on Mac (iOS 26+):
static var parameterSummary: some ParameterSummary {
Summary("Navigate to \(\.$section)")
}Conditional summaries:
static var parameterSummary: some ParameterSummary {
When(\.$useCustomDate, .equalTo, true) {
Summary("Log \(\.$item) on \(\.$date)")
} otherwise: {
Summary("Log \(\.$item)")
}
}Return types
Compose return types to declare what your intent produces:
// Returns nothing
func perform() async throws -> some IntentResult
// Returns a value
func perform() async throws -> some ReturnsValue<MyEntity>
// Returns a value with spoken dialog
func perform() async throws -> some ReturnsValue<MyEntity> & ProvidesDialog
// Returns a value with dialog and a view snippet
func perform() async throws -> some ReturnsValue<MyEntity> & ProvidesDialog & ShowsSnippetView
// Shows an interactive snippet intent (iOS 26+)
func perform() async throws -> some ReturnsValue<MyEntity> & ShowsSnippetIntent & ProvidesDialogDialog
Dialog is text that Siri can speak aloud:
return .result(
value: landmark,
dialog: IntentDialog(
full: "The closest landmark is \(landmark.name).",
supporting: "\(landmark.name) is located in \(landmark.continent)."
)
)View snippets
Attach a SwiftUI view to display alongside the result:
return .result(
value: landmark,
dialog: "The closest landmark is \(landmark.name)",
view: LandmarkCardView(landmark: landmark)
)Supported modes (foreground control)
Control whether the intent foregrounds the app:
struct GetStatusIntent: AppIntent {
static let title: LocalizedStringResource = "Get Status"
// Never foregrounds
static let supportedModes: IntentModes = .background
// Always foregrounds before perform runs
static let supportedModes: IntentModes = .foreground
// Both — intent decides at runtime
static let supportedModes: IntentModes = [.background, .foreground]
func perform() async throws -> some IntentResult {
// Check current mode
if currentMode == .foreground {
// Navigate in the app
} else {
// Return dialog only
}
return .result()
}
}Dynamic and deferred foreground modes
// Dynamic: intent decides whether to foreground
static let supportedModes: IntentModes = [.background, .dynamic]
// Deferred: intent will foreground eventually, but not immediately
static let supportedModes: IntentModes = [.background, .deferred]To foreground from a dynamic/deferred intent:
func perform() async throws -> some IntentResult {
let canForeground = systemContext.canContinueInForeground
if canForeground {
try await continueInForeground(alwaysConfirm: false)
// Now in foreground — navigate
} else {
// Return dialog-only result
}
return .result()
}Dependencies
Inject shared objects into intents using @Dependency:
struct FindLandmarkIntent: AppIntent {
static let title: LocalizedStringResource = "Find Landmark"
@Dependency
var modelData: ModelData
func perform() async throws -> some ReturnsValue<LandmarkEntity> {
let landmark = try await modelData.findClosestLandmark()
return .result(value: landmark)
}
}Register dependencies early in the app lifecycle:
@main
struct MyApp: App {
init() {
AppDependencyManager.shared.add { ModelData() }
}
}Open Intent
Use OpenIntent for intents that navigate to content in your app:
struct OpenLandmarkIntent: OpenIntent {
static let title: LocalizedStringResource = "Open Landmark"
@Parameter(title: "Landmark", requestValueDialog: "Which landmark?")
var target: LandmarkEntity
}OpenIntent automatically foregrounds the app — no need to set supportedModes. Combine with TargetContentProvidingIntent for SwiftUI navigation (see intent-driven-architecture.md).
Undoable Intent (iOS 26+)
Support system undo gestures:
struct DeleteCollectionIntent: AppIntent, UndoableIntent {
static let title: LocalizedStringResource = "Delete Collection"
@Parameter var collection: CollectionEntity
func perform() async throws -> some IntentResult {
let data = collection.backup()
undoManager?.registerUndo(withTarget: store) { store in
store.restore(data)
}
undoManager?.setActionName("Delete \(collection.name)")
store.delete(collection)
return .result()
}
}Multiple choice (iOS 26+)
Present options for the user to pick from:
func perform() async throws -> some IntentResult {
let delete = IntentOption(title: "Delete", style: .destructive)
let archive = IntentOption(title: "Archive")
let cancel = IntentOption(title: "Cancel", style: .cancel)
let choice = try await requestChoice(
between: [delete, archive, cancel],
dialog: "What would you like to do?"
)
switch choice {
case delete: store.delete(item)
case archive: store.archive(item)
default: break
}
return .result()
}Requesting values at runtime
If a parameter is not provided, request it:
func perform() async throws -> some IntentResult {
let count = try await $ticketCount.requestValue("How many tickets?")
// use count
return .result()
}Key rules
- Titles must be constant.
static let titlecannot call functions or use computed properties. - Perform is the only place for side effects. Do not mutate state in initializers.
- Return types are declarative. The system inspects the return type signature at build time.
- Register dependencies early.
AppDependencyManager.shared.add { }inApp.init(). - Use `@MainActor` on perform when the intent needs to interact with UI-related code.
Interactive Snippets (iOS 26+)
Building interactive App Intents snippets with buttons, toggles, confirmation flows, and live state updates.
Overview
Interactive snippets let your intents display SwiftUI views that users can interact with — directly in Siri, Spotlight, and the Shortcuts app. Users can tap buttons, toggle switches, and trigger additional intents without leaving the snippet.
SnippetIntent
A SnippetIntent renders a view based on its parameters and the current app state:
struct LandmarkSnippetIntent: SnippetIntent {
static let title: LocalizedStringResource = "Landmark Snippet"
@Parameter
var landmark: LandmarkEntity
@Dependency
var modelData: ModelData
func perform() async throws -> some IntentResult & ShowsSnippetView {
let isFavorite = await modelData.isFavorite(landmark)
return .result(
view: LandmarkView(landmark: landmark, isFavorite: isFavorite)
)
}
}Requirements
- All view-driving variables must be marked as `@Parameter` — the system populates them automatically
- Do NOT mutate app state in the snippet intent's
perform()— it's called multiple times during the snippet lifecycle - Render views quickly — slow snippets feel unresponsive
- The snippet view uses the same SwiftUI as interactive widgets
Returning a snippet from a parent intent
Use ShowsSnippetIntent in the return type:
struct ClosestLandmarkIntent: AppIntent {
static let title: LocalizedStringResource = "Find Closest Landmark"
@Dependency var modelData: ModelData
func perform() async throws -> some ReturnsValue<LandmarkEntity> & ShowsSnippetIntent & ProvidesDialog {
let landmark = await findClosestLandmark()
return .result(
value: landmark,
dialog: IntentDialog(
full: "The closest landmark is \(landmark.name).",
supporting: "\(landmark.name) is located in \(landmark.continent)."
),
snippetIntent: LandmarkSnippetIntent(landmark: landmark)
)
}
}The system uses the snippetIntent parameter to manage the snippet's lifecycle. The parameter values you provide (here, landmark) are stored and used to repopulate the snippet intent each time it refreshes.
Interactive buttons and toggles
Associate buttons and toggles with App Intents inside your snippet view:
struct LandmarkView: View {
let landmark: LandmarkEntity
let isFavorite: Bool
var body: some View {
HStack {
Text(landmark.name)
Button(intent: UpdateFavoritesIntent(
landmark: landmark,
isFavorite: !isFavorite
)) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
Button(intent: FindTicketsIntent(landmark: landmark)) {
Text("Find Tickets")
}
}
}
}Button(intent:)— runs the associated intent when tappedToggle(isOn:intent:)— runs the intent when toggled
These are the same APIs used for interactive widgets.
Snippet update cycle
When a button or toggle is tapped:
1. The system runs the associated intent (e.g., UpdateFavoritesIntent) 2. Waits for it to complete 3. Re-populates the snippet intent's parameters with the original values 4. App Entity parameters are re-fetched from their queries (getting latest state) 5. Runs the snippet intent's perform() again 6. The updated view replaces the previous one with animation
This cycle continues until the snippet is dismissed. Use contentTransition modifiers to customize animations between states.
Confirmation snippets
Present an interactive configuration before proceeding:
struct FindTicketsIntent: AppIntent {
static let title: LocalizedStringResource = "Find Tickets"
@Parameter var landmark: LandmarkEntity
func perform() async throws -> some IntentResult & ShowsSnippetIntent {
let searchRequest = await searchEngine.createRequest(for: landmark)
// Present interactive configuration
try await requestConfirmation(
actionName: .search,
snippetIntent: TicketRequestSnippetIntent(searchRequest: searchRequest)
)
// User tapped the action button — proceed with search
let results = try await searchEngine.search(searchRequest)
return .result(
snippetIntent: TicketResultsSnippetIntent(results: results)
)
}
}If the user cancels the confirmation, requestConfirmation throws an error. Do not catch this error — let it terminate the perform method.
Snippet chaining
A button inside a result snippet can trigger an intent that presents its own snippet, replacing the current one:
Result Snippet → Button tap → New Intent → Confirmation Snippet or Result SnippetThis only works when the original snippet is showing a result (not a confirmation).
Reloading snippets
Force a snippet to refresh when app state changes:
// Reload a specific snippet intent
await LandmarkSnippetIntent.reload()Useful when background state changes should be reflected in a visible snippet.
Key design rules
- Model entities, not values. Use
AppEntityfor parameters that need fresh data on refresh. Primitive values are only set once and never re-fetched. - Don't store mutable state on snippet intents. The system recreates them each cycle.
- Keep perform fast. Slow rendering makes the snippet feel broken.
- The system won't terminate your app while a snippet is visible — feel free to hold state in memory.
- Dark mode and dynamic type — the system may re-run your snippet intent to adapt to device changes.
- Use `contentTransition` to animate changes between snippet updates.
Complete example
// The snippet intent
struct OrderSnippetIntent: SnippetIntent {
static let title: LocalizedStringResource = "Order Snippet"
@Parameter var order: OrderEntity
@Dependency var store: OrderStore
func perform() async throws -> some IntentResult & ShowsSnippetView {
let status = await store.status(for: order)
return .result(
view: OrderStatusView(order: order, status: status)
)
}
}
// The view with interactive buttons
struct OrderStatusView: View {
let order: OrderEntity
let status: OrderStatus
var body: some View {
VStack {
Text(order.name)
Text(status.description)
if status == .ready {
Button(intent: ConfirmPickupIntent(order: order)) {
Text("Confirm Pickup")
}
}
}
.contentTransition(.numericText())
}
}Migration from SiriKit
Moving from legacy SiriKit (INIntent) to the App Intents framework.
When to migrate
- New intents: Always use App Intents. SiriKit is not receiving new features.
- Existing SiriKit intents: Migrate when you update to iOS 26+ or when you need new capabilities (Spotlight, Apple Intelligence, interactive snippets).
- Custom SiriKit domains: Migrate to App Intents. Custom intents defined in
.intentdefinitionfiles should be rewritten asAppIntentstructs. - System SiriKit domains (messaging, payments, etc.): Check Apple's documentation — some system domains still use SiriKit, but Apple is progressively moving them to App Intents.
Key differences
| Feature | SiriKit (INIntent) | App Intents |
|---|---|---|
| Definition | .intentdefinition file (Xcode editor) | Swift structs in code |
| Code generation | Xcode generates classes | No code generation — you write the structs |
| Parameters | Properties on generated classes | @Parameter property wrapper |
| Resolution | resolve methods on handler | EntityQuery and parameter resolution |
| Confirmation | confirm method on handler | requestConfirmation() in perform() |
| Execution | handle method on handler | perform() method on intent |
| Extension | Requires Intents Extension | Can run in app or extension |
| Shortcuts | Requires INShortcut donation | AppShortcutsProvider with phrases |
| Siri dialog | INIntentResponse with templates | IntentDialog returned from perform() |
| Spotlight | Not integrated | IndexedEntity + donation |
| Apple Intelligence | Limited | Full integration via @AppIntent macro |
Migration steps
Step 1: Identify your SiriKit intents
Review your .intentdefinition file and Intents Extension. List each intent and its parameters.
Step 2: Create equivalent AppIntent structs
For each SiriKit intent, create a corresponding AppIntent:
Before (SiriKit):
// Generated from .intentdefinition
class OrderFoodIntent: INIntent {
@NSManaged var restaurant: INObject?
@NSManaged var menuItem: INObject?
@NSManaged var quantity: NSNumber?
}
class OrderFoodIntentHandler: NSObject, OrderFoodIntentHandling {
func resolveRestaurant(for intent: OrderFoodIntent) async -> INObjectResolutionResult { /* ... */ }
func confirm(intent: OrderFoodIntent) async -> OrderFoodIntentResponse { /* ... */ }
func handle(intent: OrderFoodIntent) async -> OrderFoodIntentResponse { /* ... */ }
}After (App Intents):
struct OrderFoodIntent: AppIntent {
static let title: LocalizedStringResource = "Order Food"
@Parameter(title: "Restaurant")
var restaurant: RestaurantEntity
@Parameter(title: "Menu Item")
var menuItem: MenuItemEntity
@Parameter(title: "Quantity", default: 1)
var quantity: Int
static var parameterSummary: some ParameterSummary {
Summary("Order \(\.$quantity) \(\.$menuItem) from \(\.$restaurant)")
}
func perform() async throws -> some IntentResult & ProvidesDialog {
try await requestConfirmation(
dialog: "Order \(quantity) \(menuItem.name) from \(restaurant.name)?"
)
try await OrderService.shared.place(
restaurant: restaurant.id,
item: menuItem.id,
quantity: quantity
)
return .result(dialog: "Order placed!")
}
}Step 3: Convert INObject types to AppEntity
Replace SiriKit's INObject with proper AppEntity types:
struct RestaurantEntity: AppEntity {
var id: String
@ComputedProperty var name: String { restaurant.name }
let restaurant: Restaurant
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Restaurant")
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
static let defaultQuery = RestaurantEntityQuery()
}Step 4: Convert resolution to queries
SiriKit's resolve methods become EntityQuery implementations:
Before:
func resolveRestaurant(for intent: OrderFoodIntent) async -> INObjectResolutionResult {
if let restaurant = intent.restaurant {
return .success(with: restaurant)
}
return .needsValue()
}After:
struct RestaurantEntityQuery: EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [RestaurantEntity] {
RestaurantStore.shared.restaurants(for: identifiers).map(RestaurantEntity.init)
}
func entities(matching string: String) async throws -> [RestaurantEntity] {
RestaurantStore.shared.search(string).map(RestaurantEntity.init)
}
func suggestedEntities() async throws -> [RestaurantEntity] {
RestaurantStore.shared.recentRestaurants.map(RestaurantEntity.init)
}
}Step 5: Replace INShortcut donations with AppShortcutsProvider
Before:
let intent = OrderFoodIntent()
intent.restaurant = INObject(identifier: "pizza-place", display: "Pizza Place")
let shortcut = INShortcut(intent: intent)
INVoiceShortcutCenter.shared.setShortcutSuggestions([shortcut])After:
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OrderFoodIntent(),
phrases: [
"Order from \(\.$restaurant) in \(.applicationName)",
"Order food in \(.applicationName)"
],
shortTitle: "Order Food",
systemImageName: "fork.knife"
)
}
}Step 6: Remove Intents Extension (if possible)
If all your intents are migrated to App Intents and can run in-process, you can remove your Intents Extension. App Intents can run directly in your app process.
If you need background execution (e.g., for Shortcuts automations), keep an App Intents Extension instead.
Coexistence
During migration, SiriKit and App Intents can coexist in the same app. You don't have to migrate everything at once:
- Keep existing SiriKit intents that work and aren't being changed
- Build new intents with App Intents
- Migrate SiriKit intents one at a time as you update features
Key benefits after migration
- No code generation. Intents are pure Swift structs you control.
- Spotlight integration. Entities appear in Spotlight search.
- Apple Intelligence. Intents can use assistant schemas.
- Interactive snippets. Rich, interactive views in Siri and Shortcuts.
- Swift Packages. Share intent code across targets without frameworks.
- Better type safety.
AppEntityandAppEnumvs untypedINObject.
App Shortcuts Provider
Creating AppShortcutsProvider to make your intents discoverable in Siri, Spotlight, Shortcuts app, Action Button, and Apple Pencil.
AppShortcutsProvider
Your app should define a single provider containing all your App Shortcuts:
import AppIntents
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: NavigateIntent(),
phrases: [
"Navigate in \(.applicationName)",
"Navigate to \(\.$section) in \(.applicationName)"
],
shortTitle: "Navigate",
systemImageName: "arrowshape.forward"
)
AppShortcut(
intent: FindClosestLandmarkIntent(),
phrases: [
"Find closest landmark in \(.applicationName)"
],
shortTitle: "Closest Landmark",
systemImageName: "location"
)
AppShortcut(
intent: OpenLandmarkIntent(),
phrases: [
"Open \(\.$target) in \(.applicationName)",
"Open landmark in \(.applicationName)"
],
shortTitle: "Open Landmark",
systemImageName: "mappin"
)
}
}AppShortcut structure
Each AppShortcut requires:
| Parameter | Type | Description |
|---|---|---|
intent | AppIntent | An instance of the intent to run |
phrases | [LocalizedStringResource] | Siri phrases — each must include \(.applicationName) |
shortTitle | LocalizedStringResource | Shown in Shortcuts app and Spotlight |
systemImageName | String | SF Symbol for the shortcut's icon |
Phrase rules
1. Every phrase must include `\(.applicationName)` — the system substitutes your app's name 2. At most one `@Parameter` per phrase — e.g., \(\.$target) 3. Parameterized phrases create one shortcut per suggested entity value 4. Mix parameterized and non-parameterized phrases — provide fallbacks without parameters 5. Phrases are localized — provide translations for all supported locales
Non-parameterized phrase
Creates a single App Shortcut:
"Find closest landmark in \(.applicationName)"Parameterized phrase with AppEnum
Creates one shortcut per enum case:
"Navigate to \(\.$section) in \(.applicationName)"
// Creates: "Navigate to Landmarks in MyApp", "Navigate to Map in MyApp", etc.Parameterized phrase with AppEntity
Creates one shortcut per suggested entity:
"Open \(\.$target) in \(.applicationName)"
// Creates: "Open Niagara Falls in MyApp", "Open Eiffel Tower in MyApp", etc.Requires suggestedEntities() on the entity's query.
Updating parameterized shortcuts
When suggested entities change (e.g., user adds a new favorite), update the shortcuts:
// Call this whenever suggested entities change
Task {
try await MyAppShortcuts.updateAppShortcutParameters()
}Common places to call this:
- After the user adds/removes a favorite
- On app launch when data has changed
- After a sync operation completes
Where App Shortcuts appear
Once registered, your shortcuts automatically appear in:
- Spotlight — shown when searching, and can run actions directly on Mac (iOS 26+)
- Siri — invoked by speaking or typing phrases
- Shortcuts app — listed in your app's section without user setup
- Action Button — configurable on iPhone 15 Pro+ and iPhone 16+
- Apple Pencil Pro — configurable squeeze action
Spotlight integration (iOS 26+)
New in iOS 26, Shortcuts can run directly from Spotlight on Mac. For this to work:
1. Implement a ParameterSummary that includes all required parameters 2. The intent's parameters must be resolvable from Spotlight's context
struct NavigateIntent: AppIntent {
static let title: LocalizedStringResource = "Navigate to Section"
static var parameterSummary: some ParameterSummary {
Summary("Navigate to \(\.$section)")
}
@Parameter(title: "Section")
var section: NavigationOption
func perform() async throws -> some IntentResult {
// ...
}
}Best practices
- Define one `AppShortcutsProvider` per app. The system expects a single provider.
- Start with 2-3 shortcuts. Focus on your app's most important actions.
- Use descriptive `shortTitle` values. These appear in the Shortcuts app and Spotlight.
- Choose recognizable SF Symbols. The icon helps users identify shortcuts visually.
- Call `updateAppShortcutParameters()` after any change to suggested entities or favorites.
- Test in Shortcuts app. Verify your shortcuts appear correctly and run as expected.
Siri Integration
Making your App Intents work with Siri, including phrases, dialog, SiriTipView, and the Gemini-powered capabilities in iOS 26.4+.
How Siri discovers your intents
Siri finds your intents through two mechanisms:
1. App Shortcuts — intents registered in AppShortcutsProvider with phrases (see shortcuts-provider.md) 2. Assistant schemas — intents annotated with @AppIntent macro for Apple Intelligence domains (see apple-intelligence.md)
Siri does NOT automatically discover all AppIntent conforming types. You must explicitly surface them through one of these mechanisms.
Siri phrases
Phrases are defined in AppShortcut and must include \(.applicationName):
AppShortcut(
intent: FindClosestLandmarkIntent(),
phrases: [
"Find closest landmark in \(.applicationName)",
"What's near me in \(.applicationName)",
"Nearest landmark in \(.applicationName)"
],
shortTitle: "Closest Landmark",
systemImageName: "location"
)Parameterized phrases
Include at most one parameter per phrase. The system creates a shortcut for each suggested entity value:
AppShortcut(
intent: OpenLandmarkIntent(),
phrases: [
"Open \(\.$target) in \(.applicationName)",
"Show \(\.$target) in \(.applicationName)"
],
shortTitle: "Open Landmark",
systemImageName: "mappin"
)For this to work, the entity's query must implement suggestedEntities(), and you must call updateAppShortcutParameters() when those entities change.
Dialog
Dialog is how your intent communicates results through Siri's voice:
Simple dialog
return .result(dialog: "Done! Your collection has been created.")Structured dialog (iOS 26+)
Provide both full and supporting dialog:
return .result(
dialog: IntentDialog(
full: "The closest landmark is \(name).",
supporting: "\(name) is located in \(continent)."
)
)full— the primary spoken/displayed textsupporting— additional context shown visually
Request value dialog
Customize the prompt when asking for parameter values:
@Parameter(title: "Landmark", requestValueDialog: "Which landmark would you like to open?")
var target: LandmarkEntitySiriTipView
Display a tip in your app's UI that teaches users the Siri phrase:
import AppIntents
struct LandmarkDetailView: View {
var body: some View {
VStack {
// Your content
SiriTipView(intent: OpenLandmarkIntent())
}
}
}SiriTipView automatically shows the registered phrase for the intent. It respects the user's Siri settings and only appears when relevant.
ShortcutsLink
Link to the Shortcuts app to show all your app's shortcuts:
ShortcutsLink()This opens the Shortcuts app filtered to your app's available shortcuts.
Confirmation flows
Ask the user to confirm before performing destructive or significant actions:
func perform() async throws -> some IntentResult {
try await requestConfirmation(
actionName: .delete,
dialog: "Are you sure you want to delete \(collection.name)?"
)
// User confirmed — proceed
store.delete(collection)
return .result(dialog: "Deleted \(collection.name).")
}Confirmation with snippet view
try await requestConfirmation(
actionName: .send,
dialog: "Send this order?",
view: OrderSummaryView(order: order)
)Siri and foreground behavior
When Siri runs your intent:
- By default, the app stays in the background — Siri shows dialog and snippets
- Set
supportedModes: .foregroundto open the app before performing - Use dynamic/deferred modes when the decision depends on runtime state
For voice-only contexts (AirPods, CarPlay), Siri reads the dialog aloud. Always provide meaningful dialog even if you also show a view.
Best practices
- Keep phrases natural. Write them as things a person would actually say.
- Provide 2-5 phrase variations. More variations increase the chance Siri matches the user's speech.
- Always include dialog. Even if your intent opens the app, Siri needs dialog for voice-only contexts.
- Use `SiriTipView` strategically. Place it where users are likely to want to repeat the action with Siri.
- Test with Siri. Run your phrases through Siri on device to verify recognition and behavior.
- Localize phrases. Phrases are
LocalizedStringResource— provide translations for each supported locale.
Spotlight Indexing
Making your entities searchable in Spotlight with IndexedEntity, property indexing keys, and entity donation.
IndexedEntity
Conform your entity to IndexedEntity to make it searchable in Spotlight:
struct LandmarkEntity: IndexedEntity {
var id: Int { landmark.id }
@Property(indexingKey: \.displayName)
var name: String
@Property(indexingKey: \.contentDescription)
var description: String
let landmark: Landmark
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Landmark")
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
static let defaultQuery = LandmarkEntityQuery()
}Indexing keys (iOS 26+)
The indexingKey parameter on @Property maps entity properties to Core Spotlight attribute keys. Common keys:
| Indexing Key | Purpose |
|---|---|
\.displayName | Primary searchable name |
\.contentDescription | Description text for search and display |
\.keywords | Additional search keywords |
\.thumbnailData | Thumbnail image data |
Custom indexing keys are also supported for domain-specific search filtering. For example, adding a continent property with a custom key allows Spotlight to filter landmarks by typing "Asia."
Donating entities to Spotlight
After conforming to IndexedEntity, donate entities so Spotlight can index them:
// Donate a single entity
try await CSSearchableIndex.default().indexAppEntities([landmarkEntity])
// Donate multiple entities
let entities = landmarks.map(LandmarkEntity.init)
try await CSSearchableIndex.default().indexAppEntities(entities)
// Remove entities
try await CSSearchableIndex.default().deleteAppEntities(
ofType: LandmarkEntity.self,
identifiedBy: [removedLandmark.id]
)When to donate
- On app launch (for initial indexing)
- When new content is created or imported
- After a sync operation
- When content is updated (re-donate with same ID to update)
Handling Spotlight taps
When a user taps a Spotlight result, the system looks for a matching OpenIntent:
struct OpenLandmarkIntent: OpenIntent, TargetContentProvidingIntent {
static let title: LocalizedStringResource = "Open Landmark"
@Parameter(title: "Landmark", requestValueDialog: "Which landmark?")
var target: LandmarkEntity
}You must implement an `OpenIntent` for your entity type. Without it, tapping a Spotlight result will just foreground the app without navigation.
SwiftUI navigation from Spotlight
Use onAppIntentExecution to handle the navigation in your view:
struct LandmarksNavigationStack: View {
@State var path: [Landmark] = []
var body: some View {
NavigationStack(path: $path) {
LandmarkListView()
}
.onAppIntentExecution(OpenLandmarkIntent.self) { intent in
path.append(intent.target.landmark)
}
}
}Spotlight actions on Mac (iOS 26+)
New in iOS 26, intents with a complete ParameterSummary can run directly from Spotlight on Mac:
struct NavigateIntent: AppIntent {
static let title: LocalizedStringResource = "Navigate to Section"
static var parameterSummary: some ParameterSummary {
Summary("Navigate to \(\.$section)")
}
@Parameter(title: "Section")
var section: NavigationOption
// ...
}Requirements:
ParameterSummarymust include all required parameters- Parameters must be resolvable from Spotlight's context (enum values, suggested entities)
Prioritizing entities with on-screen context
Associate entities with visible content so Spotlight prioritizes them in suggestions:
struct LandmarkDetailView: View {
let landmark: LandmarkEntity
var body: some View {
ScrollView { /* content */ }
.userActivity("com.myapp.landmark") { activity in
activity.appEntityIdentifier = EntityIdentifier(landmark)
}
}
}This tells the system which entities are currently relevant, improving Spotlight suggestions and Apple Intelligence context.
Auto-generated Find actions
When you adopt IndexedEntity with property indexing keys, the Shortcuts app automatically generates Find and Filter actions for your entities. Users can build shortcuts that search and filter your entities by the indexed properties without you writing any additional code.
Best practices
- Donate frequently. Spotlight rankings improve with fresh data.
- Use meaningful indexing keys. Map properties to the right Core Spotlight attributes for better search relevance.
- Always implement OpenIntent. Without it, Spotlight results are dead ends.
- Keep entity IDs stable. Spotlight stores references by ID — changing IDs creates orphaned entries.
- Re-donate on update. Donating an entity with an existing ID updates its Spotlight entry.
- Batch donations. Use
indexAppEntities([...])with arrays for better performance.
Testing App Intents
Strategies for unit testing intents, entities, queries, and integration testing with Siri and Shortcuts.
Unit testing intents
App Intents are plain Swift structs, making them directly testable:
import Testing
import AppIntents
@testable import MyApp
@Suite
struct NavigateIntentTests {
@Test
func navigateToLandmarks() async throws {
var intent = NavigateIntent()
intent.section = .landmarks
let result = try await intent.perform()
// Verify navigation occurred
#expect(Navigator.shared.currentSection == .landmarks)
}
@Test
func navigateToMap() async throws {
var intent = NavigateIntent()
intent.section = .map
let result = try await intent.perform()
#expect(Navigator.shared.currentSection == .map)
}
}Setting parameter values
Set @Parameter values directly on the intent struct:
var intent = OpenLandmarkIntent()
intent.target = LandmarkEntity(landmark: testLandmark)Testing with dependencies
Register test doubles before running intents:
@Suite
struct FindLandmarkTests {
init() {
// Register mock dependencies
AppDependencyManager.shared.add { MockModelData() as ModelData }
}
@Test
func findClosestLandmark() async throws {
let intent = FindClosestLandmarkIntent()
let result = try await intent.perform()
// Verify the returned entity
}
}Dependency injection pattern
Design your dependencies as protocols for easy mocking:
protocol ModelDataProviding {
func findClosestLandmark() async throws -> Landmark
func landmarks(for ids: [Int]) -> [Landmark]
}
class ModelData: ModelDataProviding { /* real implementation */ }
class MockModelData: ModelDataProviding { /* test implementation */ }Register the mock in tests:
AppDependencyManager.shared.add { MockModelData() as ModelDataProviding }Testing entities and queries
Testing entity creation
@Test
func entityCreation() {
let landmark = Landmark(id: 1, name: "Niagara Falls", state: "New York")
let entity = LandmarkEntity(landmark: landmark)
#expect(entity.id == 1)
#expect(entity.name == "Niagara Falls")
}Testing queries
@Test
func queryByIdentifiers() async throws {
AppDependencyManager.shared.add { MockModelData() as ModelData }
let query = LandmarkEntityQuery()
let results = try await query.entities(for: [1, 2, 3])
#expect(results.count == 3)
#expect(results[0].id == 1)
}
@Test
func queryStringSearch() async throws {
AppDependencyManager.shared.add { MockModelData() as ModelData }
let query = LandmarkEntityQuery()
let results = try await query.entities(matching: "Niagara")
#expect(results.count == 1)
#expect(results[0].name == "Niagara Falls")
}
@Test
func querySuggestedEntities() async throws {
AppDependencyManager.shared.add { MockModelData() as ModelData }
let query = LandmarkEntityQuery()
let suggestions = try await query.suggestedEntities()
#expect(!suggestions.isEmpty)
}Testing App Shortcuts
Verify your AppShortcutsProvider is correctly configured:
@Test
func shortcutsProviderHasExpectedShortcuts() {
let shortcuts = MyAppShortcuts.appShortcuts
#expect(shortcuts.count >= 2)
}Verifying phrases
While you can't directly test Siri phrase matching, you can verify the shortcuts are configured with phrases:
@Test
func shortcutPhrasesIncludeAppName() {
// Verify at build time by inspecting the provider
// Siri matching is tested manually on device
let shortcuts = MyAppShortcuts.appShortcuts
#expect(!shortcuts.isEmpty)
}Integration testing
Testing in Shortcuts app
1. Build and run your app on a device or simulator 2. Open the Shortcuts app 3. Create a new shortcut with your intent 4. Verify parameters are editable and the intent runs correctly 5. Test with different parameter combinations
Testing with Siri
1. Build and run on a physical device (Siri works best on device) 2. Invoke Siri and speak your App Shortcut phrases 3. Verify:
- Siri recognizes the phrase
- Parameters are resolved correctly
- Dialog is spoken/displayed
- View snippets render properly
- The app foregrounds when expected
Testing Spotlight integration
1. Donate entities to Spotlight 2. Open Spotlight and search for entity names 3. Verify entities appear in results 4. Tap results and verify navigation
Common testing patterns
Resetting state between tests
@Suite
struct IntentTests {
init() {
// Reset shared state
AppDependencyManager.shared.add { FreshModelData() as ModelData }
}
}Testing error cases
@Test
func performThrowsWhenLandmarkNotFound() async throws {
AppDependencyManager.shared.add { EmptyModelData() as ModelData }
let intent = FindClosestLandmarkIntent()
await #expect(throws: AppIntentError.self) {
try await intent.perform()
}
}Testing return values
@Test
func intentReturnsCorrectEntity() async throws {
var intent = FindClosestLandmarkIntent()
let result = try await intent.perform()
// Access the returned value through the result
// The exact API depends on your return type composition
}What you can't unit test
Some aspects require manual testing on device:
- Siri phrase recognition — depends on speech recognition
- Spotlight ranking — depends on the indexing service
- Interactive snippets rendering — requires the system snippet host
- App Shortcut parameter generation — requires
updateAppShortcutParameters() - Apple Intelligence integration — requires device with Apple Intelligence enabled
- Visual Intelligence image search — requires camera/screenshot context
Best practices
- Test intents as plain structs. They're just Swift — use standard testing patterns.
- Mock dependencies with protocols. Register test doubles in
AppDependencyManager. - Test queries thoroughly. Query correctness is critical — the system relies on them.
- Test edge cases. Empty results, invalid IDs, network failures.
- Use device testing for Siri. Simulator Siri support is limited.
- Automate what you can. Unit test the logic, manually test the system integration.