Build an App with Siri and App Intents: Voice-Controlled Task Manager
“Hey Siri, add Buzz Lightyear to my toy box.” Imagine your users managing their data entirely by voice — adding items, checking status, even undoing mistakes — without ever touching the screen. With the App Intents framework, this isn’t a distant future. It’s a few Swift structs away.
In this tutorial, you’ll build Toy Box Manager — a voice-controlled inventory app for managing Andy’s toy
collection. You’ll expose every action to Siri, Spotlight, and the Shortcuts app, and you’ll implement iOS 26’s
Interactive Snippets for rich visual responses. Along the way, you’ll learn how to define
AppIntent, create
AppEntity models that Siri can query, register
AppShortcut phrases, and build an
UndoableIntent that lets users reverse actions
by voice.
Prerequisites
- Xcode 26+ with iOS 26 deployment target
- A device or Simulator running iOS 26 (Siri testing works best on a real device)
- Familiarity with App Intents and Siri integration
- Familiarity with SwiftData basics
- Familiarity with async/await
Contents
- Getting Started
- Step 1: Creating the SwiftData Model
- Step 2: Building the Toy Store
- Step 3: Building the Toy Box UI
- Step 4: Defining Your First App Intent
- Step 5: Making Toys Queryable with AppEntity
- Step 6: Building the Entity Query
- Step 7: Creating a Remove Toy Intent with Undo
- Step 8: Registering App Shortcuts for Siri
- Step 9: Adding Spotlight and Shortcuts App Integration
- Step 10: Building Interactive Snippets for iOS 26
- Step 11: Final Polish and Testing
- Where to Go From Here?
Getting Started
Let’s set up the project with SwiftData and the App Intents framework.
- Open Xcode 26 and create a new project using the App template.
- Set the product name to ToyBoxManager.
- Ensure the interface is SwiftUI and the language is Swift.
- Set the deployment target to iOS 26.0.
- Under Storage, select SwiftData if offered by the template. If not, we’ll configure it manually.
The App Intents framework is available automatically — no imports beyond AppIntents are needed, and there are no
entitlements or capabilities to enable. Siri integration works out of the box once you define your intents.
Open ToyBoxManagerApp.swift and set up the SwiftData container:
import SwiftUI
import SwiftData
@main
struct ToyBoxManagerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Toy.self)
}
}
We reference a Toy model that doesn’t exist yet — that’s our first step.
Step 1: Creating the SwiftData Model
Every toy in Andy’s collection needs a name, a category (action figure, stuffed animal, vehicle, etc.), a location (toy box, shelf, under the bed), and a favorite flag. This model will be the foundation that both our UI and Siri intents operate on.
Create a new file called Models/Toy.swift:
import Foundation
import SwiftData
@Model
final class Toy {
var name: String
var category: String
var location: String
var isFavorite: Bool
var dateAdded: Date
var notes: String
init(
name: String,
category: String = "Action Figure",
location: String = "Toy Box",
isFavorite: Bool = false,
notes: String = ""
) {
self.name = name
self.category = category
self.location = location
self.isFavorite = isFavorite
self.dateAdded = .now
self.notes = notes
}
}
The @Model macro from SwiftData handles persistence, change
tracking, and iCloud sync readiness. We keep the model flat and simple — the complexity in this tutorial lives in the
intents layer, not the data layer.
Let’s also define some constants for categories and locations so they stay consistent across the UI and Siri. Create
Models/ToyConstants.swift:
import Foundation
enum ToyCategory {
static let all = [
"Action Figure",
"Stuffed Animal",
"Vehicle",
"Building Set",
"Doll",
"Board Game",
"Dinosaur",
"Space Toy",
"Musical Toy",
"Other"
]
}
enum ToyLocation {
static let all = [
"Toy Box",
"Shelf",
"Under the Bed",
"Closet",
"Backyard",
"Andy's Room",
"Bonnie's Room",
"Sunnyside Daycare",
"Pizza Planet"
]
}
Pixar fans will recognize some of those locations. Sunnyside Daycare and Pizza Planet are iconic Toy Story locations that make the app feel more fun to use.
Checkpoint: Build the project (Cmd+B). The
Toymodel should compile without errors. No visible changes yet — we’re building the data layer that everything else depends on.
Step 2: Building the Toy Store
We need a central place to perform CRUD operations on toys that both the UI and App Intents can use. This shared access layer is important — if Siri adds a toy through an intent, the SwiftUI views need to see that change immediately.
Create Store/ToyStore.swift:
import Foundation
import SwiftData
import Observation
@MainActor
@Observable
class ToyStore {
static let shared = ToyStore()
private var modelContext: ModelContext?
func configure(with modelContext: ModelContext) {
self.modelContext = modelContext
}
func addToy(
name: String,
category: String = "Action Figure",
location: String = "Toy Box",
isFavorite: Bool = false,
notes: String = ""
) throws -> Toy {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
let toy = Toy(
name: name,
category: category,
location: location,
isFavorite: isFavorite,
notes: notes
)
modelContext.insert(toy)
try modelContext.save()
return toy
}
func deleteToy(_ toy: Toy) throws {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
modelContext.delete(toy)
try modelContext.save()
}
func fetchAllToys() throws -> [Toy] {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
let descriptor = FetchDescriptor<Toy>(
sortBy: [SortDescriptor(\.dateAdded, order: .reverse)]
)
return try modelContext.fetch(descriptor)
}
func fetchToys(matching query: String) throws -> [Toy] {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
let descriptor = FetchDescriptor<Toy>(
predicate: #Predicate<Toy> { toy in
toy.name.localizedStandardContains(query)
},
sortBy: [SortDescriptor(\.name)]
)
return try modelContext.fetch(descriptor)
}
func fetchToys(in category: String) throws -> [Toy] {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
let descriptor = FetchDescriptor<Toy>(
predicate: #Predicate<Toy> { toy in
toy.category == category
},
sortBy: [SortDescriptor(\.name)]
)
return try modelContext.fetch(descriptor)
}
func toggleFavorite(_ toy: Toy) throws {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
toy.isFavorite.toggle()
try modelContext.save()
}
func moveToy(_ toy: Toy, to location: String) throws {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
toy.location = location
try modelContext.save()
}
}
enum ToyStoreError: LocalizedError {
case notConfigured
case toyNotFound
var errorDescription: String? {
switch self {
case .notConfigured:
return "Toy store has not been configured with a model context."
case .toyNotFound:
return "The requested toy could not be found."
}
}
}
We use a singleton pattern here so that App Intents — which are instantiated by the system, not by our app — can access
the same store. The configure(with:) method needs to be called when the app launches, passing in the SwiftData model
context.
Update ToyBoxManagerApp.swift to configure the store:
import SwiftUI
import SwiftData
@main
struct ToyBoxManagerApp: App {
let container: ModelContainer
init() {
do {
container = try ModelContainer(for: Toy.self)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
ToyStore.shared.configure(
with: container.mainContext
)
}
}
.modelContainer(container)
}
}
Tip: In a production app, you’d want a more robust dependency injection approach. For this tutorial, the singleton works well because App Intents need to access the store from outside the SwiftUI view hierarchy.
Step 3: Building the Toy Box UI
Let’s build the main interface: a list of toys with the ability to add, favorite, and organize them. This gives us a visual way to verify that our Siri intents are working correctly later.
Replace the contents of ContentView.swift:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Toy.dateAdded, order: .reverse) private var toys: [Toy]
@State private var showingAddToy = false
@State private var searchText = ""
var filteredToys: [Toy] {
if searchText.isEmpty { return toys }
return toys.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List {
if filteredToys.isEmpty {
emptyState
} else {
ForEach(filteredToys) { toy in
ToyRow(toy: toy)
}
.onDelete(perform: deleteToys)
}
}
.navigationTitle("Toy Box")
.searchable(text: $searchText, prompt: "Search toys...")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddToy = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddToy) {
AddToyView()
}
}
}
private var emptyState: some View {
ContentUnavailableView {
Label("No Toys Yet", systemImage: "teddybear")
} description: {
Text("Andy's toy box is empty. Add some toys to get started, or say \"Hey Siri, add a toy to my toy box.\"")
} actions: {
Button("Add Your First Toy") {
showingAddToy = true
}
.buttonStyle(.borderedProminent)
}
}
private func deleteToys(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(filteredToys[index])
}
}
}
Create Views/ToyRow.swift:
import SwiftUI
struct ToyRow: View {
let toy: Toy
@Environment(\.modelContext) private var modelContext
var body: some View {
HStack(spacing: 12) {
Image(systemName: iconForCategory(toy.category))
.font(.title2)
.foregroundStyle(.purple)
.frame(width: 36)
VStack(alignment: .leading, spacing: 4) {
Text(toy.name)
.font(.headline)
HStack(spacing: 8) {
Label(toy.category, systemImage: "tag")
Label(toy.location, systemImage: "mappin")
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
toy.isFavorite.toggle()
} label: {
Image(
systemName: toy.isFavorite
? "star.fill"
: "star"
)
.foregroundStyle(toy.isFavorite ? .yellow : .gray)
}
.buttonStyle(.plain)
}
.padding(.vertical, 4)
}
private func iconForCategory(_ category: String) -> String {
switch category {
case "Action Figure": return "figure.stand"
case "Stuffed Animal": return "teddybear"
case "Vehicle": return "car.fill"
case "Building Set": return "building.2"
case "Doll": return "person.fill"
case "Dinosaur": return "fossil.shell.fill"
case "Space Toy": return "sparkle"
case "Musical Toy": return "music.note"
default: return "gift.fill"
}
}
}
Create Views/AddToyView.swift:
import SwiftUI
struct AddToyView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var name = ""
@State private var category = "Action Figure"
@State private var location = "Toy Box"
@State private var notes = ""
var body: some View {
NavigationStack {
Form {
Section("Toy Details") {
TextField("Name (e.g., Buzz Lightyear)", text: $name)
Picker("Category", selection: $category) {
ForEach(ToyCategory.all, id: \.self) { cat in
Text(cat).tag(cat)
}
}
Picker("Location", selection: $location) {
ForEach(ToyLocation.all, id: \.self) { loc in
Text(loc).tag(loc)
}
}
}
Section("Notes") {
TextField(
"Any special notes about this toy...",
text: $notes,
axis: .vertical
)
.lineLimit(3...6)
}
}
.navigationTitle("Add Toy")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") { addToy() }
.disabled(name.isEmpty)
}
}
}
}
private func addToy() {
let toy = Toy(
name: name,
category: category,
location: location,
notes: notes
)
modelContext.insert(toy)
dismiss()
}
}
Checkpoint: Build and run the app. You should see an empty state with a teddy bear icon and a message about Andy’s empty toy box. Tap the plus button, enter “Woody” as the name, select “Action Figure” as the category and “Andy’s Room” as the location, then tap “Add.” Woody should appear in the list with a cowboy-appropriate icon, his category, and his location. Add a few more toys — Buzz Lightyear (Space Toy), Rex (Dinosaur), Slinky Dog (Stuffed Animal) — so we have data for testing Siri later.
Step 4: Defining Your First App Intent
Now the exciting part: making our app respond to Siri. An
AppIntent is a struct that describes an action your
app can perform. The system uses its metadata — title, description, parameter definitions — to understand when and how
to invoke it.
Let’s start with the most basic intent: adding a toy.
Create Intents/AddToyIntent.swift:
import AppIntents
import SwiftData
struct AddToyIntent: AppIntent {
static let title: LocalizedStringResource = "Add Toy to Toy Box"
static let description: IntentDescription = "Adds a new toy to Andy's toy box collection."
@Parameter(title: "Toy Name")
var name: String
@Parameter(
title: "Category",
default: "Action Figure"
)
var category: String?
@Parameter(
title: "Location",
default: "Toy Box"
)
var location: String?
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let toyCategory = category ?? "Action Figure"
let toyLocation = location ?? "Toy Box"
let _ = try ToyStore.shared.addToy(
name: name,
category: toyCategory,
location: toyLocation
)
return .result(
dialog: "\(name) has been added to the toy box! Category: \(toyCategory), Location: \(toyLocation)."
)
}
}
Let’s break down what’s happening:
static let title— The human-readable name that appears in Shortcuts and Siri suggestions.static let description— A short explanation of what the intent does.@Parameter— Each parameter becomes a slot that Siri can fill from the user’s voice input. Thetitleis what Siri says when asking for clarification: “What’s the toy name?”perform()— The code that runs when the intent is invoked. It must return anIntentResult. TheProvidesDialogprotocol lets us return a spoken response that Siri reads aloud.
Apple Docs:
AppIntent— App Intents
Now let’s add a second intent for listing toys. Create Intents/ListToysIntent.swift:
import AppIntents
import SwiftData
struct ListToysIntent: AppIntent {
static let title: LocalizedStringResource = "List Toys in Toy Box"
static let description: IntentDescription = "Shows all toys currently in Andy's collection."
@Parameter(title: "Category", default: nil)
var category: String?
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let toys: [Toy]
if let category {
toys = try ToyStore.shared.fetchToys(in: category)
} else {
toys = try ToyStore.shared.fetchAllToys()
}
if toys.isEmpty {
return .result(
dialog: "Andy's toy box is empty. No toys found."
)
}
let toyNames = toys.map(\.name).joined(separator: ", ")
let count = toys.count
return .result(
dialog: "Found \(count) toy\(count == 1 ? "" : "s"): \(toyNames)."
)
}
}
Checkpoint: Build and run the app on a device with Siri enabled. Open the Shortcuts app and search for “Toy Box.” You should see “Add Toy to Toy Box” and “List Toys in Toy Box” listed as available actions. You can tap either one to test it directly in Shortcuts. Try adding “Rex” and then listing all toys — Siri should respond with the current inventory, just like Woody doing roll call in Toy Story.
Step 5: Making Toys Queryable with AppEntity
For Siri to understand commands like “Move Buzz Lightyear to the shelf,” it needs to know what a “toy” is in the context
of your app. This is where AppEntity comes in — it
creates a bridge between your data model and the system’s natural language understanding.
Create Intents/ToyEntity.swift:
import AppIntents
import SwiftData
struct ToyEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(
name: "Toy",
numericFormat: "\(placeholder: .int) toys"
)
static let defaultQuery = ToyEntityQuery()
let id: String
let name: String
let category: String
let location: String
let isFavorite: Bool
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(name)",
subtitle: "\(category) — \(location)",
image: .init(systemName: isFavorite ? "star.fill" : "teddybear")
)
}
init(from toy: Toy) {
self.id = toy.persistentModelID.description
self.name = toy.name
self.category = toy.category
self.location = toy.location
self.isFavorite = toy.isFavorite
}
}
The AppEntity protocol requires three things:
typeDisplayRepresentation— Tells the system how to describe this entity type in UI (“Toy,” “2 toys”).defaultQuery— A query object the system uses to look up entities by ID or search string. We’ll build this next.displayRepresentation— How a single entity appears in Siri results, Spotlight, and Shortcuts.
We create ToyEntity from our SwiftData Toy model. This separation is intentional — AppEntity is a lightweight,
sendable value type that the system can work with across processes, while our @Model class stays in the SwiftData
layer.
Apple Docs:
AppEntity— App Intents
Step 6: Building the Entity Query
The EntityQuery protocol tells the system how to
find entities. When a user says “Move Buzz Lightyear,” Siri uses your query to resolve “Buzz Lightyear” to a specific
ToyEntity.
Create Intents/ToyEntityQuery.swift:
import AppIntents
struct ToyEntityQuery: EntityQuery {
@MainActor
func entities(for identifiers: [String]) async throws -> [ToyEntity] {
let allToys = try ToyStore.shared.fetchAllToys()
return allToys
.filter { identifiers.contains($0.persistentModelID.description) }
.map { ToyEntity(from: $0) }
}
@MainActor
func suggestedEntities() async throws -> [ToyEntity] {
let allToys = try ToyStore.shared.fetchAllToys()
return allToys.map { ToyEntity(from: $0) }
}
}
extension ToyEntityQuery: EntityStringQuery {
@MainActor
func entities(matching query: String) async throws -> [ToyEntity] {
let matchingToys = try ToyStore.shared.fetchToys(matching: query)
return matchingToys.map { ToyEntity(from: $0) }
}
}
There are three key methods here:
entities(for:)— Resolves specific entities by their IDs. The system calls this when it already knows which entity is needed.suggestedEntities()— Returns a list of all available entities for disambiguation. When Siri isn’t sure which toy the user means, it presents this list.entities(matching:)— From theEntityStringQueryextension, this enables fuzzy search. When the user says “Buzz,” this method finds “Buzz Lightyear.”
Tip: The
suggestedEntities()method is also used by the Shortcuts app to populate picker UIs. If your app has thousands of entities, consider returning only the most recently used or most popular items here.
Now let’s create an intent that uses the entity. Create Intents/MoveToyIntent.swift:
import AppIntents
struct MoveToyIntent: AppIntent {
static let title: LocalizedStringResource = "Move Toy"
static let description: IntentDescription = "Moves a toy to a different location in Andy's house."
@Parameter(title: "Toy")
var toy: ToyEntity
@Parameter(title: "New Location")
var newLocation: String
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let allToys = try ToyStore.shared.fetchAllToys()
guard let matchingToy = allToys.first(where: {
$0.persistentModelID.description == toy.id
}) else {
throw ToyStoreError.toyNotFound
}
let oldLocation = matchingToy.location
try ToyStore.shared.moveToy(matchingToy, to: newLocation)
return .result(
dialog: "Done! \(toy.name) has been moved from \(oldLocation) to \(newLocation). Just like Andy rearranging his room!"
)
}
}
Notice that the toy parameter is typed as ToyEntity, not String. This tells the system to use our ToyEntityQuery
to resolve the parameter from the user’s speech. When someone says “Move Woody to the shelf,” Siri uses the query to
find the Woody entity and passes it to our intent.
Checkpoint: Build and run the app. Make sure you have at least a few toys added (Woody, Buzz, Rex). Open the Shortcuts app, create a new shortcut, and add the “Move Toy” action. You should see a toy picker that lists all your toys with their names and categories. Select Woody, type “Shelf” as the new location, and run the shortcut. Go back to the Toy Box app — Woody’s location should now show “Shelf.” It’s like the scene where Andy packs his toys for the move, except you’re doing it with Siri.
Step 7: Creating a Remove Toy Intent with Undo
Sometimes you need to undo an action — like when Sid accidentally throws away a toy he actually wanted to keep. The
UndoableIntent protocol lets users reverse an
intent’s effects by saying “Hey Siri, undo that.”
Create Intents/RemoveToyIntent.swift:
import AppIntents
struct RemoveToyIntent: AppIntent {
static let title: LocalizedStringResource = "Remove Toy from Collection"
static let description: IntentDescription = "Removes a toy from Andy's toy box. Can be undone."
@Parameter(title: "Toy")
var toy: ToyEntity
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let allToys = try ToyStore.shared.fetchAllToys()
guard let matchingToy = allToys.first(where: {
$0.persistentModelID.description == toy.id
}) else {
throw ToyStoreError.toyNotFound
}
// Remember the toy's details for undo
let name = matchingToy.name
let category = matchingToy.category
let location = matchingToy.location
try ToyStore.shared.deleteToy(matchingToy)
return .result(
dialog: "\(name) has been removed from the collection. Say 'undo' if that was a mistake — we wouldn't want a Woody situation at the yard sale!"
)
}
}
Note: The
UndoableIntentprotocol in iOS 26 allows the system to present an “Undo” option after the intent completes. For full undo support, you would store the removed toy’s data and implement theundo()method to re-insert it. The exact API surface is still evolving — check the latest App Intents documentation for the current implementation pattern.
Let’s also add a “Toggle Favorite” intent that’s quick and useful. Create Intents/ToggleFavoriteToyIntent.swift:
import AppIntents
struct ToggleFavoriteToyIntent: AppIntent {
static let title: LocalizedStringResource = "Toggle Favorite Toy"
static let description: IntentDescription = "Marks or unmarks a toy as a favorite in Andy's collection."
@Parameter(title: "Toy")
var toy: ToyEntity
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let allToys = try ToyStore.shared.fetchAllToys()
guard let matchingToy = allToys.first(where: {
$0.persistentModelID.description == toy.id
}) else {
throw ToyStoreError.toyNotFound
}
try ToyStore.shared.toggleFavorite(matchingToy)
let status = matchingToy.isFavorite ? "is now a favorite" : "is no longer a favorite"
return .result(
dialog: "\(toy.name) \(status). \(matchingToy.isFavorite ? "A star toy, just like Buzz!" : "")"
)
}
}
Checkpoint: Build and run. Open the Shortcuts app, create a shortcut using “Remove Toy from Collection,” and select one of your toys. Run it, then go back to the Toy Box app to verify the toy was removed. The toy list should update automatically thanks to SwiftData’s observation. Now add the toy back through the app UI — in a real undo scenario, Siri would handle the re-insertion for you.
Step 8: Registering App Shortcuts for Siri
So far, our intents are available in the Shortcuts app, but users have to find and configure them manually.
AppShortcut lets you define natural-language
phrases that Siri recognizes automatically — no setup required from the user.
Create Intents/ToyBoxShortcuts.swift:
import AppIntents
struct ToyBoxShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: AddToyIntent(),
phrases: [
"Add \(\.$name) to \(.applicationName)",
"Add a toy to \(.applicationName)",
"Put \(\.$name) in the toy box with \(.applicationName)",
"New toy \(\.$name) in \(.applicationName)"
],
shortTitle: "Add Toy",
systemImageName: "plus.circle"
)
AppShortcut(
intent: ListToysIntent(),
phrases: [
"Show my toys in \(.applicationName)",
"List toys in \(.applicationName)",
"What's in my toy box with \(.applicationName)",
"Show \(.applicationName) collection"
],
shortTitle: "List Toys",
systemImageName: "list.bullet"
)
AppShortcut(
intent: MoveToyIntent(),
phrases: [
"Move \(\.$toy) with \(.applicationName)",
"Move a toy with \(.applicationName)",
"Relocate \(\.$toy) with \(.applicationName)"
],
shortTitle: "Move Toy",
systemImageName: "arrow.right.circle"
)
AppShortcut(
intent: ToggleFavoriteToyIntent(),
phrases: [
"Favorite \(\.$toy) in \(.applicationName)",
"Star \(\.$toy) in \(.applicationName)",
"Mark \(\.$toy) as favorite with \(.applicationName)"
],
shortTitle: "Toggle Favorite",
systemImageName: "star"
)
}
}
Each AppShortcut connects an intent to natural language phrases. The \(\.$name) and \(\.$toy) placeholders map
directly to the intent’s @Parameter properties — Siri fills them from the user’s speech. The \(.applicationName)
placeholder is automatically replaced with your app’s name.
Warning: Phrase patterns must include
\(.applicationName)so Siri knows which app to route the command to. Without it, your phrases won’t be registered.Apple Docs:
AppShortcutsProvider— App Intents
Step 9: Adding Spotlight and Shortcuts App Integration
Let’s make our toys discoverable through Spotlight search and donate completed actions so Siri can suggest them proactively.
To make toys appear in Spotlight, we need to donate them to the system when they’re created or updated. Update
Store/ToyStore.swift by adding a method for donating to Spotlight:
// Add this import at the top of ToyStore.swift
// import CoreSpotlight // ← Add this
// Add this method inside ToyStore
func donateToSpotlight(_ toy: Toy) {
let attributes = CSSearchableItemAttributeSet(
contentType: .content
)
attributes.title = toy.name
attributes.contentDescription = "\(toy.category) in \(toy.location)"
attributes.keywords = [toy.name, toy.category, toy.location]
let item = CSSearchableItem(
uniqueIdentifier: toy.persistentModelID.description,
domainIdentifier: "com.toyboxmanager.toys",
attributeSet: attributes
)
CSSearchableIndex.default().indexSearchableItems([item])
}
Note: You’ll need to add
import CoreSpotlightat the top of the file. Core Spotlight lets your content appear in system search results.
Now call donateToSpotlight whenever a toy is added. Update the addToy method:
// Update addToy in ToyStore to donate after inserting
func addToy(
name: String,
category: String = "Action Figure",
location: String = "Toy Box",
isFavorite: Bool = false,
notes: String = ""
) throws -> Toy {
guard let modelContext else {
throw ToyStoreError.notConfigured
}
let toy = Toy(
name: name,
category: category,
location: location,
isFavorite: isFavorite,
notes: notes
)
modelContext.insert(toy)
try modelContext.save()
donateToSpotlight(toy) // ← New line
return toy
}
Next, let’s add Siri suggestions by donating intent completions. This makes Siri smarter over time — if the user
frequently adds Space Toys, Siri might proactively suggest the “Add Toy” action. Create Intents/IntentDonation.swift:
import AppIntents
enum IntentDonation {
@MainActor
static func donateAddToy(name: String, category: String) {
let intent = AddToyIntent()
intent.name = name
intent.category = category
Task {
try? await intent.donate()
}
}
@MainActor
static func donateListToys() {
let intent = ListToysIntent()
Task {
try? await intent.donate()
}
}
}
Update AddToyView.swift to donate the intent when a toy is added through the UI:
// Update the addToy() method in AddToyView
private func addToy() {
let toy = Toy(
name: name,
category: category,
location: location,
notes: notes
)
modelContext.insert(toy)
IntentDonation.donateAddToy(name: name, category: category) // ← New line
dismiss()
}
Checkpoint: Build and run the app. Add a toy called “Jessie” with category “Action Figure.” Now open Spotlight (swipe down from the home screen) and search for “Jessie.” You should see your toy appear in the search results with its name and category. Tapping it should open your app. In the Shortcuts app, your four shortcuts should appear with their icons. Try saying “Hey Siri, show my toys in Toy Box Manager” — Siri should list all the toys in your collection, reading them aloud like Woody taking attendance at the toy meeting.
Step 10: Building Interactive Snippets for iOS 26
iOS 26 introduces Interactive Snippets — rich visual cards that Siri displays inline when your intent returns results. Instead of just hearing a spoken response, users see a SwiftUI view right in the Siri interface. Think of it like the difference between hearing someone describe a scene from Monsters, Inc. versus actually seeing it.
To provide a visual snippet, your intent’s perform method returns a view alongside the dialog. Let’s update our
ListToysIntent to return an Interactive Snippet.
First, create the snippet view at Views/ToyListSnippet.swift:
import SwiftUI
import AppIntents
struct ToyListSnippet: View {
let toys: [ToyEntity]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "teddybear.fill")
.foregroundStyle(.purple)
Text("Andy's Toy Box")
.font(.headline)
Spacer()
Text("\(toys.count) toy\(toys.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
ForEach(toys.prefix(5), id: \.id) { toy in
HStack(spacing: 8) {
Image(systemName: toy.isFavorite ? "star.fill" : "teddybear")
.foregroundStyle(toy.isFavorite ? .yellow : .purple)
.frame(width: 20)
VStack(alignment: .leading) {
Text(toy.name)
.font(.subheadline)
Text("\(toy.category) — \(toy.location)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
}
if toys.count > 5 {
Text("and \(toys.count - 5) more...")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
}
}
Now update Intents/ListToysIntent.swift to provide the snippet:
import AppIntents
import SwiftUI
struct ListToysIntent: AppIntent {
static let title: LocalizedStringResource = "List Toys in Toy Box"
static let description: IntentDescription = "Shows all toys currently in Andy's collection."
@Parameter(title: "Category", default: nil)
var category: String?
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ProvidesSnippetView {
let toys: [Toy]
if let category {
toys = try ToyStore.shared.fetchToys(in: category)
} else {
toys = try ToyStore.shared.fetchAllToys()
}
if toys.isEmpty {
return .result(
dialog: "Andy's toy box is empty. No toys found."
) {
VStack(spacing: 12) {
Image(systemName: "teddybear")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No Toys Found")
.font(.headline)
.foregroundStyle(.secondary)
}
.padding()
}
}
let toyEntities = toys.map { ToyEntity(from: $0) }
let toyNames = toys.map(\.name).joined(separator: ", ")
let count = toys.count
return .result(
dialog: "Found \(count) toy\(count == 1 ? "" : "s"): \(toyNames)."
) {
ToyListSnippet(toys: toyEntities)
}
}
}
The key change is the return type: ProvidesSnippetView alongside ProvidesDialog. The trailing closure after
.result(dialog:) provides the SwiftUI view that Siri renders inline. Users can see and interact with the snippet
directly in the Siri interface.
Let’s also create a snippet for the Add Toy intent. Update Intents/AddToyIntent.swift:
import AppIntents
import SwiftUI
struct AddToyIntent: AppIntent {
static let title: LocalizedStringResource = "Add Toy to Toy Box"
static let description: IntentDescription = "Adds a new toy to Andy's toy box collection."
@Parameter(title: "Toy Name")
var name: String
@Parameter(
title: "Category",
default: "Action Figure"
)
var category: String?
@Parameter(
title: "Location",
default: "Toy Box"
)
var location: String?
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ProvidesSnippetView {
let toyCategory = category ?? "Action Figure"
let toyLocation = location ?? "Toy Box"
let _ = try ToyStore.shared.addToy(
name: name,
category: toyCategory,
location: toyLocation
)
return .result(
dialog: "\(name) has been added to the toy box!"
) {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.title)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.headline)
HStack(spacing: 8) {
Label(toyCategory, systemImage: "tag")
Label(toyLocation, systemImage: "mappin")
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
}
}
}
Apple Docs:
ProvidesSnippetView— App IntentsCheckpoint: Build and run on a device running iOS 26. Say “Hey Siri, show my toys in Toy Box Manager.” Instead of just hearing the list spoken aloud, you should see a rich visual card showing Andy’s Toy Box with toy names, categories, locations, and star icons for favorites. It should look like a miniature version of your app embedded right in Siri — similar to how Apple Maps shows a card when you ask for directions. Try “Hey Siri, add Forky to Toy Box Manager” — you should see a confirmation card with a green checkmark, Forky’s name, and his category.
Step 11: Final Polish and Testing
Let’s add one final piece: a SiriTipView in our main interface that teaches users about the voice commands available.
This is an Apple-recommended pattern that helps users discover your Siri integration.
Update ContentView.swift to include a Siri tip at the top:
// Add this import at the top of ContentView.swift
// import AppIntents // ← Add this
// Add this computed property to ContentView
private var siriTipSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Label("Try Siri", systemImage: "waveform")
.font(.headline)
.foregroundStyle(.purple)
Text("\"Hey Siri, add Buzz Lightyear to Toy Box Manager\"")
.font(.callout.italic())
.foregroundStyle(.secondary)
Text("\"Hey Siri, show my toys in Toy Box Manager\"")
.font(.callout.italic())
.foregroundStyle(.secondary)
Text("\"Hey Siri, move Woody with Toy Box Manager\"")
.font(.callout.italic())
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
Then add it to the list in the body:
// Update the List in ContentView's body
var body: some View {
NavigationStack {
List {
if !toys.isEmpty {
siriTipSection
}
if filteredToys.isEmpty && searchText.isEmpty {
emptyState
} else {
ForEach(filteredToys) { toy in
ToyRow(toy: toy)
}
.onDelete(perform: deleteToys)
}
}
.navigationTitle("Toy Box")
.searchable(text: $searchText, prompt: "Search toys...")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddToy = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddToy) {
AddToyView()
}
}
}
Now let’s do a comprehensive test pass. Here’s a testing checklist:
Siri voice testing (requires device):
- “Hey Siri, add Rex to Toy Box Manager” — Should add a dinosaur and show confirmation snippet.
- “Hey Siri, show my toys in Toy Box Manager” — Should list all toys with an interactive snippet card.
- “Hey Siri, move Woody with Toy Box Manager” — Should prompt for the new location, then confirm the move.
- “Hey Siri, favorite Buzz Lightyear in Toy Box Manager” — Should toggle the favorite status.
Shortcuts app testing:
- Open Shortcuts and search for “Toy Box” — all four actions should appear.
- Create a shortcut that adds a toy and then lists all toys — run it to verify chaining works.
Spotlight testing:
- Add a toy through the app.
- Swipe down to Spotlight and search for the toy name — it should appear in results.
Checkpoint: Run through the full testing checklist above. Every Siri command should work, the Shortcuts app should show all four actions, and Spotlight should index your toys. The complete app should feel like a natural extension of iOS — as seamless as asking Siri to set a timer or send a message. Andy would be proud of how well-organized his toy box is.
Where to Go From Here?
Congratulations! You’ve built Toy Box Manager — a voice-controlled inventory app that exposes every major action to Siri, Spotlight, and the Shortcuts app, complete with iOS 26 Interactive Snippets for rich visual responses.
Here’s what you learned:
- How to define
AppIntentstructs with typed parameters that Siri fills from voice input - How to create
AppEntitymodels so Siri can understand and query your app’s data - How to build
EntityQueryandEntityStringQueryfor resolving entities from natural language - How to register
AppShortcutphrases so Siri recognizes your commands without user configuration - How to implement Interactive Snippets with
ProvidesSnippetViewfor rich Siri responses - How to donate intents and Spotlight items for proactive suggestions
- How to structure a shared data layer that both SwiftUI views and App Intents can access
Ideas for extending this project:
- Add a “Find Toy” intent that tells the user where a specific toy is located — “Hey Siri, where is Buzz Lightyear?” would respond “Buzz Lightyear is on the Shelf.”
- Implement Focus Filters so different toy categories appear based on the active Focus mode — only show Space Toys during “Play Time” Focus.
- Add Parameterized Shortcuts with dynamic options for category and location pickers, so users can build custom automations like “Move all dinosaurs to the backyard.”
- Integrate with Foundation Models so users can say “Hey Siri, suggest a toy for a 6-year-old” and get an AI-powered recommendation. See Build an On-Device AI App with Foundation Models for how.
- Add Widget integration with a WidgetKit timeline that shows recently added toys and an interactive “Quick Add” button.