Hit the Lights
I’m in the kitchen, hands wet, a record sleeve drying on the counter. I say “Hey Siri, play Master of Puppets on VinylCrate” out loud to nothing in particular, and a second later Hetfield is coming out of the HomePod. I didn’t unlock my phone. I didn’t open VinylCrate. I didn’t tap a play button. The album exists in my collection — VinylCrate knows about it — and Siri routed straight through to the intent that knows what to do with it.
That “on VinylCrate” matters, and we’ll come back to why. App Shortcut phrases anchor to the app name unless you’ve claimed one of the assistant schema domains (music, photos, messaging) that lets the system route bare utterances to your intent. VinylCrate hasn’t — that’s a separate entitlement story for another post. For now the price of admission to a parameterized phrase is the four-syllable suffix, and once you’re past it the experience is the same: no UI, no foreground, just the right action firing in the background while the user keeps their hands wet.
That’s the user-facing pitch for App Intents, and once you’ve felt it, the wiring underneath is worth understanding. This post is a walk through the App Intents surface I built into VinylCrate — my personal vinyl-collection app — over the last week. Six intents, two entities, a Spotlight donation pass, and a frankly humbling tile-visibility bug that took three separate fixes before I understood what Shortcuts.app was actually doing.
There’s no companion repo this time. The code excerpts here are the real implementation, lifted out of VinylCrate and quoted whole where it matters. The point isn’t “go read my repo” — the point is to leave you with something you can recognize next time the same trap shows up in your project.
Sad But True
Before any code, the honest framing: App Intents are the wrong tool for most of your app.
The right shape for an App Intent is an atomic, headless task the system can perform without bringing your full UI forward. Play this album. Open this crate. Add this record to the wantlist. Each one resolves in under a second, returns a dialog the user can read on a watch face or hear through AirPods, and exits cleanly. The system is the host. Your app is a vendor of small, well-described actions.
The wrong shape is anything that wants a navigation stack, multi-step modal state, or your own UI to carry the user through a flow. If the action needs “first show the chooser, then confirm, then preview,” it’s not an intent — it’s a screen, and you’re trying to evict your own user experience into a Siri prompt that doesn’t have room for it.
VinylCrate fits the App Intents shape in six specific places:
- Play an album from the collection (parameterized).
- Open an album detail screen (parameterized).
- Open a crate (parameterized).
- Add an album to the wantlist (parameterized).
- Open the wantlist (parameter-less).
- Search the collection (parameter-less; the query is requested at runtime).
Two entities back those intents: AlbumEntity and CrateEntity. Everything else in the app — the SwiftData store, the marketplace, the import flow, the CarPlay screens — stays where it is. The intents are a thin, deliberate surface that the system reaches into.
Eye of the Beholder
Phase 1 was getting the four pieces of an App Intents surface in place. They’re worth naming because each one has a specific job and breaking the boundary between them is where most of the pain comes from.
The entity is a value-type snapshot the system can serialize and cache across process boundaries. It is not your SwiftData model. It is a small, Sendable mirror of the parts of your data Siri and Spotlight need to identify, display, and round-trip.
struct AlbumEntity: AppEntity, Identifiable, Sendable {
static var typeDisplayRepresentation: TypeDisplayRepresentation {
TypeDisplayRepresentation(name: "Album")
}
static var defaultQuery = AlbumQuery()
let id: Int
@Property(title: "Title")
var title: String
@Property(title: "Artist")
var artist: String
@Property(title: "Year")
var year: Int?
@Property(title: "Genres")
var genres: [String]
init(id: Int, title: String, artist: String, year: Int? = nil, genres: [String] = []) {
self.id = id
self.title = title
self.artist = artist
self.year = year
self.genres = genres
}
init(_ release: ReleaseDetail) {
self.init(
id: release.id,
title: release.title,
artist: release.primaryArtistName,
year: release.year,
genres: release.genres
)
}
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(artist)",
image: .init(systemName: "opticaldisc")
)
}
}
The id: Int is the Discogs release id — the same value that keys the SwiftData SavedRelease row. The @Property wrappers expose searchable fields to Apple Intelligence and Spotlight. The displayRepresentation is what shows up in a Siri confirmation card or a Shortcuts picker.
The query resolves entities from whatever the system happens to have: a list of ids, a search string, a request for “suggested” entities, or a full enumeration for a Shortcuts picker.
struct AlbumQuery: EntityQuery, EntityStringQuery, EnumerableEntityQuery {
private static let resultLimit = 200
@MainActor
func entities(for identifiers: [Int]) async throws -> [AlbumEntity] {
let ids = Set(identifiers)
let descriptor = FetchDescriptor<SavedRelease>(
predicate: #Predicate { ids.contains($0.id) }
)
return try ModelContext(SharedModelContainer.shared)
.fetch(descriptor)
.compactMap(Self.makeEntity)
}
@MainActor
func entities(matching string: String) async throws -> [AlbumEntity] {
let query = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return try await suggestedEntities() }
var descriptor = FetchDescriptor<SavedRelease>(
predicate: #Predicate { saved in
saved.title.localizedStandardContains(query)
|| saved.artistName.localizedStandardContains(query)
},
sortBy: [SortDescriptor(\.dateAdded, order: .reverse)]
)
descriptor.fetchLimit = Self.resultLimit
return try ModelContext(SharedModelContainer.shared)
.fetch(descriptor)
.compactMap(Self.makeEntity)
}
@MainActor
func suggestedEntities() async throws -> [AlbumEntity] {
var descriptor = FetchDescriptor<SavedRelease>(
sortBy: [SortDescriptor(\.dateAdded, order: .reverse)]
)
descriptor.fetchLimit = Self.resultLimit
return try ModelContext(SharedModelContainer.shared)
.fetch(descriptor)
.compactMap(Self.makeEntity)
}
}
Three resolution paths covering three different system needs: id-to-entity rehydration when Spotlight or Siri unpacks something it cached earlier, string-matching for voice and free-text search, and a suggested-entities list that doubles as the empty-state for Shortcuts. The localizedStandardContains predicates push the filter down into SQLite so we only decode the ReleaseDetail JSON blob for survivors — a small thing, but it matters when the collection runs into the thousands.
Note what’s missing: a @Dependency var modelContainer: ModelContainer. The query reaches for SharedModelContainer.shared instead. We’ll come back to why in the next section.
The intent is the action itself. PlayAlbumIntent is the most interesting one because it conforms to AudioPlaybackIntent, which tells the system this intent starts audio and should be allowed to run without foregrounding the app — exactly what you want when someone says “play this album” while driving or wearing AirPods.
struct PlayAlbumIntent: AudioPlaybackIntent {
static var title: LocalizedStringResource = "Play Album"
static var description = IntentDescription(
"Play a record from your collection."
)
static var openAppWhenRun: Bool = false
@Parameter(title: "Album", requestValueDialog: "Which album would you like to play?")
var album: AlbumEntity
@Dependency private var container: ViewModelContainer
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let release: ReleaseDetail? = container.collection.collection.first(where: { $0.id == album.id })
?? SharedModelContainer.fetchRelease(id: album.id)
guard let release else {
return .result(dialog: "I couldn't find that album in your collection.")
}
let useCase = PlayAlbumUseCase(music: container.music, playback: container.playback)
switch await useCase.play(release) {
case .played:
return .result(dialog: "Playing \(album.title) by \(album.artist).")
case .notSubscribed:
return .result(
dialog: "Apple Music is required to play albums. Open VinylCrate to set it up."
)
case .notOnAppleMusic:
return .result(dialog: "I couldn't find \(album.title) on Apple Music.")
}
}
}
A few things to call out. @Dependency works here because we’re inside perform() — the intent-perform flow is the only context where the system has registered dependencies. The requestValueDialog on the @Parameter is what Siri reads aloud when she needs to ask the user which album they meant. And the openAppWhenRun = false is the difference between “play in the background” and “yank VinylCrate to the foreground” — the latter would be wrong for an audio intent and would also defeat the point of running headless.
The shortcuts provider is the last piece. It’s a static, compile-time declaration of voice phrases that show up in the Shortcuts app the moment the user installs your build. No setup, no user-side configuration.
AppShortcut(
intent: OpenWantlistIntent(),
phrases: [
"Show my wantlist in \(.applicationName)",
"Open my wantlist in \(.applicationName)"
],
shortTitle: "Open Wantlist",
systemImageName: "heart.text.square.fill"
)
AppShortcut(
intent: PlayAlbumIntent(),
phrases: [
"Play \(\.$album) on \(.applicationName)",
"Play \(\.$album) in \(.applicationName)"
],
shortTitle: "Play Album",
systemImageName: "play.circle.fill"
)
Two examples there. The first is parameter-less — no entity placeholder in any phrase. The second is parameterized — every phrase carries \(\.$album), the magic interpolation that tells Siri “this slot is an AlbumEntity, resolve it via the album’s default query.”
That second shortcut is the one that’s going to bite us. Hold on to it — we’ll come back to it in Whiplash.
Trapped Under Ice
I built the entity, the query, the intent, the shortcuts provider. I ran it, said “Hey Siri, play Master of Puppets,” and Siri replied with the kind of unhelpful generic non-answer that means she has no idea what I’m talking about.
I opened the Shortcuts app. Of the six intents I’d declared, only the two parameter-less ones — Open Wantlist and Search Collection — showed up. Four parameterized tiles were missing entirely. No errors. No console output. No filed radars. Just absent.
The clue came from running the intent in the perform path directly, without going through Shortcuts at all. The moment a parameterized intent’s query was touched outside perform(), the runtime trapped with:
AppDependency of type ModelContainer.Type was not initialized prior to access. Dependency values can only be accessed inside of the intent perform flow.
Here’s what’s happening. When Shortcuts.app builds its per-app tile list, it launches your app process headlessly to enumerate the declared intents and validate their entity parameters. That launch does not run App.init. It does not run your WindowGroup. It runs the App Intents subsystem with just enough of your binary to read the metadata and call your query’s resolution methods.
If your query reaches for a @Dependency-injected ModelContainer, that property wrapper traps because dependencies haven’t been registered yet — registration happens in App.init, which never ran. The trap is caught silently by the system. Your intent is dropped from the per-app list. No log entry. No symptom you can grep for. The user sees a missing tile and you see nothing.
This is the part I want every iOS dev to remember: App Intents discovery and intent perform run in different processes with different initialization guarantees. A query that works fine inside perform() can be invisible during discovery because the discovery context has no main app to lean on. Anything load-bearing in your query has to be available without the rest of your app booting.
…And Justice for All
The fix is structural. Pull the ModelContainer out of dependency-injection-land and put it where it’s available the moment any code touches it — main app, headless discovery, donation pass, every context.
enum SharedModelContainer {
private static let logger = Logger(
subsystem: "com.vinylcrate.app",
category: "SharedModelContainer"
)
nonisolated static let schema = Schema([
SavedRelease.self,
CustomCrate.self,
CrateAlbumAssignment.self,
SavedTrack.self,
CrateTrackAssignment.self,
SavedListing.self,
SavedOrder.self,
SavedOrderMessage.self
])
nonisolated static let shared: ModelContainer = {
do {
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true
)
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
logger.error("Failed to initialize shared ModelContainer: \(error.localizedDescription)")
fatalError("Failed to initialize shared ModelContainer: \(error)")
}
}()
@MainActor
static func fetchRelease(id: Int) -> ReleaseDetail? {
let descriptor = FetchDescriptor<SavedRelease>(
predicate: #Predicate { $0.id == id }
)
return try? ModelContext(shared).fetch(descriptor).first?.releaseDetail
}
}
A nonisolated static let for the schema and one for the container. That’s the whole pattern. SwiftData opens the same on-disk SQLite store regardless of which process — or which actor — touches it first. The nonisolated keyword means the discovery context doesn’t pay a main-actor hop just to grab the container handle; the ModelContext itself we instantiate per-call still needs @MainActor, but the container reference is free.
A couple of side benefits this pattern bought me that I didn’t expect.
The schema becomes the single source of truth for the app. VinylCrateApp references it. The App Intents queries reference it. The donation helper references it. Any test that needs an in-memory configuration references it. Adding a new @Model type — and I did add SavedListing, SavedOrder, and SavedOrderMessage while building out the marketplace work — only requires touching one array.
The discovery-time trap goes away because no code path reaches for a @Dependency outside of perform(). The query is now safe to call from any context the system invents. @Dependency is still in PlayAlbumIntent.perform() for the live ViewModelContainer — that’s fine, because by the time perform() runs, dependencies have been registered. The boundary is clean: queries use the shared container, intent bodies use registered dependencies, never the other way around.
One small thing worth flagging while we’re in the neighborhood of intent shape. OpenIntent already inherits from AppIntent — you don’t dual-conform. struct OpenAlbumIntent: OpenIntent is the right declaration; struct OpenAlbumIntent: AppIntent, OpenIntent will compile but it muddies which protocol’s perform() requirement you’re satisfying. And every @Parameter that takes an entity wants a requestValueDialog: so Siri has something to read when the slot is empty. Skip it and you get her generic “Which one?” prompt, which sounds exactly as unhelpful as it reads.
Until It Sleeps
With the discovery trap gone, the parameter-less tiles were still showing up fine and the parameterized ones were still missing. That ruled out the trap as the only issue.
Next suspect: indexing. iOS 26 won’t render a tile whose phrase contains an entity placeholder until the system has actually seen instances of that entity type. The phrase Play \(\.$album) on VinylCrate is a promise — there are albums to play — and the system wants evidence before it surfaces the promise to the user.
Two things make that evidence visible. First, the entity has to conform to IndexedEntity:
extension AlbumEntity: IndexedEntity {
var attributeSet: CSSearchableItemAttributeSet {
let attrs = CSSearchableItemAttributeSet(contentType: .audio)
attrs.title = title
attrs.displayName = title
attrs.album = title
attrs.artist = artist
attrs.contentDescription = artist
attrs.keywords = genres
if let year, let date = Calendar.current.date(from: DateComponents(year: year)) {
attrs.contentCreationDate = date
}
return attrs
}
}
IndexedEntity is the bridge between App Intents and Core Spotlight. Conforming to it means “this entity can be indexed in Spotlight, and here’s the attribute set that describes one.” The contentType: .audio plus the album/artist/keywords combination is what makes the entries surface for “Master of Puppets” in Spotlight search even when VinylCrate isn’t the active filter.
Second, the app has to actively donate entities to Spotlight. Conformance alone doesn’t do it — you have to call indexAppEntities:
enum AppIntentsDonation {
private static let logger = Logger(
subsystem: "com.vinylcrate.app",
category: "AppIntentsDonation"
)
private static let albumsIndex = CSSearchableIndex(name: "net.insoc.VinylCrate.Albums")
private static let cratesIndex = CSSearchableIndex(name: "net.insoc.VinylCrate.Crates")
@MainActor
static func donateAllEntities() async {
do {
try await Task.sleep(for: .seconds(2))
} catch {
return
}
let context = ModelContext(SharedModelContainer.shared)
do {
let albums = try AlbumQuery.buildAllEntities(in: context)
try await albumsIndex.indexAppEntities(albums)
logger.info("Donated \(albums.count) albums to Spotlight")
} catch {
logger.error("Album donation failed: \(error.localizedDescription)")
}
do {
let crates = try CrateQuery.buildAllEntities(in: context)
try await cratesIndex.indexAppEntities(crates)
logger.info("Donated \(crates.count) crates to Spotlight")
} catch {
logger.error("Crate donation failed: \(error.localizedDescription)")
}
}
}
Three things to notice here.
Named indices. CSSearchableIndex(name: "net.insoc.VinylCrate.Albums") instead of .default(). App-scoped names mean VinylCrate’s entries live in their own namespace and don’t fight with anything else on the device that’s also writing to Spotlight. It also makes a future “wipe my data” path trivial — delete the named index, the entries are gone.
Idempotency. indexAppEntities replaces entries with the same id in place. You can call this on every launch and it does the right thing. There’s no diff tracking, no “is this album new?” logic, no per-write debouncing.
A two-second cancellation-aware sleep at the top. This is the part that surprised me. The donation runs from a .task(id: collection.count) on the root view, which re-runs whenever the collection count changes. During first launch, VinylCrate ingests Discogs collection rows in chunks — the count goes from 0 to 24 to 87 to 412 to 1,213 over the course of a few seconds. Without the sleep, that’s five donations in a row, each indexing a progressively-larger snapshot. With the sleep, .task(id:)’s built-in cancellation kicks in: each new count value cancels the prior task, the Task.sleep throws cancellation, the donation never runs. Only the final stable count survives the two-second window and gets donated. It’s a debounce built out of structured concurrency primitives, costing exactly one line.
I shipped this, ran it, and watched the log: Donated 1213 albums to Spotlight. Switched to Shortcuts.app. Four parameterized tiles still missing.
Whiplash
This is the part that took me longer than it should have.
IndexedEntity was in place. donateAllEntities() was donating. SharedModelContainer had killed the discovery trap. The query was resolving entities correctly when I called it manually. Every individual piece worked. The tiles were still missing.
I went and read the WWDC sessions for App Intents in iOS 26 three more times. I read the iOS 26 Shortcuts.app changelog. I tried different systemImageName values. I deleted the app, reinstalled, restarted the phone, gave it twenty minutes to settle. Nothing.
The fix, when I finally found it, is in one place and one place only: the phrases array of each parameterized AppShortcut. Here’s what I had:
// Four tiles invisible.
AppShortcut(
intent: PlayAlbumIntent(),
phrases: [
"Play \(\.$album) on \(.applicationName)",
"Play \(\.$album) in \(.applicationName)"
],
shortTitle: "Play Album",
systemImageName: "play.circle.fill"
)
Here’s what made all six render:
// All six tiles visible.
AppShortcut(
intent: PlayAlbumIntent(),
phrases: [
"Play an album on \(.applicationName)",
"Play \(\.$album) on \(.applicationName)",
"Play \(\.$album) in \(.applicationName)"
],
shortTitle: "Play Album",
systemImageName: "play.circle.fill"
)
One added line. A parameter-less lead-in phrase, sitting in the first position of the phrases array.
Here’s the model that fits every symptom I observed. iOS 26’s Shortcuts.app per-app tile list and the Siri authorization dialog both need a “safe” string to label and announce the tile with — a phrase they can render to the user without committing to a specific entity instance. When picking that safe string, the system filters out any phrase that carries an entity-parameter placeholder, because that placeholder is undefined until the user picks one. If every phrase in a parameterized intent carries a placeholder, the system has no safe string to fall back to and the tile is silently dropped from the visible list.
The fix is to give the system something to render: a phrase with no placeholders in it, sitting first in the array. The parameterized phrases still do their original job — they’re the patterns Siri matches when the user says “Play Master of Puppets on VinylCrate” directly, and the entity slot gets pre-filled from the matched substring. But when the tile is tapped from the per-app list, or when the user just says the lead-in phrase (“Play an album on VinylCrate”) without naming one, Siri falls through to the @Parameter’s requestValueDialog. “Which album would you like to play?” Then the user names it, and we’re back in the resolution path the query already handles.
This isn’t documented anywhere I could find. The WWDC session on App Intents in iOS 26 talks about parameter dialogs and entity indexing as two separate things and doesn’t connect them to tile visibility. The header docs on AppShortcut don’t mention it. I figured it out by stripping every other variable away and noticing that the two intents whose tiles always rendered were the two that had no entity placeholders in any phrase. The third fix wasn’t a new pattern — it was the absence of one I hadn’t realized I needed.
If you’re building a parameterized App Shortcut and the tile won’t show up, this is the first thing to check. Put a parameter-less lead-in phrase first. The five seconds of typing will save you the afternoon I spent.
The Unforgiven
With the surface working, I shipped a build to TestFlight and submitted to App Store Connect. The next morning, an ITMS-90626 rejection email was waiting.
The relevant part:
Your app has metadata that references Apple Music, but is not properly entitled or does not appear to use the service.
The offending string was in PlayAlbumIntent:
// Before — rejected by App Store Connect's Siri validator (ITMS-90626).
static var description = IntentDescription(
"Play one of your records on Apple Music."
)
// After — accepted.
static var description = IntentDescription(
"Play a record from your collection."
)
One word. “Apple Music” in the static intent description. The app does use Apple Music — that’s the whole point of PlayAlbumIntent — but Apple’s submission tooling apparently doesn’t scan the runtime intent body to verify that. It scans Metadata.appintents, the build artifact that captures every IntentDescription, every LocalizedStringResource, every @Parameter title declared on your App Intents types. If the word “Apple” or “Apple Music” shows up in there in a context the validator doesn’t expect, the build is rejected.
The fix is to keep static App Intents metadata generic and push any service-specific language into runtime .result(dialog:) text, which is not scanned. From the same PlayAlbumIntent:
case .notSubscribed:
return .result(
dialog: "Apple Music is required to play albums. Open VinylCrate to set it up."
)
case .notOnAppleMusic:
return .result(dialog: "I couldn't find \(album.title) on Apple Music.")
Both of those strings stayed exactly the way I wrote them. They reference Apple Music explicitly because the user genuinely needs to know — “I couldn’t find this album” is meaningfully different from “I couldn’t find this album on Apple Music” when the second one tells you which subscription you’d need to fix the gap. That’s the right place for service-specific language. It runs only when the intent actually executes, on a device that has already passed the entitlement check, in a dialog the user is reading or hearing in context.
The takeaway: Metadata.appintents is a separate compliance surface from your runtime UI. Apple’s submission scanner treats every static string in that bundle as a marketing claim about your app’s relationship to Apple services, and it will reject builds that imply integrations it can’t verify. Runtime dialog text — .result(dialog:), requestValueDialog: text, anything generated inside perform() — is yours to write the way you’d want a user to read it.
This is the kind of rejection that took me one resubmission to fix and would have taken zero if I’d known it existed. Now you know.
Atlas, Rise!
The last thing I want to walk through is a small architectural win that fell out of the App Intents work almost by accident.
When I first wrote PlayAlbumIntent.perform(), the body was about thirty lines of subscription-guard, catalog-id-lookup, and playback-start logic — basically the same code CarPlay’s handleAlbumTap had been carrying inlined for months. Two call sites, same chain of conditional awaits, slightly different error-handling shapes because the contexts wanted different things.
The intent’s need for a clean, dialog-returning shape made the duplication obvious. The CarPlay code could swallow failures and just log them. The intent had to convert every failure mode into a specific user-facing sentence. The chain was the same; the reporting was different.
I pulled the chain out into a use case:
struct PlayAlbumUseCase {
enum Outcome: Sendable, Equatable {
case played
case notSubscribed
case notOnAppleMusic
}
let music: AppleMusicViewModel
let playback: MusicPlaybackService
@MainActor
func play(_ release: ReleaseDetail, startingTrackID: String? = nil) async -> Outcome {
guard playback.canPlayCatalogContent else { return .notSubscribed }
guard let albumID = await music.getAlbumID(for: release) else { return .notOnAppleMusic }
await playback.playAlbum(albumID: albumID, startingTrackID: startingTrackID)
return .played
}
}
Six lines of actual logic, wrapped in an Outcome enum that describes what happened without dictating what to say about it. The intent’s perform() switches on the outcome and turns each case into a dialog. CarPlay’s handleAlbumTap switches on the same outcome and logs the failure cases without bothering the driver. Same chain, two presentations.
A couple of things deliberately stayed inline. AlbumDetailView doesn’t use this — its flow splits the resolve and play phases across user interactions, so the play button can render the right state before the user taps. CarPlay’s handleTrackTap also stays inline, because it needs a getTrackID lookup wedged between the album lookup and the playback start. Different lifecycles, different intermediate resolution needs, different right answers. The use case is what every call site needed in common, not a god-object that subsumes every variation.
This is the thing about App Intents I didn’t expect going in. The framework forces you to answer the question “what is the atomic, headless version of this user action?” If the answer is a chain of conditional service calls ending in a single outcome, then somewhere else in your app — CarPlay, a widget, a watch complication, a deep link handler, an Apple Intelligence semantic action you haven’t written yet — there’s a call site that wants the same chain. The duplication was always there. The App Intents work just surfaced it.
I came into this week thinking of App Intents as a Siri checkbox: ship the surface, get the “works with Siri” footnote, move on. I’m coming out of it thinking of them as a forcing function — every intent body is a tiny architectural pressure test for the rest of your app. If the body is clean, your service layer is probably in good shape. If it’s a tangle, you’ve found a refactor your codebase has been waiting for. The intent didn’t create the problem. It just made it impossible to ignore.
The records are playing. The tiles are visible. The build is in TestFlight. And somewhere underneath, the playback chain CarPlay was carrying alone for months is finally a thing the rest of the app can lean on.