A vinyl record frozen inside a cracked block of blue ice, neon light glowing through the grooves, against a magenta and tangerine sunburst with floating ice shards

Trapped Under Ice: Auditing VinylCrate's App Intents for Siri AI

A code audit of VinylCrate's shipping intents against Apple's Siri AI docs, the day after they landed: what should carry over, what I'd change first, and what can wait for the terms.

Siri can use your app now. You just can’t ship it yet.

Apple opened the Siri AI Developer Preview on November 3 with the 27.3 betas. Any developer can join, with no new entitlement and no separate program. Apps come in through App Intents, so anyone who already wired up intents has a head start. I opened VinylCrate’s AppIntents folder to see how much of a head start I actually have.

Less than the headline suggests. As far as Apple’s docs go, an existing intent doesn’t become something Siri AI can call on its own. The docs are plain about it: “Siri can only find custom content and perform custom actions an app makes explicitly available using App Intents.” Your entities get pretty close to free. Your actions don’t.

I haven’t run any of this. I did this audit on November 4, the day after the announcement, on a Mac running Xcode 27.2, and VinylCrate hasn’t been built against 27.3. I read my shipping intents against what Apple has published and sorted them into three piles: what should carry over untouched, what I’d change before testing, and what I’d skip until the terms land.

When I say I expect something, that’s a prediction, and some of those will be wrong the first time this runs on a real phone.

If you want the App Intents basics, the surface I’m auditing is the one I walked through in Hardwired for App Intents. I won’t repeat it here.

…And Justice for All — What Apple Actually Published

Tools. A custom intent reaches Siri AI when you add @AppIntent(.tool), which adds a ToolIntent conformance. Apple marks both iOS 27.3.

Content. Siri finds your content through Spotlight. The announcement says Siri AI “can now surface any entity in the Spotlight semantic index, not just the ones that fit an existing schema.” If an entity isn’t indexed, the docs say the system can also retrieve it from an EnumerableEntityQuery.

Skills. The first summary I read described skills as chaining actions together. They don’t do that. A skill is a SKILL.md file of natural-language guidance inside a Skills.skillsbundle, and Apple is clear about what it isn’t:

You can’t use app skills to enforce deterministic behavior. Instead, app skills provide additional information that Siri AI uses to respond to complex requests. Siri AI doesn’t sequentially perform the tool intents that your skill lists.

So it’s notes for Siri, not a pipeline. If you’ve written a Claude Code skill the shape will look familiar. Apple says it’s a subset of the Agent Skills format: only name and description in the frontmatter, a 10,000-character body, and no scripts or supporting files.

Reach. Apple says people don’t have to name your app while it’s in the foreground, while a Live Activity is active, or when they’re continuing a conversation that already mentioned it. In the background, they have to name it. In May I explained why “on VinylCrate” had to stay in every phrase unless you claimed a schema domain. That’s no longer the only way past it.

Where it runs. The preview is on iOS, iPadOS, macOS, and visionOS, on Apple Intelligence devices. Not watchOS, even though Siri AI itself is in the watchOS 27.3 beta. You can test with a development-signed build on a device or with internal TestFlight testers. External testers and App Store users don’t get the functionality. A separate footnote says Siri AI is currently unavailable in the EU on iOS, iPadOS, and watchOS.

And none of it is final: “During the preview, App Intents API and Siri functionality may change.”

The Memory Remains — The Work from May Should Carry Over

The Spotlight index. This is the best news in the audit. VinylCrate has no schema macros anywhere. AlbumEntity and CrateEntity are plain IndexedEntity types, and the donation pass pushes them into named indexes:

let albumsIndex = CSSearchableIndex(name: "net.insoc.VinylCrate.Albums")
let cratesIndex = CSSearchableIndex(name: "net.insoc.VinylCrate.Crates")

let albums = try AlbumQuery.buildAllEntities(in: context)
try await albumsIndex.indexAppEntities(albums)

Apple’s Spotlight guidance says to use a named index and keep the default one for prototyping, so there’s nothing to fix. A crate doesn’t map to any schema I’d want to claim, and per the announcement that no longer keeps it out.

The ToolIntent page adds something I didn’t expect. It lists “A relevant entity from that app appears in a search result” as one of the situations where Siri can call a tool from your app. If I’m reading that right, the index is also part of how an app gets considered at all. The preview overview says background requests need the app named, and I don’t know yet how those two statements fit together. That’s the first thing I’d try.

The queries. Apple says Siri fills an entity parameter by running the entity’s query. Both of mine already conform to EntityQuery, EntityStringQuery, and EnumerableEntityQuery, and they reach for SharedModelContainer.shared instead of a @Dependency. That was the fix for the discovery trap in May. I expect it to keep paying off, since a query that doesn’t lean on the app having booted is the safe shape no matter who’s calling it.

App Shortcuts. VinylCrateShortcuts stays as it is, parameter-less lead-in phrases included. Nothing I read changes them, and Apple’s verification doc still treats App Shortcuts as their own thing to test. I expect them to keep doing their job.

Open intents for search results. Apple asks for an OpenIntent for each entity type you donate, so a tap on a Spotlight result lands on the right screen. OpenAlbumIntent and OpenCrateIntent already cover both.

One parameter description. AddToWantlistIntent has "Priority rating from 0 (unrated) to 5." Apple specifically asks you to include the range of a number in the description. Small win.

That’s a decent list, and none of it is an action.

Cure — Six Intents, and What’s Wrong with Them

Mark the tools, and pick them on purpose

@AppIntent(.tool)
struct PlayAlbumIntent: AudioPlaybackIntent {

That’s the whole change for one intent. The macro doc says it uses the availability declared on the type, or ToolIntent’s availability when the type declares none. VinylCrate’s deployment target is iOS 26.0, so I read that as the tool conformance not forcing the floor up. I’ll know when it compiles.

The harder part is choosing. Apple says the best candidates are actions a person can request in a single sentence, with parameters Siri can fill from that sentence. It also says some intents don’t make good tools, including intents with “side effects that a person needs to start explicitly, such as placing an order and paying for it.”

Here’s where I landed for VinylCrate’s six:

Intent What the code does today What I’d do on a preview branch
PlayAlbumIntent Returns a dialog only. Failures return normally. Add .tool. Throw on failure.
SearchCollectionIntent Returns [AlbumEntity], no dialog. Matches title and artist only. Add .tool. Sharpen the description. Check how it sounds with no screen.
OpenWantlistIntent No parameters. Add .tool.
OpenAlbumIntent Plain OpenIntent. Try .system.open instead of .tool.
OpenCrateIntent Plain OpenIntent. Does nothing if the crate isn’t found. Try .system.open instead of .tool. Throw when the crate isn’t found.
AddToWantlistIntent Returns the album even when the add didn’t happen. Collection albums only. Fix the failure path now. Keep it off the tool list.

The open intents get different treatment because .system.open is in a primary schema domain, and Apple says not to add .tool to an intent that conforms to a schema Siri already supports. The ToolIntent page calls that combination a build error. The catch is that .system.open is iOS 27.0 and my floor is 26.0. That’s fine on a preview branch, but I’d need an answer before it ships.

Write descriptions for a stranger, and watch the floor

Apple says Siri relies on the title and description of an intent, each of its parameters, and each entity property. The line that matters most is on the ToolIntent page: “Write each description for someone who doesn’t know your app.”

My code doesn’t do this. album and target have no parameter descriptions, and no @Property on either entity has one. “Open a crate in Vinyl Crate.” doesn’t tell a stranger what a crate is. Parameters are the easy half:

@Parameter(
    title: "Album",
    description: "A vinyl album the person owns in their Vinyl Crate collection.",
    requestValueDialog: "Which album would you like to play?"
)
var album: AlbumEntity

@Parameter(
    title: "Query",
    description: "Text matched against album titles and artist names only. Genre, year, and mood won't match."
)
var query: String

That second description is just what AlbumQuery.entities(matching:) does. It filters on title and artistName and nothing else.

Entity properties are where I hit a real snag:

@Property(title: "Genres", description: "Discogs genres for this release, such as Rock or Jazz.")
var genres: [String]

That form resolves to init(title:description:authenticationPolicy:visibility:), which Apple marks iOS 27.3, and it’s the only description: family I found on EntityProperty. These are stored properties, and Swift won’t let you gate a stored property with @available. So the tool macro looks like it leaves my iOS 26 floor alone, but the entity descriptions don’t. On a preview branch I’d bump the target and move on. For spring, I don’t have an answer I like yet.

One warning carried over from May. These strings go into the same static metadata that got me an ITMS-90626 for saying “Apple Music” in an IntentDescription. Writing for a stranger doesn’t get to mean naming Apple Music. I expect the same validator to read the new strings, but that’s a guess, and App Store submission isn’t open, so I can’t find out yet.

Stop reporting failure as success

This is the change I’d make first, because it’s wrong today even without Siri AI. Here’s AddToWantlistIntent:

if success {
    return .result(
        value: album,
        dialog: "Added \(album.title) to your wantlist."
    )
} else {
    return .result(
        value: album,
        dialog: "I couldn't add \(album.title) right now. Make sure you're signed in to Discogs and try again."
    )
}

For a person listening, that’s fine. They hear the apology. But the intent returned normally, and it returned the album. Apple’s verification doc says that when your intent is one step of a larger workflow, “the result from your intent then flows into the next action.” So a follow-up step gets the album either way.

Apple has a documented path for this. The AppIntentError page says Apple Intelligence, Siri, and Shortcuts use thrown error information “to determine the appropriate response — such as prompting a person, retrying the operation, or reporting the failure.” The Calls expectation in Siri Evaluations doesn’t count a call that fails. My inference is that my version wouldn’t register as a failure at all, so an evaluation could pass while nothing got added. How Siri’s model treats a returned value next to an apologetic dialog isn’t something I found documented. It might read the dialog and work it out. I don’t want to depend on that.

The fix follows the approach Apple recommends, an error type that conforms to CustomLocalizedStringResourceConvertible:

enum WantlistIntentError: Error, CustomLocalizedStringResourceConvertible {
    case couldNotAdd(title: String)

    var localizedStringResource: LocalizedStringResource {
        switch self {
        case let .couldNotAdd(title):
            "I couldn't add \(title) right now. Make sure you're signed in to Discogs and try again."
        }
    }
}
guard success else {
    throw WantlistIntentError.couldNotAdd(title: album.title)
}
return .result(
    value: album,
    dialog: "Added \(album.title) to your wantlist."
)
Two flows that start with the same failed add. Today the intent returns a result with the album and an apology, so the next step gets the album. After the fix the intent throws WantlistIntentError, so the caller gets an error instead of an album.

There’s a second layer under that. WantlistService.addToWantlist throws real errors, including WantlistError.notAuthenticated. WantlistViewModel.addToWantlist catches them and returns a Bool, and it has its own auth and username guards that return false before the service is ever called. So the intent’s dialog is guessing when it tells you to check your Discogs sign-in, and a network failure gets the same advice. The real fix passes the typed error through, which means dealing with those guards too.

PlayAlbumIntent has the same shape, and the refactor from May makes it easy. PlayAlbumUseCase already returns an Outcome:

switch await useCase.play(release) {
case .played:
    return .result(dialog: "Playing \(album.title) by \(album.artist).")
case .notSubscribed:
    throw PlayAlbumIntentError.notSubscribed
case .notOnAppleMusic:
    throw PlayAlbumIntentError.notOnAppleMusic(title: album.title)
}

There’s one more failure path above that switch. It returns normally too, so it needs to throw as well:

guard let release else {
    return .result(dialog: "I couldn't find that album in your collection.")
}

I wouldn’t add a return value to this intent just to look chain-friendly. Playing a record ends the request. A dialog with no output is fine once failure actually throws.

OpenCrateIntent is the easiest one to miss. If the crate isn’t found, the if let falls through and it returns .result() as if nothing happened.

After these changes I’d check the voice path. Those dialogs are what people hear today, and Apple tells you to test important workflows in voice-only contexts like AirPods. I need to hear that a thrown error still sounds like a sentence. The Apple Music strings also move out of perform() into an error type, which is one more thing to watch at validation time.

Decide what the wantlist intent means

The intent’s description says “Add an album from your collection to your Discogs wantlist.” That’s accurate. Every path through AlbumQuery fetches SavedRelease, which is the collection, so the album parameter can only be a record you already own.

As an App Shortcut, that’s a small oddity. As a tool, I think it’s a bigger deal. Someone saying “add the new Deftones to my wantlist” is talking about a record they don’t own, and the query has nothing to offer. I don’t know what Siri does next. It might ask. It might say it can’t. It might pick the closest thing in my collection, and that one writes to a real Discogs account.

So it stays off the tool list until there’s a Discogs-backed way to name a release. That’s a design job, not a one-line change.

Give the index something to work with

Apple says Apple Intelligence uses Spotlight’s semantic search to find your content “even when someone describes it vaguely.” Here’s what VinylCrate gives it: title, artist, year, and genres as keywords. ReleaseDetail also carries styles and a tracklist, and neither reaches the index. “The one with Bleeding Me on it” has nothing to match against. Neither does a Discogs style that isn’t also a genre.

The back of a record sleeve for Metallica's Load. Title, artist, year, and genres sit on a bright panel marked in the index. Styles and the tracklist are frozen in a block of ice marked not in the index, and a search for the one with Bleeding Me on it points at a track under the ice.

With two new stored properties on AlbumEntity, the attribute set picks them up:

attrs.keywords = genres + styles
attrs.textContent = trackTitles.joined(separator: ", ")

I expect that to help. How much is exactly the kind of thing I can’t tell you from a code read.

I found three more problems in there. The first is donationSignature, which hashes each album’s id, title, artist, year, genres, and favorite status, plus each crate’s id, name, and album count. That hash is the .task(id:) that triggers re-donation, so new fields have to go into it or the richer metadata never reaches anyone who’s already donated.

The second is AlbumQuery.makeEntity:

guard let release = saved.releaseDetail else { return nil }
return AlbumEntity(release)

SavedRelease has a displayableReleaseDetail for exactly this case, and its comment says “An unreadable blob (external-storage asset CloudKit hasn’t delivered) must not make an owned album vanish.” The query path makes it vanish anyway. Using displayableReleaseDetail here is a small fix.

The third is reindexing. Apple says that if you donate with indexAppEntities, you should implement IndexedEntityQuery so Spotlight can ask for a rebuild. Neither query does. It’s iOS 27.0, so on my floor it needs a gate:

@available(iOS 27.0, *)
extension AlbumQuery: IndexedEntityQuery {

    func reindexEntities(
        for identifiers: [AlbumEntity.ID],
        indexDescription: CSSearchableIndexDescription
    ) async throws {
        try await CSSearchableIndex(name: "net.insoc.VinylCrate.Albums")
            .indexAppEntities(entities(for: identifiers))
    }

    func reindexAllEntities(indexDescription: CSSearchableIndexDescription) async throws {
        try await CSSearchableIndex(name: "net.insoc.VinylCrate.Albums")
            .indexAppEntities(allEntities())
    }
}

These two can’t be @MainActor like my other query methods, because CSSearchableIndexDescription isn’t Sendable and Swift 6 rejects main-actor-isolated witnesses of these requirements.

While I’m in there, donateAllEntities() only adds and updates. Nothing in the app calls deleteAppEntities, so I need to trace what happens to an album someone removes. I think a stale entry matters more once Siri can surface it, because entities(for:) won’t return it.

Let entities travel

Apple says that if your intent uses custom entities for its parameters or results, you should provide transferable representations so Siri can pass them from one intent to another and between apps. SearchCollectionIntent and AddToWantlistIntent do return entities, so those return types look right. But nothing in the AppIntents folder conforms to Transferable. Plain text is the simplest place to start, using ProxyRepresentation:

extension AlbumEntity: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        ProxyRepresentation(exporting: { album -> String in
            "\(album.title) by \(album.artist)"
        })
    }
}

Whether text is enough for a cross-app request is something to find out on the beta.

Retire openAppWhenRun

Every intent still uses it, and it’s been deprecated since iOS 26. Apple’s preview docs don’t tie this to Siri AI, so call it housekeeping. It’s cheap and it matches my floor.

static var supportedModes: IntentModes { .background }

That replaces openAppWhenRun = false. The three that set it to true become .foreground.

Two items on Apple’s list I haven’t audited: onscreen context and action donations. Both would live outside this folder, in the views and view models. Despite its name, AppIntentsDonation only handles Spotlight. Those are next.

Bleeding Me — The Skill I’d Write, and the Tool I’m Missing

Apple’s order is tools first, then skills. That order exposed a gap. CrateEntity has a name and an album count, and the only intent that takes a crate opens it. So “play something from my thrash crate” has no tool behind it, and I don’t think a skill can make up for that. A skill can only guide Siri through tools that exist.

What a skill can do is say which tool to prefer and what to avoid, so that’s what I’d write first. It would live at Skills.skillsbundle/Skills/play-from-collection/SKILL.md. This is a first draft that nothing has read yet, Siri included:

---
name: play-from-collection
description: Helps a person choose and play a record they own in Vinyl Crate when they describe it by artist, song, genre, or era instead of its exact title.
---
Everything in the collection is a record the person owns. When they describe
a record loosely, find the album first and check with them if more than one
fits. Then call the play album tool.

The search collection tool only matches album titles and artist names. Use it
when someone asks whether they own something, not to look up a genre or mood.

If a record isn't in their collection, say so. Don't add it to the wantlist
unless they asked for that.

Since Apple says a skill can’t enforce anything, that last line is a request and not a guarantee. The guarantee has to come from checking, and that’s what the Siri Evaluations framework is for. Apple suggests starting with a few simple, direct requests:

import AppIntentsTesting
import SiriEvaluations

let vinylCrate = IntentDefinitions(bundleIdentifier: "net.insoc.VinylCrate")
let playAlbum = vinylCrate.intents["PlayAlbumIntent"]
let addToWantlist = vinylCrate.intents["AddToWantlistIntent"]

let playByTrack = SiriScenario {
    UserTurn("In Vinyl Crate, play the album with Bleeding Me on it") {
        Calls { playAlbum }
        Calls(.disallowed) { addToWantlist }
    }
}

Per the docs, that scenario goes into a SiriScenarioLoader and runs through SiriService. I haven’t seen one run, so I can’t tell you what the report looks like. I’d expect this one to come back better after the tracklist reaches the index, and if it doesn’t, I’ve learned something about my prediction. The .disallowed line matters because Apple says a plain Calls doesn’t catch extra calls. Apple also says Siri’s responses can vary between runs and to run the set more than once, so a flaky result here doesn’t mean what it means in a unit test.

The docs also describe a Siri Integration Advisor in Xcode that gives feedback on your App Intents implementation. I’d run it before trusting my own list, and I’m curious how much of this it catches.

Until It Sleeps — What Waits for the Terms

The announcement says “we will follow up with more details about the terms and usage policies for experiences enabled through Siri AI.” When I did the audit, I couldn’t find those details anywhere. Until they show up, here’s what I’m holding off on.

Anything customer-facing. No release-note line and no App Store copy. External testers and App Store users can’t get this, and Apple says submission opens “in the spring.” The work stays on a branch.

Beta feedback from real collectors. Internal TestFlight testers only. Anyone I’d actually want opinions from has to wait.

Anything near money or private data. VinylCrate has seller tools, listings and orders included, and I’m not making tools out of any of it. Apple already lists placing an order as a poor fit. Apple also says entities don’t require authentication by default, and the iOS 27.3 beta release notes carry a known issue: “If you define authenticationPolicy on an AppEntity or AppEntity property that requires unlock, Siri AI might not request an unlock before it displays these entities.” So purchase prices and personal notes stay off the entities. They aren’t on them today, and they won’t be added during the preview.

Custom card snippets. Apple says Siri shows content in card snippets you provide. That’s design work on an API Apple has said may change.

Moving PlayAlbumIntent to .audio.playAudio. This one is a judgment call, not a terms issue. The audio domain asks for the schema plus an IntentValueQuery, and its example phrases include “Play music.” I don’t know yet if VinylCrate should answer that. .tool is one line and easy to back out. The schema is a commitment.

@AppEntity(.generable). Apple’s guidance is for entities the model can fill in from the request without looking anything up. An album in my collection has to be looked up. I’d revisit this when I get to the wantlist design.

A big evaluation suite. A handful of scenarios is enough for now. The announcement’s footnotes say Siri AI is subject to daily usage limits, and I didn’t find anything saying whether evaluation runs count.

Plans that assume the EU. VinylCrate is built for iPhone and iPad, and both are on Apple’s EU exclusion list. So on those devices there’s no Siri AI in the EU for now, and Siri can’t be the only way to do anything. Language matters too. As of this writing, Apple lists English, French, Japanese, Korean, Portuguese, and Spanish.

I won’t guess at what the terms will say, but these are the questions I want answered:

  • What kinds of actions and content do the usage policies cover?
  • Does App Review add rules for tools and skills?
  • Can a build containing @AppIntent(.tool) go to external testers or the App Store before spring with the functionality just inactive? The docs say the functionality isn’t available there. They don’t say whether the build is blocked.
  • How do the daily limits, and the footnote’s “for a fee” language about expanded access, apply to requests that reach third-party apps?
  • How does Siri treat a failure that comes back as a normal result? That’s the biggest gap I found between what the docs say and what a real intent has to decide.

Escape — Can’t Ship It Yet

In May I wrote that somewhere in your app there’s a call site that wants the same chain, and one of my examples was “an Apple Intelligence semantic action you haven’t written yet.” I didn’t expect to get here in six months.

Most of what this preview asks for isn’t new Siri code. It’s descriptions that make sense to a stranger, errors that are really errors, and an index that knows what’s on the record. I’d want those even if the terms come back strange.

Everything in this post is a prediction until 27.3 is on a phone, and I’m sure some of it won’t hold up. But now I have a list, and I can start checking it.

Can’t ship it yet. I can get it ready.

Stay in the loop

Deep dives on Swift, SwiftUI, and building real apps — from the code to the App Store. No fluff, no toy projects.