Build App Intents That Travel Across Apps And Devices

The first version of an App Intents integration is usually about reach.

You add an AppIntent, expose a few AppEntity types, make the obvious action show up in Shortcuts, maybe wire a widget button, and prove that the system can run your code outside the normal app UI. That is a good first milestone.

The next milestone is different: make the integration behave like part of the system.

For a travel/photo app, that means a landmark should move to Maps as a real place, not just as text. New trip albums should appear when they are relevant, not only after someone searches for them. A “tag these 1,000 photos” action should not spend most of its time resolving entities it never reads. Siri should be able to carry a photo reference from iPhone to iPad. A widget button should upload or tag content without guessing which process owns the write.

That is the shape of the WWDC26 App Intents updates. Apple frames these APIs as part of its 2027 release cycle, so treat the exact spelling in the snippets below as beta-shaped and check your installed SDK before pasting code into production. The concepts are the important part: structured transfer, relevance, scale, stable identity, longer execution, cancellation, and process routing.

The snippets are intentionally focused on the new App Intents surface area. Real AppEntity and AppIntent types still need the usual display representations, queries, authorization checks, dependency wiring, and app-specific error handling.

Start with a product boundary

Imagine an app called Trailbook. It has:

  • landmarks and trips
  • travel photos
  • albums and landmark collections
  • a Home Screen widget that can show a slideshow or quick actions
  • a Share Sheet and Shortcuts surface
  • long-running photo uploads and on-device tagging flows
  • an app target, a widget extension, and maybe an App Intents extension

The App Schemas work is still important. If PhotoEntity can adopt a photo schema and your UI can annotate visible content, do that. The AppIntentsTesting work is still important too. Test your intents and queries through the system pathway.

This post is the next layer: once the system can see your entities and actions, how do you make them travel, scale, and execute in the right place?

Share structured values with IntentValueRepresentation

Transferable already gives you file and data representations. That works well for images, PDFs, plain text, and other values with an obvious payload format.

A place is different. Maps does not need your landmark as a PDF. It needs structured location information: a coordinate, a name, or an address-like value it can navigate to.

WWDC26 introduces IntentValueRepresentation for structured values the system understands. In Apple’s session, a landmark entity exports a PlaceDescriptor from the GeoToolbox framework:

import AppIntents
import CoreTransferable
import GeoToolbox

struct LandmarkEntity: AppEntity, Transferable {
    var id: Int
    var landmark: Landmark

    static var transferRepresentation: some TransferRepresentation {
        IntentValueRepresentation(exporting: { entity in
            PlaceDescriptor(
                representations: [
                    .coordinate(entity.landmark.locationCoordinate)
                ],
                commonName: entity.landmark.name
            )
        })
    }
}

If your entity already exposes the structured value as a property, the key-path version is cleaner:

import AppIntents
import CoreTransferable
import GeoToolbox

struct LandmarkEntity: AppEntity, Transferable {
    var id: Int

    @Property
    var placeDescriptor: PlaceDescriptor

    static var transferRepresentation: some TransferRepresentation {
        IntentValueRepresentation(exporting: \.placeDescriptor)
    }
}

The important difference is not syntax. It is what crosses the app boundary.

Without the structured representation, Trailbook can share a landmark as text or a file. With it, a Shortcut can pass that landmark to Maps and Maps receives a PlaceDescriptor it can use for directions.

One beta note: early session code, and even some generated documentation examples, may show the shorter ValueRepresentation spelling. Apple’s symbol page documents IntentValueRepresentation, so prefer the spelling accepted by the SDK and docs you are building against.

Register entities when they become relevant

Spotlight indexing and interaction donation solve different problems.

Use Spotlight when content should be searchable and retrievable by Siri. Use interaction donation when a person actually did something and the system should learn that pattern.

But an app often knows useful context before either of those things happens. Maybe a music app has a new running playlist right when the user starts a workout. Maybe a travel app has content that should be suggested when a system-defined context matches the moment. No one has searched for it. No one has opened it. It is still relevant.

RelevantEntities lets you register entities with a context and later remove them. Current documentation frames this around media and audio suggestions, especially workout-related contexts. Treat this as media/audio relevance unless Apple documents another AppEntityContext family for your domain.

import AppIntents

let dailyRun = PlaylistEntity(id: "daily-run")
let runningMix = PlaylistEntity(id: "running-mix")

let context = AppEntityContext.audio(
    .workout(activityType: .running)
)

try await RelevantEntities.shared.updateEntities(
    [dailyRun, runningMix],
    for: context
)

The exact context families are SDK-defined, so do not invent your own cases. The stable design point is this: register content for a context when your app knows it is useful, and unregister it when that stops being true. Updating a context replaces the prior entity set, and Apple’s docs say suggestions expire if the app is not launched for about four weeks.

try await RelevantEntities.shared.removeEntities(
    [dailyRun],
    from: context
)

try await RelevantEntities.shared.removeAllEntities(for: context)

try await RelevantEntities.shared.removeAllEntities()

That last cleanup matters. Relevant entities stay registered until removed. If the user leaves Lisbon, deletes the trip, signs out, or revokes a permission your suggestion depends on, clear the stale entities. A stale suggestion is worse than no suggestion because it teaches people not to trust the surface.

Use EntityCollection for bulk operations

Most entity parameters should resolve to real entities before perform() runs. That is the point of App Intents: the system can populate titles, display representations, properties, and schema data before your code acts.

Bulk actions are the exception.

Trailbook might have a “Tag Travel Photos” action that receives hundreds or thousands of photos from Shortcuts. If the intent only needs identifiers, resolving every PhotoEntity first is wasted work. EntityCollection is built for that case. It stores entity identifiers instead of fully resolved entities.

import AppIntents

struct TagPhotosIntent: AppIntent {
    static let title: LocalizedStringResource = "Tag Travel Photos"

    @Parameter
    var photos: EntityCollection<PhotoEntity>

    @Parameter
    var tag: String

    @Dependency
    var library: PhotoLibrary

    func perform() async throws -> some IntentResult {
        try await library.tagPhotos(
            ids: photos.identifiers,
            tag: tag
        )

        return .result()
    }
}

Use EntityCollection when the operation is fundamentally identifier-based:

  • tag these photos
  • delete these imported drafts
  • add these assets to an album
  • mark these items as exported
  • enqueue these IDs for background processing

Do not use it as a habit. If your intent needs names, dates, location metadata, permissions, or per-entity validation, let the system resolve the entities and make the behavior explicit.

Make IDs stable with SyncableEntity

Cross-device Siri and system experiences make entity identity more serious.

If Trailbook creates a photo on iPhone with local row ID 42, the same photo might be row ID 918 on iPad. A local database ID is fine inside one process on one device. It is not enough when a conversation, shortcut output, or system reference needs to travel.

SyncableEntity tells the system that your entity has an identifier that is stable across devices. If your id is already stable, adoption is small:

import AppIntents

struct PhotoEntity: AppEntity, SyncableEntity {
    var id: UUID
}

That UUID should be a real stable ID: a server identifier, a CloudKit record identifier mapped into your model, or another value that every device agrees on. Do not wrap a local primary key in UUID() and call it stable.

If your app needs both a local ID and a stable ID, use SyncableEntityIdentifier:

import AppIntents

struct PhotoEntity: AppEntity, SyncableEntity {
    var id: SyncableEntityIdentifier<String, String>

    init(localID: String, stableID: String) {
        self.id = SyncableEntityIdentifier(
            local: localID,
            stable: stableID
        )
    }
}

That pairing lets your device-local code keep using the local identifier while the system has a stable reference for cross-device behavior. It is also a good forcing function for your own architecture. If an entity can be named by Siri, shown in a widget, opened from Spotlight, and synced to another device, it deserves a stable identity story.

Use @UnionValue when one parameter can be several entity types

Some App Intents parameters really are one kind of thing. A “Tag Photos” action should probably take photos.

Other surfaces are more flexible. Trailbook’s widget might show a gallery from either a landmark collection or a photo album. Two widgets would work, but one configurable widget is nicer if the user sees both options as “gallery content.”

@UnionValue lets one parameter accept a small set of wrapped types:

Beta caveat: the WWDC26 session shape below is useful for understanding the model, but current docs describe union values with an AppUnionValue type and an AppUnionValueCasesProviding cases enum generated around @UnionValue. Use the exact generated type shape from your installed SDK before shipping this snippet.

import AppIntents

@UnionValue
enum TravelGalleryContent {
    case landmarkCollection(LandmarkCollectionEntity)
    case photoAlbum(PhotoAlbumEntity)

    static let typeDisplayRepresentation: TypeDisplayRepresentation =
        "Travel Gallery"

    static let caseDisplayRepresentations: [Cases: DisplayRepresentation] = [
        .landmarkCollection: "Landmark Collection",
        .photoAlbum: "Photo Album"
    ]
}

Use this sparingly. It is good for a real product concept with two or three shapes. It is not a substitute for a clear model. If every parameter becomes “album or landmark or trip or person or string,” the Shortcuts picker gets confusing and your perform() method becomes a type switch with ambitions.

Move long work into LongRunningIntent

Most intents are expected to finish quickly. Apple’s WWDC26 session calls out the familiar 30-second limit for ordinary intent execution.

Uploading travel photos, generating tags, preparing a shared album, or running on-device photo processing can take longer. LongRunningIntent extends the background execution time for that kind of action and manages the background task lifecycle. It also builds on progress reporting so the system can show progress, including Live Activity behavior, while the work continues.

Apple’s beta-shaped session code looks like this:

import AppIntents

struct UploadPhotoIntent: LongRunningIntent, CancellableIntent {
    static let title: LocalizedStringResource = "Upload Photo"

    @Parameter
    var photo: IntentFile

    func perform() async throws -> some IntentResult & ProvidesDialog {
        let result = try await performBackgroundTask {
            let chunks = calculateChunks(for: photo)
            progress.totalUnitCount = Int64(chunks)

            for chunk in 1...chunks {
                try Task.checkCancellation()
                try await uploadChunk(chunk)
                progress.completedUnitCount = Int64(chunk)
            }

            return "Upload complete!"
        } onCancel: { reason in
            cleanup(for: reason)
        }

        return .result(dialog: "\(result)")
    }
}

There are three product details hiding in that small snippet.

First, progress is not decoration. The system uses it to know the task is alive, and the person sees it as visible status. Set totalUnitCount, update completedUnitCount, and choose units that correspond to real work.

Second, cancellation is part of the contract. CancellableIntent gives your intent a chance to clean up when the person taps stop, the system times out, or resources need to be reclaimed. For Trailbook, that might mean cancelling an upload task, deleting a partial server object, saving resumable state, or marking a job as “needs retry” instead of “failed forever.”

Third, long-running does not mean unbounded. Persist enough state to recover, make repeated progress, and keep the operation understandable from outside the app. A Live Activity that sits at 0 percent for several minutes is a bug report with a prettier background.

Apple also notes that LongRunningIntent supports background GPU access on supported devices for work such as photo processing or on-device inference, with the appropriate entitlement. I would keep that path isolated behind a capability check and a small processing service. Beta background execution APIs are exactly where you want a boring boundary around your app logic.

Route work with IntentExecutionTargets

As your App Intents code grows, you will probably move common entities and intents into a shared package. The app target imports it. The widget extension imports it. An App Intents extension may import it too.

That is convenient, but it creates a routing question: when the same intent is visible from multiple processes, which process should run it?

The system has heuristics. If the app is already running, it may prefer the app. Otherwise it may launch an extension. Sometimes that is exactly what you want. Sometimes it is not.

Trailbook’s widget can read the latest slideshow state, but only the main app should write to the shared database. A favorite button in the widget should therefore execute in the main app:

One beta naming note before you copy these examples: Apple’s WWDC26 session code used ExecutionTargets, while current documentation types allowedExecutionTargets as IntentExecutionTargets. The product idea is stable, but use the exact symbol spelling from your installed SDK.

import AppIntents

struct UpdateFavoriteIntent: AppIntent {
    static let title: LocalizedStringResource = "Update Favorite"

    static var allowedExecutionTargets: IntentExecutionTargets {
        .main
    }
}

A standalone download or lookup might be safe in an App Intents extension:

import AppIntents

struct DownloadPhotoIntent: AppIntent {
    static let title: LocalizedStringResource = "Download Photo"

    static var allowedExecutionTargets: IntentExecutionTargets {
        .appIntentsExtension
    }
}

A display-only widget lookup can stay in the widget extension:

import AppIntents

struct GetLandmarkStatusIntent: AppIntent {
    static let title: LocalizedStringResource = "Get Landmark Status"

    static var allowedExecutionTargets: IntentExecutionTargets {
        .widgetKitExtension
    }
}

And if an operation is safe in more than one place, allow both:

import AppIntents

struct TagPhotosIntent: AppIntent {
    static let title: LocalizedStringResource = "Tag Travel Photos"

    static var allowedExecutionTargets: IntentExecutionTargets {
        [.main, .appIntentsExtension]
    }
}

The design rule is more stable than the name:

  • writes to a single-writer store should run in the process that owns writes
  • quick reads can run in lightweight extensions
  • widget-only state can stay in the widget extension
  • long work should run where the required background capability, entitlement, and data access exist
  • shared packages should not mean every process is allowed to do every job

A practical adoption order

If Trailbook already has basic App Intents, I would not adopt all of this in one pass. Start where the product pain is obvious.

Add IntentValueRepresentation to entities that naturally leave your app. Landmarks should become places. Contacts should become structured people. Photos should still use image/file transfer where that is the correct representation.

Use RelevantEntities only for content with a real context and a clear removal path. It is a relevance hint, not a second search index.

Switch bulk identifier-only actions to EntityCollection. Measure before and after. This is the rare API where a small type change can remove a large amount of unnecessary work.

Adopt SyncableEntity for any entity that can participate in cross-device Siri, Spotlight, Shortcuts, widget, or handoff-like flows. Stable IDs are plumbing, but they are also product polish.

Use @UnionValue when a user-facing control honestly accepts a small family of related types.

Reserve LongRunningIntent for work that actually needs it, then treat progress and cancellation as part of the feature, not as framework ceremony.

Finally, set IntentExecutionTargets on intents where process choice affects correctness. Data ownership bugs across app and extension processes are deeply annoying because they look like random sync problems. Be explicit early.

The mental model

Basic App Intents adoption answers: “Can the system run this action?”

The WWDC26 layer answers a better set of questions:

  • Can this entity move to another app as the structured value that app needs?
  • Can the system suggest this entity at the moment it becomes useful?
  • Can this bulk action run without resolving data it never reads?
  • Can this entity keep the same identity across devices?
  • Can this parameter model the user’s real choice without duplicating intents?
  • Can this long operation continue, report progress, and cancel cleanly?
  • Can this intent run in the process that actually owns the work?

That is when App Intents stop feeling like a set of Shortcut actions and start feeling like a system surface for your app’s model.

Official Apple sources: