An app feature usually begins life behind a button. Then product asks for it in a widget. Someone wants a Siri phrase. A power user expects a Shortcut, and the same action would be useful from the Action button. If each entry point owns a separate implementation, small differences accumulate: one path validates input, another forgets authentication, and the widget writes data differently from the app.

App Intents offers a better boundary. You describe an action and the values it operates on, while the system decides where that capability can surface. The important architectural move is not conforming to AppIntent; it is implementing the capability once and making the intent a thin adapter.

We will build a concrete example: Create Task. The same domain operation will serve the app UI, Siri, Shortcuts, a widget, Spotlight-driven workflows, and the Action button. The runnable examples use established App Intents APIs. Features Apple introduced for the iOS 27-era SDK at WWDC26 are called out separately so preview syntax never quietly leaks into your production target.

1. Design the capability before the intent

Start below the framework boundary. Our application creates a task in a list, validates the title, and persists the result. None of that code needs to know whether the request came from SwiftUI or Siri.

import Foundation

struct Task: Identifiable, Codable, Sendable {
    let id: UUID
    let title: String
    let listID: UUID
    let createdAt: Date
}

enum CreateTaskError: LocalizedError {
    case emptyTitle
    case signedOut
    case listNotFound

    var errorDescription: String? {
        switch self {
        case .emptyTitle:
            return "Enter a task title."
        case .signedOut:
            return "Sign in to create a task."
        case .listNotFound:
            return "That list is no longer available."
        }
    }
}

protocol TaskRepository: Sendable {
    func create(title: String, in listID: UUID) async throws -> Task
    func lists(ids: [UUID]) async throws -> [TaskList]
    func suggestedLists() async throws -> [TaskList]
}

struct CreateTask: Sendable {
    let repository: any TaskRepository

    func callAsFunction(title rawTitle: String, listID: UUID) async throws -> Task {
        let title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !title.isEmpty else { throw CreateTaskError.emptyTitle }
        return try await repository.create(title: title, in: listID)
    }
}

This use case belongs in a shared Swift package with the model and repository interface. That package becomes the seam between system integration and domain behavior. It is the same principle used in a well-factored MVVM and Clean Architecture implementation: dependencies point inward, while framework-specific code stays at the edge.

The repository must also be safe for the processes that may host the intent. An in-memory singleton is not a shared database. Prefer a real persistence layer in an App Group container or another concurrency-safe store that the app and extension can both open.

2. Model your data with AppEntity and EntityQuery

Primitive parameters are enough for a task title, but a list is an app-specific concept. Apple’s AppEntity documentation defines the system-facing identity and display contract. A separate intent-facing value keeps framework annotations out of the domain model.

import AppIntents
import Foundation

struct TaskList: Identifiable, Codable, Sendable {
    let id: UUID
    let name: String
}

struct TaskListEntity: AppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Task List")
    static var defaultQuery = TaskListQuery()

    let id: UUID
    let name: String

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)")
    }

    init(_ list: TaskList) {
        id = list.id
        name = list.name
    }
}

struct TaskListQuery: EntityQuery {
    @Dependency private var repository: any TaskRepository

    func entities(for identifiers: [UUID]) async throws -> [TaskListEntity] {
        try await repository.lists(ids: identifiers).map(TaskListEntity.init)
    }

    func suggestedEntities() async throws -> [TaskListEntity] {
        try await repository.suggestedLists().map(TaskListEntity.init)
    }
}

The identifier is the contract. Do not use a list name as an ID because names can change and collide. When Shortcuts saves a configured action, the system later asks entities(for:) to resolve those stored identifiers. Preserve input ordering where practical, omit records that genuinely no longer exist, and keep the query fast.

suggestedEntities() powers a useful picker before the user types. For a large database, return recent or favorite lists rather than thousands of rows. More specialized query protocols can support string matching, but an EntityQuery is the essential foundation: identifier lookup plus useful suggestions.

3. Make AppIntent a thin adapter

Now the public system action is pleasantly small. It declares its vocabulary, receives resolved values, calls the use case, and returns a result suitable for spoken or visual presentation.

import AppIntents

struct CreateTaskIntent: AppIntent {
    static var title: LocalizedStringResource = "Create Task"
    static var description = IntentDescription(
        "Creates a task in one of your task lists."
    )

    static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication

    @Parameter(title: "Title")
    var taskTitle: String

    @Parameter(title: "List")
    var list: TaskListEntity

    @Dependency private var createTask: CreateTask

    static var parameterSummary: some ParameterSummary {
        Summary("Create \(.$taskTitle) in \(.$list)")
    }

    func perform() async throws -> some IntentResult & ProvidesDialog {
        let task = try await createTask(title: taskTitle, listID: list.id)
        return .result(dialog: "Created \(task.title) in \(list.name).")
    }
}

Authentication policy protects execution with device authentication; it does not replace your account-session checks or authorization rules. The repository should still verify that the active account owns the selected list. Treat every parameter as untrusted input even when the system resolved it.

For a destructive intent—deleting a project, sending money, or publishing content—ask for confirmation immediately before the irreversible operation:

func perform() async throws -> some IntentResult & ProvidesDialog {
    try await requestConfirmation()
    try await deleteProject(id: project.id)
    return .result(dialog: "Deleted \(project.name).")
}

Avoid confirmation for routine, reversible actions such as creating a task. Requiring a prompt on every invocation makes voice and Action button workflows tedious. Instead, return concise success dialog, throw meaningful errors, and reserve confirmation for genuine consequence.

4. Register dependencies in every host process

Apple’s dependency manager lets the system construct an intent while your application supplies production services. Register dependencies as early as possible when a process launches.

import AppIntents
import SwiftUI

@main
struct TasksApp: App {
    init() {
        let repository = SQLiteTaskRepository(location: .appGroup)
        AppDependencyManager.shared.add(dependency: repository as any TaskRepository)
        AppDependencyManager.shared.add(
            dependency: CreateTask(repository: repository)
        )
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

The important phrase is every host process. For shipping SDKs, a regular AppIntent included in a widget extension runs in the widget extension process by default. It moves into the app process when openAppWhenRun is true, or when it adopts a process-specific protocol such as ForegroundContinuableIntent, LiveActivityIntent, AudioPlaybackIntent, or PushToTalkTransmissionIntent. Choose those behaviors because the capability truly requires them, not as a substitute for extension-safe storage.

The widget extension does not inherit objects registered by the main app. Give each target a small composition root that opens the same App Group store and registers the same interfaces. If a service needs UI state owned by the app process, the action should explicitly continue in the foreground rather than pretending the extension has that state. Apple’s WidgetKit interactivity documentation describes these shipping execution rules.

Put intent declarations in a shared package only when every linking target also has the required resources, entitlements, dependencies, and database access. Swift Package Manager modularization helps enforce that boundary, but target membership still determines where code can execute.

Also assume concurrent invocations. Siri and a widget can trigger work close together. Actor-isolate mutable state, make writes transactional, and add an idempotency strategy where duplicate side effects would hurt. Never rely on a view model or UIApplication.shared being available inside perform().

5. Surface the intent through Shortcuts and Siri

An AppIntent appears as an action in Shortcuts. AppShortcutsProvider additionally publishes ready-made phrases so users can discover the capability without first assembling a shortcut.

import AppIntents

struct TasksShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: CreateTaskIntent(),
            phrases: [
                "Create a task in \(.applicationName)",
                "Add a task with \(.applicationName)"
            ],
            shortTitle: "Create Task",
            systemImageName: "checkmark.circle"
        )
    }
}

Keep phrases natural and specific to the action. \(.applicationName) lets the system insert the localized app name. Parameter resolution then asks for a title and list when the phrase does not provide them.

Shortcuts are not merely voice commands. They compose your action with automations, Focus modes, share sheets, and other apps. That is why output and error behavior matter. A dialog should be brief enough to speak, while errors should tell the user what action to take next.

After relevant in-app behavior succeeds, donate the corresponding intent so the system can learn that the action is useful. Donation is a discoverability signal, not a second write path: represent an operation that already occurred; do not call perform() again and create a duplicate task.

6. Reuse the intent in widgets and the Action button

Interactive widgets can trigger an AppIntent directly. Configure the intent with the values represented by that widget rather than rebuilding repository logic in the extension.

import SwiftUI
import WidgetKit

struct QuickTaskButton: View {
    let list: TaskListEntity

    var body: some View {
        Button(intent: makeIntent()) {
            Label("Add follow-up", systemImage: "plus.circle.fill")
        }
    }

    private func makeIntent() -> CreateTaskIntent {
        var intent = CreateTaskIntent()
        intent.taskTitle = "Follow up"
        intent.list = list
        return intent
    }
}

This example is intentionally simple. A widget has tight runtime and memory budgets, so the intent should make one bounded database write and finish. Refresh only the affected timeline rather than every widget kind.

The Action button is another launcher, not another business layer. Once the intent is discoverable in Shortcuts, a user can assign the resulting shortcut to supported hardware. Design for execution without an open scene: no assumptions about navigation, no mandatory text field, and no success UI that exists only inside the app.

If the action needs arbitrary text, a preconfigured shortcut or voice interaction may be more natural than a one-tap hardware action. A second intent such as “Create Inbox Task” can wrap the same use case with a default list while exposing fewer parameters. That is reuse of domain behavior, not duplication.

7. Make entities discoverable through Spotlight

App Intents makes entities understandable to the system, but it does not mean every database row is automatically indexed. On shipping SDKs, Core Spotlight remains a dependable path for searchable app content. Index useful records with stable domain identifiers and a deep link back into the app.

import CoreSpotlight
import UniformTypeIdentifiers

actor TaskSpotlightIndexer {
    private let taskIndex = CSSearchableIndex(
        name: "PrivateTasks",
        protectionClass: .complete
    )

    func index(_ task: Task) async throws {
        let attributes = CSSearchableItemAttributeSet(contentType: .item)
        attributes.title = task.title
        attributes.contentDescription = "Task"
        attributes.relatedUniqueIdentifier = task.id.uuidString
        attributes.contentURL = URL(string: "warmotasks://task/\(task.id.uuidString)")

        let item = CSSearchableItem(
            uniqueIdentifier: task.id.uuidString,
            domainIdentifier: "tasks",
            attributeSet: attributes
        )

        try await taskIndex.indexSearchableItems([item])
    }
}

Apple recommends named indexes for production; the default index is for prototypes and testing. Tasks can contain personal information, so this example chooses .complete, which keeps the index protected while the device is locked. If your product must search immediately after the first unlock, .completeUntilFirstUserAuthentication trades some protection for availability. Make that choice from your threat model, not convenience, and serialize access because Apple warns against modifying a custom index from multiple tasks concurrently.

Register the warmotasks URL scheme in the app target. At the scene boundary, parse the host and UUID, load the record, and route through the same navigation model used by ordinary app links:

WindowGroup {
    ContentView()
        .onOpenURL { url in
            guard url.scheme == "warmotasks",
                  url.host == "task",
                  let idText = url.pathComponents.dropFirst().first,
                  let taskID = UUID(uuidString: idText) else { return }
            navigation.openTask(id: taskID)
        }
}

Index after the repository transaction commits, update the searchable item when content changes, and delete it when the model is deleted. Do not index secrets merely because Spotlight can display them. Search results can appear outside your app, so choose titles and descriptions with lock-screen privacy in mind.

Spotlight and Siri work best when identity agrees everywhere. Use the same UUID in storage, deep links, Core Spotlight, and AppEntity. That consistency prevents a search result and an intent parameter from pointing at subtly different records.

8. Test the behavior at two boundaries

Most bugs belong in the shared capability, so test it without App Intents first. A fake repository makes validation and persistence behavior deterministic.

import Testing

actor RecordingTaskRepository: TaskRepository {
    private(set) var lastCreatedTitle: String?
    private(set) var lastCreatedListID: UUID?
    private let availableLists: [TaskList]

    init(availableLists: [TaskList] = []) {
        self.availableLists = availableLists
    }

    func create(title: String, in listID: UUID) async throws -> Task {
        lastCreatedTitle = title
        lastCreatedListID = listID
        return Task(
            id: UUID(),
            title: title,
            listID: listID,
            createdAt: Date()
        )
    }

    func lists(ids: [UUID]) async throws -> [TaskList] {
        ids.compactMap { id in availableLists.first { $0.id == id } }
    }

    func suggestedLists() async throws -> [TaskList] {
        availableLists
    }
}

@Test func trimsTitleBeforeCreatingTask() async throws {
    let repository = RecordingTaskRepository()
    let createTask = CreateTask(repository: repository)
    let listID = UUID()

    _ = try await createTask(title: "  Ship release  ", listID: listID)

    #expect(await repository.lastCreatedTitle == "Ship release")
    #expect(await repository.lastCreatedListID == listID)
}

Then keep a smaller integration suite for parameter resolution, dependency registration, target membership, and the returned dialog. This complements the broader techniques in Swift Testing for senior engineers: fast domain tests carry most of the matrix, and framework tests protect the adapter.

iOS 27 preview: AppIntentsTesting

At WWDC26, Apple introduced AppIntentsTesting, which executes intents through the same infrastructure used by Siri, Shortcuts, and Spotlight. It can inspect results and test entity queries, Spotlight indexing, and view annotations without UI automation. This belongs to the iOS 27-era preview toolchain at the time of writing, so do not paste beta-only imports into a target built by a stable SDK.

Apple’s session begins tests by loading IntentDefinitions for an application bundle identifier, then drives the discovered definitions. Adopt its exact signatures from the beta SDK documentation installed with your Xcode version. Keep your domain tests regardless: system-level tests are valuable, but slower and more sensitive to SDK evolution.

9. Keep WWDC26 preview APIs behind a clear boundary

Apple announced several powerful App Intents additions for the 2027 platform releases in its session on new App Intents capabilities. They solve real problems, but they are not prerequisites for the architecture in this article.

Two timeline distinctions matter. IndexedEntity already ships and lets an AppEntity provide Spotlight attributes for indexing. App Schemas also predate the iOS 27 toolchain and map supported actions and entities to system-defined semantic domains. iOS 27 adds richer Siri and Apple Intelligence behavior for those schematized entities and actions; that newer behavior should not be confused with either existing API.

The following capabilities are preview APIs in the iOS 27-era SDK:

  • ExecutionTargets allows an intent to specify the main app, App Intents extension, WidgetKit extension, or combinations instead of relying only on process-selection heuristics.
  • LongRunningIntent supports work beyond the normal short execution window, including progress and cancellation behavior.
  • ValueRepresentation lets structured entity values travel across app boundaries.
  • AppIntentsTesting exercises the system-facing integration without UI automation.

Treat those names as a migration map, not stable deployment advice. Do not invent compatibility wrappers that imitate them. Keep process-sensitive decisions in one intent layer so adopting ExecutionTargets later is localized. Keep long work behind a service so a future LongRunningIntent conformance changes orchestration, not business rules. Continue to guard beta-only code with appropriate availability and build configuration after verifying the SDK shipped with your Xcode.

This is also where provider-style boundaries pay off. The same architectural discipline used for a provider-agnostic Foundation Models layer applies here: the platform adapter can evolve while the capability remains stable.

10. Production checklist and common pitfalls

Before shipping, exercise the action from every surface you claim to support. A Shortcut running while the app is open is not proof that a widget extension can access the database after a reboot.

Use this checklist:

  1. Keep perform() small and delegate to a Sendable, concurrency-safe capability.
  2. Give each entity a durable ID and keep queries bounded and ordered.
  3. Register dependencies at startup in every eligible process.
  4. Store shared data in an App Group container when extensions require access.
  5. Enforce account authorization in the repository, independent of device authentication.
  6. Confirm only destructive or consequential actions.
  7. Return short, actionable dialog and localized errors.
  8. Test with the device locked, the app terminated, and stale entity identifiers.
  9. Measure intent duration, database contention, and widget refresh cost.
  10. Keep preview-only API adoption isolated and availability-checked.

The most common mistake is treating the intent as a miniature view controller. It should not navigate, own a long-lived cache, or coordinate half the application. Another is assuming an extension and app share memory. They share files only when you configure that relationship; they do not share dependency containers or singleton instances.

Finally, avoid making one giant “Do Everything” intent. System surfaces work better with focused verbs and meaningful parameters. CreateTaskIntent, CompleteTaskIntent, and OpenTaskIntent are easier to discover, authorize, compose, and test than an enum-driven universal command.

Key Takeaways

App Intents is most valuable as an architectural doorway. Define the task-creation capability once, then let small adapters expose it wherever the system can use it. AppIntent describes the verb, AppEntity describes your nouns, and EntityQuery resolves stable identity. AppShortcutsProvider improves discovery, while widgets and hardware triggers reuse the same action.

Production quality comes from what surrounds those types: shared persistence, early dependency registration, strict authorization, bounded execution, clear errors, and tests below and above the framework boundary. If you get that foundation right, adding a new system surface stops being a rewrite. It becomes another well-behaved entrance to code you already trust.