When AppEnum Breaks in App Intents — Designing EntityQuery so Siri Can Pick From a Catalog That Grows Every Day
Writing an App Intents parameter with AppEnum works fine while the options are fixed, but it cannot survive content that grows daily. Here is the AppEntity + EntityQuery design that lets Siri and Shortcuts correctly pick from a dynamic catalog, including identifier stability and Spotlight pitfalls.
When I asked Antigravity to "add an App Intent to my wallpaper app so a collection can be opened by name," the Swift it returned ran cleanly. It listed a few representative collection names in an AppEnum and offered them as parameter options. Testing in the Shortcuts app, the suggestions appeared. Had I been satisfied and shipped it there, it would have quietly broken a few weeks later.
The problem is that the collections in the wallpaper apps I run as an indie developer grow and shrink almost daily. AppEnum is a type that assumes its options are fixed at compile time; it cannot represent a catalog that changes at runtime. The generated code wasn't bad — the prompt simply never carried the fact that this catalog is dynamic. Here I'll walk through the exact conditions under which AppEnum breaks, and the move to AppEntity + EntityQuery, all the way down to identifier stability.
AppEnum Is a Fixed Menu, Not a Catalog
App Intents gives you two main ways to offer parameter options: AppEnum and AppEntity. They produce similar-looking code, but they assume completely different worlds.
Aspect
AppEnum
AppEntity
How options are decided
Fixed at compile time
Resolved by a query at runtime
Good for
Finite settings: sort order, theme, quality
Data that grows: articles, collections, products
Count
A handful to a dozen
Hundreds to thousands
Search
No (present all only)
Filter by string match
Spotlight exposure
No
Yes, via IndexedEntity
So a fixed value like "wallpaper quality (standard / high / max)" is a correct fit for AppEnum, while a growing target like "the user's collections" should use AppEntity. Get this line wrong early and it will pass your tests while the catalog is small, then degrade silently in production. The code I first received was exactly this mix-up.
The deciding question is simple: can these options grow without waiting for the next app update? If yes, it's AppEntity.
AppEntity Is the Data; EntityQuery Is How to Find It
An AppEntity is the type that represents one item you expose to Siri and Shortcuts. One collection in the catalog looks like this.
import AppIntentsstruct WallpaperCollection: AppEntity { // Stable identifier: use the data's persistent ID, not an array index let id: String let name: String let itemCount: Int static var typeDisplayRepresentation: TypeDisplayRepresentation { TypeDisplayRepresentation(name: "Wallpaper Collection") } // How a single item looks in Siri / Shortcuts var displayRepresentation: DisplayRepresentation { DisplayRepresentation( title: "\(name)", subtitle: "\(itemCount) images" ) } static var defaultQuery = WallpaperCollectionQuery()}
On its own, AppEntity has no idea how to find one item. That job belongs to EntityQuery. This is the biggest difference from AppEnum: most of the implementation weight sits on the query side.
struct WallpaperCollectionQuery: EntityQuery { // (1) Rebuild entities from identifiers. Called every time a saved shortcut re-resolves. func entities(for identifiers: [String]) async throws -> [WallpaperCollection] { let all = await CollectionStore.shared.load() let map = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) return identifiers.compactMap { map[$0] } } // (2) The first candidates shown in the parameter editor func suggestedEntities() async throws -> [WallpaperCollection] { let all = await CollectionStore.shared.load() // Don't return everything — just the most recently used return Array(all.sorted { $0.lastUsedAt > $1.lastUsedAt }.prefix(10)) }}
Do not treat entities(for:) lightly. It runs every time a user later executes a shortcut they saved earlier. If you can't rebuild the entity from its identifier here, the saved shortcut breaks with "not found." AppEnum has no concept of this rebuild, which is precisely why using it for dynamic data undermines the durability of saved shortcuts.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand exactly when AppEnum fails to represent a dynamic catalog, so you can decide with confidence when to move to AppEntity
✦Implement EntityQuery's entities(for:) / suggestedEntities() / entities(matching:) by role, so both Siri and Shortcuts can search and present your catalog
✦Avoid the silent breakage of using an array index as an identifier, and land on a design with stable IDs and Spotlight integration that actually holds up in production
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Without String Search, a Large Catalog Is Unusable
suggestedEntities() is only the first set of candidates. When you have hundreds of collections, the user will want to narrow by name. To match when someone says to Siri, "open the Kyoto Nights collection," implement EntityStringQuery.
struct WallpaperCollectionQuery: EntityStringQuery { func entities(for identifiers: [String]) async throws -> [WallpaperCollection] { let all = await CollectionStore.shared.load() let map = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) return identifiers.compactMap { map[$0] } } func suggestedEntities() async throws -> [WallpaperCollection] { let all = await CollectionStore.shared.load() return Array(all.sorted { $0.lastUsedAt > $1.lastUsedAt }.prefix(10)) } // (3) Narrow by what Siri heard; fold case differences away func entities(matching string: String) async throws -> [WallpaperCollection] { let all = await CollectionStore.shared.load() let needle = string.lowercased() return all.filter { $0.name.lowercased().contains(needle) } }}
Where I actually stumbled was leaving entities(matching:) unimplemented and relying on suggestedEntities() alone. Any collection outside the top ten suggestions couldn't be picked even by saying its exact name. Trying to open "the one I added yesterday" by voice, if it wasn't in the suggestions, it would never surface. For a dynamic catalog, presenting (suggested) and searching (matching) are separate responsibilities and you need both — that's my current conclusion.
The Intent Itself Just Rides on the Query
Once the design is this far, the Intent itself becomes remarkably thin. Specify an AppEntity on the @Parameter and the OS handles both suggestion and search through your EntityQuery.
struct OpenCollectionIntent: AppIntent { static var title: LocalizedStringResource = "Open Collection" static var openAppWhenRun = true // hand display work back to the app @Parameter(title: "Collection") var collection: WallpaperCollection @MainActor func perform() async throws -> some IntentResult { AppRouter.shared.open(collectionID: collection.id) return .result() }}
Back when I used AppEnum, I rewrote the enum cases every time the options changed. After moving to this shape, adding a single record to the data store made both Siri and Shortcuts candidates follow automatically. The Intent code is written once and never touched again. Only the data grows — reaching that state was the real payoff.
Using an Array Index as the Identifier Fails Silently
The most common accident in AppEntity design is how you assign id. Not just Antigravity — generated code tends to grab whatever value is at hand for the identifier, and array offsets or loop counters slip in. For a dynamic catalog this is fatal.
If the sort order changes, or one item in the middle is deleted, an index-based ID points somewhere else. A shortcut the user saved by specifying "Kyoto Nights" opens a different collection the next day. And because it doesn't crash, it's hard to notice — entities(for:) simply returns "a different entity that happened to match."
The fix is simple: always use the persistent ID the data itself owns. In my wallpaper apps, each collection carries a stable slug assigned at creation time. Reorder or delete all you want; the ID of a living collection never changes. When you receive generated code, it's worth checking with your own eyes what the id derives from before anything else.
// Bad: the target shifts when ordering changeslet entities = collections.enumerated().map { WallpaperCollection(id: "\($0.offset)", name: $0.element.name, itemCount: $0.element.count)}// Good: use a stable, data-derived IDlet entities = collections.map { WallpaperCollection(id: $0.stableSlug, name: $0.name, itemCount: $0.count)}
To catch this kind of mix-up before push in an agent workflow, the same idea from the static check in gating agent-written App Intents with a confirmation step applies. Distrust the output and mechanically verify where the identifier comes from — that one extra step prevents most of these re-resolution accidents.
For Spotlight, Add IndexedEntity
With the new intelligence framework widening assistant exposure, you'll want to open collections from Spotlight search too. Conform your AppEntity to IndexedEntity and the entity itself becomes a Spotlight index target.
import CoreSpotlightextension WallpaperCollection: IndexedEntity { var attributeSet: CSSearchableItemAttributeSet { let attrs = CSSearchableItemAttributeSet(contentType: .content) attrs.title = name attrs.contentDescription = "\(itemCount)-image collection" return attrs }}
The thing to watch here is that the responsibility for updating the index stays with the app. If you don't update CSSearchableIndex when a collection is added or removed, Spotlight's results diverge from the real data. App Intents makes it tempting to feel that "the OS just handles it," but the freshness of a dynamic catalog is still yours to maintain. Until I automated this, I shipped inconsistencies more than once where deleted collections lingered in search.
To close, let me distill the decision you make before writing any code. If the parameter's options are "a finite set of settings that can't grow without an app update," use AppEnum. If they are "data that grows on the user or server side," use AppEntity + EntityQuery, and implement EntityStringQuery too when you want name-based selection. Use a stable, data-derived identifier, and if you put it in Spotlight, pair IndexedEntity with index updates.
Start by picking just one parameter in your own app and putting into words whether it's a fixed menu or a dynamic catalog. Once that's clear, which type to choose decides itself. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.