Introduction: Why App Intents Matter in 2026
There was a time when building an iOS app meant building a rectangle. You designed screens, pushed view controllers, and the boundaries of your product ended at the edge of your app's process. That era? It's over. In 2026, the most successful apps are the ones that show up everywhere — on the Lock Screen, in Control Center, through Siri, inside Spotlight, on the Action Button, and now, with iOS 26, as interactive snippets that appear contextually across the entire operating system.
Apple's trajectory has been unmistakable. Each year since iOS 16, the system has gained new surfaces where your app can participate. Widgets became interactive in iOS 17. Control Center opened up to third-party controls in iOS 18. And iOS 26 introduced interactive snippets — rich, intent-driven UI that the system can surface proactively without the user ever opening your app.
The connective tissue behind all of these surfaces is a single framework: App Intents.
App Intents isn't merely a Siri framework. It's the unified programming model for every system interaction point. When you define an AppIntent, you're teaching the operating system what your app can do. That same intent can power a Siri voice command, a Shortcuts action, a widget button tap, a Control Center toggle, and an interactive snippet — all from one implementation. If you've been putting off learning App Intents, this is the article that'll get you from zero to productive. We'll build real features, write real code, and cover every surface from widgets to the brand-new snippet system in iOS 26.
The Building Blocks: AppIntent, AppEntity, and AppEnum
Before we build anything visible, we need to understand the three foundational protocols that everything else is built on. Think of these as the vocabulary your app uses to describe itself to the system.
AppIntent: Defining an Action
An AppIntent is a struct that conforms to the AppIntent protocol. It represents a single action your app can perform. Every intent has a title, zero or more parameters, and a perform() method that executes the action and returns a result.
import AppIntents
struct CreateTaskIntent: AppIntent {
static let title: LocalizedStringResource = "Create Task"
static let description: IntentDescription = "Creates a new task in your task list."
@Parameter(title: "Title")
var taskTitle: String
@Parameter(title: "Priority", default: .medium)
var priority: TaskPriority
@Parameter(title: "Due Date")
var dueDate: Date?
func perform() async throws -> some IntentResult & ProvidesDialog {
let task = TaskItem(title: taskTitle, priority: priority, dueDate: dueDate)
try await TaskStore.shared.save(task)
return .result(dialog: "Created task: \(taskTitle)")
}
}
A few things to notice here. The @Parameter property wrapper tells the system about each input. Parameters can have default values, and they can be optional. The perform() method is async throws, so you can do real work inside it — hit a database, call a network service, whatever your action requires. The return type uses Swift's opaque return types to compose result capabilities: here we're returning a result that also provides a spoken dialog string for Siri.
AppEntity: Dynamic Custom Types
Parameters aren't limited to built-in types like String and Date. When you need to reference a domain object — a task, a project, a playlist — you define an AppEntity. Entities are the nouns in your app's vocabulary.
import AppIntents
struct TaskEntity: AppEntity {
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Task"
static let defaultQuery = TaskEntityQuery()
let id: String
let title: String
let priority: TaskPriority
let isCompleted: Bool
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(priority.rawValue) priority",
image: isCompleted
? .init(systemName: "checkmark.circle.fill")
: .init(systemName: "circle")
)
}
}
Every entity must provide an id, a displayRepresentation for how it appears in system UI, a typeDisplayRepresentation for what this kind of entity is called, and a defaultQuery that tells the system how to look up instances.
EntityQuery: How the System Finds Your Entities
The query is where the system asks your app, "Give me the task with this ID," or "Give me all tasks that match this search string." At minimum, you implement entities(for:) to resolve entities by their identifiers. For richer integration — like Siri asking users to pick from a list — you also implement suggestedEntities().
struct TaskEntityQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [TaskEntity] {
let store = TaskStore.shared
return try await store.tasks(for: identifiers).map { task in
TaskEntity(
id: task.id,
title: task.title,
priority: task.priority,
isCompleted: task.isCompleted
)
}
}
func suggestedEntities() async throws -> [TaskEntity] {
let store = TaskStore.shared
return try await store.recentTasks(limit: 10).map { task in
TaskEntity(
id: task.id,
title: task.title,
priority: task.priority,
isCompleted: task.isCompleted
)
}
}
}
AppEnum: Static Enumerations
For fixed sets of values — priorities, categories, statuses — use AppEnum. Unlike AppEntity, an AppEnum has a known, compile-time set of cases.
enum TaskPriority: String, AppEnum {
case low
case medium
case high
case urgent
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Priority"
static let caseDisplayRepresentations: [TaskPriority: DisplayRepresentation] = [
.low: "Low",
.medium: "Medium",
.high: "High",
.urgent: "Urgent"
]
}
Putting It Together: A Complete Task Intent
Now let's wire an intent that uses our entity. Here's an intent to mark a task as completed:
struct CompleteTaskIntent: AppIntent {
static let title: LocalizedStringResource = "Complete Task"
static let description: IntentDescription = "Marks a task as completed."
@Parameter(title: "Task")
var task: TaskEntity
static var parameterSummary: some ParameterSummary {
Summary("Complete \(\.$task)")
}
func perform() async throws -> some IntentResult & ProvidesDialog {
try await TaskStore.shared.markCompleted(taskId: task.id)
return .result(dialog: "Done! \(task.title) is complete.")
}
}
The parameterSummary is critical. It tells Shortcuts and Siri how to present this intent in a human-readable sentence. Without it, the system falls back to a generic parameter list that honestly looks unprofessional. Always provide a parameter summary.
Interactive Widgets with WidgetKit
Widgets were the first major surface where App Intents proved their value. Since iOS 17, widgets can contain interactive controls — but the interaction model is intentionally constrained. You get exactly two interactive elements: Button(intent:) and Toggle(isOn:intent:). That's it. No text fields, no sliders, no gesture recognizers. Every interaction is an intent execution.
Honestly, that constraint sounds limiting at first, but it's actually a brilliant design decision. It keeps widgets lightweight and predictable.
The Interaction Model
When a user taps a Button in a widget, the system runs the associated AppIntent's perform() method in your widget extension's process. After the intent completes, WidgetKit requests a new timeline from your TimelineProvider, and the widget re-renders with fresh data.
This is the entire data flow — there's no state management, no bindings, no observation. It's purely functional: tap triggers intent, intent mutates data, timeline provides updated view.
Sharing Data with App Groups
Your widget extension runs in a separate process from your main app. They can't directly share memory. The standard solution is an App Group — a shared container that both your app and widget extension can read from and write to. You'll typically use UserDefaults(suiteName:) for simple values or a shared FileManager container for larger data.
enum SharedData {
static let appGroupID = "group.com.example.watertracker"
static var defaults: UserDefaults {
UserDefaults(suiteName: appGroupID)!
}
static var todayGlasses: Int {
get { defaults.integer(forKey: "todayGlasses") }
set { defaults.set(newValue, forKey: "todayGlasses") }
}
static var dailyGoal: Int {
get {
let value = defaults.integer(forKey: "dailyGoal")
return value > 0 ? value : 8
}
set { defaults.set(newValue, forKey: "dailyGoal") }
}
}
Complete Example: Water Tracker Widget
Let's build a water tracker widget with a button that adds a glass of water. This is the kind of widget that genuinely improves daily life — one tap on the Home Screen and you've logged your hydration. I've used a version of this in my own projects, and it's always the feature people notice first.
First, the intent that handles the tap:
import AppIntents
import WidgetKit
struct LogWaterIntent: AppIntent {
static let title: LocalizedStringResource = "Log Water"
static let description: IntentDescription = "Logs a glass of water."
func perform() async throws -> some IntentResult {
SharedData.todayGlasses += 1
return .result()
}
}
struct ResetWaterIntent: AppIntent {
static let title: LocalizedStringResource = "Reset Water Count"
static let description: IntentDescription = "Resets today's water count to zero."
func perform() async throws -> some IntentResult {
SharedData.todayGlasses = 0
return .result()
}
}
Next, the timeline provider:
import WidgetKit
struct WaterEntry: TimelineEntry {
let date: Date
let glasses: Int
let goal: Int
}
struct WaterTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> WaterEntry {
WaterEntry(date: .now, glasses: 3, goal: 8)
}
func getSnapshot(in context: Context, completion: @escaping (WaterEntry) -> Void) {
let entry = WaterEntry(
date: .now,
glasses: SharedData.todayGlasses,
goal: SharedData.dailyGoal
)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
let entry = WaterEntry(
date: .now,
glasses: SharedData.todayGlasses,
goal: SharedData.dailyGoal
)
let nextUpdate = Calendar.current.startOfDay(for: .now).addingTimeInterval(86400)
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
And finally, the widget view and configuration:
import SwiftUI
import WidgetKit
struct WaterTrackerWidgetView: View {
let entry: WaterEntry
private var progress: Double {
guard entry.goal > 0 else { return 0 }
return min(Double(entry.glasses) / Double(entry.goal), 1.0)
}
var body: some View {
VStack(spacing: 12) {
Text("\(entry.glasses) / \(entry.goal)")
.font(.title)
.fontWeight(.bold)
.contentTransition(.numericText())
ProgressView(value: progress)
.tint(progress >= 1.0 ? .green : .blue)
Button(intent: LogWaterIntent()) {
Label("Add Glass", systemImage: "plus.circle.fill")
.font(.caption)
}
.tint(.blue)
}
.padding()
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WaterTrackerWidget: Widget {
let kind: String = "WaterTrackerWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: WaterTimelineProvider()) { entry in
WaterTrackerWidgetView(entry: entry)
}
.configurationDisplayName("Water Tracker")
.description("Track your daily water intake.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
Optimistic UI Updates
There's a subtle UX problem with widget interactions. After a user taps the button, there's a brief delay while the intent runs and the timeline refreshes. During that delay, the widget shows stale data.
The solution is the invalidatableContent modifier, which tells WidgetKit to apply a visual treatment (like dimming) to content that's being refreshed. You can also use .contentTransition(.numericText()) on text views to get smooth animations when values change, as we did above. It's a small touch, but it makes the widget feel way more polished.
Control Widgets: iOS 18 and Beyond
iOS 18 opened Control Center to third-party developers through Control Widgets. Unlike Home Screen widgets, control widgets are extremely focused: they're either a button or a toggle. They can also appear on the Lock Screen and be assigned to the Action Button on iPhone 15 Pro and later.
Building a Control Widget
A control widget pairs a ControlWidgetButton or ControlWidgetToggle with an App Intent. Here's a control widget that lets users quickly add a task from Control Center:
import WidgetKit
import AppIntents
struct QuickAddTaskControl: ControlWidget {
static let kind: String = "com.example.quickAddTask"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: Self.kind) {
ControlWidgetButton(action: QuickAddTaskControlIntent()) {
Label("Add Task", systemImage: "plus.square.fill")
}
}
.displayName("Quick Add Task")
.description("Quickly add a new task from Control Center.")
}
}
struct QuickAddTaskControlIntent: AppIntent {
static let title: LocalizedStringResource = "Quick Add Task"
static var openAppWhenRun: Bool { true }
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(
name: .init("ShowQuickAddTask"),
object: nil
)
return .result()
}
}
Notice openAppWhenRun set to true. For a quick-add flow, we want the app to open so the user can type a task title. For simpler operations — like toggling a setting — you can run entirely in the background.
Toggle Controls
Toggle-style controls are perfect for binary states: Do Not Disturb for your app, enabling a focus mode, or toggling a smart home device. Here's a toggle that controls whether the task app shows notifications for due tasks:
struct TaskNotificationsControl: ControlWidget {
static let kind: String = "com.example.taskNotifications"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: Self.kind) {
ControlWidgetToggle(
"Task Reminders",
isOn: SharedData.areRemindersEnabled,
action: ToggleRemindersIntent()
) { isOn in
Label(
isOn ? "Reminders On" : "Reminders Off",
systemImage: isOn ? "bell.fill" : "bell.slash"
)
}
}
.displayName("Task Reminders")
.description("Toggle task reminder notifications.")
}
}
struct ToggleRemindersIntent: SetValueIntent {
static let title: LocalizedStringResource = "Toggle Reminders"
@Parameter(title: "Enabled")
var value: Bool
func perform() async throws -> some IntentResult {
SharedData.areRemindersEnabled = value
return .result()
}
}
The SetValueIntent protocol provides the value parameter automatically. When the system toggles the control, it passes the new boolean value into your intent. Pretty elegant, honestly.
Interactive Snippets: The iOS 26 Revolution
If App Intents powered the incremental evolution from iOS 16 through 18, iOS 26 represents a genuine leap. Interactive snippets are the most ambitious new surface yet. They let the system present rich, interactive UI from your app anywhere — in Siri responses, in Spotlight, in proactive suggestions — without opening your app at all.
The key difference from widgets is that snippets are conversational. They can present information, accept user input through button taps, chain through multi-step flows with confirmations, and update themselves in response to user actions. All of this is driven by a new protocol: SnippetIntent.
SnippetIntent: The Foundation
A SnippetIntent is an AppIntent that can return a SwiftUI view as part of its result. The system renders this view inline wherever the snippet appears. Here's the minimal anatomy:
import AppIntents
import SwiftUI
struct OrderStatusSnippetIntent: AppIntent, ShowsSnippetIntent {
static let title: LocalizedStringResource = "Check Order Status"
@Parameter(title: "Order")
var order: OrderEntity
@MainActor
func perform() async throws -> some IntentResult & ShowsSnippetView {
let status = try await OrderService.shared.status(for: order.id)
return .result(
snippetView: OrderStatusView(
orderName: order.name,
status: status.displayName,
estimatedArrival: status.estimatedArrival
)
)
}
}
struct OrderStatusView: View {
let orderName: String
let status: String
let estimatedArrival: Date?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(orderName)
.font(.headline)
HStack {
Image(systemName: "shippingbox.fill")
Text(status)
}
.foregroundStyle(.secondary)
if let arrival = estimatedArrival {
Text("Arrives \(arrival, style: .relative)")
.font(.subheadline)
.foregroundStyle(.green)
}
}
.padding()
}
}
Making Snippets Interactive
The real power of snippets emerges when you add interactivity. Just like widgets, snippets support Button(intent:). When a user taps the button, the system runs the referenced intent, and the snippet re-renders with the result. But unlike widgets, there's no timeline provider — the snippet view is returned directly from perform().
Here's a more complete example — an interactive order configuration snippet where a user can customize their order directly within the snippet UI:
import AppIntents
import SwiftUI
struct ConfigureOrderSnippetIntent: AppIntent, ShowsSnippetIntent {
static let title: LocalizedStringResource = "Configure Order"
@Parameter(title: "Product")
var product: ProductEntity
@Parameter(title: "Quantity", default: 1)
var quantity: Int
@Parameter(title: "Size", default: .medium)
var size: ProductSize
static var parameterSummary: some ParameterSummary {
Summary("Configure \(\.$product) order")
}
@Dependency
var orderManager: OrderManager
@MainActor
func perform() async throws -> some IntentResult & ShowsSnippetView {
let pricing = try await orderManager.pricing(
for: product.id,
size: size,
quantity: quantity
)
return .result(
snippetView: OrderConfigView(
productName: product.name,
quantity: quantity,
size: size,
unitPrice: pricing.unitPrice,
totalPrice: pricing.totalPrice,
productID: product.id
)
)
}
}
struct OrderConfigView: View {
let productName: String
let quantity: Int
let size: ProductSize
let unitPrice: Decimal
let totalPrice: Decimal
let productID: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(productName)
.font(.headline)
HStack {
Text("Size: \(size.displayName)")
Spacer()
Text("Qty: \(quantity)")
}
.font(.subheadline)
.foregroundStyle(.secondary)
Divider()
HStack {
Text("Total")
.font(.headline)
Spacer()
Text(totalPrice, format: .currency(code: "USD"))
.font(.title2)
.fontWeight(.bold)
}
HStack(spacing: 12) {
Button(intent: AdjustQuantityIntent(
productID: productID,
size: size,
delta: -1,
currentQuantity: quantity
)) {
Image(systemName: "minus.circle")
}
.disabled(quantity <= 1)
Button(intent: AdjustQuantityIntent(
productID: productID,
size: size,
delta: 1,
currentQuantity: quantity
)) {
Image(systemName: "plus.circle")
}
Spacer()
Button(intent: PlaceOrderIntent(
productID: productID,
quantity: quantity,
size: size
)) {
Text("Place Order")
.fontWeight(.semibold)
}
.tint(.green)
}
}
.padding()
}
}
The Reload Mechanism
When a button inside a snippet triggers an intent, the system calls that intent's perform() method. If the triggered intent also conforms to ShowsSnippetIntent, the snippet UI re-renders with the new result. This is how you create multi-step flows entirely within the snippet surface.
struct AdjustQuantityIntent: AppIntent, ShowsSnippetIntent {
static let title: LocalizedStringResource = "Adjust Quantity"
@Parameter(title: "Product ID")
var productID: String
@Parameter(title: "Size")
var size: ProductSize
@Parameter(title: "Delta")
var delta: Int
@Parameter(title: "Current Quantity")
var currentQuantity: Int
@Dependency
var orderManager: OrderManager
init() {}
init(productID: String, size: ProductSize, delta: Int, currentQuantity: Int) {
self.productID = productID
self.size = size
self.delta = delta
self.currentQuantity = currentQuantity
}
@MainActor
func perform() async throws -> some IntentResult & ShowsSnippetView {
let newQuantity = max(1, currentQuantity + delta)
let product = try await orderManager.product(for: productID)
let pricing = try await orderManager.pricing(
for: productID,
size: size,
quantity: newQuantity
)
return .result(
snippetView: OrderConfigView(
productName: product.name,
quantity: newQuantity,
size: size,
unitPrice: pricing.unitPrice,
totalPrice: pricing.totalPrice,
productID: productID
)
)
}
}
Confirmation Flows with requestConfirmation
For actions with real consequences — placing an order, deleting data, sending a message — you should use the requestConfirmation API to present a confirmation step before executing. This is the "wizard flow" pattern that makes snippets feel like mini-apps. (And yes, users absolutely love it when you don't just fire off irreversible actions without asking.)
struct PlaceOrderIntent: AppIntent {
static let title: LocalizedStringResource = "Place Order"
@Parameter(title: "Product ID")
var productID: String
@Parameter(title: "Quantity")
var quantity: Int
@Parameter(title: "Size")
var size: ProductSize
@Dependency
var orderManager: OrderManager
init() {}
init(productID: String, quantity: Int, size: ProductSize) {
self.productID = productID
self.quantity = quantity
self.size = size
}
func perform() async throws -> some IntentResult & ProvidesDialog {
let pricing = try await orderManager.pricing(
for: productID,
size: size,
quantity: quantity
)
try await requestConfirmation(
actionName: "Place Order",
dialog: "Place order for \(quantity) items totaling \(pricing.totalPrice)?"
)
let order = try await orderManager.placeOrder(
productID: productID,
quantity: quantity,
size: size
)
return .result(dialog: "Order #\(order.confirmationNumber) placed successfully.")
}
}
What You Can't Do in Snippets
Snippets have hard constraints that you need to internalize. There's no @State. There's no TextField. There are no gesture recognizers beyond button taps and toggles. Every piece of dynamic behavior must flow through an intent. The snippet view is a pure function of the data you pass into it from perform().
Also, perform() in a snippet context must be lightweight. The system enforces strict time limits. Don't kick off long-running network requests or heavy computation inside a snippet intent. Fetch what you need quickly, return the view, and defer heavy work to background processing in your main app.
Dependencies with @Dependency
You'll notice the @Dependency property wrapper in the examples above. This is how you inject shared services into your intents. Dependencies must be registered early in your app's lifecycle using AppDependencyManager:
import AppIntents
final class OrderManager: Sendable {
static let shared = OrderManager()
func pricing(for productID: String, size: ProductSize, quantity: Int) async throws -> Pricing {
// pricing logic
}
func placeOrder(productID: String, quantity: Int, size: ProductSize) async throws -> Order {
// order placement logic
}
func product(for id: String) async throws -> Product {
// product lookup
}
}
// In your App's init or AppDelegate:
struct MyApp: App {
init() {
AppDependencyManager.shared.add(dependency: OrderManager.shared)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Siri and Shortcuts Integration
Every AppIntent you define is automatically available in the Shortcuts app. But to make intents truly shine with Siri, you need to register App Shortcuts — predefined voice phrases that map directly to your intents.
AppShortcutsProvider
The AppShortcutsProvider protocol declares the phrases users can speak to invoke your intents. You define these at the app level, and the system indexes them so they work immediately — no user setup required.
struct TaskAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: CreateTaskIntent(),
phrases: [
"Create a task in \(.applicationName)",
"Add a new task to \(.applicationName)",
"New \(.applicationName) task"
],
shortTitle: "Create Task",
systemImageName: "plus.circle"
)
AppShortcut(
intent: CompleteTaskIntent(),
phrases: [
"Complete a task in \(.applicationName)",
"Mark task done in \(.applicationName)",
"Finish task in \(.applicationName)"
],
shortTitle: "Complete Task",
systemImageName: "checkmark.circle"
)
AppShortcut(
intent: LogWaterIntent(),
phrases: [
"Log water in \(.applicationName)",
"I drank water \(.applicationName)",
"Add a glass of water to \(.applicationName)"
],
shortTitle: "Log Water",
systemImageName: "drop.fill"
)
}
}
The .applicationName token is mandatory in at least one phrase. It ensures the system can distinguish your intent from other apps. Use natural, conversational phrases — think about how a user would actually speak the command out loud.
SiriTipView for Discoverability
Here's a thing that's easy to overlook: users can't use voice commands they don't know about. The SiriTipView solves this by displaying a contextual tip inside your app that teaches users the relevant phrase:
import AppIntents
struct TaskListView: View {
@State private var tasks: [TaskItem] = []
var body: some View {
NavigationStack {
List(tasks) { task in
TaskRowView(task: task)
}
.navigationTitle("Tasks")
.toolbar {
ToolbarItem(placement: .bottomBar) {
SiriTipView(intent: CreateTaskIntent())
}
}
}
}
}
Place SiriTipView contextually. Show it on the screen where the action is most relevant. After the user taps or dismisses it, the system handles the tip lifecycle automatically.
Foreground vs. Background Execution
By default, intents triggered through Siri run in the background. If your intent needs to present UI — like opening a specific screen in your app — set openAppWhenRun to true:
struct ShowTaskDetailIntent: AppIntent {
static let title: LocalizedStringResource = "Show Task"
static var openAppWhenRun: Bool { true }
@Parameter(title: "Task")
var task: TaskEntity
func perform() async throws -> some IntentResult {
NavigationManager.shared.navigate(to: .taskDetail(id: task.id))
return .result()
}
}
Intent Chaining
In Shortcuts, users can chain intents together. Your intent's output becomes the next intent's input. To support this, return typed results that downstream intents can consume:
struct CreateTaskIntent: AppIntent {
static let title: LocalizedStringResource = "Create Task"
@Parameter(title: "Title")
var taskTitle: String
@Parameter(title: "Priority", default: .medium)
var priority: TaskPriority
func perform() async throws -> some IntentResult & ReturnsValue {
let task = TaskItem(title: taskTitle, priority: priority)
try await TaskStore.shared.save(task)
let entity = TaskEntity(
id: task.id,
title: task.title,
priority: task.priority,
isCompleted: false
)
return .result(value: entity)
}
}
Now a Shortcuts user can chain "Create Task" into "Complete Task" — the created TaskEntity flows directly into the next action. This composability is one of App Intents' most underrated features.
WWDC25 Advanced Features
WWDC25 introduced several powerful enhancements to the App Intents framework that are worth understanding, even if you don't adopt them right away.
@ComputedProperty and @DeferredProperty
Before iOS 26, every property on an AppEntity was eagerly resolved. If your entity had a property that required an expensive computation or a network call, that cost was paid every time the entity was created — even if nobody needed that property. The new @ComputedProperty and @DeferredProperty macros fix this.
@ComputedProperty marks a property that's derived from other properties and computed on access. @DeferredProperty marks a property that's expensive to resolve and should only be fetched when explicitly requested by the system or user.
struct TaskEntity: AppEntity {
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Task"
static let defaultQuery = TaskEntityQuery()
let id: String
let title: String
let priority: TaskPriority
let isCompleted: Bool
let createdAt: Date
@ComputedProperty
var age: String {
let formatter = RelativeDateTimeFormatter()
return formatter.localizedString(for: createdAt, relativeTo: .now)
}
@DeferredProperty
var subtaskCount: Int
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)")
}
}
The subtaskCount property will only be resolved when the system or a Shortcuts action specifically needs it, saving resources in the common case. This is a big deal if your entities have expensive-to-compute properties (and let's be honest, most non-trivial apps do).
UnionValues for Mixed Entity Queries
Sometimes you need a single query that can return different types of entities. For example, a universal search that returns both tasks and projects. UnionValue lets you define a heterogeneous result type:
enum SearchResult: UnionValue {
case task(TaskEntity)
case project(ProjectEntity)
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Search Result"
var displayRepresentation: DisplayRepresentation {
switch self {
case .task(let task):
return task.displayRepresentation
case .project(let project):
return project.displayRepresentation
}
}
}
struct UniversalSearchIntent: AppIntent {
static let title: LocalizedStringResource = "Search Everything"
@Parameter(title: "Query")
var query: String
func perform() async throws -> some IntentResult & ReturnsValue<[SearchResult]> {
let tasks = try await TaskStore.shared.search(query)
.map { SearchResult.task(TaskEntity(from: $0)) }
let projects = try await ProjectStore.shared.search(query)
.map { SearchResult.project(ProjectEntity(from: $0)) }
return .result(value: tasks + projects)
}
}
Visual Intelligence Integration
iOS 26 also extends the App Intents framework into Visual Intelligence. When the user points their camera at an object, the system can match it against your entities using IntentValueQuery. A plant identification app, for instance, could register an entity query that accepts image data and returns matching plant entities. It's an advanced use case, sure, but it shows how App Intents is becoming the universal bridge between every system capability and your app's domain logic.
Best Practices and Common Pitfalls
After building intents across every surface Apple offers, here are the lessons that'll save you hours of debugging and user frustration.
Always Provide parameterSummary
I can't stress this enough. Without a parameterSummary, your intent looks generic and confusing in Shortcuts. A well-written summary turns a list of parameters into a readable sentence:
// Without parameterSummary: users see a generic form with labeled fields.
// With parameterSummary: users see "Complete [Task Name]"
static var parameterSummary: some ParameterSummary {
Summary("Complete \(\.$task)")
}
For intents with conditional parameters, use When and Otherwise to show different summaries based on parameter values:
static var parameterSummary: some ParameterSummary {
When(\.$scheduleReminder, .equalTo, true) {
Summary("Create \(\.$taskTitle) with reminder at \(\.$reminderDate)")
} otherwise: {
Summary("Create \(\.$taskTitle)")
}
}
Keep perform() Lightweight in Snippets
Snippet intents run in a constrained environment with strict time budgets. Your perform() method shouldn't initiate complex business logic, long-running network calls, or heavy database transactions. Fetch what you need, build the view, return it.
If you need to perform real work (like placing an order), use requestConfirmation and then do the minimum necessary in the confirmed path. Heavy processing should happen asynchronously after returning the result, or be deferred to the main app.
Register Dependencies Early
If you use @Dependency in your intents, the dependency must be registered before any intent runs. The safest place is your App's init() method or your AppDelegate's application(_:didFinishLaunchingWithOptions:). If a dependency isn't registered when an intent tries to resolve it, you'll get a runtime crash with a not-very-helpful error message. Ask me how I know.
@main
struct TaskManagerApp: App {
init() {
let store = TaskStore.shared
let orderManager = OrderManager.shared
AppDependencyManager.shared.add(dependency: store)
AppDependencyManager.shared.add(dependency: orderManager)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Use App Groups for Widget Communication
This is the single most common source of confusion for developers new to widgets. Your widget extension and your main app are separate processes. They don't share UserDefaults.standard, they don't share the same file system sandbox, and they can't communicate through in-memory singletons. You must use an App Group for shared data.
- Add an App Group capability to both your main app target and your widget extension target.
- Use the same group identifier (e.g.,
"group.com.example.myapp") in both targets. - Access shared data through
UserDefaults(suiteName: "group.com.example.myapp")orFileManager.default.containerURL(forSecurityApplicationGroupIdentifier:). - After your main app writes data, call
WidgetCenter.shared.reloadTimelines(ofKind:)to trigger a widget refresh.
// In your main app, after saving data:
func saveTask(_ task: TaskItem) async throws {
try await persistenceController.save(task)
// Sync to shared container for widget
let sharedDefaults = UserDefaults(suiteName: "group.com.example.taskmanager")
let recentTasks = try await persistenceController.recentTasks(limit: 5)
let encoded = try JSONEncoder().encode(recentTasks)
sharedDefaults?.set(encoded, forKey: "recentTasks")
// Tell WidgetKit to refresh
WidgetCenter.shared.reloadTimelines(ofKind: "TaskListWidget")
}
Watch Memory Limits
Widget extensions have a hard memory limit of approximately 30 MB. That sounds generous until you load a few images or instantiate a complex data model. Monitor your widget's memory usage in Instruments, and be particularly careful with:
- Image loading — use thumbnails and resize images before displaying them.
- Data model instantiation — don't load your entire Core Data or SwiftData stack in the widget extension if you can avoid it.
- Third-party SDKs — some analytics or crash-reporting SDKs allocate significant memory on initialization. Consider whether they actually belong in your widget extension at all.
Timeline Intelligence
Don't blindly refresh your widget on a fixed schedule. WidgetKit provides several timeline policies, and choosing the right one dramatically affects both battery life and data freshness:
.atEnd— refresh when the last entry in the timeline expires. Good for content that changes at predictable intervals..after(date)— refresh at a specific future time. Perfect for data tied to schedules (e.g., refresh after the next meeting ends)..never— don't automatically refresh. Use this when your app controls updates viaWidgetCenter.shared.reloadTimelines(ofKind:).
For our water tracker example, we used .after(nextMidnight) because we want to reset the count at midnight. During the day, updates come from intent-driven reloads triggered by the user tapping the button.
Stop Using URL Schemes for Interactivity
Before iOS 17, the only way to make a widget "interactive" was to use widgetURL or Link to deep-link into your app. This opened the app, which was jarring and broke the user's flow. With intent-driven buttons and toggles, there's really no reason to use URL schemes for widget interaction anymore. Reserve widgetURL for its original purpose: navigating to a specific screen when the user taps the widget background.
// Old approach - don't do this for interactive elements
Link(destination: URL(string: "myapp://addwater")!) {
Text("Add Water")
}
// Modern approach - use intent-driven buttons
Button(intent: LogWaterIntent()) {
Text("Add Water")
}
Structuring Your Intent Code
As your app grows, you'll accumulate many intents, entities, and enums. Establish a clear file structure early:
Intents/— one file per intent or group of closely related intents.Entities/— one file per entity, including its query.Enums/— one file perAppEnum.Shortcuts/— yourAppShortcutsProvider.Dependencies/— services and managers used by intents via@Dependency.
Make sure intent files are added to both your main app target and any extension targets that use them. A common bug is defining an intent in your app target but forgetting to include it in the widget extension target, causing a mysterious "intent not found" error at runtime. It's the kind of mistake you make once and never forget.
Testing Intents
Here's the good news: App Intents are plain structs with a single async method. This makes them highly testable. You can instantiate an intent, set its parameters, call perform(), and assert on the result — no UI testing required:
import Testing
@testable import TaskManager
struct CreateTaskIntentTests {
@Test("Creating a task persists it to the store")
func createTask() async throws {
let intent = CreateTaskIntent()
intent.taskTitle = "Write unit tests"
intent.priority = .high
let result = try await intent.perform()
let tasks = try await TaskStore.shared.allTasks()
let created = tasks.first { $0.title == "Write unit tests" }
#expect(created != nil)
#expect(created?.priority == .high)
}
}
The Bigger Picture
App Intents isn't just another framework to learn. It represents a fundamental shift in how iOS apps relate to the operating system. In the old model, your app was a walled garden — users had to open it, navigate to the right screen, and perform actions through your custom UI. In the new model, your app's capabilities are distributed across the entire system. The user might interact with your app through Siri while driving, through a widget while glancing at their phone, through a control in Control Center, or through a snippet that appears proactively based on context.
The unifying principle is simple: define what your app can do, and let the system figure out where to surface it.
An AppIntent you write once can appear in Shortcuts, respond to a Siri phrase, power a widget button, drive a control widget toggle, and render an interactive snippet. The investment pays compound returns across every surface Apple adds in the future.
So start small. Pick one action your users perform frequently — logging a meal, starting a timer, toggling a setting — and build an intent for it. Add a widget button. Register a Siri phrase. Once you see how the pieces connect, expanding to more intents, entities, and surfaces becomes surprisingly straightforward. The framework is well-designed, and the mental model, once internalized, applies uniformly across every integration point.
Your app is no longer just a rectangle on a screen. It's a set of capabilities that the entire operating system can invoke. App Intents is how you teach the system what those capabilities are.