| name | axiom-core-spotlight-ref |
| description | Use when indexing app content for Spotlight search, using NSUserActivity for prediction/handoff, or choosing between CSSearchableItem and IndexedEntity - covers Core Spotlight framework and NSUserActivity integration for iOS 9+ |
| license | MIT |
| metadata | {"version":"1.0.0"} |
Core Spotlight & NSUserActivity Reference
Overview
Comprehensive guide to Core Spotlight framework and NSUserActivity for making app content discoverable in Spotlight search, enabling Siri predictions, and supporting Handoff. Core Spotlight directly indexes app content while NSUserActivity captures user engagement for prediction.
Key distinction Core Spotlight = indexing all app content; NSUserActivity = marking current user activity for prediction/handoff.
When to Use This Skill
Use this skill when:
- Indexing app content (documents, notes, orders, messages) for Spotlight
- Using NSUserActivity for Handoff or Siri predictions
- Choosing between CSSearchableItem, IndexedEntity, and NSUserActivity
- Implementing activity continuation from Spotlight results
- Batch indexing for performance
- Deleting indexed content
- Debugging Spotlight search not finding app content
- Integrating NSUserActivity with App Intents (appEntityIdentifier)
Do NOT use this skill for:
- App Shortcuts implementation (use app-shortcuts-ref)
- App Intents basics (use app-intents-ref)
- Overall discoverability strategy (use app-discoverability)
Related Skills
- app-intents-ref — App Intents framework including IndexedEntity
- app-discoverability — Strategic guide for making apps discoverable
- app-shortcuts-ref — App Shortcuts for instant availability
When to Use Each API
| Use Case | Approach | Example |
|---|
| User viewing specific screen | NSUserActivity | User opened order details |
| Index all app content | CSSearchableItem | All 500 orders searchable |
| App Intents entity search | IndexedEntity | "Find orders where..." |
| Handoff between devices | NSUserActivity | Continue editing note on Mac |
| Background content indexing | CSSearchableItem batch | Index documents on launch |
Apple guidance Use NSUserActivity for user-initiated activities (screens currently visible), not as a general indexing mechanism. For comprehensive content indexing, use Core Spotlight's CSSearchableItem.
Core Spotlight (CSSearchableItem)
Creating Searchable Items
import CoreSpotlight
import UniformTypeIdentifiers
func indexOrder(_ order: Order) {
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Ordered on \(order.date.formatted())"
attributes.keywords = ["coffee", "order", order.coffeeName.lowercased()]
attributes.thumbnailData = order.imageData
attributes.latitude = order.location.coordinate.latitude
attributes.longitude = order.location.coordinate.longitude
attributes.rating = NSNumber(value: order.rating)
let item = CSSearchableItem(
uniqueIdentifier: order.id.uuidString,
domainIdentifier: "orders",
attributeSet: attributes
)
item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error = error {
print("Indexing error: \(error.localizedDescription)")
}
}
}
Key Properties
uniqueIdentifier
Purpose Stable, persistent ID unique to this item within your app.
uniqueIdentifier: order.id.uuidString
Requirements:
- Must be stable (same item = same identifier)
- Used for updates and deletion
- Scoped to your app
domainIdentifier
Purpose Groups related items for bulk operations.
domainIdentifier: "orders"
Use cases:
- Delete all items in a domain
- Organize by type (orders, documents, messages)
- Batch operations
Pattern:
item1.domainIdentifier = "orders"
item2.domainIdentifier = "documents"
CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["orders"]
) { error in }
CSSearchableItemAttributeSet
Metadata describing the searchable content.
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = "Order #1234"
attributes.displayName = "Coffee Order"
attributes.contentDescription = "Medium latte with oat milk"
attributes.keywords = ["coffee", "latte", "order"]
attributes.thumbnailData = imageData
attributes.contentCreationDate = Date()
attributes.contentModificationDate = Date()
attributes.rating = NSNumber(value: 5)
attributes.comment = "My favorite order"
Common Attributes
| Attribute | Purpose | Example |
|---|
title | Primary title | "Coffee Order #1234" |
displayName | User-visible name | "Morning Latte" |
contentDescription | Description text | "Medium latte with oat milk" |
keywords | Search terms | ["coffee", "latte"] |
thumbnailData | Preview image | JPEG/PNG data |
contentCreationDate | When created | Date() |
contentModificationDate | Last modified | Date() |
rating | Star rating | NSNumber(value: 5) |
latitude / longitude | Location | 37.7749, -122.4194 |
Document-Specific Attributes
attributes.contentType = UTType.pdf
attributes.author = "John Doe"
attributes.pageCount = 10
attributes.fileSize = 1024000
attributes.path = "/path/to/document.pdf"
Message-Specific Attributes
attributes.recipients = ["jane@example.com"]
attributes.recipientNames = ["Jane Doe"]
attributes.authorNames = ["John Doe"]
attributes.subject = "Meeting notes"
Batch Indexing for Performance
❌ DON'T: Index items one at a time
for order in orders {
CSSearchableIndex.default().indexSearchableItems([order.asSearchableItem()]) { _ in }
}
✅ DO: Batch index operations
let items = orders.map { $0.asSearchableItem() }
CSSearchableIndex.default().indexSearchableItems(items) { error in
if let error = error {
print("Batch indexing error: \(error)")
} else {
print("Indexed \(items.count) items")
}
}
Recommended batch size 100-500 items per call. For larger sets, split into multiple batches.
Deletion Patterns
Delete by Identifier
let identifiers = ["order-1", "order-2", "order-3"]
CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: identifiers
) { error in
if let error = error {
print("Deletion error: \(error)")
}
}
Delete by Domain
CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["orders"]
) { error in }
Delete All
CSSearchableIndex.default().deleteAllSearchableItems { error in
if let error = error {
print("Failed to delete all: \(error)")
}
}
When to delete:
- User deletes content
- Content expires
- User logs out
- App reset/reinstall
App Entity Integration (App Intents)
Create from App Entity
import AppIntents
struct OrderEntity: AppEntity, IndexedEntity {
var id: UUID
@Property(title: "Coffee", indexingKey: \.title)
var coffeeName: String
@Property(title: "Date", indexingKey: \.contentCreationDate)
var orderDate: Date
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Order"
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(coffeeName)", subtitle: "Order from \(orderDate.formatted())")
}
}
let order = OrderEntity(id: UUID(), coffeeName: "Latte", orderDate: Date())
let item = CSSearchableItem(appEntity: order)
CSSearchableIndex.default().indexSearchableItems([item])
Associate Entity with Existing Item
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = "Order #1234"
let item = CSSearchableItem(
uniqueIdentifier: "order-1234",
domainIdentifier: "orders",
attributeSet: attributes
)
item.associateAppEntity(orderEntity, priority: .default)
Benefits:
- Automatic "Find" actions in Shortcuts
- Spotlight search returns entities directly
- App Intents integration
NSUserActivity
Overview
NSUserActivity captures user engagement for:
- Handoff — Continue activity on another device
- Spotlight search — Index currently viewed content
- Siri predictions — Suggest returning to this screen
- Quick Note — Link notes to app content
Platform support iOS 8.0+, iPadOS 8.0+, macOS 10.10+, tvOS 9.0+, watchOS 2.0+, axiom-visionOS 1.0+
Eligibility Properties
let activity = NSUserActivity(activityType: "com.app.viewOrder")
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.isEligibleForHandoff = true
activity.isEligibleForPublicIndexing = false
Privacy note Only set isEligibleForPublicIndexing = true for publicly accessible content (e.g., blog posts with public URLs).
Basic Pattern
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
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Your \(order.coffeeName) order"
attributes.thumbnailData = order.imageData
activity.contentAttributeSet = attributes
activity.becomeCurrent()
self.userActivity = activity
}
Critical Maintain strong reference to activity. It won't appear in search without one.
becomeCurrent() and resignCurrent()
class OrderDetailViewController: UIViewController {
var currentActivity: NSUserActivity?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let activity = NSUserActivity(activityType: "com.app.viewOrder")
activity.title = order.coffeeName
activity.isEligibleForSearch = true
activity.becomeCurrent()
self.currentActivity = activity
self.userActivity = activity
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
currentActivity?.resignCurrent()
}
}
struct OrderDetailView: View {
let order: Order
var body: some View {
VStack {
Text(order.coffeeName)
}
.onAppear {
let activity = NSUserActivity(activityType: "com.app.viewOrder")
activity.title = order.coffeeName
activity.isEligibleForSearch = true
activity.becomeCurrent()
self.userActivity = activity
}
}
}
App Intents Integration (appEntityIdentifier)
Connect NSUserActivity to App Intent entities.
func viewOrder(_ order: Order) {
let activity = NSUserActivity(activityType: "com.app.viewOrder")
activity.title = order.coffeeName
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.appEntityIdentifier = order.id.uuidString
activity.becomeCurrent()
self.userActivity = activity
}
Benefits:
- Siri suggests this order in relevant contexts
- App Intents can reference this activity
- Shortcuts integration
On-Screen Content Tagging
Pattern from WWDC Tag currently visible content for Spotlight parameter suggestions.
func showEvent(_ event: Event) {
let activity = NSUserActivity(activityType: "com.app.viewEvent")
activity.persistentIdentifier = event.id.uuidString
activity.appEntityIdentifier = event.id.uuidString
activity.becomeCurrent()
userActivity = activity
}
Result When users invoke intents requiring an event parameter, Spotlight suggests the currently visible event.
Quick Note Integration (macOS/iPadOS)
For Quick Note linking, activities must:
- Be the app's current activity (via
becomeCurrent())
- Have a clear, concise
title (nouns, not verbs)
- Provide stable, consistent identifiers
- Support navigation to linked content indefinitely
- Gracefully handle missing content
let activity = NSUserActivity(activityType: "com.app.viewNote")
activity.title = note.title
activity.persistentIdentifier = note.id.uuidString
activity.targetContentIdentifier = note.id.uuidString
activity.becomeCurrent()
Activity Continuation (Handling Spotlight Taps)
When users tap Spotlight results, handle continuation:
UIKit
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard userActivity.activityType == "com.app.viewOrder" else {
return false
}
if let identifier = userActivity.persistentIdentifier,
let orderID = UUID(uuidString: identifier) {
navigateToOrder(orderID)
return true
}
return false
}
SwiftUI
@main
struct CoffeeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity("com.app.viewOrder") { userActivity in
if let identifier = userActivity.persistentIdentifier,
let orderID = UUID(uuidString: identifier) {
navigateToOrder(orderID)
}
}
}
}
}
Searchable Item Continuation
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
if userActivity.activityType == CSSearchableItemActionType {
if let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
navigateToItem(identifier)
return true
}
}
return false
}
Deletion APIs
Delete All Saved Activities
NSUserActivity.deleteAllSavedUserActivities { }
Delete Specific Activities
let identifiers = ["order-1", "order-2"]
NSUserActivity.deleteSavedUserActivities(
withPersistentIdentifiers: identifiers
) { }
When to delete:
- User deletes content
- User logs out
- Content no longer accessible
NSUserActivity vs CSSearchableItem
| Aspect | NSUserActivity | CSSearchableItem |
|---|
| Purpose | Current user activity | Indexing all content |
| When to use | User viewing a screen | Background content indexing |
| Scope | One item at a time | Batch operations |
| Handoff | Supported | Not supported |
| Prediction | Supported | Not supported |
| Search | Limited | Full Spotlight integration |
| Example | User viewing order detail | Index all 500 orders |
Recommended Use both:
- NSUserActivity for screens currently visible
- CSSearchableItem for comprehensive content indexing
Testing & Debugging
Verify Indexed Items
Using Spotlight
- Open Spotlight (swipe down on Home Screen)
- Search for indexed content keywords
- Verify your app's results appear
- Tap result → Verify navigation works
Using Console Logs
CSSearchableIndex.default().fetchLastClientState { clientState, error in
if let error = error {
print("Error fetching client state: \(error)")
} else {
print("Client state: \(clientState?.base64EncodedString() ?? "none")")
}
}
Common Issues
Items not appearing in Spotlight
- Wait 1-2 minutes for indexing
- Verify
isEligibleForSearch = true
- Check System Settings → Siri & Search → [App] → Show App in Search
- Restart device
- Check console for indexing errors
Activity not triggering Handoff
- Verify
isEligibleForHandoff = true
- Ensure both devices signed into same iCloud account
- Check Bluetooth and Wi-Fi enabled on both devices
- Verify activityType is reverse DNS (com.company.app.action)
Continuation not working
- Verify
application(_:continue:restorationHandler:) implemented
- Check activityType matches exactly
- Ensure persistentIdentifier is set
- Test with debugger to verify method is called
Best Practices
1. Selective Indexing
❌ DON'T: Index everything
let allItems = try await ItemService.shared.all()
✅ DO: Index selectively
let recentItems = try await ItemService.shared.recent(limit: 100)
let favoriteItems = try await ItemService.shared.favorites()
Why Performance, quota limits, user experience.
2. Use Domain Identifiers
❌ DON'T: Rely only on unique identifiers
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: allOrderIDs)
✅ DO: Group with domains
CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: ["orders"])
3. Set Expiration Dates
❌ DON'T: Index items forever
let item = CSSearchableItem()
✅ DO: Set reasonable expiration
item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365)
4. Provide Rich Metadata
❌ DON'T: Minimal metadata
attributes.title = "Item"
✅ DO: Rich, searchable metadata
attributes.title = "Medium Latte Order"
attributes.contentDescription = "Ordered on December 12, 2025"
attributes.keywords = ["coffee", "latte", "order", "medium"]
attributes.thumbnailData = imageData
5. Handle Missing Content Gracefully
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard let identifier = userActivity.persistentIdentifier else {
return false
}
if let item = try? await ItemService.shared.fetch(id: identifier) {
navigate(to: item)
return true
} else {
showAlert("This content is no longer available")
NSUserActivity.deleteSavedUserActivities(
withPersistentIdentifiers: [identifier]
)
return true
}
}
Complete Example
Comprehensive Integration
import CoreSpotlight
import UniformTypeIdentifiers
class OrderManager {
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.lowercased()]
attributes.thumbnailData = order.thumbnailImageData
attributes.contentCreationDate = order.date
attributes.rating = NSNumber(value: order.rating)
let item = CSSearchableItem(
uniqueIdentifier: order.id.uuidString,
domainIdentifier: "orders",
attributeSet: attributes
)
item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365)
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error = error {
print("Indexing error: \(error)")
}
}
}
func deleteOrder(_ orderID: UUID) {
CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: [orderID.uuidString]
)
NSUserActivity.deleteSavedUserActivities(
withPersistentIdentifiers: [orderID.uuidString]
)
}
func deleteAllOrders() {
CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["orders"]
)
}
func createActivityForOrder(_ order: Order) -> NSUserActivity {
let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder")
activity.title = order.coffeeName
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.persistentIdentifier = order.id.uuidString
activity.appEntityIdentifier = order.id.uuidString
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Your \(order.coffeeName) order"
attributes.thumbnailData = order.thumbnailImageData
activity.contentAttributeSet = attributes
return activity
}
}
class OrderDetailViewController: UIViewController {
var order: Order!
var currentActivity: NSUserActivity?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
currentActivity = OrderManager.shared.createActivityForOrder(order)
currentActivity?.becomeCurrent()
self.userActivity = currentActivity
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
currentActivity?.resignCurrent()
}
}
struct OrderDetailView: View {
let order: Order
var body: some View {
VStack {
Text(order.coffeeName)
.font(.largeTitle)
Text("Ordered on \(order.date.formatted())")
.foregroundColor(.secondary)
}
.userActivity("com.coffeeapp.viewOrder") { activity in
activity.title = order.coffeeName
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.persistentIdentifier = order.id.uuidString
activity.appEntityIdentifier = order.id.uuidString
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Your \(order.coffeeName) order"
activity.contentAttributeSet = attributes
}
}
}
Resources
WWDC: 260, 2015-709
Docs: /corespotlight, /corespotlight/cssearchableitem, /foundation/nsuseractivity
Skills: axiom-app-intents-ref, axiom-app-discoverability, axiom-app-shortcuts-ref
Remember Core Spotlight indexes all your app's content; NSUserActivity marks what the user is currently doing. Use CSSearchableItem for batch indexing, NSUserActivity for active screens, and connect them to App Intents with appEntityIdentifier for comprehensive discoverability.