Claude Code Plugins

Community-maintained marketplace

Feedback

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 Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name spotlight-discovery
description 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.
allowed-tools Bash, Read, Write, Edit

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

  1. CoreSpotlight - Index any content at any time (comprehensive)
  2. 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