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+
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
Use this skill when:
Do NOT use this skill for:
| 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.
import CoreSpotlight
import UniformTypeIdentifiers
func indexOrder(_ order: Order) {
// 1. Create attribute set with metadata
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
// Optional: Add location
attributes.latitude = order.location.coordinate.latitude
attributes.longitude = order.location.coordinate.longitude
// Optional: Add rating
attributes.rating = NSNumber(value: order.rating)
// 2. Create searchable item
let item = CSSearchableItem(
uniqueIdentifier: order.id.uuidString, // Stable ID
domainIdentifier: "orders", // Grouping
attributeSet: attributes
)
// Optional: Set expiration
item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365) // 1 year
// 3. Index the item
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error = error {
print("Indexing error: \(error.localizedDescription)")
}
}
}
Purpose Stable, persistent ID unique to this item within your app.
uniqueIdentifier: order.id.uuidString
Requirements:
Purpose Groups related items for bulk operations.
domainIdentifier: "orders"
Use cases:
Pattern:
// Index with domains
item1.domainIdentifier = "orders"
item2.domainIdentifier = "documents"
// Delete entire domain
CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["orders"]
) { error in }
Metadata describing the searchable content.
let attributes = CSSearchableItemAttributeSet(contentType: .item)
// Required
attributes.title = "Order #1234"
attributes.displayName = "Coffee Order"
// Highly recommended
attributes.contentDescription = "Medium latte with oat milk"
attributes.keywords = ["coffee", "latte", "order"]
attributes.thumbnailData = imageData
// Optional but valuable
attributes.contentCreationDate = Date()
attributes.contentModificationDate = Date()
attributes.rating = NSNumber(value: 5)
attributes.comment = "My favorite order"
| 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 |
// For document types
attributes.contentType = UTType.pdf
attributes.author = "John Doe"
attributes.pageCount = 10
attributes.fileSize = 1024000
attributes.path = "/path/to/document.pdf"
// For messages
attributes.recipients = ["jane@example.com"]
attributes.recipientNames = ["Jane Doe"]
attributes.authorNames = ["John Doe"]
attributes.subject = "Meeting notes"
// Bad: 100 index operations
for order in orders {
CSSearchableIndex.default().indexSearchableItems([order.asSearchableItem()]) { _ in }
}
// Good: 1 index operation
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.
let identifiers = ["order-1", "order-2", "order-3"]
CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: identifiers
) { error in
if let error = error {
print("Deletion error: \(error)")
}
}
// Delete all items in "orders" domain
CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["orders"]
) { error in }
// Nuclear option: delete everything
CSSearchableIndex.default().deleteAllSearchableItems { error in
if let error = error {
print("Failed to delete all: \(error)")
}
}
When to delete:
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())")
}
}
// Create searchable item from entity
let order = OrderEntity(id: UUID(), coffeeName: "Latte", orderDate: Date())
let item = CSSearchableItem(appEntity: order)
CSSearchableIndex.default().indexSearchableItems([item])
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = "Order #1234"
let item = CSSearchableItem(
uniqueIdentifier: "order-1234",
domainIdentifier: "orders",
attributeSet: attributes
)
// Associate with App Intent entity
item.associateAppEntity(orderEntity, priority: .default)
Benefits:
NSUserActivity captures user engagement for:
Platform support iOS 8.0+, iPadOS 8.0+, macOS 10.10+, tvOS 9.0+, watchOS 2.0+, visionOS 1.0+
let activity = NSUserActivity(activityType: "com.app.viewOrder")
// Enable Spotlight search
activity.isEligibleForSearch = true
// Enable Siri predictions
activity.isEligibleForPrediction = true
// Enable Handoff to other devices
activity.isEligibleForHandoff = true
// Contribute URL to global search (public content only)
activity.isEligibleForPublicIndexing = false
Privacy note Only set isEligibleForPublicIndexing = true for publicly accessible content (e.g., blog posts with public URLs).
func viewOrder(_ order: Order) {
// 1. Create activity
let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder")
activity.title = order.coffeeName
// 2. Set eligibility
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
// 3. Provide identifier for updates/deletion
activity.persistentIdentifier = order.id.uuidString
// 4. Provide rich metadata
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Your \(order.coffeeName) order"
attributes.thumbnailData = order.imageData
activity.contentAttributeSet = attributes
// 5. Mark as current
activity.becomeCurrent()
// 6. Store reference (important!)
self.userActivity = activity
}
Critical Maintain strong reference to activity. It won't appear in search without one.
// UIKit pattern
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() // Mark as active
self.currentActivity = activity
self.userActivity = activity // UIKit integration
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
currentActivity?.resignCurrent() // Mark as inactive
}
}
// SwiftUI pattern
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()
// SwiftUI automatically manages userActivity
self.userActivity = activity
}
}
}
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
// Connect to App Intent entity
activity.appEntityIdentifier = order.id.uuidString
// Now Spotlight can surface this as an entity suggestion
activity.becomeCurrent()
self.userActivity = activity
}
Benefits:
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
// Spotlight suggests this event for intent parameters
activity.appEntityIdentifier = event.id.uuidString
activity.becomeCurrent()
userActivity = activity
}
Result When users invoke intents requiring an event parameter, Spotlight suggests the currently visible event.
For Quick Note linking, activities must:
becomeCurrent())title (nouns, not verbs)let activity = NSUserActivity(activityType: "com.app.viewNote")
activity.title = note.title // ✅ "Project Ideas" not ❌ "View Note"
activity.persistentIdentifier = note.id.uuidString
activity.targetContentIdentifier = note.id.uuidString
activity.becomeCurrent()
When users tap Spotlight results, handle continuation:
// AppDelegate or SceneDelegate
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard userActivity.activityType == "com.app.viewOrder" else {
return false
}
// Extract identifier
if let identifier = userActivity.persistentIdentifier,
let orderID = UUID(uuidString: identifier) {
// Navigate to order
navigateToOrder(orderID)
return true
}
return false
}
@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) {
// Navigate to order
navigateToOrder(orderID)
}
}
}
}
}
// When continuing from CSSearchableItem
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
if userActivity.activityType == CSSearchableItemActionType {
// Get identifier from Core Spotlight item
if let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
// Navigate based on identifier
navigateToItem(identifier)
return true
}
}
return false
}
NSUserActivity.deleteAllSavedUserActivities { }
let identifiers = ["order-1", "order-2"]
NSUserActivity.deleteSavedUserActivities(
withPersistentIdentifiers: identifiers
) { }
When to delete:
| 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:
CSSearchableIndex.default().fetchLastClientState { clientState, error in
if let error = error {
print("Error fetching client state: \(error)")
} else {
print("Client state: \(clientState?.base64EncodedString() ?? "none")")
}
}
isEligibleForSearch = trueisEligibleForHandoff = trueapplication(_:continue:restorationHandler:) implemented// Bad: Index all 10,000 items
let allItems = try await ItemService.shared.all()
// Good: Index recent/important items
let recentItems = try await ItemService.shared.recent(limit: 100)
let favoriteItems = try await ItemService.shared.favorites()
Why Performance, quota limits, user experience.
// Hard to delete all orders
CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: allOrderIDs)
// Easy to delete all orders
CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: ["orders"])
// Bad: Items never expire
let item = CSSearchableItem(/* ... */)
// Good: Expire after 1 year
item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365)
attributes.title = "Item"
attributes.title = "Medium Latte Order"
attributes.contentDescription = "Ordered on December 12, 2025"
attributes.keywords = ["coffee", "latte", "order", "medium"]
attributes.thumbnailData = imageData
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard let identifier = userActivity.persistentIdentifier else {
return false
}
// Attempt to load content
if let item = try? await ItemService.shared.fetch(id: identifier) {
navigate(to: item)
return true
} else {
// Content deleted or unavailable
showAlert("This content is no longer available")
// Delete activity from search
NSUserActivity.deleteSavedUserActivities(
withPersistentIdentifiers: [identifier]
)
return true // Still handled
}
}
import CoreSpotlight
import UniformTypeIdentifiers
class OrderManager {
// MARK: - Core Spotlight Indexing
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) {
// Delete from Core Spotlight
CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: [orderID.uuidString]
)
// Delete NSUserActivity
NSUserActivity.deleteSavedUserActivities(
withPersistentIdentifiers: [orderID.uuidString]
)
}
func deleteAllOrders() {
CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["orders"]
)
}
// MARK: - NSUserActivity for Current Screen
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
// Connect to App Intents
activity.appEntityIdentifier = order.id.uuidString
// Rich metadata
let attributes = CSSearchableItemAttributeSet(contentType: .item)
attributes.title = order.coffeeName
attributes.contentDescription = "Your \(order.coffeeName) order"
attributes.thumbnailData = order.thumbnailImageData
activity.contentAttributeSet = attributes
return activity
}
}
// UIKit view controller
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()
}
}
// SwiftUI view
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
}
}
}
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.