Agent skill
spotlight-discovery
CoreSpotlight for content indexing, NSUserActivity for handoff and search, and making app content discoverable. Use when user asks about Spotlight search, CoreSpotlight, NSUserActivity, content indexing, or app discoverability.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/spotlight-discovery
SKILL.md
Spotlight and Content Discovery
Comprehensive guide to CoreSpotlight for content indexing, NSUserActivity for handoff and search, and making your app's content discoverable in iOS 26.
Prerequisites
- iOS 9+ for CoreSpotlight (iOS 26 recommended)
- Xcode 26+
Overview
Two Indexing Approaches
- CoreSpotlight - Index any content at any time (comprehensive)
- NSUserActivity - Index content user actually views (usage-based)
When to Use Each
| Feature | CoreSpotlight | NSUserActivity |
|---|---|---|
| Timing | Any time | When user views content |
| Scope | All content | Viewed content |
| Handoff | No | Yes |
| Web indexing | No | Yes |
| Ranking | Default | Usage-boosted |
CoreSpotlight
Import
import CoreSpotlight
import MobileCoreServices
Basic Indexing
import CoreSpotlight
func indexNote(_ note: Note) {
// Create searchable item attributes
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
attributes.lastUsedDate = note.modifiedAt
attributes.keywords = note.tags
// Optional: thumbnail
if let thumbnailData = note.thumbnailData {
attributes.thumbnailData = thumbnailData
}
// Create searchable item
let item = CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
// Optional: Set expiration
item.expirationDate = Date().addingTimeInterval(30 * 24 * 60 * 60) // 30 days
// Index the item
CSSearchableIndex.default().indexSearchableItems([item]) { error in
if let error {
print("Indexing failed: \(error)")
}
}
}
Async Indexing
func indexNote(_ note: Note) async throws {
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
let item = CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
try await CSSearchableIndex.default().indexSearchableItems([item])
}
Batch Indexing
func indexAllNotes(_ notes: [Note]) async throws {
let items = notes.map { note -> CSSearchableItem in
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
attributes.lastUsedDate = note.modifiedAt
return CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
}
try await CSSearchableIndex.default().indexSearchableItems(items)
}
Rich Attribute Set
func createRichAttributes(for note: Note) -> CSSearchableItemAttributeSet {
let attributes = CSSearchableItemAttributeSet(contentType: .text)
// Basic info
attributes.title = note.title
attributes.contentDescription = note.content
attributes.displayName = note.title
// Dates
attributes.contentCreationDate = note.createdAt
attributes.contentModificationDate = note.modifiedAt
attributes.lastUsedDate = note.lastViewedAt
// Keywords and categorization
attributes.keywords = note.tags
attributes.subject = note.category
// Media (if applicable)
if let imageData = note.thumbnailData {
attributes.thumbnailData = imageData
}
// Contact info (for contact-related content)
attributes.authorNames = [note.author]
attributes.authorEmailAddresses = [note.authorEmail]
// Location (if applicable)
if let location = note.location {
attributes.latitude = location.latitude as NSNumber
attributes.longitude = location.longitude as NSNumber
attributes.namedLocation = location.name
}
// Custom attributes
attributes.identifier = note.id.uuidString
attributes.relatedUniqueIdentifier = note.folder?.id.uuidString
return attributes
}
Deleting from Index
// Delete specific item
func deleteFromIndex(noteId: UUID) async throws {
try await CSSearchableIndex.default().deleteSearchableItems(
withIdentifiers: [noteId.uuidString]
)
}
// Delete by domain
func deleteAllNotes() async throws {
try await CSSearchableIndex.default().deleteSearchableItems(
withDomainIdentifiers: ["com.yourapp.notes"]
)
}
// Delete all indexed content
func deleteAllIndexedContent() async throws {
try await CSSearchableIndex.default().deleteAllSearchableItems()
}
Updating Index
func updateNoteInIndex(_ note: Note) async throws {
// Simply re-index with same identifier
// CoreSpotlight replaces existing item
try await indexNote(note)
}
NSUserActivity
Basic Setup
import UIKit
class NoteViewController: UIViewController {
var note: Note!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setupUserActivity()
}
func setupUserActivity() {
let activity = NSUserActivity(activityType: "com.yourapp.viewNote")
// Basic properties
activity.title = note.title
activity.userInfo = ["noteId": note.id.uuidString]
// Enable features
activity.isEligibleForSearch = true // Spotlight search
activity.isEligibleForPrediction = true // Siri suggestions
activity.isEligibleForHandoff = true // Handoff to other devices
// Search attributes
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
activity.contentAttributeSet = attributes
// Keywords
activity.keywords = Set(note.tags)
// Associate with view controller
userActivity = activity
activity.becomeCurrent()
}
}
SwiftUI Integration
struct NoteDetailView: View {
let note: Note
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text(note.title)
.font(.largeTitle)
Text(note.content)
}
}
.userActivity("com.yourapp.viewNote") { activity in
activity.title = note.title
activity.userInfo = ["noteId": note.id.uuidString]
activity.isEligibleForSearch = true
activity.isEligibleForHandoff = true
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.content
activity.contentAttributeSet = attributes
}
}
}
Web Page Integration
func setupWebEligibleActivity() {
let activity = NSUserActivity(activityType: "com.yourapp.viewArticle")
activity.title = article.title
activity.webpageURL = URL(string: "https://yourapp.com/articles/\(article.id)")
activity.isEligibleForSearch = true
activity.isEligibleForPublicIndexing = true // Can appear in public search
activity.isEligibleForHandoff = true
userActivity = activity
activity.becomeCurrent()
}
Correlating with CoreSpotlight
// Use same identifier in both
let uniqueId = note.id.uuidString
// CoreSpotlight item
let spotlightItem = CSSearchableItem(
uniqueIdentifier: uniqueId,
domainIdentifier: "com.yourapp.notes",
attributeSet: attributes
)
// NSUserActivity
let activity = NSUserActivity(activityType: "com.yourapp.viewNote")
activity.contentAttributeSet?.relatedUniqueIdentifier = uniqueId
Handling Spotlight Results
In App Delegate
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// Handle NSUserActivity
if userActivity.activityType == "com.yourapp.viewNote" {
if let noteId = userActivity.userInfo?["noteId"] as? String {
navigateToNote(id: noteId)
return true
}
}
// Handle CoreSpotlight result
if userActivity.activityType == CSSearchableItemActionType {
if let uniqueId = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
navigateToNote(id: uniqueId)
return true
}
}
return false
}
}
In Scene Delegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(
_ scene: UIScene,
continue userActivity: NSUserActivity
) {
handleUserActivity(userActivity)
}
func handleUserActivity(_ activity: NSUserActivity) {
switch activity.activityType {
case "com.yourapp.viewNote":
if let noteId = activity.userInfo?["noteId"] as? String {
navigateToNote(id: noteId)
}
case CSSearchableItemActionType:
if let uniqueId = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
navigateToNote(id: uniqueId)
}
default:
break
}
}
}
In SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity("com.yourapp.viewNote") { activity in
handleNoteActivity(activity)
}
.onContinueUserActivity(CSSearchableItemActionType) { activity in
handleSpotlightActivity(activity)
}
}
}
func handleNoteActivity(_ activity: NSUserActivity) {
guard let noteId = activity.userInfo?["noteId"] as? String else { return }
// Navigate to note
router.navigateTo(.note(id: noteId))
}
func handleSpotlightActivity(_ activity: NSUserActivity) {
guard let uniqueId = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return }
router.navigateTo(.note(id: uniqueId))
}
}
Query Indexing Status
Check Index Status
func checkIndexStatus() async throws {
let index = CSSearchableIndex.default()
// Check if indexing is enabled
let status = try await index.fetchLastClientState()
print("Last indexed: \(status)")
}
Client State for Incremental Updates
class IndexManager {
func performIncrementalUpdate() async throws {
let index = CSSearchableIndex.default()
// Get last sync state
let lastState = try await index.fetchLastClientState()
// Fetch changes since last state
let changes = try await fetchChangesSince(lastState)
// Index new/modified items
let newItems = changes.added + changes.modified
if !newItems.isEmpty {
let searchableItems = newItems.map { createSearchableItem(for: $0) }
try await index.indexSearchableItems(searchableItems)
}
// Delete removed items
if !changes.deleted.isEmpty {
try await index.deleteSearchableItems(withIdentifiers: changes.deleted)
}
// Save new state
let newState = createCurrentState()
try await index.beginBatch()
try await index.endBatch(withClientState: newState)
}
}
Spotlight Delegate
Index Maintenance
import CoreSpotlight
class SpotlightDelegate: NSObject, CSSearchableIndexDelegate {
func searchableIndex(
_ searchableIndex: CSSearchableIndex,
reindexAllSearchableItemsWithAcknowledgementHandler acknowledgementHandler: @escaping () -> Void
) {
// System requested full reindex
Task {
try? await reindexAllContent()
acknowledgementHandler()
}
}
func searchableIndex(
_ searchableIndex: CSSearchableIndex,
reindexSearchableItemsWithIdentifiers identifiers: [String],
acknowledgementHandler: @escaping () -> Void
) {
// System requested reindex of specific items
Task {
try? await reindexItems(withIds: identifiers)
acknowledgementHandler()
}
}
func data(for searchableIndex: CSSearchableIndex, itemIdentifier: String, typeIdentifier: String) throws -> Data {
// Provide data for a searchable item (e.g., for preview)
guard let note = NoteManager.shared.find(id: itemIdentifier) else {
throw IndexError.itemNotFound
}
return note.content.data(using: .utf8) ?? Data()
}
func fileURL(for searchableIndex: CSSearchableIndex, itemIdentifier: String, typeIdentifier: String, inPlace: Bool) throws -> URL {
// Provide file URL for item
throw IndexError.notSupported
}
}
Registering Delegate
@main
struct MyApp: App {
let spotlightDelegate = SpotlightDelegate()
init() {
CSSearchableIndex.default().indexDelegate = spotlightDelegate
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Best Practices
1. Index on Data Changes
class NoteManager {
func save(_ note: Note) async throws {
try await database.save(note)
// Index immediately after save
try? await SpotlightIndexer.shared.index(note)
}
func delete(_ note: Note) async throws {
// Remove from index first
try? await SpotlightIndexer.shared.removeFromIndex(note.id)
try await database.delete(note)
}
}
2. Set Appropriate Expiration
// Short-lived content
item.expirationDate = Date().addingTimeInterval(7 * 24 * 60 * 60) // 7 days
// Long-lived content
item.expirationDate = Date().addingTimeInterval(365 * 24 * 60 * 60) // 1 year
// Never expire
item.expirationDate = nil
3. Use Domain Identifiers
// Group related content
CSSearchableItem(
uniqueIdentifier: note.id.uuidString,
domainIdentifier: "com.yourapp.notes", // Easy bulk operations
attributeSet: attributes
)
// Delete all notes at once
try await index.deleteSearchableItems(withDomainIdentifiers: ["com.yourapp.notes"])
4. Provide Rich Metadata
// Include all relevant attributes
attributes.title = note.title
attributes.contentDescription = note.content
attributes.keywords = note.tags
attributes.lastUsedDate = note.lastViewedAt
attributes.thumbnailData = note.thumbnail
// Better search relevance
5. Handle Edge Cases
func safeIndex(_ note: Note) async {
do {
try await indexNote(note)
} catch {
// Log but don't crash
logger.error("Failed to index note: \(error)")
}
}
6. Test with Spotlight
// In simulator/device:
// 1. Index content
// 2. Pull down on home screen
// 3. Search for indexed content
// 4. Tap result to verify deep linking
Official Resources
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?