Highlight
SiriKit Intents introduced in iOS 10 were Objective-C based, required a separate Extension, and had high integration cost. App Intents completely redesigns this stack: define Intents in Swift directly in the main app target, with no Extension and no extra framework target.
Core Content
SiriKit Intents let apps connect to Siri, but they mainly work around fixed system domains such as messaging, fitness, and payments. When developers want to hand arbitrary app functionality to Siri, Spotlight, and Shortcuts, they often maintain extra definition files, Extensions, and shared frameworks.
App Intents turns this into Swift code. An Intent is a single action the app exposes to the system. An Entity represents an object in the app. An App Shortcut wraps an Intent into a discoverable, voice-triggerable entry point.
The talk uses a library app as an example. Users often open the “Currently Reading” shelf, so the first Intent does one thing: open that shelf. The code then expands step by step to open any shelf, open a book, add a book, find books by property, and return text and SwiftUI snippets to Siri.
Detailed Content
Write an Intent in Swift
(05:33) The minimal shape of an Intent is a Swift type conforming to AppIntent. It includes a title, a perform() method, and optional run behavior.
struct OpenCurrentlyReading: AppIntent {
static var title: LocalizedStringResource = "Open Currently Reading"
@MainActor
func perform() async throws -> some IntentResult {
Navigator.shared.openShelf(.currentlyReading)
return .result()
}
static var openAppWhenRun: Bool = true
}
Key points:
struct OpenCurrentlyReading: AppIntentdefines an action the system can run.titleis the localized title users see in the Shortcuts editor.perform()is the code that actually runs when the Intent executes.@MainActorruns navigation on the main thread because the exampleNavigatorrequires it.Navigator.shared.openShelf(.currentlyReading)reuses existing in-app navigation logic.return .result()tells the system the action completed.openAppWhenRun = truelaunches the app when this UI-opening Intent runs.
(06:42) If you want users to discover this action in Siri and Spotlight without manually creating a Shortcut, also provide AppShortcutsProvider.
struct LibraryAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OpenCurrentlyReading(),
phrases: ["Open Currently Reading in \(.applicationName)"],
systemImageName: "books.vertical.fill"
)
}
}
Key points:
LibraryAppShortcutsdeclares the set of shortcut actions the app provides.AppShortcutturns an Intent into an entry point the system can show and invoke.intent: OpenCurrentlyReading()binds the action defined above.phrasesdefines phrases users can say to Siri.\(.applicationName)inserts the app name into the spoken phrase.systemImageNamesupplies an icon for Shortcuts and Spotlight.
Parameters let one Intent cover multiple targets
(07:11) If you can only open “Currently Reading,” the Intent is too narrow. Shelves are a fixed set, so an enum fits. To use an enum as an Intent parameter, conform to AppEnum.
enum Shelf: String {
case currentlyReading
case wantToRead
case read
}
extension Shelf: AppEnum {
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Shelf"
static var caseDisplayRepresentations: [Shelf: DisplayRepresentation] = [
.currentlyReading: "Currently Reading",
.wantToRead: "Want to Read",
.read: "Read",
]
}
Key points:
Shelf: Stringgives each shelf a stable raw value.AppEnumlets the enum be used as an Intent parameter.typeDisplayRepresentationis the user-visible name of the enum type.caseDisplayRepresentationssupplies titles shown in Shortcuts for each case.- Dictionary literals are read by the compiler at build time to generate Intent metadata.
(07:49) The Intent receives this enum with @Parameter and uses it in perform().
struct OpenShelf: AppIntent {
static var title: LocalizedStringResource = "Open Shelf"
@Parameter(title: "Shelf")
var shelf: Shelf
@MainActor
func perform() async throws -> some IntentResult {
Navigator.shared.openShelf(shelf)
return .result()
}
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$shelf)")
}
static var openAppWhenRun: Bool = true
}
Key points:
@Parameter(title: "Shelf")declares user-configurable input.var shelf: Shelfuses the enum exposed to App Intents above.Navigator.shared.openShelf(shelf)lets one Intent open different shelves.parameterSummarycontrols sentence-style display in the Shortcuts editor.Summary("Open \(\.$shelf)")embeds the parameter in the summary for a more natural UI than table rows.openAppWhenRun = truesuits actions that need to show app UI.
Entity represents dynamic objects
(10:56) Shelves are a fixed set; books are not. Users keep adding books. Dynamic, user-defined values should be represented as Entities.
struct BookEntity: AppEntity, Identifiable {
var id: UUID
var displayRepresentation: DisplayRepresentation { "\(title)" }
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
static var defaultQuery = BookQuery()
}
Key points:
AppEntitymakesBookEntityan object App Intents can pass around.Identifiableprovides a stable identifier the system saves into Shortcuts.id: UUIDis the book entity’s persistent identity.displayRepresentationis the text users see in pickers and results.typeDisplayRepresentationis the entity type name.defaultQuerytells the system how to find the entity again from a saved identifier.
(12:29) The most basic query only needs to fetch entities by identifier.
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
}
Key points:
EntityQueryis the system’s entry point for fetching Entities.entities(for:)receives a set of persistent identifiers.Database.shared.book(for:)looks up books in the app’s own database.compactMapdrops books that no longer exist or cannot be found.- The returned
[BookEntity]is used by Intent parameters, Shortcuts pickers, or downstream actions.
(13:40) To let users select and search books in Shortcuts, upgrade the query to EntityStringQuery.
struct BookQuery: EntityStringQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
func suggestedEntities() async throws -> [BookEntity] {
Database.shared.books
}
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { book in
book.title.lowercased().contains(string.lowercased())
}
}
}
Key points:
EntityStringQueryadds text search on top of identifier lookup.suggestedEntities()provides a default candidate list for the picker.entities(matching:)returns entities for the user’s search string.- The example matches titles case-insensitively.
- With many objects, return only common items in
suggestedEntities()and put full search inentities(matching:).
Intents can return values and chain to another Intent
(15:11) Opening a book only reads existing data. Adding a book modifies the model, and a common next step is opening that book immediately. App Intents expresses this with return values and openIntent.
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ReturnsValue<BookEntity> & OpensIntent {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(
value: book,
openIntent: OpenBook(book: book)
)
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
Key points:
- Three
@Parameterfields collect title, author, and recommender. authorNameandrecommendedByare optional parameters.- The return type combines
IntentResult,ReturnsValue<BookEntity>, andOpensIntent. BooksAPI.shared.findBookslooks up books with async/await.- Throws
Error.notFoundwhen no book is found. CustomLocalizedStringResourceConvertiblelocalizes error display.Database.shared.add(book:)writes the result to the app database..result(value: book, openIntent: OpenBook(book: book))returns the new book and provides a follow-up open action.
This design lets users compose freely in Shortcuts: add a book only, or turn on “Open When Run” to enter the app’s book page after adding.
Property Query turns app data into Shortcuts filters
(18:21) If an Entity only has a title, Shortcuts can only select and pass it. With @Property, users can read those fields in a Shortcut.
struct BookEntity: AppEntity, Identifiable {
var id: UUID
@Property(title: "Title")
var title: String
@Property(title: "Publishing Date")
var datePublished: Date
@Property(title: "Read Date")
var dateRead: Date?
var recommendedBy: String?
var displayRepresentation: DisplayRepresentation { "\(title)" }
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
static var defaultQuery = BookQuery()
init(id: UUID) {
self.id = id
}
init(id: UUID, title: String) {
self.id = id
self.title = title
}
}
Key points:
@Property(title: "Title")exposes the title to Shortcuts.datePublishedanddateReadlet Shortcuts read date fields.dateReadis optional for books not yet finished.recommendedBywithout@Propertyis not exposed as a queryable property.displayRepresentationstill controls how the entity itself is shown.- Initializers show Entities can be constructed for different scenarios.
(20:59) EntityPropertyQuery further turns properties into Shortcuts “Find” and “Filter” actions. The system owns editor UI; the app maps comparison conditions to its data layer.
struct BookQuery: EntityPropertyQuery {
static var sortingOptions = SortingOptions {
SortableBy(\BookEntity.$title)
SortableBy(\BookEntity.$dateRead)
SortableBy(\BookEntity.$datePublished)
}
static var properties = QueryProperties {
Property(\BookEntity.$title) {
EqualToComparator { NSPredicate(format: "title = %@", $0) }
ContainsComparator { NSPredicate(format: "title CONTAINS %@", $0) }
}
Property(\BookEntity.$datePublished) {
LessThanComparator { NSPredicate(format: "datePublished < %@", $0 as NSDate) }
GreaterThanComparator { NSPredicate(format: "datePublished > %@", $0 as NSDate) }
}
Property(\BookEntity.$dateRead) {
LessThanComparator { NSPredicate(format: "dateRead < %@", $0 as NSDate) }
GreaterThanComparator { NSPredicate(format: "dateRead > %@", $0 as NSDate) }
}
}
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
identifiers.compactMap { identifier in
Database.shared.book(for: identifier)
}
}
func suggestedEntities() async throws -> [BookEntity] {
Model.shared.library.books.map { BookEntity(id: $0.id, title: $0.title) }
}
func entities(matching string: String) async throws -> [BookEntity] {
Database.shared.books.filter { book in
book.title.lowercased().contains(string.lowercased())
}
}
func entities(
matching comparators: [NSPredicate],
mode: ComparatorMode,
sortedBy: [Sort<BookEntity>],
limit: Int?
) async throws -> [BookEntity] {
Database.shared.findBooks(matching: comparators, matchAll: mode == .and, sorts: sortedBy.map { (keyPath: $0.by, ascending: $0.order == .ascending) })
}
}
Key points:
sortingOptionslists fields Shortcuts can sort by.SortableBy(\BookEntity.$title)allows sorting by title.QueryPropertiesdeclares which properties can be queried.EqualToComparatorandContainsComparatormap title comparisons.- Date properties use
LessThanComparatorandGreaterThanComparator. - The example uses
NSPredicateas the mapping type because the data layer is Core Data. entities(matching:mode:sortedBy:limit:)receives comparators, AND/OR mode, sorting, and limit.Database.shared.findBooks(...)maps system-generated query conditions to the app’s own data query.
The concrete result: users can find books published in the early 20th century in Shortcuts, filter by author, export books read this year to CSV, or hand a decade of reading to a chart app.
Dialog, snippets, and clarification make Intents fit voice
(24:10) When Siri runs an Intent, users need to hear or see the result. ProvidesDialog gives text or spoken feedback about what the action did.
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ReturnsValue<BookEntity> & ProvidesDialog {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(
value: book,
dialog:"Added \(book) to Library!"
)
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
Key points:
- Return type adds
ProvidesDialog. .result(value:dialog:)returns both entity and feedback text.dialogis spoken or shown in interfaces Siri or Shortcuts support.- Voice Intents should provide this feedback or users won’t know whether the action completed.
(24:25) Visual feedback uses a snippet. It is essentially a SwiftUI view returned as part of the result.
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult & ShowsSnippetView {
guard var book = await BooksAPI.shared.findBooks(named: title, author: authorName).first else {
throw Error.notFound
}
book.recommendedBy = recommendedBy
Database.shared.add(book: book)
return .result(value: book) {
CoverView(book: book)
}
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
Key points:
ShowsSnippetViewmeans the Intent returns a visual snippet.- The trailing closure of
.result(value:) { ... }supplies a SwiftUI view. CoverView(book: book)can show the cover of the book just added.- This view is archived and sent to Siri or Shortcuts for display.
(24:50) When a parameter is missing or imprecise, throw requestValue so the system asks the user.
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult {
let books = await BooksAPI.shared.findBooks(named: title, author: authorName)
guard !books.isEmpty else {
throw Error.notFound
}
if books.count > 1 && authorName == nil {
throw $authorName.requestValue("Who wrote the book?")
}
return .result()
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
Key points:
BooksAPI.shared.findBookslooks up candidate books with existing parameters first.guard !books.isEmptyhandles finding nothing at all.books.count > 1 && authorName == nilmeans the title alone does not distinguish results.$authorName.requestValue("Who wrote the book?")asks the user for the author.- Thrown as an error; the system prompts the user and reruns the action with new parameters.
(25:22) When the system already has multiple candidates, use requestDisambiguation so the user picks from a list.
struct AddBook: AppIntent {
static var title: LocalizedStringResource = "Add Book"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Author Name")
var authorName: String?
@Parameter(title: "Recommended By")
var recommendedBy: String?
func perform() async throws -> some IntentResult {
let books = await BooksAPI.shared.findBooks(named: title, author: authorName)
guard !books.isEmpty else {
throw Error.notFound
}
if books.count > 1 {
let chosenAuthor = try await $authorName.requestDisambiguation(among: books.map { $0.authorName }, dialog: "Which author?")
}
return .result()
}
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
case notFound
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound: return "Book Not Found"
}
}
}
}
Key points:
books.map { $0.authorName }extracts optional author names from candidates.$authorName.requestDisambiguation(...)hands the list to the system for display.dialog: "Which author?"supplies the prompt text.try awaitwaits for user selection; cancellation may throw.chosenAuthoris the user’s choice for further lookup or write.
(26:26) Transactional actions should also confirm results. The buy-book order example shows result confirmation and a post-confirmation result snippet.
struct BuyBook: AppIntent {
@Parameter(title: "Book")
var book: BookEntity
@Parameter(title: "Count")
var count: Int
static var title: LocalizedStringResource = "Buy Book"
func perform() async throws -> some IntentResult & ShowsSnippetView & ProvidesDialog {
let order = OrderEntity(book: book, count: count)
try await requestConfirmation(output: .result(value: order, dialog: "Are you ready to order?") {
OrderPreview(order: order)
})
return .result(value: order, dialog: "Thank you for your order!") {
OrderConfirmation(order: order)
}
}
}
Key points:
bookandcountare the two parameters needed to place an order.OrderEntity(book:count:)constructs the result awaiting confirmation.requestConfirmation(output:)asks the user to confirm the order.- The confirmation request includes
OrderPreviewso users see a pre-order preview. requestConfirmationthrows if the user cancels.- The final
.resultreturns the confirmed order, thank-you text, and confirmation view.
Choose in-app implementation or Extension
(27:09) App Intents has two deployment options. The simplest is directly in the app. No framework, no code duplication, no cross-process coordination. It also has higher memory limits and can do work unsuitable for Extensions, such as playing audio.
If openAppWhenRun is true, the app comes to the foreground. Otherwise the app launches in the background in a special mode without bringing up a scene, for better performance. The talk specifically recommends implementing scene support if you implement background App Intents in the app.
(28:05) Extensions are lighter because the process only handles App Intents and does not launch the full app. For Focus intents, Focus changes can run immediately in the Extension without the app being foregrounded. The cost is a new target, moving shared code into a framework, and coordinating app and Extension.
(28:52) App Intents metadata comes from static extraction at build time. Xcode generates a metadata file at build containing information the compiler reads from Intent, Entity, Query, and Parameter code. For extraction to work, App Intents types should live directly in the app target or Extension target, not in a framework. Related localized strings should also live in strings files in the same bundle.
Core Takeaways
1. Add a Siri entry for high-frequency pages
- What to do: Turn pages users open every day into
AppIntent, e.g. “open today’s tasks,” “open current order,” “open currently reading.” - Why it’s worth it: The talk’s
OpenCurrentlyReadingtakes only a few lines to reach Shortcuts and appear in Siri and Spotlight via App Shortcut. - How to start: Define a struct conforming to
AppIntent, call existing navigation inperform(); if it opens UI, setopenAppWhenRuntotrue.
2. Expose in-app objects as Entities
- What to do: Let users select your model objects in Shortcuts, such as projects, documents, contacts, playlists.
- Why it’s worth it:
BookEntitylets the system save object identifiers and recover objects viaBookQuerywhen a Shortcut runs. - How to start: Conform your model or wrapper to
AppEntityandIdentifiable; providedisplayRepresentation,typeDisplayRepresentation, anddefaultQuery.
3. Build searchable parameter pickers for large lists
- What to do: When users select objects, support searching the full database, not just fixed candidates.
- Why it’s worth it:
EntityStringQuerylets a book picker show suggestions first, then search the full library by user input. - How to start: Implement
suggestedEntities()andentities(matching:)in Query; return common objects as suggestions and query the larger dataset in search.
4. Let Shortcuts compose filtering and sorting
- What to do: Let users build “find all matching objects” actions in Shortcuts, e.g. overdue tasks, this year’s photos, this month’s orders for export.
- Why it’s worth it:
EntityPropertyQuerymakes the system generate Find and Filter actions; the app only maps comparators to its database query. - How to start: Expose queryable fields with
@Property, then implementQueryProperties,SortingOptions, andentities(matching:mode:sortedBy:limit:).
5. Add confirmation and feedback for voice actions
- What to do: Let add, buy, delete, and send actions ask follow-up questions, disambiguate, confirm, and report results in Siri.
- Why it’s worth it: The talk shows
requestValue,requestDisambiguation,requestConfirmation,ProvidesDialog, andShowsSnippetView, covering key voice interaction states. - How to start: Throw
$parameter.requestValue()for missing parameters,$parameter.requestDisambiguation(...)for multiple candidates,requestConfirmationfor transactions or destructive actions, then return results with dialog or SwiftUI snippet.
Related Sessions
- Implement App Shortcuts with App Intents — Covers App Shortcut implementation; good follow-up after mastering
AppIntentto make actions appear automatically in Siri, Spotlight, and Shortcuts. - Design App Shortcuts — Covers phrase design, visuals, and information collection, complementing this article’s code focus.
- Meet Focus filters — Shows App Intents in Focus filters for the same framework applied to system-level state changes.
Comments
GitHub Issues · utterances