
Axiom Integration
- 682 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-integration is a Claude Code skill that guides developers through wiring Siri, Shortcuts, widgets, StoreKit, EventKit, Live Activities, and other Apple system APIs into iOS apps using the Axiom playbook and linked
About
axiom-integration is a Mobile Development skill from charleswiltgen/axiom that provides a playbook for any iOS system feature integration. The skill routes tasks to reference docs for App Intents, WidgetKit, StoreKit, EventKit, Contacts, background tasks, push notifications, localization, and privacy requirements. Developers reach for axiom-integration when adding Siri phrases, home-screen widgets, in-app purchases, calendar access, or Live Activities and need stack-correct Swift patterns instead of scattered Apple documentation. The readme mandates using this skill for every iOS system integration, making it a cross-cutting guide across multiple build milestones rather than a one-off snippet.
- Mandatory entry point for ANY iOS system integration including Siri, Shortcuts, widgets, IAP, and push
- Quick-reference table routing tasks to app-intents, widgets, live-activities, StoreKit, and EventKit skill files
- Covers App Intents, WidgetKit, Live Activities, Core Spotlight, background tasks, and localization/privacy touchpoints
- Redirects Apple Pay physical-goods flows to the separate axiom-payments skill
Axiom Integration by the numbers
- 682 all-time installs (skills.sh)
- Ranked #268 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 682 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you integrate Siri and widgets in iOS?
Wire Siri, Shortcuts, widgets, StoreKit, EventKit, Live Activities, and other Apple system APIs into an iOS app using the Axiom integration playbook and linked reference docs.
Who is it for?
iOS developers implementing Siri, Shortcuts, widgets, IAP, push, or calendar features who want a structured Axiom playbook instead of ad hoc Apple doc searches.
Skip if: Android or cross-platform Flutter teams with no native SwiftUI or UIKit system API requirements.
When should I use this skill?
A developer asks to add Siri App Intents, home-screen widgets, StoreKit purchases, Live Activities, or other Apple system APIs to an iOS app.
What you get
Swift integration code, entitlements, Info.plist keys, and linked reference implementations for targeted Apple system APIs.
- Swift integration modules
- Entitlements and plist entries
- Linked reference implementations
By the numbers
- Routes integrations through 10+ Apple system API areas including App Intents, WidgetKit, and StoreKit
Files
iOS System Integration
You MUST use this skill for ANY iOS system integration including Siri, Shortcuts, widgets, in-app purchases, background tasks, push notifications, and more.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| Siri, App Intents, entity queries | See skills/app-intents-ref.md |
| App Shortcuts, phrases, Spotlight | See skills/app-shortcuts-ref.md |
| App discoverability strategy | See skills/app-discoverability.md |
| Core Spotlight indexing | See skills/core-spotlight-ref.md |
| LLM search over app content, SpotlightSearchTool | See skills/core-spotlight-ref.md |
| Widgets, Control Center controls | See skills/extensions-widgets.md |
| Widget API reference | See skills/extensions-widgets-ref.md |
| Live Activities, Dynamic Island, push-to-start, broadcast | See skills/live-activities.md |
| Live Activities / ActivityKit API reference | See skills/live-activities-ref.md |
| Apple Pay (physical goods, services, donations) | Use `axiom-payments` instead |
| In-app purchases, subscriptions | See skills/in-app-purchases.md |
| StoreKit 2 API reference | See skills/storekit-ref.md |
| Commitment billing plans, subscription bundles/suites, group/volume subscriptions, retention offers, offer codes | See skills/storekit-ref.md |
| In-game content (Unity plug-ins, asset packs + IAP) | See skills/in-app-purchases.md, skills/background-assets.md |
| Calendar events, reminders (EventKit) | See skills/eventkit.md |
| EventKit API reference | See skills/eventkit-ref.md |
| Contacts, contact picker | See skills/contacts.md |
| Contacts API reference | See skills/contacts-ref.md |
| Localization, String Catalogs | See skills/localization.md |
| Apple terminology matching, glossary, pseudolocalization, TMS | See skills/localization-research-ref.md |
| Privacy manifests, permissions UX | See skills/privacy-ux.md |
| Bluetooth/Wi-Fi accessory pairing (AccessorySetupKit, iOS 18+) | See skills/accessorysetupkit.md |
| AccessorySetupKit API (descriptor fields, events, auth-settings flow) | See skills/accessorysetupkit-ref.md |
| Measure distance/direction to a paired accessory (Bluetooth Channel Sounding, iOS 27) | See skills/accessorysetupkit-ref.md (Part 7) |
| Weather data, forecasts, attribution (WeatherKit) | See skills/weatherkit.md |
| VoIP calls, CallKit, VoIP push, caller ID/blocking | See skills/callkit-livecommunicationkit.md |
| CallKit / LiveCommunicationKit / IdentityLookup API reference | See skills/callkit-livecommunicationkit-ref.md |
| AlarmKit (iOS 26+) | See skills/alarmkit-ref.md |
| Timer patterns, scheduling | See skills/timer-patterns.md |
| Timer API reference | See skills/timer-patterns-ref.md |
| Background tasks, BGTaskScheduler | See skills/background-processing.md |
| Background task debugging | See skills/background-processing-diag.md |
| Background task API reference | See skills/background-processing-ref.md |
| Background Assets (large content delivery, FM adapter shipping, Apple-hosted vs server-hosted) | See skills/background-assets.md |
| Background Assets API reference (incl. localized asset packs, Steam conversion) | See skills/background-assets-ref.md |
| On-Demand Resources migration (deprecated in 27) | See skills/background-assets.md |
| Push notifications, APNs | See skills/push-notifications.md |
| Push notification debugging | See skills/push-notifications-diag.md |
| Push notification API reference | See skills/push-notifications-ref.md |
Decision Tree
digraph integration {
start [label="Integration task" shape=ellipse];
what [label="Which system feature?" shape=diamond];
start -> what;
what -> "skills/app-intents-ref.md" [label="Siri / App Intents"];
what -> "skills/app-shortcuts-ref.md" [label="Shortcuts / phrases"];
what -> "skills/app-discoverability.md" [label="discoverability\nstrategy"];
what -> "skills/extensions-widgets.md" [label="widgets /\nControl Center"];
what -> "skills/live-activities.md" [label="Live Activities /\nDynamic Island"];
what -> "skills/in-app-purchases.md" [label="IAP / subscriptions"];
what -> "skills/eventkit.md" [label="calendar / reminders"];
what -> "skills/contacts.md" [label="contacts"];
what -> "skills/localization.md" [label="localization"];
what -> "skills/privacy-ux.md" [label="privacy / permissions"];
what -> "skills/accessorysetupkit.md" [label="accessory pairing\n(Bluetooth/Wi-Fi)"];
what -> "skills/weatherkit.md" [label="weather / forecasts"];
what -> "skills/callkit-livecommunicationkit.md" [label="VoIP calls /\ncaller ID"];
what -> "skills/alarmkit-ref.md" [label="alarms (iOS 26+)"];
what -> "skills/timer-patterns.md" [label="timers"];
what -> "skills/background-processing.md" [label="background tasks"];
what -> "skills/background-assets.md" [label="large asset delivery /\nFM adapter shipping"];
what -> "skills/push-notifications.md" [label="push notifications"];
}1. Siri / App Intents / entity queries? → skills/app-intents-ref.md 2. App Shortcuts / phrases? → skills/app-shortcuts-ref.md 3. App discoverability / Spotlight strategy? → skills/app-discoverability.md, skills/core-spotlight-ref.md 3a. LLM search over your app's index (SpotlightSearchTool, LanguageModelSession tool-calling)? → skills/core-spotlight-ref.md 4. Widgets / Control Center controls? → skills/extensions-widgets.md, skills/extensions-widgets-ref.md 4a. Live Activities / Dynamic Island / push-to-start / broadcast? → skills/live-activities.md, skills/live-activities-ref.md 5. In-app purchases / StoreKit? → skills/in-app-purchases.md, skills/storekit-ref.md 5a. Subscription billing plans (12-month commitment), bundles/suites, group/volume purchasing, retention offers, offer-code redemption? → skills/storekit-ref.md 6. Calendar / reminders / EventKit? → skills/eventkit.md, skills/eventkit-ref.md 7. Contacts / contact picker? → skills/contacts.md, skills/contacts-ref.md 8. Localization mechanics (String Catalogs, plurals, RTL)? → skills/localization.md 8a. Localization research (Apple terminology, glossary, pseudolocalization, TMS)? → skills/localization-research-ref.md 9. Privacy / permissions? → skills/privacy-ux.md 10. Alarms (iOS 26+)? → skills/alarmkit-ref.md 10a. Bluetooth/Wi-Fi accessory pairing (iOS 18+)? → skills/accessorysetupkit.md 10b. Weather data / forecasts / attribution (WeatherKit)? → skills/weatherkit.md 10c. VoIP calls / CallKit / VoIP push / caller ID / blocking? → skills/callkit-livecommunicationkit.md 11. Timers? → skills/timer-patterns.md, skills/timer-patterns-ref.md 12. Background tasks / BGTaskScheduler? → skills/background-processing.md, skills/background-processing-diag.md, skills/background-processing-ref.md 12a. Large asset delivery (game packs, localized asset packs, ML models, Foundation Models adapters, ODR migration)? → skills/background-assets.md, skills/background-assets-ref.md 13. Push notifications? → skills/push-notifications.md, skills/push-notifications-diag.md, skills/push-notifications-ref.md 14. Want IAP audit? → Launch iap-auditor agent 15. Want full IAP implementation? → Launch iap-implementation agent 16. Camera / photos / audio / haptics / ShazamKit? → Use `axiom-media` instead
Cross-Domain Routing
Widget + data sync (widget not showing updated data):
- Widget timeline not refreshing → stay here (extensions-widgets)
- SwiftData/Core Data not shared with extension → also invoke axiom-data (App Groups)
App Intents + security (lock-screen intent risk, prompt injection via Siri):
- Intent/schema API surface → stay here (app-intents-ref)
- Threat modeling, authenticationPolicy gating, schema risk metadata → also invoke axiom-security (skills/agentic-security.md)
Live Activity + push notification:
- ActivityKit push token / broadcast setup → stay here (live-activities)
- Push delivery failures → also invoke axiom-networking (networking-diag)
- Entitlements/certificates → also invoke axiom-build
VoIP push + CallKit (VoIP app killed / pushes stopped arriving):
- VoIP push must report a call to CallKit → stay here (callkit-livecommunicationkit, Part 1)
- PushKit token vs APNs delivery → stay here (push-notifications for the APNs contrast)
- Call audio silent/misrouted → also invoke axiom-media (AVAudioSession category)
Push + background processing (silent push not triggering background work):
- Push payload and delivery → stay here (push-notifications-diag)
- BGTaskScheduler execution → stay here (background-processing)
In-game content (StoreKit + Background Assets, Unity plug-ins):
- Asset packs, IAP mechanics, payment sheet, mock server → stay here (background-assets, in-app-purchases)
- Game input, rendering, game-side performance → also invoke axiom-games / axiom-graphics
Calendar/Contacts + data sync:
- EventKit/Contacts data issues → stay here
- Shared data with widget via App Groups → also invoke axiom-data
watchOS surfaces
- Complications + Smart Stack widgets → See axiom-watchos (skills/smart-stack-and-complications.md)
- Live Activities on Apple Watch → See axiom-watchos (skills/controls-and-live-activities.md)
Conflict Resolution
integration vs axiom-build: When system features fail with entitlement/certificate errors:
- Use axiom-build for signing and provisioning issues
- Use integration for API usage and permission patterns
integration vs axiom-data: When widgets or extensions can't access shared data:
- App Groups and shared containers → axiom-data
- Specifically: GRDB/SQLite database shared with widget/extension/Live Activity → See axiom-data (skills/grdb-app-groups.md)
- Widget timeline, Live Activity updates → integration
integration vs axiom-media: When media features overlap with system features:
- Camera/photo/audio/haptics code → axiom-media
- Privacy manifests for camera/microphone → stay here (privacy-ux)
- Background audio mode → stay here (background-processing)
Anti-Rationalization
| Thought | Reality |
|---|---|
| "App Intents are just a protocol conformance" | App Intents have parameter validation, entity queries, and background execution. |
| "Widgets are simple, I've done them before" | Widgets have timeline, interactivity, and Live Activity patterns that evolve yearly. |
| "Localization is just String Catalogs" | Xcode 26 has type-safe localization, generated symbols, and #bundle macro. |
| "Push notifications are just a payload and a token" | Token lifecycle, Focus levels, service extension gotchas cause 80% of push bugs. |
| "I'll just bundle the assets, it's simpler" | Bundling ≥10 MB inflates first-install size and pays the cost every update. Background Assets ships with App Store install-progress integration; FM adapters can't be bundled at all. See skills/background-assets.md. |
| "I'll use URLSession for the asset download" | URLSession doesn't integrate with App Store install progress, charging-aware scheduling, or per-app quota — and can't reach Apple-hosted asset packs at all. Background Assets is the supported channel. |
| "On-Demand Resources still works fine" | The entire NSBundleResourceRequest family is deprecated in the 27 SDKs ("Use Background Assets instead"). Migrate ODR tags to asset packs. See skills/background-assets.md. |
| "Just request full Calendar access" | Most apps only need to add events — EventKitUI does that with zero permissions. |
| "I'll request Bluetooth permission and scan for the accessory" | AccessorySetupKit (iOS 18+) pairs in one tap with no broad Bluetooth prompt and grants scoped BT+Wi-Fi access. See skills/accessorysetupkit.md. |
| "I'll process the VoIP push, then report the call when ready" | iOS terminates your app and stops delivering VoIP pushes if a push doesn't report a call before completion. Report first, fetch after. See skills/callkit-livecommunicationkit.md. |
| "I'll activate the audio session when the call connects" | CallKit owns the audio session — activate only in provider(_:didActivate:) or audio is silent/misrouted. See skills/callkit-livecommunicationkit.md. |
| "I'll use CNContactStore directly for picking" | CNContactPickerViewController needs no authorization and shows all contacts. |
Example Invocations
User: "How do I add Siri support?" → Read: skills/app-intents-ref.md
User: "My widget isn't updating" → Read: skills/extensions-widgets.md
User: "My Live Activity won't update" / "How do I add a Dynamic Island?" / "Broadcast scores to thousands of Live Activities" → Read: skills/live-activities.md
User: "Implement in-app purchases with StoreKit 2" → Read: skills/in-app-purchases.md
User: "Offer a monthly plan with a 12-month commitment" / "Sell subscription seats to teams" → Read: skills/storekit-ref.md
User: "Deliver level packs only in the player's language" / "Convert my Steam depots to asset packs" → Read: skills/background-assets-ref.md
User: "Let the on-device model answer questions about my app's content" → Read: skills/core-spotlight-ref.md
User: "How do I implement push notifications?" → Read: skills/push-notifications.md
User: "Push notifications work in dev but not production" → Read: skills/push-notifications-diag.md
User: "My background task never runs" → Read: skills/background-processing-diag.md
User: "How do I add an event to the user's calendar?" → Read: skills/eventkit.md
User: "How do I let users pick a contact?" → Read: skills/contacts.md
User: "How do I pair a Bluetooth accessory without the permission prompt?" → Read: skills/accessorysetupkit.md
User: "How do I show the weather forecast with WeatherKit?" / "Why was my weather app rejected?" → Read: skills/weatherkit.md
User: "My VoIP app gets killed" / "How do I handle CallKit incoming calls?" / "How do I block spam callers?" → Read: skills/callkit-livecommunicationkit.md
User: "Review my in-app purchase implementation" → Launch: iap-auditor agent
AccessorySetupKit — API Reference
Comprehensive API reference for AccessorySetupKit: the session, discovery descriptors, picker items, events, accessories, and the post-pairing authorization flow. For the discipline (the three-stage model, gotchas, debugging), see skills/accessorysetupkit.md.
Key Terminology
- ASAccessorySession — Central object; displays the picker, delivers events, manages accessories.
- ASDiscoveryDescriptor — Rules describing what the picker scans for (Bluetooth/Wi-Fi).
- ASPickerDisplayItem — One accessory variant to show in the picker (name, image, descriptor).
- ASAccessory — A paired accessory and its scoped identifiers.
- ASAccessoryEvent — Delivered to the session's event handler (
eventType,accessory,error). - ASAccessorySettings — Configuration applied when finishing a multi-step authorization.
Availability: iOS 18.0+, iPadOS 18.0+. No macOS / watchOS / tvOS. Bluetooth HID accessories iOS 18.4+. Wi-Fi Aware descriptor fields iOS 26.0+. Channel Sounding (Part 7) iOS 27.0+.
---
Part 1: ASAccessorySession
let session = ASAccessorySession()
session.activate(on: DispatchQueue.main) { (event: ASAccessoryEvent) in /* ... */ }
session.showPicker(for: [pickerItem]) { (error: Error?) in /* ... */ }
// Multi-step authorization (accessories needing post-pairing setup — see Part 5)
session.finishAuthorization(for: accessory, settings: settings) { error in /* ... */ }
session.failAuthorization(for: accessory) { error in /* ... */ }
// Upgrade an authorized accessory's permissions (e.g. add Wi-Fi) with a broader descriptor
session.updateAuthorization(for: accessory, descriptor: broaderDescriptor) { error in /* ... */ }
// Lifecycle management
session.removeAccessory(accessory) { error in /* ... */ }
session.renameAccessory(accessory, options: []) { error in /* ... */ } // ASAccessory.RenameOptions
session.accessories // [ASAccessory] — previously paired accessories for this appactivate(on:eventHandler:) must complete (the .activated event) before reading accessories or calling showPicker.
---
Part 2: ASDiscoveryDescriptor
A descriptor needs at least one of bluetoothServiceUUID or bluetoothCompanyIdentifier. Everything else refines the match.
let d = ASDiscoveryDescriptor()
// Bluetooth (one of the first two is required)
d.bluetoothServiceUUID = CBUUID(string: "FFF0")
d.bluetoothCompanyIdentifier = 0x004C
d.bluetoothNameSubstring = "Dice"
d.bluetoothManufacturerDataBlob = manufacturerData // with matching mask
d.bluetoothManufacturerDataMask = manufacturerMask
d.bluetoothServiceDataBlob = serviceData
d.bluetoothServiceDataMask = serviceMask
d.bluetoothRange = .default // ASDiscoveryDescriptor.Range
// Wi-Fi
d.ssid = "MyAccessoryNet"
d.ssidPrefix = "Accessory-"
// Wi-Fi Aware (iOS 26+)
d.wifiAwareServiceName = "_service._udp"
d.wifiAwareServiceRole = .subscriber
d.wifiAwareModelNameMatch = .init(/* ... */)
d.wifiAwareVendorNameMatch = .init(/* ... */)
d.supportedOptions = [] // ASAccessory.SupportOptions (e.g. .bluetoothPairingLE, .bluetoothHID)Every UUID, company identifier, and name used here must also appear in the matching NSAccessorySetup* Info.plist array (Part 6) or discovery returns nothing.
---
Part 3: Picker items
let item = ASPickerDisplayItem(name: "Pink Dice", productImage: image, descriptor: descriptor)
item.setupOptions = [] // ASPickerDisplayItem.SetupOptions — drives the confirmation + in-app-finish flow
item.renameOptions = [] // ASAccessory.RenameOptions
// Migration of an already-paired accessory (subclass of ASPickerDisplayItem)
let migration = ASMigrationDisplayItem(name: "My Sensor", productImage: image, descriptor: descriptor)
migration.peripheralIdentifier = knownPeripheralUUID // CoreBluetooth peripheral UUID
migration.hotspotSSID = "MyAccessoryNet" // Wi-Fi accessory
migration.wifiAwarePairedDeviceID = pairedID // Wi-Fi Aware (iOS 26+)setupOptions controls whether the system asks for an extra authorization confirmation and whether final setup happens in-app (which drives the Part 5 finishAuthorization flow).
---
Part 4: Events and accessories
// ASAccessoryEvent
event.eventType // ASAccessoryEvent.EventType
event.accessory // ASAccessory?
event.error // Error?EventType cases you'll handle most often: .activated, .accessoryAdded, .accessoryChanged, .accessoryRemoved, .pickerDidPresent, .pickerDidDismiss. The remaining cases are .invalidated, .accessoryDiscovered, .migrationComplete, .pickerSetupBridging, .pickerSetupPairing, .pickerSetupFailed, .pickerSetupRename, and .unknown — always include a `default` case so new cases don't break your switch.
// ASAccessory
accessory.displayName // String
accessory.state // ASAccessory.AccessoryState: .unauthorized | .awaitingAuthorization | .authorized
accessory.descriptor // ASDiscoveryDescriptor
accessory.bluetoothIdentifier // UUID? — per-app SCOPED id, not the hardware UUID
accessory.ssid // String?Use bluetoothIdentifier with CBCentralManager.retrievePeripherals(withIdentifiers:) only within your app.
---
Part 5: Post-pairing setup (multi-step authorization)
Some accessories aren't fully usable the instant the user taps the picker — a Wi-Fi accessory may need credentials, a bridged Bluetooth Classic accessory needs its transport identifier. For these, the accessory arrives in .awaitingAuthorization; you collect what you need in-app, then finish (or fail) the authorization.
// In your event handler, an accessory may be .awaitingAuthorization rather than .authorized
let settings = ASAccessorySettings.defaultSettings
settings.ssid = collectedHotspotSSID // Wi-Fi hotspot to join
settings.bluetoothTransportBridgingIdentifier = sixByteID // bridge Bluetooth Classic profiles
session.finishAuthorization(for: accessory, settings: settings) { error in /* now authorized */ }
// or, if the user backs out / setup fails:
session.failAuthorization(for: accessory) { error in /* ... */ }ASAccessorySettings properties: ssid (hotspot to connect to), bluetoothTransportBridgingIdentifier (6-byte classic-transport bridge ID), and the defaultSettings empty settings object (the class accessor — ASAccessorySettings.default does not exist). Separately, updateAuthorization(for:descriptor:) upgrades an authorized accessory's permissions — e.g. grant Wi-Fi to a Bluetooth-only accessory by passing a broader ASDiscoveryDescriptor (it does not mutate ASAccessorySettings).
---
Part 6: Info.plist keys
| Key | Value |
|---|---|
NSAccessorySetupSupports | array of "Bluetooth" / "WiFi" |
NSAccessorySetupBluetoothServices | array of service UUID strings |
NSAccessorySetupBluetoothCompanyIdentifiers | array of company-ID numbers |
NSAccessorySetupBluetoothNames | array of name strings |
Every value referenced by an ASDiscoveryDescriptor must be declared here.
---
Part 7: Channel Sounding — measure distance to a paired accessory iOS27
Once an accessory is paired through AccessorySetupKit and connected over CoreBluetooth (Part 3 of skills/accessorysetupkit.md), Bluetooth Channel Sounding measures the actual distance to it — a real measurement, not an RSSI estimate. The iPhone (the initiator) exchanges tones with the accessory (the reflector) across the 2.4 GHz band and derives distance from how the signal's phase changes from one channel to the next; each measurement is a procedure. ("Reflector" is the accessory's protocol role, not an SDK Role case — the config exposes only .initiator.)
Requires an iPhone with the N1 chip, a foreground app (the session pauses when the app backgrounds on iOS 27), and accessory hardware supporting Bluetooth 6.3 with inline PCT, phase-ranging modes 0 and 2, and a T_FCS of at least 100 µs. iOS only — API_UNAVAILABLE on macOS, watchOS, tvOS, visionOS.
Distance only — CoreBluetooth
import CoreBluetooth
// 1. Gate on hardware + region support (central must be .poweredOn first)
guard CBCentralManager.supports(.channelSounding) else { return } // CBCentralManager.Feature
// 2. Start a session on an already-connected CBPeripheral
let config = CBChannelSoundingSessionConfiguration(role: .initiator) // .initiator is the only Role case in this SDK
peripheral.startChannelSoundingSession(config)
// 3. Each completed procedure delivers a distance in meters
func peripheral(_ peripheral: CBPeripheral,
didReceiveChannelSoundingProcedureResults results: CBChannelSoundingProcedureResults?,
error: Error?) {
guard let results else { return }
let meters = results.distance // Double
}
// 4. Stop — cancelChannelSoundingSession takes NO argument
peripheral.cancelChannelSoundingSession()
func peripheral(_ peripheral: CBPeripheral,
didCompleteChannelSoundingSession error: Error?) { /* session ended */ }iOS keeps running procedures until you cancel; it filters outliers and smooths the stream, and may lower measurement frequency when other Bluetooth/Wi-Fi traffic is heavy. Failures surface as CBError.channelSoundingConfigurationFailed or CBError.channelSoundingProcedureFailed.
Distance + direction — Nearby Interaction
For direction as well as distance, hand the same paired peripheral to a NISession (NearbyInteraction). Direction additionally needs camera assistance.
import NearbyInteraction
guard NISession.deviceCapabilities.supportsBluetoothChannelSounding else { return } // iOS 27
let config = NINearbyAccessoryConfiguration(
bluetoothChannelSoundingIdentifier: peripheral.identifier, // the CoreBluetooth peripheral UUID
previousBluetoothIdentifier: nil) // non-nil only to resume across reconnects
if NISession.deviceCapabilities.supportsCameraAssistance {
config.isCameraAssistanceEnabled = true // required for direction
}
let session = NISession()
session.delegate = self
session.run(config)
// Hint motion for better direction: session.updateMotionState(.stationary, forObjectWithToken: object.discoveryToken)
// session(_:didUpdate:) delivers NINearbyObject .distance and .horizontalAngle (both optional — nil on a failed measurement)NearbyInteraction has no other Axiom coverage; this is the AccessorySetupKit-adjacent ranging path only. For full UWB Nearby Interaction, see WWDC's "Explore Nearby Interaction with third-party accessories".
---
Resources
WWDC: 2024-10203, 2024-10123, 2025-228, 2026-369
Docs: /accessorysetupkit, /accessorysetupkit/asaccessorysession, /accessorysetupkit/asdiscoverydescriptor, /accessorysetupkit/aspickerdisplayitem, /accessorysetupkit/asmigrationdisplayitem, /accessorysetupkit/asaccessory, /accessorysetupkit/asaccessoryevent, /accessorysetupkit/asaccessorysettings, /corebluetooth/cbchannelsoundingsessionconfiguration, /corebluetooth/cbchannelsoundingprocedureresults
Skills: skills/accessorysetupkit.md, axiom-networking (CoreBluetooth / NetworkExtension), skills/privacy-ux.md
AccessorySetupKit — Privacy-Friendly Accessory Pairing
AccessorySetupKit (iOS/iPadOS 18+) replaces the old "request broad Bluetooth permission, then scan for everything" flow with a one-tap, privacy-preserving picker. Your app declares exactly which accessories it can pair with; the system runs the scan in a separate process and shows a picker with your artwork and friendly name. One tap grants your app scoped Bluetooth and Wi-Fi access to that single accessory — with no broad Bluetooth permission prompt.
Core mental model
There are three stages, and AccessorySetupKit only owns the first two:
1. Discovery — the system scans for accessories matching your ASDiscoveryDescriptor rules. 2. Authorization — the user taps your accessory in the picker; it's paired and scoped to your app. 3. Communication — you keep using CoreBluetooth and NetworkExtension exactly as before.
The win is privacy and friction: the picker runs out-of-process, the user sees only the accessories you can pair, and your app never asks for the system-wide Bluetooth permission. Your app receives a scoped identifier for the peripheral, not the real hardware UUID.
When to Use This Skill
- Pairing a Bluetooth and/or Wi-Fi hardware accessory (wearable, sensor, smart-home device, toy)
- Replacing a
CBCentralManager-scans-everything setup flow with the system picker - Migrating accessories your app already manages onto the new permission model
- Wanting Bluetooth + Wi-Fi access from a single one-tap setup
For the full type/property surface (every descriptor field, event case, and the authorization-settings flow), see skills/accessorysetupkit-ref.md. For the CoreBluetooth/NetworkExtension communication that follows pairing, see axiom-networking. For the broader permission-prompt UX, see skills/privacy-ux.md.
System Requirements
| Capability | Minimum |
|---|---|
AccessorySetupKit (ASAccessorySession, Bluetooth + Wi-Fi) | iOS 18.0+, iPadOS 18.0+ |
| Bluetooth HID accessories | iOS 18.4+ |
Wi-Fi Aware descriptor fields (wifiAwareServiceName, etc.) | iOS 26.0+ |
No macOS, watchOS, or tvOS. (If you saw "iOS 26" as the floor — that's wrong; the framework shipped in iOS 18.0.)
Critical Gotchas
| Gotcha | Why it bites | Fix |
|---|---|---|
| Descriptor with only a name substring | A descriptor must include at least one of bluetoothServiceUUID or bluetoothCompanyIdentifier; a name alone is rejected | Always set a service UUID or company identifier |
| Picker finds nothing | Info.plist keys don't match your descriptors | The NSAccessorySetup* arrays must list every UUID/company/name your descriptors use |
| Querying accessories too early | The session isn't usable until it activates | Wait for the .activated event before showPicker or reading accessories |
| Expecting a Bluetooth permission prompt | With ASK declared, there is none — and CBCentralManager only reaches .poweredOn once you have a paired accessory | Drive connection off the accessoryAdded event / existing accessories, not a permission callback |
Treating bluetoothIdentifier as the hardware UUID | It's a per-app scoped identifier | Use it only within your app; don't compare it across apps |
| Migration silently doesn't happen | Mixing migration items with normal display items defers migration until a new device is set up | Pass only ASMigrationDisplayItems to migrate immediately |
Accessory stuck in .awaitingAuthorization | A Wi-Fi or bridged accessory needs a post-pairing setup step | Collect what's needed in-app, then call finishAuthorization(for:settings:) (Part 4) |
Part 1 — Declare what you can pair
Two halves that must agree: Info.plist entitlement-style keys, and the runtime ASDiscoveryDescriptor. If they disagree, discovery returns nothing.
<!-- Info.plist -->
<key>NSAccessorySetupSupports</key>
<array><string>Bluetooth</string><string>WiFi</string></array>
<key>NSAccessorySetupBluetoothServices</key>
<array><string>0000FFF0-0000-1000-8000-00805F9B34FB</string></array>
<!-- also: NSAccessorySetupBluetoothCompanyIdentifiers, NSAccessorySetupBluetoothNames -->import AccessorySetupKit
import CoreBluetooth
let descriptor = ASDiscoveryDescriptor()
descriptor.bluetoothServiceUUID = CBUUID(string: "FFF0") // must be listed in Info.plist
// Optional refinements (each still needs the UUID/company ID above):
descriptor.bluetoothNameSubstring = "Dice"A descriptor needs at least one of bluetoothServiceUUID or bluetoothCompanyIdentifier. Add Wi-Fi rules (ssid, ssidPrefix) for Wi-Fi accessories.
Part 2 — Session lifecycle
Activate, wait for .activated, then present the picker with one display item per accessory variant.
let session = ASAccessorySession()
session.activate(on: DispatchQueue.main) { event in
switch event.eventType {
case .activated:
// safe to read session.accessories or present the picker now
case .accessoryAdded:
if let accessory = event.accessory { connect(to: accessory) }
case .accessoryRemoved:
break
case .accessoryChanged:
break // e.g. user renamed it in Settings
case .pickerDidPresent, .pickerDidDismiss:
break // your UI is occluded while the picker is up
default:
break
}
}
func presentPicker() {
let item = ASPickerDisplayItem(
name: "Pink Dice",
productImage: UIImage(named: "dice-pink")!,
descriptor: descriptor)
session.showPicker(for: [item]) { error in
if let error { /* handle / log */ }
}
}Bind showPicker to an explicit user action (a button) and give context first — calling it unprompted surprises the user with a system sheet on top of your app.
Part 3 — Connect after pairing
Pairing hands you an ASAccessory. Use its bluetoothIdentifier with ordinary CoreBluetooth — no permission prompt, because ASK already granted scoped access.
func connect(to accessory: ASAccessory) {
guard let id = accessory.bluetoothIdentifier else { return }
// central was created earlier; it reaches .poweredOn once an accessory is paired
if let peripheral = central.retrievePeripherals(withIdentifiers: [id]).first {
central.connect(peripheral)
}
}central.scanForPeripherals also works and returns only accessories paired with your app. ASAccessory exposes displayName, state (an ASAccessory.AccessoryState: .unauthorized / .awaitingAuthorization / .authorized), descriptor, bluetoothIdentifier, and ssid.
Once connected, on iOS 27 you can measure the distance to the accessory with Bluetooth Channel Sounding — CBCentralManager.supports(.channelSounding), then peripheral.startChannelSoundingSession(_:) for distance, or feed peripheral.identifier into a NearbyInteraction NISession for distance and direction. Needs an N1-chip iPhone and a foreground app. Full surface: Part 7 of skills/accessorysetupkit-ref.md.
Part 4 — Accessories that need post-pairing setup
Not every accessory is usable the instant the user taps the picker. A Wi-Fi accessory may need credentials; a bridged Bluetooth Classic accessory needs its transport identifier. These arrive in `.awaitingAuthorization`, not .authorized — you collect what you need in-app, then finish (or fail) the authorization.
case .accessoryAdded:
guard let accessory = event.accessory else { break }
if accessory.state == .awaitingAuthorization {
let settings = ASAccessorySettings.defaultSettings
settings.ssid = collectedHotspotSSID // Wi-Fi hotspot to join
// settings.bluetoothTransportBridgingIdentifier = sixByteID // bridge BT Classic profiles
session.finishAuthorization(for: accessory, settings: settings) { _ in }
} else {
connect(to: accessory)
}If the user backs out or setup fails, call session.failAuthorization(for: accessory) { _ in }. To upgrade an already-authorized accessory's permissions (e.g. add Wi-Fi to a Bluetooth-only accessory), use updateAuthorization(for:descriptor:) with a broader descriptor. Drive the post-pairing path by setting setupOptions on your ASPickerDisplayItem (see skills/accessorysetupkit-ref.md).
Part 5 — Migrate existing accessories
If your app already manages accessories via the old broad-permission model, upgrade them with ASMigrationDisplayItem (an ASPickerDisplayItem subclass) seeded with the known peripheral identifier or SSID.
let migration = ASMigrationDisplayItem(
name: "My Sensor", productImage: image, descriptor: descriptor)
migration.peripheralIdentifier = knownPeripheralUUID // or .hotspotSSID for Wi-Fi
session.showPicker(for: [migration]) { _ in }A showPicker call containing only migration items shows an informational page and migrates immediately. Mix them with normal items and migration is deferred until a new accessory is set up.
Part 6 — Picker assets
The picker box is 180×120 pt. Ship a high-resolution, transparent-background product image that reads well in light and dark mode; widen the transparent border to pad the artwork smaller. Don't update occluded UI while the picker is presented — gate UI changes on pickerDidDismiss.
Common Mistakes
- A descriptor with only
bluetoothNameSubstringand no service UUID / company ID — rejected. - Info.plist
NSAccessorySetup*arrays that don't list the UUIDs your descriptors use — empty picker. - Reading
session.accessoriesor callingshowPickerbefore the.activatedevent. - Waiting for a Bluetooth permission prompt that never comes (ASK suppresses it).
- Storing/comparing
bluetoothIdentifieras if it were the global hardware UUID. - Mixing migration and normal items and expecting immediate migration.
- Calling
showPickerwithout user context, or not bound to a button.
Resources
WWDC: 2024-10203, 2024-10123, 2025-228
Docs: /accessorysetupkit, /accessorysetupkit/asaccessorysession, /accessorysetupkit/asdiscoverydescriptor, /accessorysetupkit/aspickerdisplayitem, /accessorysetupkit/asaccessory, /accessorysetupkit/asaccessoryevent, /accessorysetupkit/asmigrationdisplayitem
Skills: skills/accessorysetupkit-ref.md (full API surface), axiom-networking (CoreBluetooth / NetworkExtension communication), skills/privacy-ux.md (permission UX), axiom-security (accessory data handling)
AlarmKit Reference
Complete API reference for AlarmKit, Apple's framework for scheduling alarms and countdown timers with system-level alerting, Dynamic Island integration, and focus/silent mode override.
Overview
AlarmKit lets apps create alarms and timers that behave like the built-in Clock app -- they override Do Not Disturb, appear in the Dynamic Island, and show on the Lock Screen. The framework handles scheduling, snooze, pause/resume, and UI presentation through a small set of types centered on AlarmManager.
System Requirements
- iOS 26+ (AlarmKit introduced in iOS 26). Not available on macCatalyst.
- Widget Extension required for Live Activity / Dynamic Island presentation
- Physical device recommended for alarm sound and notification testing
---
Part 1: Key Components
AlarmManager
Singleton entry point for all alarm operations.
import AlarmKit
let manager = AlarmManager.sharedAll scheduling, cancellation, and observation flows through this shared instance.
Alarm
Describes an alarm that can alert once or on a repeating schedule. Schedule, CountdownDuration, and State are nested types.
struct Alarm: Identifiable, Codable, Sendable {
var id: UUID
var schedule: Alarm.Schedule?
var countdownDuration: Alarm.CountdownDuration?
var state: Alarm.State // .scheduled | .countdown | .paused | .alerting
}AlarmButton
Every custom button in an alarm presentation is an AlarmButton. There is no convenience initializer or static helper (.stopButton, .snoozeButton, etc. do not exist) -- always supply text, a tint color, and an SF Symbol name.
struct AlarmButton: Codable, Sendable {
var text: LocalizedStringResource
var textColor: Color
var systemImageName: String
init(text: LocalizedStringResource, textColor: Color, systemImageName: String)
}
let snooze = AlarmButton(text: "Snooze", textColor: .white, systemImageName: "zzz")AlarmPresentation
Content for the alarm UI across three states -- alerting, counting down, and paused.
struct AlarmPresentation {
var alert: Alert // Required: shown when alarm fires
var countdown: Countdown? // Optional: shown during countdown
var paused: Paused? // Optional: shown when paused
}AlarmAttributes
Generic container pairing presentation with app-specific metadata and tint color. Used to configure the Live Activity widget. Its ContentState is AlarmPresentationState.
struct AlarmAttributes<Metadata: AlarmMetadata>: ActivityAttributes {
var presentation: AlarmPresentation
var metadata: Metadata? // Optional
var tintColor: Color
init(presentation: AlarmPresentation, metadata: Metadata? = nil, tintColor: Color)
}AlarmMetadata
Protocol for app-specific data attached to an alarm. Conform an empty struct for minimal usage, or add properties for richer UI. Requires Codable, Hashable, Sendable.
struct RecipeMetadata: AlarmMetadata {
let recipeName: String
let cookingStep: String
}---
Part 2: Authorization
Apps must request permission before scheduling alarms. Add NSAlarmKitUsageDescription to Info.plist.
Requesting Authorization
requestAuthorization() is async throws and returns the resulting state.
func requestAlarmAuthorization() async -> Bool {
do {
let state = try await AlarmManager.shared.requestAuthorization()
return state == .authorized
} catch {
print("Authorization error: \(error)")
return false
}
}Checking Current State
authorizationState is a synchronous property -- read it directly, no await:
let state = AlarmManager.shared.authorizationState
// .notDetermined | .denied | .authorizedObserving Authorization Changes
for await authState in AlarmManager.shared.authorizationUpdates {
switch authState {
case .authorized: enableAlarmUI()
case .denied: showPermissionPrompt()
case .notDetermined: break
@unknown default: break
}
}---
Part 3: Scheduling Alarms
Every alarm requires a UUID, an AlarmManager.AlarmConfiguration, and a call to schedule(id:configuration:) (which is async throws and returns the scheduled Alarm).
Build the configuration with the .alarm(...) / .timer(...) factory methods, or the full AlarmConfiguration(...) initializer when you need both a schedule and a countdown (for snooze).
One-Time Alarm
The system supplies the stop button automatically; you only configure the title and any secondary action. (The stopButton parameter was deprecated in iOS 26.1 and is no longer used.)
let id = UUID()
let time = Alarm.Schedule.Relative.Time(hour: 7, minute: 30)
let schedule = Alarm.Schedule.relative(.init(time: time, repeats: .never))
let alert = AlarmPresentation.Alert(
title: "Wake Up",
secondaryButton: AlarmButton(text: "Snooze", textColor: .white, systemImageName: "zzz"),
secondaryButtonBehavior: .countdown
)
struct EmptyMetadata: AlarmMetadata {}
let attributes = AlarmAttributes(
presentation: AlarmPresentation(alert: alert),
metadata: EmptyMetadata(),
tintColor: .blue
)
let config = AlarmManager.AlarmConfiguration.alarm(
schedule: schedule,
attributes: attributes,
sound: .default
)
let alarm = try await AlarmManager.shared.schedule(id: id, configuration: config)Fixed-Date Alarm
Use .fixed(Date) for an absolute one-shot alarm:
let schedule = Alarm.Schedule.fixed(Date.now.addingTimeInterval(3600))Repeating Alarm
Use .weekly([Locale.Weekday]) for specific days:
let time = Alarm.Schedule.Relative.Time(hour: 6, minute: 0)
let schedule = Alarm.Schedule.relative(.init(
time: time,
repeats: .weekly([.monday, .tuesday, .wednesday, .thursday, .friday])
))Countdown Timer
Use the .timer(duration:attributes:...) factory for a countdown:
let config = AlarmManager.AlarmConfiguration.timer(
duration: 300, // 5 minutes
attributes: attributes,
sound: .default
)For finer control (e.g. a post-alert window), use the full initializer with an Alarm.CountdownDuration:
let countdown = Alarm.CountdownDuration(
preAlert: 300, // 5 minutes until it fires
postAlert: 10 // post-alert window (e.g. snooze)
)
let config = AlarmManager.AlarmConfiguration(
countdownDuration: countdown,
schedule: nil,
attributes: attributes,
sound: .default
)Timers support pause/resume and show a countdown presentation when AlarmPresentation.countdown is provided.
Snooze Configuration
Snooze uses CountdownDuration.postAlert combined with a secondary action whose behavior is .countdown. Because snooze pairs a schedule with a countdown, use the full initializer:
let alert = AlarmPresentation.Alert(
title: "Alarm",
secondaryButton: AlarmButton(text: "Snooze", textColor: .white, systemImageName: "zzz"),
secondaryButtonBehavior: .countdown // Starts the post-alert countdown
)
let config = AlarmManager.AlarmConfiguration(
countdownDuration: Alarm.CountdownDuration(preAlert: nil, postAlert: 9 * 60),
schedule: schedule,
attributes: AlarmAttributes(
presentation: AlarmPresentation(alert: alert),
metadata: EmptyMetadata(),
tintColor: .blue
),
sound: .default
)Custom Stop / Secondary Actions
To run your own code when the user stops or taps the secondary button, pass a LiveActivityIntent as stopIntent or secondaryIntent. A .custom secondary behavior fires secondaryIntent (for example, to open your app):
let config = AlarmManager.AlarmConfiguration.alarm(
schedule: schedule,
attributes: attributes,
stopIntent: StopWorkoutIntent(), // any LiveActivityIntent
secondaryIntent: OpenWorkoutIntent(), // fired when secondaryButtonBehavior == .custom
sound: .default
)---
Part 4: Customizing Alarm UI
Alert Presentation
The alert state is shown when the alarm fires. The system provides the stop button; the secondary button is optional.
// Minimal -- system-provided stop button only
let basic = AlarmPresentation.Alert(title: "Alarm")
// With a custom secondary action
let custom = AlarmPresentation.Alert(
title: "Medication Reminder",
secondaryButton: AlarmButton(text: "Remind Later", textColor: .white, systemImageName: "clock"),
secondaryButtonBehavior: .countdown
)
// Secondary action that runs a custom intent (e.g. open the app)
let openApp = AlarmPresentation.Alert(
title: "Workout Time",
secondaryButton: AlarmButton(text: "Open", textColor: .white, systemImageName: "figure.run"),
secondaryButtonBehavior: .custom // Pair with a secondaryIntent in the configuration
)Countdown Presentation
Shown while a timer counts down. Only relevant for alarms with a countdown. pauseButton is optional.
let countdown = AlarmPresentation.Countdown(
title: "Timer Running",
pauseButton: AlarmButton(text: "Pause", textColor: .white, systemImageName: "pause.fill")
)Paused Presentation
Shown when a countdown timer is paused. resumeButton is required.
let paused = AlarmPresentation.Paused(
title: "Timer Paused",
resumeButton: AlarmButton(text: "Resume", textColor: .white, systemImageName: "play.fill")
)Full Three-State Presentation
Combine all three for a complete timer experience:
let presentation = AlarmPresentation(
alert: AlarmPresentation.Alert(
title: "Timer Complete",
secondaryButton: AlarmButton(text: "Repeat", textColor: .white, systemImageName: "repeat"),
secondaryButtonBehavior: .countdown
),
countdown: AlarmPresentation.Countdown(
title: "Cooking Timer",
pauseButton: AlarmButton(text: "Pause", textColor: .white, systemImageName: "pause.fill")
),
paused: AlarmPresentation.Paused(
title: "Timer Paused",
resumeButton: AlarmButton(text: "Resume", textColor: .white, systemImageName: "play.fill")
)
)---
Part 5: Managing Alarms
Retrieve All Alarms
alarms is a throwing property (no await):
let alarms = try AlarmManager.shared.alarmsCountdown / Pause / Resume / Stop / Cancel
These mutating operations are synchronous throws functions -- do not call them with await:
try AlarmManager.shared.countdown(id: alarmID) // Start the countdown
try AlarmManager.shared.pause(id: alarmID)
try AlarmManager.shared.resume(id: alarmID)
try AlarmManager.shared.stop(id: alarmID) // Stop a ringing alarm
try AlarmManager.shared.cancel(id: alarmID) // Remove the alarm entirelyHandling the Alarm Limit
Scheduling can throw AlarmManager.AlarmError.maximumLimitReached when the app exceeds the system cap:
do {
_ = try await AlarmManager.shared.schedule(id: id, configuration: config)
} catch AlarmManager.AlarmError.maximumLimitReached {
showTooManyAlarmsMessage()
}Observe Alarm Updates
Use alarmUpdates to keep UI in sync. An alarm absent from the emitted array is no longer scheduled.
for await alarms in AlarmManager.shared.alarmUpdates {
self.alarms = alarms
}---
Part 6: Live Activity Integration
AlarmKit alarms appear in the Dynamic Island and Lock Screen through ActivityConfiguration. Add a Widget Extension target and implement the widget using AlarmAttributes.
The content state is AlarmPresentationState, whose mode is an enum with associated values -- pattern-match it with if case. Countdown details (including fireDate) live on mode's .countdown payload; there is no countdownEndDate property. Text(timerInterval:countsDown:) takes a ClosedRange<Date>.
struct AlarmWidgetView: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: AlarmAttributes<YourMetadata>.self) { context in
// Lock Screen presentation
VStack {
Text(context.attributes.presentation.alert.title)
if case .countdown(let countdown) = context.state.mode {
Text(timerInterval: countdown.startDate...countdown.fireDate, countsDown: true)
.bold()
}
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text(context.attributes.presentation.alert.title)
}
DynamicIslandExpandedRegion(.trailing) {
if case .countdown(let countdown) = context.state.mode {
Text(timerInterval: countdown.startDate...countdown.fireDate, countsDown: true)
}
}
} compactLeading: {
Image(systemName: "alarm")
} compactTrailing: {
if case .countdown(let countdown) = context.state.mode {
Text(timerInterval: countdown.startDate...countdown.fireDate, countsDown: true)
}
} minimal: {
Image(systemName: "alarm")
}
}
}
}---
Part 7: SwiftUI Integration
ViewModel Pattern with @Observable
import AlarmKit
@Observable
class AlarmViewModel {
var alarms: [Alarm] = []
private let manager = AlarmManager.shared
func requestAuthorization() {
Task {
_ = try? await manager.requestAuthorization()
}
}
func loadAndObserve() {
Task {
alarms = (try? manager.alarms) ?? []
for await updated in manager.alarmUpdates {
alarms = updated
}
}
}
func addAlarm(hour: Int, minute: Int, weekdays: Set<Locale.Weekday>) {
Task {
let time = Alarm.Schedule.Relative.Time(hour: hour, minute: minute)
let schedule = Alarm.Schedule.relative(.init(
time: time,
repeats: weekdays.isEmpty ? .never : .weekly(Array(weekdays))
))
let alert = AlarmPresentation.Alert(
title: "Alarm",
secondaryButton: AlarmButton(text: "Snooze", textColor: .white, systemImageName: "zzz"),
secondaryButtonBehavior: .countdown
)
struct EmptyMetadata: AlarmMetadata {}
let config = AlarmManager.AlarmConfiguration(
countdownDuration: Alarm.CountdownDuration(preAlert: nil, postAlert: 9 * 60),
schedule: schedule,
attributes: AlarmAttributes(
presentation: AlarmPresentation(alert: alert),
metadata: EmptyMetadata(),
tintColor: .blue
),
sound: .default
)
_ = try? await manager.schedule(id: UUID(), configuration: config)
}
}
func cancel(id: UUID) {
try? manager.cancel(id: id) // synchronous
}
func togglePause(id: UUID, isPaused: Bool) {
if isPaused {
try? manager.resume(id: id)
} else {
try? manager.pause(id: id)
}
}
}Alarm List View
struct AlarmListView: View {
@State private var viewModel = AlarmViewModel()
var body: some View {
NavigationStack {
List(viewModel.alarms, id: \.id) { alarm in
AlarmRow(alarm: alarm, viewModel: viewModel)
}
.navigationTitle("Alarms")
.onAppear {
viewModel.requestAuthorization()
viewModel.loadAndObserve()
}
}
}
}---
Part 8: Best Practices
| Practice | Detail |
|---|---|
| Request authorization early | On first launch or first alarm creation attempt |
| Handle denial gracefully | Guide users to Settings if permission was denied |
| Persist alarm UUIDs | Store IDs to manage alarms across app launches |
| Implement widget extension | Required for countdown/Dynamic Island presentation |
Use alarmUpdates | Keep UI in sync; don't poll or cache stale state |
| Test on physical device | Alarm sounds, notifications, and Live Activities require real hardware |
Handle maximumLimitReached | Scheduling throws when the app's alarm cap is exceeded |
Don't supply a stopButton | Deprecated in iOS 26.1; the system provides stop. Use stopIntent for custom stop logic |
authorizationState is synchronous | Read it directly; only requestAuthorization() is async |
---
Resources
WWDC: 2025-230
Docs: /alarmkit, /alarmkit/alarmmanager, /alarmkit/alarm, /alarmkit/alarmpresentation, /alarmkit/alarmattributes
Skills: skills/live-activities-ref.md (AlarmKit renders as a Live Activity), skills/extensions-widgets-ref.md, axiom-swiftui
App Discoverability
Overview
Core principle Feed the system metadata across multiple APIs, let the system decide when to surface your app.
iOS surfaces apps in Spotlight, Siri suggestions, and system experiences based on metadata you provide through App Intents, App Shortcuts, Core Spotlight, and NSUserActivity. The system learns from actual usage and boosts frequently-used actions. No single API is sufficient—comprehensive discoverability requires a multi-API strategy.
Key insight iOS boosts shortcuts and activities that users actually invoke. If nobody uses an intent, the system hides it. Provide clear, action-oriented metadata and the system does the heavy lifting.
---
When to Use This Skill
Use this skill when:
- Making your app appear in Spotlight search results
- Enabling Siri to suggest your app in relevant contexts
- Adding app actions to Action Button (iPhone/Apple Watch Ultra)
- Making app content discoverable system-wide
- Planning discoverability architecture before implementation
- Troubleshooting "why isn't my app being suggested?"
Do NOT use this skill when:
- You need detailed API reference (use app-intents-ref, skills/app-shortcuts-ref.md, skills/core-spotlight-ref.md)
- You're implementing a specific API (use the reference skills)
- You just want to add a single App Intent (use app-intents-ref)
---
The 6-Step Discoverability Strategy
This is a proven strategy from developers who've implemented discoverability across multiple production apps. Implementation time: One evening for minimal viable discoverability.
Step 1: Add App Intents
App Intents power Spotlight search, Siri requests, and Shortcut suggestions. Without AppIntents, your app will never surface meaningfully.
struct OrderCoffeeIntent: AppIntent {
static var title: LocalizedStringResource = "Order Coffee"
static var description = IntentDescription("Orders coffee for pickup")
@Parameter(title: "Coffee Type")
var coffeeType: CoffeeType
@Parameter(title: "Size")
var size: CoffeeSize
func perform() async throws -> some IntentResult {
try await CoffeeService.shared.order(type: coffeeType, size: size)
return .result(dialog: "Your \(size) \(coffeeType) is ordered")
}
}Why this matters App Intents are the foundation. Everything else builds on them.
See: app-intents-ref for complete API reference
---
Step 2: Add App Shortcuts with Suggested Phrases
App Shortcuts make your intents instantly available after install. No configuration required.
struct CoffeeAppShortcuts: AppShortcutsProvider {
@AppShortcutsBuilder
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OrderCoffeeIntent(),
phrases: [
"Order coffee in \(.applicationName)",
"Get my usual coffee from \(.applicationName)"
],
shortTitle: "Order Coffee",
systemImageName: "cup.and.saucer.fill"
)
}
static var shortcutTileColor: ShortcutTileColor = .tangerine
}Why this matters Without App Shortcuts, users must manually configure shortcuts. With them, your actions appear immediately in Siri, Spotlight, Action Button, and Control Center.
Critical Use suggestedPhrase patterns—this increases the chance that the system proposes them in Spotlight action suggestions and Siri's carousel.
See: app-shortcuts-ref for phrase patterns and best practices
---
Step 3: Expose Searchable Content via Core Spotlight
Index content that matters. The system will surface items that match user queries.
import CoreSpotlight
import UniformTypeIdentifiers
func indexOrder(_ order: Order) {
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Order from \(order.date.formatted())"
attributes.keywords = ["coffee", "order", order.coffeeName]
let item = CSSearchableItem(
uniqueIdentifier: order.id.uuidString,
domainIdentifier: "orders",
attributeSet: attributes
)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error = error {
print("Indexing error: \(error)")
}
}
}Why this matters Core Spotlight makes your app's content searchable. When users search for "latte" in Spotlight, your app's orders appear.
Index only what matters Don't index everything. Focus on user-facing content (orders, documents, notes, etc.).
See: core-spotlight-ref for batching, deletion patterns, and best practices
---
Step 4: Use NSUserActivity for High-Value Screens
Mark important screens as eligible for search and prediction.
func viewOrder(_ order: Order) {
let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder")
activity.title = order.coffeeName
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.persistentIdentifier = order.id.uuidString
// Connect to App Intents
activity.appEntityIdentifier = order.id.uuidString
// Provide rich metadata
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.contentDescription = "Your \(order.coffeeName) order"
attributes.thumbnailData = order.imageData
activity.contentAttributeSet = attributes
activity.becomeCurrent()
// In your view controller or SwiftUI view
self.userActivity = activity
}Why this matters The system learns which screens users visit frequently and suggests them proactively. Lock screen widgets, Siri suggestions, and Spotlight all benefit.
Critical Only mark screens that users would want to return to. Not settings, not onboarding, not error states.
See: core-spotlight-ref for eligibility patterns and activity continuation
---
Step 5: Provide Correct Intent Metadata
Clear descriptions and titles are critical because Spotlight displays them directly.
❌ DON'T: Generic or unclear
static var title: LocalizedStringResource = "Do Thing"
static var description = IntentDescription("Performs action")✅ DO: Specific, action-oriented
static var title: LocalizedStringResource = "Order Coffee"
static var description = IntentDescription("Orders coffee for pickup")Parameter summaries must be natural language:
static var parameterSummary: some ParameterSummary {
Summary("Order \(\.$size) \(\.$coffeeType)")
}
// Siri: "Order large latte"Why this matters Poor metadata means users won't understand what your intent does. Clear metadata = higher usage = system boosts it.
---
Step 6: Usage-Based Boosting
The system boosts shortcuts and activities that users actually invoke. If nobody uses an intent, the system hides it.
This is automatic—you don't control it. What you control: 1. Discoverability — Make it easy to find (Steps 1-5) 2. Utility — Make it worth using (design good intents) 3. Promotion — Show users available shortcuts (SiriTipView)
// Promote your shortcuts in-app
SiriTipView(intent: OrderCoffeeIntent(), isVisible: $showTip)
.siriTipViewStyle(.dark)Why this matters Even perfect metadata won't help if users don't know shortcuts exist. Educate users in your app's UI.
See: app-shortcuts-ref for SiriTipView and ShortcutsLink patterns
---
Decision Tree: Which API for Which Use Case
┌─ Need to expose app functionality? ────────────────────────────────┐
│ │
│ ┌─ YES → App Intents (AppIntent protocol) │
│ │ └─ Want instant availability without user setup? │
│ │ └─ YES → App Shortcuts (AppShortcutsProvider) │
│ │ │
│ └─ NO → Exposing app CONTENT (not actions)? │
│ │ │
│ ├─ User-initiated activity (viewing screen)? │
│ │ └─ YES → NSUserActivity with isEligibleForSearch │
│ │ │
│ └─ Indexing all content (documents, orders, notes)? │
│ └─ YES → Core Spotlight (CSSearchableItem) │
│ │
│ ┌─ Already using App Intents? │
│ │ └─ Want automatic Spotlight search for entities? │
│ │ └─ YES → IndexedEntity protocol │
│ │ │
│ └─ Want to connect screen to App Intent entity? │
│ └─ YES → NSUserActivity.appEntityIdentifier │
└──────────────────────────────────────────────────────────────────┘Quick Reference Table
| Use Case | API | Example |
|---|---|---|
| Expose action to Siri/Shortcuts | AppIntent | "Order coffee" |
| Make action available instantly | AppShortcut | Appear in Spotlight immediately |
| Index all app content | CSSearchableItem | All coffee orders searchable |
| Mark current screen important | NSUserActivity | User viewing order detail |
| Auto-generate Find actions | IndexedEntity | "Find orders where..." |
| Link screen to App Intent | appEntityIdentifier | Deep link to specific order |
---
Quick Implementation Pattern ("One Evening" Approach)
For minimal viable discoverability:
1. Define 1-3 Core App Intents (30 minutes)
// Your app's most valuable actions
struct OrderCoffeeIntent: AppIntent { /* ... */ }
struct ReorderLastIntent: AppIntent { /* ... */ }
struct ViewOrdersIntent: AppIntent { /* ... */ }2. Create AppShortcutsProvider (15 minutes)
struct CoffeeAppShortcuts: AppShortcutsProvider {
@AppShortcutsBuilder
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OrderCoffeeIntent(),
phrases: ["Order coffee in \(.applicationName)"],
shortTitle: "Order",
systemImageName: "cup.and.saucer.fill"
)
// Add 2-3 more shortcuts
}
}3. Index Top-Level Content (30 minutes)
// Index most recent/important content only
func indexRecentOrders() {
let recentOrders = try await OrderService.shared.recent(limit: 20)
let items = recentOrders.map { createSearchableItem(from: $0) }
CSSearchableIndex.default().indexSearchableItems(items)
}4. Add NSUserActivity to Detail Screens (30 minutes)
// In your detail view controllers/views
let activity = NSUserActivity(activityType: "com.app.viewOrder")
activity.isEligibleForSearch = true
activity.becomeCurrent()
self.userActivity = activity5. Test in Spotlight and Shortcuts (15 minutes)
- Open Shortcuts app → Search for your app → Verify shortcuts appear
- Search Spotlight → Search for your content → Verify results
- Invoke Siri → "Order coffee in [YourApp]" → Verify works
Total time: ~2 hours for basic discoverability
---
Batch Indexing for Large Content Libraries
When indexing 1,000+ items, index in batches to avoid launch slowdowns:
func indexAllContent() async {
let allItems = try await ContentService.shared.all()
let batchSize = 100
for batch in stride(from: 0, to: allItems.count, by: batchSize) {
let slice = Array(allItems[batch..<min(batch + batchSize, allItems.count)])
let searchableItems = slice.map { createSearchableItem(from: $0) }
CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in
if let error { print("Batch index error: \(error)") }
}
// Yield between batches to avoid blocking
try? await Task.sleep(for: .milliseconds(50))
}
}Best practices:
- Index in batches of 100 during background processing, not at launch
- Use
domainIdentifierto group content for efficient bulk deletion - Re-index incrementally when content changes (don't re-index everything)
- For 50,000+ items, use
CSSearchableIndex.beginBatch()/endBatch()for atomic updates
Spotlight Debugging
When indexed content doesn't appear in Spotlight:
Verification Checklist
1. Check indexing succeeded — Add completion handler logging to indexSearchableItems 2. Wait for processing — Spotlight may take 10-30 seconds to process new items 3. Search by exact title — Spotlight may not match partial keywords initially 4. Check `contentType` — Use .item for general content; wrong type may affect ranking
Common Indexing Mistakes
| Problem | Cause | Fix |
|---|---|---|
| Content not appearing | Missing title attribute | Always set attributeSet.title |
| Low ranking | No keywords | Add relevant keywords array |
| Stale results | Not deleting removed items | Call deleteSearchableItems(withIdentifiers:) |
| Duplicate results | Unstable unique identifiers | Use persistent IDs (UUID, database primary key) |
| Quota exceeded | Indexing too many items | Limit to user-relevant content (recent, favorited) |
Testing Spotlight Indexing
// Verify items are indexed
CSSearchableIndex.default().fetchLastClientState { state, error in
print("Last client state: \(String(describing: state))")
}
// Search programmatically to verify
let query = CSSearchQuery(queryString: "title == 'My Item'*", attributes: ["title"])
query.foundItemsHandler = { items in
print("Found \(items.count) items")
}
query.start()---
Anti-Patterns (What NOT to Do)
❌ ANTI-PATTERN 1: Implementing just App Intents without App Shortcuts
Problem Users must manually configure shortcuts. Your app won't appear in Spotlight/Siri automatically.
Fix Always create AppShortcutsProvider with suggested phrases.
---
❌ ANTI-PATTERN 2: Indexing everything in Core Spotlight
Problem Indexing thousands of items causes poor performance and quota issues. Users get overwhelmed.
// ❌ BAD: Index all 10,000 orders
let allOrders = try await OrderService.shared.all()Fix Index selectively—recent items, favorites, frequently accessed.
// ✅ GOOD: Index recent orders only
let recentOrders = try await OrderService.shared.recent(limit: 50)---
❌ ANTI-PATTERN 3: Generic intent titles and descriptions
Problem Spotlight displays these directly. Generic text confuses users.
// ❌ BAD
static var title: LocalizedStringResource = "Action"
static var description = IntentDescription("Does something")Fix Use specific, action-oriented language.
// ✅ GOOD
static var title: LocalizedStringResource = "Order Coffee"
static var description = IntentDescription("Orders your favorite coffee for pickup")---
❌ ANTI-PATTERN 4: Not educating users about shortcuts
Problem Perfect implementation means nothing if users don't know it exists.
Fix Use SiriTipView to promote shortcuts in your app's UI.
// Show tip after user places order
SiriTipView(intent: ReorderLastIntent(), isVisible: $showTip)---
❌ ANTI-PATTERN 5: Marking every screen as eligible for search
Problem System gets confused about what's important. Low-quality suggestions.
// ❌ BAD: Settings screen marked for prediction
activity.isEligibleForPrediction = true // Don't predict Settings!Fix Only mark screens users would want to return to (content, not chrome).
// ✅ GOOD: Mark content screens only
if order != nil {
activity.isEligibleForPrediction = true
}---
❌ ANTI-PATTERN 6: Forgetting to connect NSUserActivity to App Intents
Problem NSUserActivity and App Intents remain siloed. Lost integration opportunities.
Fix Use appEntityIdentifier to connect them.
// ✅ GOOD: Connect activity to App Intent entity
activity.appEntityIdentifier = order.id.uuidString---
Code Review Checklist
When reviewing discoverability implementation, verify:
App Intents:
- [ ] Intents have clear, action-oriented titles
- [ ] Descriptions explain what the intent does
- [ ] Parameter summaries use natural language phrasing
- [ ]
isDiscoverable = truefor public intents
App Shortcuts:
- [ ] AppShortcutsProvider is implemented
- [ ] Suggested phrases include
\(.applicationName) - [ ] Phrases are short and action-oriented
- [ ] ShortcutTileColor matches app branding
- [ ] 3-5 core shortcuts defined (not too many)
Core Spotlight:
- [ ] Only valuable content is indexed (not everything)
- [ ] Unique identifiers are stable and persistent
- [ ] Domain identifiers group related content
- [ ] Attributes include title, description, keywords
- [ ] Deletion logic exists (when content removed)
NSUserActivity:
- [ ] Only high-value screens marked eligible
- [ ]
becomeCurrent()called when screen appears - [ ]
resignCurrent()called when screen disappears - [ ]
appEntityIdentifierconnects to App Intent entities - [ ]
contentAttributeSetprovides rich metadata
User Education:
- [ ] SiriTipView used to promote shortcuts
- [ ] ShortcutsLink available in settings/help
- [ ] Onboarding mentions Siri/Spotlight support
Testing:
- [ ] Shortcuts appear in Shortcuts app
- [ ] Siri recognizes suggested phrases
- [ ] Spotlight returns app content
- [ ] Activity continuation works (tap Spotlight result)
---
Related Skills
- app-intents-ref — Complete App Intents API reference
- app-shortcuts-ref — App Shortcuts implementation guide
- core-spotlight-ref — Core Spotlight and NSUserActivity reference
---
Resources
WWDC: 260, 275, 2022-10170
Docs: /appintents/making-your-app-s-functionality-available-to-siri, /corespotlight
Skills: skills/app-intents-ref.md, skills/app-shortcuts-ref.md, skills/core-spotlight-ref.md
---
Remember Discoverability isn't one API—it's a strategy. Feed the system metadata across App Intents, App Shortcuts, Core Spotlight, and NSUserActivity. Let iOS decide when to surface your app based on context and user behavior.
App Intents Integration
Overview
Comprehensive guide to App Intents framework for exposing app functionality to Siri, Apple Intelligence, Shortcuts, Spotlight, and other system experiences. Replaces older SiriKit custom intents with modern Swift-first API.
Core principle App Intents make your app's actions discoverable across Apple's ecosystem. Well-designed intents feel natural in Siri conversations, Shortcuts automation, and Spotlight search.
When to Use This Skill
- Exposing app functionality to Siri and Apple Intelligence
- Making app actions available in Shortcuts app
- Enabling Spotlight search for app content
- Integrating with Focus filters, widgets, Live Activities
- Adding Action button support (Apple Watch Ultra)
- Debugging intent resolution or parameter validation failures
- Testing intents with Shortcuts app
- Implementing entity queries for app content
Related Skills
- app-shortcuts-ref — App Shortcuts for instant Siri/Spotlight availability without user setup
- core-spotlight-ref — Core Spotlight and NSUserActivity integration for content indexing
- app-discoverability — Strategic guide for making apps surface system-wide across all APIs
System Experiences Supported
App Intents integrate with:
- Siri — Voice commands and Apple Intelligence
- Shortcuts — Automation workflows
- App Shortcuts — Pre-configured actions available instantly (see app-shortcuts-ref)
- Spotlight — Search discovery
- Focus Filters — Contextual filtering
- Action Button — Quick actions (Apple Watch Ultra)
- Control Center — Custom controls
- WidgetKit — Interactive widgets
- Live Activities — Dynamic Island updates
- Visual Intelligence — Image-based interactions
Visual Intelligence Integration
IntentValueQuery
Allow users to circle objects in the Visual Intelligence camera and see matching results from your app:
@UnionValue
enum VisualSearchResult {
case landmark(LandmarkEntity)
case collection(CollectionEntity)
}
struct LandmarkIntentValueQuery: IntentValueQuery {
func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
// Match visual input to app entities
}
}
// Each entity type needs an OpenIntent
struct OpenLandmarkIntent: OpenIntent { /* ... */ }
struct OpenCollectionIntent: OpenIntent { /* ... */ }SemanticContentDescriptor lives in the VisualIntelligence framework — add import VisualIntelligence. It's the Input associated type of IntentValueQuery for visual-intelligence search.
Onscreen Entities
Associate app entities with visible content so users can ask Siri or ChatGPT about what's on screen:
struct LandmarkDetailView: View {
let landmark: LandmarkEntity
var body: some View {
Group { /* View content */ }
.userActivity("com.landmarks.ViewingLandmark") { activity in
activity.title = "Viewing \(landmark.name)"
activity.appEntityIdentifier = EntityIdentifier(for: landmark)
}
}
}---
Core Concepts
The Three Building Blocks
1. AppIntent — Executable actions with parameters
struct OrderSoupIntent: AppIntent {
static var title: LocalizedStringResource = "Order Soup"
static var description: IntentDescription = "Orders soup from the restaurant"
@Parameter(title: "Soup")
var soup: SoupEntity
@Parameter(title: "Quantity")
var quantity: Int?
func perform() async throws -> some IntentResult {
guard let quantity = quantity, quantity < 10 else {
throw $quantity.needsValue("Please specify how many soups")
}
try await OrderService.shared.order(soup: soup, quantity: quantity)
return .result()
}
}2. AppEntity — Objects users interact with
struct SoupEntity: AppEntity {
var id: String
var name: String
var price: Decimal
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Soup"
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)", subtitle: "$\(price)")
}
static var defaultQuery = SoupQuery()
}3. AppEnum — Enumeration types for parameters
enum SoupSize: String, AppEnum {
case small
case medium
case large
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Size"
static var caseDisplayRepresentations: [SoupSize: DisplayRepresentation] = [
.small: "Small (8 oz)",
.medium: "Medium (12 oz)",
.large: "Large (16 oz)"
]
}---
AppIntent: Defining Actions
Essential Properties
struct SendMessageIntent: AppIntent {
// REQUIRED: Short verb-noun phrase
static var title: LocalizedStringResource = "Send Message"
// REQUIRED: Purpose explanation
static var description: IntentDescription = "Sends a message to a contact"
// OPTIONAL: Discovery in Shortcuts/Spotlight
static var isDiscoverable: Bool = true
// OPTIONAL: Execution context (iOS 26+) — replaces the deprecated `openAppWhenRun`
static var supportedModes: IntentModes = .background
// iOS 18 deployment targets use the boolean instead (deprecated in iOS 26):
// static var openAppWhenRun: Bool = false
// OPTIONAL: Authentication requirement
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
}Parameter Declaration
struct BookAppointmentIntent: AppIntent {
// Required parameter (non-optional)
@Parameter(title: "Service")
var service: ServiceEntity
// Optional parameter
@Parameter(title: "Preferred Date")
var preferredDate: Date?
// Parameter with requestValueDialog for disambiguation
@Parameter(title: "Location",
requestValueDialog: "Which location would you like to visit?")
var location: LocationEntity
// Parameter with default value
@Parameter(title: "Duration")
var duration: Int = 60
}Parameter Summary (Siri Phrasing)
struct OrderIntent: AppIntent {
@Parameter(title: "Item")
var item: MenuItem
@Parameter(title: "Quantity")
var quantity: Int
static var parameterSummary: some ParameterSummary {
Summary("Order \(\.$quantity) \(\.$item)") {
\.$quantity
\.$item
}
}
}
// Siri: "Order 2 lattes"The perform() Method
func perform() async throws -> some IntentResult {
// 1. Validate parameters
guard quantity > 0 && quantity < 100 else {
throw ValidationError.invalidQuantity
}
// 2. Execute action
let order = try await orderService.placeOrder(
item: item,
quantity: quantity
)
// 3. Donate for learning (optional)
await donation()
// 4. Return result
return .result(
value: order,
dialog: "Your order for \(quantity) \(item.name) has been placed"
)
}Error Handling
enum OrderError: Error, CustomLocalizedStringResourceConvertible {
case outOfStock(itemName: String)
case paymentFailed
case networkError
var localizedStringResource: LocalizedStringResource {
switch self {
case .outOfStock(let name):
return "Sorry, \(name) is out of stock"
case .paymentFailed:
return "Payment failed. Please check your payment method"
case .networkError:
return "Network error. Please try again"
}
}
}
func perform() async throws -> some IntentResult {
if !item.isInStock {
throw OrderError.outOfStock(itemName: item.name)
}
// ...
}---
AppEntity: Representing App Content
Entity Definition
struct BookEntity: AppEntity {
// REQUIRED: Unique, persistent identifier
var id: UUID
// App data properties
var title: String
var author: String
var coverImageURL: URL?
// REQUIRED: Type display name
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
// REQUIRED: Instance display
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "by \(author)",
image: coverImageURL.map { .init(url: $0) }
)
}
// REQUIRED: Query for resolution
static var defaultQuery = BookQuery()
}Exposing Properties
struct TaskEntity: AppEntity {
var id: UUID
@Property(title: "Title")
var title: String
@Property(title: "Due Date")
var dueDate: Date?
@Property(title: "Priority")
var priority: TaskPriority
@Property(title: "Completed")
var isCompleted: Bool
// Properties exposed to system for filtering/sorting
}Computed and Deferred Properties
@ComputedProperty
Computed properties that read directly from a source of truth (no stored value):
struct SettingsEntity: UniqueAppEntity {
@ComputedProperty
var defaultPlace: PlaceDescriptor {
UserDefaults.standard.defaultPlace
}
init() { }
}@DeferredProperty
Properties that are expensive to calculate, only fetched when explicitly requested:
struct LandmarkEntity: IndexedEntity {
@DeferredProperty
var crowdStatus: Int {
get async throws {
await modelData.getCrowdStatus(self)
}
}
}Entity Query
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
// Fetch entities by IDs
return try await BookService.shared.fetchBooks(ids: identifiers)
}
func suggestedEntities() async throws -> [BookEntity] {
// Provide suggestions (recent, favorites, etc.)
return try await BookService.shared.recentBooks(limit: 10)
}
}
// Optional: Enable string-based search
extension BookQuery: EntityStringQuery {
func entities(matching string: String) async throws -> [BookEntity] {
return try await BookService.shared.searchBooks(query: string)
}
}Separating Entities from Models
❌ DON'T: Modify core data models
// DON'T make your model conform to AppEntity
struct Book: AppEntity { // Bad - couples model to intents
var id: UUID
var title: String
// ...
}✅ DO: Create dedicated entities
// Your core model
struct Book {
var id: UUID
var title: String
var isbn: String
var pages: Int
// ... lots of internal properties
}
// Separate entity for intents
struct BookEntity: AppEntity {
var id: UUID
var title: String
var author: String
// Convert from model
init(from book: Book) {
self.id = book.id
self.title = book.title
self.author = book.author.name
}
}---
Authentication & Security
Authentication Policies
struct ViewAccountIntent: AppIntent {
// No authentication required
static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
}
struct TransferMoneyIntent: AppIntent {
// Requires user to be logged in
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
}
struct UnlockVaultIntent: AppIntent {
// Requires device unlock (Face ID/Touch ID/passcode)
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication
}IntentAuthenticationPolicy itself is iOS 16+, not a 27-cycle addition.
Schema-Adopting Intents: Inherited Policies and Risk-Based Confirmations OS27
A schema-adopting intent becomes a tool in the Siri Toolbox — the model decides when to call it and generates its arguments, so prompt injection can attempt to invoke your intent without user intent. The App Intents system adds two guardrails (WWDC 2026-347):
Inherited authentication policy. Schemas carry their own default authenticationPolicy, set internally based on each schema's sensitivity and the data it handles. Your intent is automatically assigned the schema's default — no code needed. You can still set the property explicitly, but only to a stricter policy: a weaker override produces a build error that reports the minimum allowed policy. Review your intents with lock-screen behavior in mind — Siri runs on the lock screen, so an attacker in physical possession of a locked device can attempt to invoke intents.
Risk-based contextual confirmations. Schemas also carry internal risk metadata derived from side effects — deleting device state, exfiltrating data, and updating shared content are flagged risky. Before execution, the system combines that static metadata with dynamic system state to evaluate overall risk; high-risk invocations trigger an automatic user confirmation, and declining blocks execution entirely. Risk is subtle: even a seemingly harmless createTimer schema accepts a model-generated label string an attacker could poison and later read back via a list query — which is why the dynamic-state half of the evaluation exists and why low-stakes schemas still confirm in some contexts.
The security-side treatment (threat modeling, lock-screen attack framing, label-poisoning subtleties, Foundation Models mitigations) lives in axiom-security (skills/agentic-security.md) — cross-reference rather than duplicate.
---
Background vs Foreground Execution
Intent Modes
Use supportedModes (iOS 26+) for granular control over execution context. It replaces the boolean openAppWhenRun, which is deprecated in iOS 26 — keep openAppWhenRun only for iOS 18 deployment targets:
struct GetCrowdStatusIntent: AppIntent {
static let supportedModes: IntentModes = [.background, .foreground(.dynamic)]
func perform() async throws -> some ReturnsValue<Int> & ProvidesDialog {
guard await modelData.isOpen(landmark) else {
return .result(value: 0, dialog: "The landmark is currently closed.")
}
if systemContext.currentMode.canContinueInForeground {
do {
try await continueInForeground(alwaysConfirm: false)
await navigator.navigateToCrowdStatus(landmark)
} catch {
// Opening app was denied
}
}
let status = await modelData.getCrowdStatus(landmark)
return .result(value: status, dialog: "Current crowd level: \(status)")
}
}Available Modes
| Mode | Behavior |
|---|---|
.background | Performs entirely in background |
.foreground(.immediate) | App foregrounded before perform() runs |
.foreground(.dynamic) | Can request foreground during execution |
.foreground(.deferred) | Background initially, foreground before completion |
Common Combinations
| Combination | Use When |
|---|---|
[.background, .foreground] | Foreground default, background fallback |
[.background, .foreground(.dynamic)] | Background default, can request foreground |
[.background, .foreground(.deferred)] | Background initially, guaranteed foreground when requested |
Continuing in Foreground
Request foreground transition at runtime when using .foreground(.dynamic):
// Normal transition
try await continueInForeground(alwaysConfirm: false)
// Transition after an error
throw needsToContinueInForegroundError(
IntentDialog("Need to open app to complete this action"),
alwaysConfirm: true
)Background Execution — iOS 18 (pre-supportedModes)
On iOS 18, where supportedModes is unavailable, use the boolean openAppWhenRun (deprecated in iOS 26 — prefer supportedModes above when targeting iOS 26+):
struct QuickToggleIntent: AppIntent {
static var openAppWhenRun: Bool = false // Runs in background
func perform() async throws -> some IntentResult {
// Executes without opening app
await SettingsService.shared.toggle(setting: .darkMode)
return .result()
}
}Foreground Continuation — iOS 18 (pre-supportedModes)
For iOS 18 targets, pair a background intent with a foreground one via opensIntent (the iOS 26+ path is .foreground(.dynamic) + continueInForeground, shown above):
struct EditDocumentIntent: AppIntent {
@Parameter(title: "Document")
var document: DocumentEntity
func perform() async throws -> some IntentResult {
// Open app to continue in UI
return .result(opensIntent: OpenDocumentIntent(document: document))
}
}
struct OpenDocumentIntent: AppIntent {
static var openAppWhenRun: Bool = true
@Parameter(title: "Document")
var document: DocumentEntity
func perform() async throws -> some IntentResult {
// App is now foreground, safe to update UI
await MainActor.run {
DocumentCoordinator.shared.open(document: document)
}
return .result()
}
}---
Confirmation Dialogs
Requesting Confirmation
struct DeleteTaskIntent: AppIntent {
@Parameter(title: "Task")
var task: TaskEntity
func perform() async throws -> some IntentResult {
// Request confirmation before destructive action
try await requestConfirmation(
result: .result(dialog: "Are you sure you want to delete '\(task.title)'?"),
confirmationActionName: .init(stringLiteral: "Delete")
)
// User confirmed, proceed
try await TaskService.shared.delete(task: task)
return .result(dialog: "Task deleted")
}
}---
Multiple Choice API
Request user input with structured options:
let options = [
IntentChoiceOption(title: "Option 1"), // style defaults to .default
IntentChoiceOption(title: "Option 2", style: .destructive),
IntentChoiceOption.cancel // a static var, NOT a function
]
let choice = try await requestChoice(
between: options,
dialog: IntentDialog("Please select an option")
)
// IntentChoiceOption is Equatable (not Identifiable) — compare by value, not `.id`.
if choice == options[0] { // Option 1 selected
} else if choice == options[1] { // Option 2 selected
} else { // Cancelled
}IntentChoiceOption is init(title:style:) (style defaults to .default; other styles .destructive, .cancel). There is no subtitle: parameter, and cancel is a static var, not cancel(title:).
---
Interactive Snippets
Static Snippets
Return a SwiftUI view showing the outcome of an intent:
func perform() async throws -> some IntentResult {
return .result(view: Text("Order placed!").font(.title))
}SnippetIntent
Return interactive snippets with follow-up action buttons:
func perform() async throws -> some IntentResult {
let landmark = await findNearestLandmark()
return .result(
value: landmark,
opensIntent: OpenLandmarkIntent(landmark: landmark),
snippetIntent: LandmarkSnippetIntent(landmark: landmark)
)
}
struct LandmarkSnippetIntent: SnippetIntent {
static var title: LocalizedStringResource = "Landmark"
@Parameter var landmark: LandmarkEntity
// SnippetIntent requires `perform()` returning `some ShowsSnippetView` — there is
// NO `var snippet: some View` requirement (that form does not compile). Supply the
// SwiftUI view through `.result(view:)`.
func perform() async throws -> some IntentResult & ShowsSnippetView {
.result(view: VStack {
Text(landmark.name).font(.headline)
Text(landmark.description).font(.body)
HStack {
Button("Add to Favorites") { /* action */ }
Button("Search Tickets") { /* action */ }
}
}
.padding())
}
}---
Swift Package Support
AppIntentsPackage
Include App Intents in Swift Packages and static libraries:
// In your framework or dynamic library
public struct LandmarksKitPackage: AppIntentsPackage { }
// In your app target
struct LandmarksPackage: AppIntentsPackage {
static var includedPackages: [any AppIntentsPackage.Type] {
[LandmarksKitPackage.self]
}
}This enables modular intent definitions across package boundaries. The app target aggregates all packages via includedPackages.
---
Apple Intelligence: Use Model Action
Overview
The Use Model action in Shortcuts (iOS 18.1+) allows users to incorporate Apple Intelligence models into their automation workflows. Your app's entities can be passed to language models for filtering, transformation, and reasoning.
Key capability Under the hood, the action passes a JSON representation of your entity to the model, so you'll want to make sure to expose any information you want it to be able to reason over, in the entity definition.
Three Output Types
1. Text (AttributedString)
- Models often respond with Rich Text (bold, italic, lists, tables)
- Use
AttributedStringtype for text parameters to preserve formatting - Enables lossless transfer from model to your app
2. Dictionary
- Structured data extraction from unstructured input
- Useful for parsing PDFs, emails, documents
- Example: Extract vendor, amount, date from invoice
3. App Entities (Your Types)
- Pass lists of entities to models for filtering/reasoning
- Model receives JSON representation of entities
- Example: "Filter calendar events related to my trip"
Exposing Entities to Models
Models receive a JSON representation of your entities including:
1. All exposed properties (converted to strings)
struct EventEntity: AppEntity {
var id: UUID
@Property(title: "Title")
var title: String
@Property(title: "Start Date")
var startDate: Date
@Property(title: "End Date")
var endDate: Date
@Property(title: "Notes")
var notes: String?
// All @Property values included in JSON for model
}2. Type display representation (hints what entity represents)
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Calendar Event"3. Display representation (title and subtitle)
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(startDate.formatted())"
)
}Example JSON sent to model
{
"type": "Calendar Event",
"title": "Team Meeting",
"subtitle": "Jan 15, 2025 at 2:00 PM",
"properties": {
"Title": "Team Meeting",
"Start Date": "2025-01-15T14:00:00Z",
"End Date": "2025-01-15T15:00:00Z",
"Notes": "Discuss Q1 roadmap"
}
}Supporting Rich Text with AttributedString
Why it matters If your app supports Rich Text content, now is the time to make sure your app intents use the attributed string type for text parameters where appropriate.
❌ DON'T: Use plain String
struct CreateNoteIntent: AppIntent {
@Parameter(title: "Content")
var content: String // Loses formatting from model
}✅ DO: Use AttributedString
struct CreateNoteIntent: AppIntent {
@Parameter(title: "Content")
var content: AttributedString // Preserves Rich Text
func perform() async throws -> some IntentResult {
let note = Note(content: content) // Rich Text preserved
try await NoteService.shared.save(note)
return .result()
}
}Real-world example from WWDC
Bear app's Create Note accepts AttributedString, allowing diary templates from ChatGPT to include:
- Bold headings
- Mood logging tables
- Formatted lists
- All preserved losslessly
Automatic Type Conversion
When Use Model output connects to another action, the runtime automatically converts types:
Example: Boolean for If actions
// User's shortcut:
// 1. Get notes created today
// 2. For each note:
// - Use Model: "Is this note related to developing features for Shortcuts?"
// - If [model output] = yes:
// - Add to Shortcuts Projects folderInstead of returning verbose text like "Yes, this note seems to be about developing features for the Shortcuts app", the model automatically returns a Boolean (true/false) when connected to an If action.
Explicit output types available
- Text (AttributedString)
- Number
- Boolean
- Dictionary
- Date
- App Entities
Follow-Up Feature
Enable iterative refinement before passing to next action:
// User runs shortcut:
// 1. Get recipe from Safari
// 2. Use Model: "Extract ingredients list"
// - Follow Up: enabled
// - User types: "Double the recipe"
// - Model adjusts: 800g flour instead of 400g
// 3. Add to Grocery List in Things appWhen to use
- Recipe modifications (scale servings, substitute ingredients)
- Content refinement (adjust tone, length, style)
- Data validation (confirm extracted values before saving)
---
IndexedEntity: Automatic Find Actions
Overview
IndexedEntity dramatically reduces boilerplate by auto-generating Find actions from your Spotlight integration. Instead of manually implementing EntityQuery and EntityPropertyQuery, adopt IndexedEntity to get:
- Automatic Find action in Shortcuts
- Property-based filtering
- Search support
- Minimal code required
Basic Implementation
struct EventEntity: AppEntity, IndexedEntity {
var id: UUID
// 1. Properties with indexing keys
@Property(title: "Title", indexingKey: \.eventTitle)
var title: String
@Property(title: "Start Date", indexingKey: \.startDate)
var startDate: Date
@Property(title: "End Date", indexingKey: \.endDate)
var endDate: Date
// 2. Custom key for properties without standard Spotlight attribute
@Property(title: "Notes", customIndexingKey: "eventNotes")
var notes: String?
// Display representation automatically maps to Spotlight
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(title)",
subtitle: "\(startDate.formatted())"
// title → kMDItemTitle
// subtitle → kMDItemDescription
// image → kMDItemContentType (if provided)
)
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
}Indexing Key Mapping
Standard Spotlight attribute keys
// Common Spotlight keys for events
@Property(title: "Title", indexingKey: \.eventTitle)
var title: String
@Property(title: "Start Date", indexingKey: \.startDate)
var startDate: Date
@Property(title: "Location", indexingKey: \.eventLocation)
var location: String?Custom keys for non-standard attributes
@Property(title: "Notes", customIndexingKey: "eventNotes")
var notes: String?
@Property(title: "Attendee Count", customIndexingKey: "attendeeCount")
var attendeeCount: IntAuto-Generated Find Action
With IndexedEntity conformance, users get this Find action automatically:
In Shortcuts app
Find Events where:
- Title contains "Team"
- Start Date is today
- Location is "San Francisco"Without IndexedEntity, you'd need to manually implement
EnumerableEntityQueryprotocolEntityPropertyQueryprotocol- Property filters for each searchable field
- Search/suggestion logic
With IndexedEntity Just add indexing keys, done!
Search Support
Enable string-based search by implementing EntityStringQuery:
extension EventEntityQuery: EntityStringQuery {
func entities(matching string: String) async throws -> [EventEntity] {
return try await EventService.shared.search(query: string)
}
}Or rely on IndexedEntity + Spotlight for automatic search.
Explicit Spotlight Indexing
For entities that need custom searchable attributes or manual index management:
extension LandmarkEntity {
var searchableAttributes: CSSearchableItemAttributeSet {
let attributes = CSSearchableItemAttributeSet()
attributes.title = name
attributes.namedLocation = regionDescription
attributes.keywords = activities
attributes.latitude = NSNumber(value: coordinate.latitude)
attributes.longitude = NSNumber(value: coordinate.longitude)
attributes.supportsNavigation = true
return attributes
}
}
// Add entities to index
func indexLandmarks() async {
let landmarks = await fetchLandmarks()
try await CSSearchableIndex.default().indexAppEntities(landmarks, priority: .normal)
}
// Remove from index when deleted
func deleteLandmark(_ landmark: LandmarkEntity) async {
await dataStore.delete(landmark)
try await CSSearchableIndex.default().deleteAppEntities(
identifiedBy: [landmark.id],
ofType: LandmarkEntity.self
)
}Example: Travel Tracking App
Apple's sample code (App Intents Travel Tracking App) demonstrates IndexedEntity:
struct TripEntity: AppEntity, IndexedEntity {
var id: UUID
@Property(title: "Name", indexingKey: \.title)
var name: String
@Property(title: "Start Date", indexingKey: \.startDate)
var startDate: Date
@Property(title: "End Date", indexingKey: \.endDate)
var endDate: Date
@Property(title: "Destination", customIndexingKey: "destination")
var destination: String
// Auto-generated Find Trips action with filters for all properties
}---
Spotlight on Mac
Overview
Spotlight on Mac (macOS Sequoia+) allows users to run your app's intents directly from system search. Intents that work in Shortcuts automatically work in Spotlight with proper configuration.
Key principle Spotlight is all about running things quickly. To do that, people need to be able to provide all the information your intent needs to run directly in Spotlight.
Requirements for Spotlight Visibility
1. Parameter Summary Must Include All Required Parameters
The parameter summary, which is what people will see in Spotlight UI, must contain all required parameters that don't have a default value.
❌ WON'T SHOW in Spotlight
struct CreateEventIntent: AppIntent {
static var title: LocalizedStringResource = "Create Event"
@Parameter(title: "Title")
var title: String
@Parameter(title: "Start Date")
var startDate: Date
@Parameter(title: "End Date")
var endDate: Date
@Parameter(title: "Notes") // Required, no default
var notes: String
static var parameterSummary: some ParameterSummary {
Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)")
// Missing 'notes' parameter!
}
}✅ WILL SHOW in Spotlight (Option 1: Make optional)
@Parameter(title: "Notes")
var notes: String? // Optional - can omit from summary✅ WILL SHOW in Spotlight (Option 2: Provide default)
@Parameter(title: "Notes")
var notes: String = "" // Has default - can omit from summary✅ WILL SHOW in Spotlight (Option 3: Include in summary)
static var parameterSummary: some ParameterSummary {
Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)") {
\.$notes // All required params included
}
}2. Intent Must Not Be Hidden
Intents hidden from Shortcuts won't appear in Spotlight:
// ❌ Hidden from Spotlight
static var isDiscoverable: Bool = false
// ❌ Hidden from Spotlight
static var assistantOnly: Bool = true
// ❌ Hidden from Spotlight
// Intent with no perform() method (widget configuration only)Providing Suggestions
Make parameter filling quick with suggestions:
Option 1: Suggested Entities (Subset of Large List)
struct EventEntityQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [EventEntity] {
return try await EventService.shared.fetchEvents(ids: identifiers)
}
// Provide upcoming events, not all past/present events
func suggestedEntities() async throws -> [EventEntity] {
return try await EventService.shared.upcomingEvents(limit: 10)
}
}Option 2: All Entities (Small, Bounded List)
struct TimezoneQuery: EnumerableEntityQuery {
func allEntities() async throws -> [TimezoneEntity] {
// Small list - provide all
return TimezoneEntity.allTimezones
}
}Use suggested entities when List is large or unbounded (calendar events, notes, contacts) Use all entities when List is small and bounded (timezones, priority levels, categories)
On-Screen Content Tagging
Suggest currently active content:
// In your detail view controller
func showEventDetail(_ event: Event) {
let activity = NSUserActivity(activityType: "com.myapp.viewEvent")
activity.persistentIdentifier = event.id.uuidString
// Spotlight suggests this event for parameters
activity.appEntityIdentifier = event.id.uuidString
userActivity = activity
}For more details on on-screen content tagging, see the "Exploring New Advances in App Intents" session.
Search Beyond Suggestions
Basic filtering (automatic): If you provide suggestions, Spotlight automatically filters them as user types.
Deep search (requires implementation): For searching beyond suggestions:
Option 1: EntityStringQuery
extension EventQuery: EntityStringQuery {
func entities(matching string: String) async throws -> [EventEntity] {
return try await EventService.shared.search(query: string)
}
}Option 2: IndexedEntity
struct EventEntity: AppEntity, IndexedEntity {
// Spotlight search automatically supported
}Background vs Foreground Intents
Pattern: Paired Intents with opensIntent
// Background intent - runs without opening app
struct CreateEventIntent: AppIntent {
static var supportedModes: IntentModes = .background // iOS 26+ (was `openAppWhenRun = false`)
@Parameter(title: "Title")
var title: String
@Parameter(title: "Start Date")
var startDate: Date
func perform() async throws -> some IntentResult {
let event = try await EventService.shared.createEvent(
title: title,
startDate: startDate
)
// Optionally open app to view created event
return .result(
value: EventEntity(from: event),
opensIntent: OpenEventIntent(event: EventEntity(from: event))
)
}
}
// Foreground intent - opens app to specific event
struct OpenEventIntent: AppIntent {
static var supportedModes: IntentModes = .foreground // iOS 26+ (was `openAppWhenRun = true`)
@Parameter(title: "Event")
var event: EventEntity
func perform() async throws -> some IntentResult {
await MainActor.run {
EventCoordinator.shared.showEvent(id: event.id)
}
return .result()
}
}User experience
1. User runs "Create Event" in Spotlight (background) 2. Event created without opening app 3. Spotlight shows "Open in App" button (opensIntent) 4. User taps button → App opens to event detail
Predictable Intent Protocol
Enable Spotlight suggestions based on usage patterns:
struct OrderCoffeeIntent: AppIntent, PredictableIntent {
static var title: LocalizedStringResource = "Order Coffee"
@Parameter(title: "Coffee Type")
var coffeeType: CoffeeType
@Parameter(title: "Size")
var size: CoffeeSize
func perform() async throws -> some IntentResult {
// Order logic
return .result()
}
}Spotlight learns when/how user runs this intent and surfaces suggestions proactively.
---
Automations on Mac
Overview
Personal Automations arrive on macOS (macOS Sequoia+) with Mac-specific triggers:
New Mac Automation Types
- Folder Automation — Trigger when files added/removed from folder
- External Drive Automation — Trigger when drive connected/disconnected
- Time of Day (from iOS)
- Bluetooth (from iOS)
- And more...
Example use case Invoice processing shortcut runs automatically every time a new invoice is added to ~/Documents/Invoices folder.
Automatic Availability
As long as your intent is available on macOS, they will also be available to use in Shortcuts to run as a part of Automations on Mac. This includes iOS apps that are installable on macOS.
No additional code required — your existing intents work in automations automatically.
Platform Support
struct ProcessInvoiceIntent: AppIntent {
static var title: LocalizedStringResource = "Process Invoice"
// Available on macOS automatically
// Also works: iOS apps installed on Mac (Catalyst, Mac Catalyst)
@Parameter(title: "Invoice")
var invoice: FileEntity
func perform() async throws -> some IntentResult {
// Extract data, add to spreadsheet, etc.
return .result()
}
}Additional System Integration Points
With automations, your intents are now accessible from:
- Siri — Voice commands
- Shortcuts app — Manual workflows
- Spotlight — Quick actions
- Automations — Triggered workflows
- Action Button — Hardware trigger (Apple Watch Ultra)
- Control Center — Quick controls
- Widgets — Interactive elements
- Live Activities — Dynamic Island
---
App Schemas (System Schemas for Siri & Apple Intelligence)
App schemas are predefined system understandings of common concepts — messages, contacts, photos, calendar events. When your AppEntity / AppIntent / AppEnum conforms to a schema, Siri and Apple Intelligence already know how to reason about it and drive it with natural language — you map the schema's parameters onto your app's logic; the system handles the language. Schemas are grouped into domains (messages, mail, photos, calendar, …); a domain is "a contract between your app and Siri" (WWDC 2026-240).
App schemas debuted in iOS 18 (then assistant schemas). The 27 cycle renames the macros, greatly expands the domains, and adds Xcode build-time tooling. The form below is current.
Adopting a schema
Conform an entity or intent with the schema: form of the macro. Xcode autocompletes every available schema as .<domain>.<schema> (e.g. .photos.asset, .messages.sendMessage):
// Entity — adopt the photos `asset` schema so Siri understands what it represents
@AppEntity(schema: .photos.asset)
struct PhotoEntity: IndexedEntity {
var id: String
// …your existing properties…
}
// Intent — adopt the messages `sendMessage` schema; map its params to your app
@AppIntent(schema: .messages.sendMessage)
struct SendMessageIntent {
@Parameter var recipient: ContactEntity
@Parameter var content: String
@MainActor func perform() async throws -> some IntentResult & ReturnsValue<MessageEntity> {
let sent = UnicornChat.shared.send(content, to: recipient)
return .result(value: sent) // return the new entity to the system
}
}@AppEntity / @AppIntent / @AppEnum replace the deprecated @AssistantEntity / @AssistantIntent / @AssistantEnum (the SDK marks the old names deprecated, renamed: to these). Adopt IndexedEntity so Siri resolves your content by meaning (semantic search over the Spotlight index), not just exact text — the recommended path for the best Siri experience; use EntityStringQuery when data is too large/remote/volatile to index.
Available domains
| iOS 18 domains | Added in the 27 cycle OS27 |
|---|---|
| Messages, Mail, Photos, Books, Browser, Camera, Presentation, Spreadsheet, WordProcessor, Reader, Whiteboard, Journal | Calendar, Clock, Maps, Phone, Reminders, Notes, Audio, ImageGeneration, AppStore |
(VisualIntelligence arrived in the 26 cycle. Discover each domain's exact schemas via Xcode autocomplete on .<domain>.)
Xcode schema-completeness errors OS27
Some Siri flows need more than one schema. Adopt sendMessage but not the related draftMessage and Xcode fails the build with a fix-it that generates a stub adoption of the missing schema — a design hint surfaced at compile time instead of failing silently at runtime (WWDC 2026-240). Fill in the stub: wire your entities, inject dependencies, and mark perform() @MainActor if it mutates UI.
Cross-app content (on-screen awareness + transfer)
Siri requests often span apps ("email my wife this reply"). Annotate views with their entities (userActivity for one primary item; view-level annotations for lists) so Siri can resolve "this message", then adopt Transferable with an IntentValueRepresentation to export entities to other apps. On import, resolve to an existing entity with IntentValueQuery, or create a new one via IntentValueRepresentation importing.
Testing
Validate intents in isolation with AppIntentsTesting (no Siri needed), then move through Shortcuts → Spotlight → Siri (WWDC 2026-295). See the Testing & Debugging section below.
---
Testing & Debugging
Testing with Shortcuts App
1. Add intent to Shortcuts:
- Open Shortcuts app
- Tap "+" to create new shortcut
- Search for your app name
- Select your intent
2. Test parameter resolution:
- Fill in parameters
- Run shortcut
- Check Xcode console for logs
3. Test with Siri:
- "Hey Siri, [your intent name]"
- Siri should prompt for parameters
- Verify dialog text and results
Xcode Intent Testing
// In your app target, not tests
#if DEBUG
extension OrderSoupIntent {
static func testIntent() async throws {
let intent = OrderSoupIntent()
intent.soup = SoupEntity(id: "1", name: "Tomato", price: 8.99)
intent.quantity = 2
let result = try await intent.perform()
print("Result: \(result)")
}
}
#endifCommon Debugging Issues
Issue 1: Intent not appearing in Shortcuts
// ❌ Problem: isDiscoverable = false or missing
struct MyIntent: AppIntent {
// Missing isDiscoverable
}
// ✅ Solution: Make discoverable
struct MyIntent: AppIntent {
static var isDiscoverable: Bool = true
}Issue 2: Parameter not resolving
// ❌ Problem: Missing defaultQuery
struct ProductEntity: AppEntity {
var id: String
// Missing defaultQuery
}
// ✅ Solution: Add query
struct ProductEntity: AppEntity {
var id: String
static var defaultQuery = ProductQuery()
}Issue 3: Intent crashes in background
// ❌ Problem: Accessing MainActor from background
func perform() async throws -> some IntentResult {
UIApplication.shared.open(url) // Crash! MainActor only
return .result()
}
// ✅ Solution: hop to MainActor (or declare a `.foreground` mode in `supportedModes`)
func perform() async throws -> some IntentResult {
await MainActor.run {
UIApplication.shared.open(url)
}
return .result()
}Issue 4: Entity query returns empty results
// ❌ Problem: entities(for:) not implemented
struct BookQuery: EntityQuery {
// Missing entities(for:) implementation
}
// ✅ Solution: Implement required methods
struct BookQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
return try await BookService.shared.fetchBooks(ids: identifiers)
}
func suggestedEntities() async throws -> [BookEntity] {
return try await BookService.shared.recentBooks(limit: 10)
}
}---
Best Practices
1. Intent Naming
❌ DON'T: Generic or unclear
static var title: LocalizedStringResource = "Do Thing"
static var title: LocalizedStringResource = "Process"✅ DO: Verb-noun, specific
static var title: LocalizedStringResource = "Send Message"
static var title: LocalizedStringResource = "Book Appointment"
static var title: LocalizedStringResource = "Start Workout"2. Parameter Summary
❌ DON'T: Technical or confusing
static var parameterSummary: some ParameterSummary {
Summary("Execute \(\.$action) with \(\.$target)")
}✅ DO: Natural language
static var parameterSummary: some ParameterSummary {
Summary("Send \(\.$message) to \(\.$contact)")
}
// Siri: "Send 'Hello' to John"3. Error Messages
❌ DON'T: Technical jargon
throw MyError.validationFailed("Invalid parameter state")✅ DO: User-friendly
throw MyError.outOfStock("Sorry, this item is currently unavailable")4. Entity Suggestions
❌ DON'T: Return all entities
func suggestedEntities() async throws -> [TaskEntity] {
return try await TaskService.shared.allTasks() // Could be thousands!
}✅ DO: Limit to recent/relevant
func suggestedEntities() async throws -> [TaskEntity] {
return try await TaskService.shared.recentTasks(limit: 10)
}5. Async Operations
❌ DON'T: Block main thread
func perform() async throws -> some IntentResult {
let data = URLSession.shared.synchronousDataTask(url) // Blocks!
return .result()
}✅ DO: Use async/await
func perform() async throws -> some IntentResult {
let data = try await URLSession.shared.data(from: url)
return .result()
}---
Real-World Examples
Example 1: Start Workout Intent
struct StartWorkoutIntent: AppIntent {
static var title: LocalizedStringResource = "Start Workout"
static var description: IntentDescription = "Starts a new workout session"
static var supportedModes: IntentModes = .foreground // iOS 26+ (was `openAppWhenRun = true`)
@Parameter(title: "Workout Type")
var workoutType: WorkoutType
@Parameter(title: "Duration (minutes)")
var duration: Int?
static var parameterSummary: some ParameterSummary {
Summary("Start \(\.$workoutType)") {
\.$duration
}
}
func perform() async throws -> some IntentResult {
let workout = Workout(
type: workoutType,
duration: duration.map { TimeInterval($0 * 60) }
)
await MainActor.run {
WorkoutCoordinator.shared.start(workout)
}
return .result(
dialog: "Starting \(workoutType.displayName) workout"
)
}
}
enum WorkoutType: String, AppEnum {
case running
case cycling
case swimming
case yoga
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Workout Type"
static var caseDisplayRepresentations: [WorkoutType: DisplayRepresentation] = [
.running: "Running",
.cycling: "Cycling",
.swimming: "Swimming",
.yoga: "Yoga"
]
var displayName: String {
switch self {
case .running: return "running"
case .cycling: return "cycling"
case .swimming: return "swimming"
case .yoga: return "yoga"
}
}
}Example 2: Add Task with Entity Query
struct AddTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Add Task"
static var description: IntentDescription = "Creates a new task"
static var isDiscoverable: Bool = true
@Parameter(title: "Title")
var title: String
@Parameter(title: "List")
var list: TaskListEntity?
@Parameter(title: "Due Date")
var dueDate: Date?
static var parameterSummary: some ParameterSummary {
Summary("Add '\(\.$title)'") {
\.$list
\.$dueDate
}
}
func perform() async throws -> some IntentResult {
let task = try await TaskService.shared.createTask(
title: title,
list: list?.id,
dueDate: dueDate
)
return .result(
value: TaskEntity(from: task),
dialog: "Task '\(title)' added"
)
}
}
struct TaskListEntity: AppEntity {
var id: UUID
var name: String
var color: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "List"
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(name)",
image: .init(systemName: "list.bullet")
)
}
static var defaultQuery = TaskListQuery()
}
struct TaskListQuery: EntityQuery, EntityStringQuery {
func entities(for identifiers: [UUID]) async throws -> [TaskListEntity] {
return try await TaskService.shared.fetchLists(ids: identifiers)
}
func suggestedEntities() async throws -> [TaskListEntity] {
// Provide user's favorite lists
return try await TaskService.shared.favoriteLists(limit: 5)
}
func entities(matching string: String) async throws -> [TaskListEntity] {
return try await TaskService.shared.searchLists(query: string)
}
}---
App Intents Checklist
Before Submitting to App Store
- ☐ All intents have clear, localized titles and descriptions
- ☐ Parameter summaries use natural language phrasing
- ☐ Error messages are user-friendly, not technical
- ☐ Authentication policies match data sensitivity
- ☐ Entity queries return reasonable suggestion counts (< 20)
- ☐ Intents marked
isDiscoverableappear in Shortcuts - ☐ Destructive actions request confirmation
- ☐ Background intents don't access MainActor
- ☐ Foreground intents declare
.foregroundinsupportedModes(iOS 26+;openAppWhenRun = trueis the deprecated pre-26 equivalent) - ☐ Entity
displayRepresentationshows meaningful info - ☐ Tested with Siri voice commands
- ☐ Tested in Shortcuts app
- ☐ Tested with different parameter combinations
- ☐ Verified localization for all supported languages
---
Resources
WWDC: 2025-244, 2025-275, 2025-260, 2026-240, 2026-343, 2026-345, 2026-295, 2026-347
Docs: /appintents, /appintents/appintent, /appintents/appentity, /appintents/appschema, /appintents/adopting-app-intents-to-support-system-experiences, /appintents/apple-intelligence-and-siri-ai, /Updates/AppIntents
Skills: skills/app-shortcuts-ref.md, skills/core-spotlight-ref.md, skills/app-discoverability.md, axiom-security (skills/agentic-security.md)
---
Remember App Intents are how users interact with your app through Siri, Shortcuts, and system features. Well-designed intents feel like a natural extension of your app's functionality and provide value across Apple's ecosystem.
WeatherKit — Apple Weather Data
WeatherKit gives your app current conditions, minute/hourly/daily forecasts, severe-weather alerts, and historical averages from the Apple Weather service. The Swift API is a one-liner — WeatherService.shared.weather(for:) — but two things will sink you if you skip them: mandatory attribution (App Review rejects without it) and the 500,000-call/month quota (every full fetch counts).
Core mental model
You give WeatherKit a CLLocation; it returns a Weather value containing the datasets you asked for. Two cost-relevant truths:
weather(for:)fetches all datasets — convenient but quota-heavy.weather(for:including:)fetches only the datasets you name, returning them as a tuple — use this to protect your quota.
WeatherKit is a paid service with a free tier. Attribution is a contractual + App Review requirement, not a nicety.
When to Use This Skill
- Showing current conditions or forecasts (hourly, daily, minute precipitation)
- Surfacing severe-weather alerts or historical climate averages
- Deciding between the Swift API and the REST API (web/other platforms)
- Managing the 500K/month quota or planning paid tiers
- Getting attribution right before App Review
WeatherKit needs a CLLocation — for acquiring one, see axiom-location. For the REST API's JWT signing, see axiom-networking. Attribution is also an App Review gate — see axiom-shipping.
System Requirements
| Capability | Minimum |
|---|---|
| WeatherKit Swift API | iOS 16+, iPadOS 16+, macOS 13+, tvOS 16+, watchOS 9+, visionOS 1+ |
| REST API | Any platform (JWT-authenticated) |
Setup before any call: 1. Enable the WeatherKit capability on your App ID (Certificates, Identifiers & Profiles) and add it to your target's entitlements. 2. For REST, create a Service ID and a private key (.p8); you sign a JWT from Team ID + Key ID + Service ID.
Pricing and quota
- 500,000 calls/month are included with Apple Developer Program membership.
- Paid monthly tiers (USD): 1M $49.99, 2M $99.99, 5M $249.99, 10M $499.99, 20M $999.99, 50M $2,499.99, 100M $4,999.99, 150M $7,499.99, 200M $9,999.99.
- Upgrading resets your quota to 0 and starts a new billing period. Unused calls don't roll over.
A weather(for:) call that pulls every dataset costs more than a focused query — call cost is tied to the datasets returned. If you only need current + daily, request just those with weather(for:including:) and cache aggressively.
Critical Gotchas
| Gotcha | Why it bites | Fix |
|---|---|---|
| No attribution shown | App Review rejects; it also violates the WeatherKit terms | Display the Apple Weather mark + link to legalPageURL |
| 401 / auth failures | WeatherKit capability not enabled, or REST JWT misconfigured | Enable the capability; verify Service ID / Key ID / Team ID for REST |
| Quota burns fast | weather(for:) fetches all datasets on every call | Use weather(for:including:) and cache results |
| Assuming a dataset exists everywhere | Minute precipitation and alerts are region-limited | Check WeatherAvailability; handle .unsupported |
| Querying without a location | WeatherKit needs a CLLocation | Acquire one via Core Location first |
| Caching forever | Forecasts go stale; each datum has a validity window | Honor metadata.expirationDate; refetch when expired |
Querying weather
import WeatherKit
import CoreLocation
let location = CLLocation(latitude: 37.33, longitude: -122.03)
// Everything (one call, all datasets — convenient, quota-heavy)
let weather = try await WeatherService.shared.weather(for: location)
let temp = weather.currentWeather.temperature
let today = weather.dailyForecast.first
// Only what you need (quota-friendly) — `including:` returns a typed tuple
let (current, hourly) = try await WeatherService.shared.weather(
for: location, including: .current, .hourly)Datasets you can request via WeatherQuery: .current, .minute, .hourly, .daily, .alerts, .availability, plus .historicalComparisons and date-ranged variants (daily(startDate:endDate:), hourly(startDate:endDate:)) for historical averages. The tuple's element types match the order you list them; requesting a single dataset returns that type directly, not a one-element tuple.
weather.currentWeather (temperature, condition, humidity, UV index, wind), .minuteForecast (next-hour precipitation, region-limited), .hourlyForecast, .dailyForecast, .weatherAlerts (region-limited). Each result carries metadata with an expirationDate and the location.
Mandatory attribution
Apple requires the Apple Weather logo and a link to the data sources on any screen that shows WeatherKit data. Fetch it once and cache it.
let attribution = try await WeatherService.shared.attribution
// Logo (pick by color scheme), and a tap target to the legal page
let logoURL = colorScheme == .dark
? attribution.combinedMarkDarkURL
: attribution.combinedMarkLightURL
// AsyncImage(url: logoURL) ; Link(destination: attribution.legalPageURL) { ... }WeatherAttribution exposes combinedMarkLightURL, combinedMarkDarkURL, squareMarkURL, legalPageURL, serviceName, and legalAttributionText (a text fallback when you can't render the logo/links — e.g. voice or a watch complication). If you build a value-added product derived from the data, attribute the source to "Weather" with a notice that Apple's data was modified.
REST API
For websites and non-Apple platforms, call the REST endpoint with a JWT signed by your .p8 key (Service ID, Key ID, Team ID). Same datasets, same attribution requirement. See axiom-networking for JWT signing patterns.
Regional availability
Not every dataset exists everywhere. Query .availability (WeatherAvailability) and treat minuteForecast / weatherAlerts as optional — they may be .unsupported for a given location. Never assume alerts exist before checking.
Common Mistakes
- Shipping without attribution — the single most common WeatherKit App Review rejection.
- Calling
weather(for:)on every view refresh — burns quota; cache and honorexpirationDate. - Forgetting to enable the WeatherKit capability (Swift) or misconfiguring the Service ID/keys (REST).
- Assuming minute precipitation or alerts are available globally.
- Hardcoding a quota assumption — verify your tier; upgrades reset the counter and don't roll over.
- Querying before you have a
CLLocation.
Resources
WWDC: 2022-10003
Docs: /weatherkit, /weatherkit/weatherservice, /weatherkit/weather, /weatherkit/weatherattribution, /weatherkit/weatherquery, /weatherkit/weatheravailability, /weatherkit/weatheralert
Skills: axiom-location (acquiring a CLLocation), axiom-networking (REST JWT signing), axiom-shipping (attribution as an App Review requirement)
Related skills
FAQ
Which Apple APIs does axiom-integration cover?
axiom-integration covers Siri and App Intents, Shortcuts, WidgetKit widgets, StoreKit in-app purchases, EventKit, Contacts, background tasks, push notifications, localization, privacy, alarms, and Live Activities.
When must developers use axiom-integration?
Developers must use axiom-integration for any iOS system integration per the skill readme, which directs each symptom or task to a linked reference such as app-intents-ref.md or WidgetKit guides.
Is Axiom Integration safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.