Chat With Your App's Data Using Core Spotlight And SpotlightSearchTool

The interesting part of SpotlightSearchTool is not that a model can call another tool.

The interesting part is that your app probably already has a search problem. It has notes, trails, recipes, documents, messages, transactions, photos, or projects. Some of that data should be findable through the system. Some of it should be searchable inside your app. Some of it is too large to stuff into every prompt.

Core Spotlight is a good boundary for that problem. You donate searchable items with stable identifiers and useful metadata. Spotlight handles search over those items. SpotlightSearchTool lets a LanguageModelSession use that same index as retrieval context when the user asks a natural-language question.

That is a much better architecture than teaching a model to rummage through your database directly.

The snippets below follow the WWDC26 transcript shape for SpotlightSearchTool. During the beta cycle, check the SDK you are building against before treating every symbol name as final.

SpotlightSearchTool is part of the iOS, iPadOS, macOS, and visionOS 27-era SDKs. Gate the feature with platform availability and the usual SystemLanguageModel.default.availability check before showing it as a working chat surface.

Start with the retrieval boundary

Imagine a notebook app. A user can ask:

Which planning notes mention the June launch but do not include a final budget?

The model should not answer that from general knowledge. It needs your app’s data.

The retrieval boundary has three jobs:

  • Donate compact, searchable items to Core Spotlight.
  • Hydrate full items when the model needs more readable context.
  • Keep the UI informed when Spotlight returns result batches during a model response.

Here is a small app model:

import Foundation

struct NotebookEntry: Identifiable, Sendable {
    let id: String
    var title: String
    var summary: String
    var body: String
    var tags: [String]
    var projectName: String?
    var createdAt: Date
    var updatedAt: Date
}

actor NotebookStore {
    private var entriesByID: [String: NotebookEntry] = [:]

    func replaceAll(_ entries: [NotebookEntry]) {
        entriesByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
    }

    func entries(for identifiers: [String]) -> [NotebookEntry] {
        identifiers.compactMap { entriesByID[$0] }
    }

    func allEntries() -> [NotebookEntry] {
        Array(entriesByID.values)
    }
}

Nothing here is AI-specific. That is the point. Your model feature should sit on top of normal app data boundaries, not become a second persistence layer.

Core Spotlight works best when your donations are deliberate. A CSSearchableItem should have a stable identifier, a domain identifier you can delete or reindex later, and metadata that matches the way people search.

import CoreSpotlight
import Foundation
import UniformTypeIdentifiers

struct NotebookSpotlightIndexer {
    static let domainIdentifier = "notebook-entry"

    let index: CSSearchableIndex
    let store: NotebookStore

    init(
        index: CSSearchableIndex = .default(),
        store: NotebookStore
    ) {
        self.index = index
        self.store = store
    }

    func donate(_ entries: [NotebookEntry]) async throws {
        let items = entries.map(Self.makeSearchableItem)
        try await index.indexSearchableItems(items)
    }

    func deleteEntries(withIDs identifiers: [String]) async throws {
        try await index.deleteSearchableItems(withIdentifiers: identifiers)
    }

    static func makeSearchableItem(from entry: NotebookEntry) -> CSSearchableItem {
        let attributes = CSSearchableItemAttributeSet(contentType: .text)
        attributes.title = entry.title
        attributes.contentDescription = entry.summary
        attributes.textContent = entry.body
        attributes.keywords = entry.tags + [entry.projectName].compactMap(\.self)
        attributes.contentCreationDate = entry.createdAt
        attributes.contentModificationDate = entry.updatedAt

        let item = CSSearchableItem(
            uniqueIdentifier: entry.id,
            domainIdentifier: domainIdentifier,
            attributeSet: attributes
        )
        item.expirationDate = .distantFuture
        return item
    }
}

There are two easy mistakes here.

The first is under-donating. If every item only has a title, a semantic search for “launch budget notes from last month” will have very little to work with.

The second is treating Core Spotlight as a full document store. Apple calls out that some indexed metadata, like text content and HTML, may be stored in a compact searchable representation that is not recoverable as readable text for a language model. Donate what makes the item searchable, then hydrate what makes the item useful to answer with.

Also decide what expiration means for your content. Long-lived notes usually need an explicit expiration date or a reliable reindexing policy; otherwise old defaults can surprise you later.

Hydrate full items through the index delegate

SpotlightSearchTool can search the index by itself, but it may need the complete CSSearchableItem for a result. WWDC26 adds a delegate path for that:

func searchableItems(forIdentifiers identifiers: [String]) async -> [CSSearchableItem]

This is where your app can fetch the current record and attach answer-shaped metadata.

import CoreSpotlight

final class NotebookIndexDelegate: NSObject, CSSearchableIndexDelegate {
    private let indexer: NotebookSpotlightIndexer
    private let store: NotebookStore

    init(indexer: NotebookSpotlightIndexer, store: NotebookStore) {
        self.indexer = indexer
        self.store = store
    }

    func searchableItems(forIdentifiers identifiers: [String]) async -> [CSSearchableItem] {
        let entries = await store.entries(for: identifiers)

        return entries.map { entry in
            let item = NotebookSpotlightIndexer.makeSearchableItem(from: entry)

            // This can include fields that are helpful for model reasoning,
            // even if they are not fields you emphasize in ordinary search UI.
            item.attributeSet.contentDescription = """
            Project: \(entry.projectName ?? "None")
            Summary: \(entry.summary)
            Tags: \(entry.tags.joined(separator: ", "))
            """

            return item
        }
    }

    func searchableIndex(
        _ searchableIndex: CSSearchableIndex,
        reindexAllSearchableItemsWithAcknowledgementHandler acknowledgementHandler: @escaping () -> Void
    ) {
        Task {
            let entries = await store.allEntries()
            try? await indexer.donate(entries)
            acknowledgementHandler()
        }
    }

    func searchableIndex(
        _ searchableIndex: CSSearchableIndex,
        reindexSearchableItemsWithIdentifiers identifiers: [String],
        acknowledgementHandler: @escaping () -> Void
    ) {
        Task {
            let entries = await store.entries(for: identifiers)
            try? await indexer.donate(entries)
            acknowledgementHandler()
        }
    }
}

Set the delegate early and keep a strong reference to it. The indexDelegate property is weak, so assigning a freshly-created local object is a classic “works for five seconds” bug.

@MainActor
final class SearchInfrastructure {
    let store: NotebookStore
    let indexer: NotebookSpotlightIndexer
    let delegate: NotebookIndexDelegate

    init() {
        let store = NotebookStore()
        let index = CSSearchableIndex.default()

        self.store = store
        indexer = NotebookSpotlightIndexer(index: index, store: store)
        delegate = NotebookIndexDelegate(indexer: indexer, store: store)
        index.indexDelegate = delegate
    }
}

That delegate is the difference between “the model found an identifier” and “the model can reason over the current item.”

If you use the default Spotlight index and the default tool, setting the index delegate early is the basic shape. When you build a custom tool configuration, wire the delegate into the source you pass to the tool so the tool knows which attributes to fetch:

let source = CoreSpotlightSource(
    searchableIndexDelegate: delegate,
    fetchAttributes: [.title, .contentDescription, .textContent, .keywords]
)

let searchTool = SpotlightSearchTool(
    configuration: .init(source: source)
)

Add SpotlightSearchTool to the session

Once your app has donated content, the tool setup is intentionally small.

import CoreSpotlight
import FoundationModels

let searchTool = SpotlightSearchTool()
let model = SystemLanguageModel.default

let session = LanguageModelSession(
    model: model,
    tools: [searchTool],
    instructions: """
    Answer questions using the user's notebook entries.
    Use Spotlight search when the answer depends on saved entries, dates, tags, or projects.
    If the search results do not contain enough information, say that clearly.
    Keep the final answer concise and cite the entry titles you relied on.
    """
)

let response = try await session.respond(
    to: "Which planning notes mention the June launch?"
)

There is still a normal Foundation Models response at the end. The difference is the path it took to get there: the model can generate a Spotlight query, Spotlight can return matching app items, and the model can use those results as context for the final answer.

I would keep the instructions narrow. Do not tell the model to search for everything. Tell it when app data is required, what to do when retrieval is empty, and how much answer you want.

Display the answer and the search results

An assistant-style UI can show the final response as text, but search results are also useful product UI. Apple calls out that SpotlightSearchTool exposes search replies as an async sequence, and those replies can arrive in batches while a tool call is active.

The important detail is queryToken. A model may call SpotlightSearchTool more than once for one user request. For example, it might search once for “June launch”, then again for “budget”. Use the token to group result batches instead of assuming one question equals one result list.

import CoreSpotlight
import FoundationModels
import Observation

struct SpotlightResultBatch: Identifiable {
    let id = UUID()
    var queryToken: String
    var label: String
    var items: [CSSearchableItem] = []
}

@MainActor
@Observable
final class NotebookAssistantModel {
    var question = ""
    var answer = ""
    var resultBatches: [SpotlightResultBatch] = []
    var errorMessage: String?
    var isResponding = false

    private let searchTool: SpotlightSearchTool
    private let session: LanguageModelSession
    private var resultTask: Task<Void, Never>?

    init() {
        let tool = SpotlightSearchTool()

        searchTool = tool
        session = LanguageModelSession(
            model: SystemLanguageModel.default,
            tools: [tool],
            instructions: """
            Answer questions using the user's notebook entries.
            Search before answering questions about saved entries.
            Say when the notebook does not contain enough information.
            """
        )

        observeSearchResults()
    }

    deinit {
        resultTask?.cancel()
    }

    func ask() async {
        let trimmedQuestion = question.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedQuestion.isEmpty else { return }

        answer = ""
        errorMessage = nil
        resultBatches = []
        isResponding = true

        do {
            let response = try await session.respond(to: trimmedQuestion)
            answer = response.content
        } catch {
            errorMessage = error.localizedDescription
        }

        isResponding = false
    }

    private func observeSearchResults() {
        resultTask = Task { [searchTool] in
            for await reply in searchTool.searchResults {
                await MainActor.run {
                    let token = String(describing: reply.queryToken)
                    let label = reply.label ?? "Search results"

                    switch reply.content {
                    case .items(let items):
                        append(items: items, label: label, queryToken: token)
                    case .text(let result):
                        appendTextResult(result, label: label, queryToken: token)
                    default:
                        break
                    }
                }
            }
        }
    }

    private func append(
        items: [CSSearchableItem],
        label: String,
        queryToken: String
    ) {
        if let index = resultBatches.lastIndex(where: { $0.queryToken == queryToken }) {
            resultBatches[index].items.append(contentsOf: items)
        } else {
            resultBatches.append(
                SpotlightResultBatch(
                    queryToken: queryToken,
                    label: label,
                    items: items
                )
            )
        }
    }

    private func appendTextResult(
        _ result: SearchTextResult,
        label: String,
        queryToken: String
    ) {
        let header = result.header ?? label
        let body = result.body

        // Pipeline replies can include non-item data. Store these in your own
        // row model if your UI wants to show counts, tables, or generated labels.
        print("\(header): \(body)")
    }
}

The default case is not a dismissal. The WWDC26 session shows replies for items, scored items, grouped items, counts, tables, statistics, and text. A production UI should decide which of those belong on screen and which only belong in logs or evaluation output.

A simple SwiftUI view can display the final answer and the batches:

import SwiftUI

struct NotebookAssistantView: View {
    @State private var model = NotebookAssistantModel()

    var body: some View {
        Form {
            TextField("Ask about your notebook", text: $model.question)

            Button("Ask") {
                Task {
                    await model.ask()
                }
            }
            .disabled(model.question.isEmpty || model.isResponding)

            if model.isResponding {
                ProgressView()
            }

            if let errorMessage = model.errorMessage {
                Text(errorMessage)
                    .foregroundStyle(.secondary)
            }

            if !model.answer.isEmpty {
                Section("Answer") {
                    Text(model.answer)
                }
            }

            ForEach(model.resultBatches) { batch in
                Section(batch.label) {
                    ForEach(batch.items, id: \.uniqueIdentifier) { item in
                        VStack(alignment: .leading, spacing: 4) {
                            Text(item.attributeSet.title ?? "Untitled")
                                .font(.headline)

                            if let description = item.attributeSet.contentDescription {
                                Text(description)
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
            }
        }
        .navigationTitle("Notebook")
    }
}

This is a better user experience than making the assistant paragraph carry everything. The assistant can summarize. The result list can stay inspectable, tappable, and honest about what was found.

Tune the tool only when it buys you something

SpotlightSearchTool has more search capability than every app needs. The session describes guidance profiles for scoping what the model sees: text matching, dates, people, and specific metadata attributes.

That matters most when you use a smaller-context model or when your app has a narrow domain. If your notebook never donates people relationships, do not spend tool guidance on people search.

import CoreSpotlight
import FoundationModels

let profile = SpotlightSearchTool.GuidanceProfile(
    textMatch: true,
    dates: true,
    people: false,
    attributes: [.title, .contentCreationDate, .contentModificationDate, .keywords]
)

let searchTool = SpotlightSearchTool(
    configuration: .init(
        guide: .init(level: .dynamic(profile))
    )
)

I would start with the default tool, then add a guidance profile when you can name the problem it solves:

  • The model keeps generating queries against attributes your app never donates.
  • The on-device model needs a smaller, more focused tool description.
  • You have a domain-specific set of attributes that strongly improves ranking.

Custom pipeline stages are even more situational. They are useful when the model should not pull a large result set into context just to compute something your app can compute directly.

For example, if your app already has a deterministic importance score for notes, a custom stage can filter or score results before the model writes the answer. The exact registration spelling is the part I would keep closest to the current SDK, but the stage itself follows the shape Apple showed:

import CoreSpotlight
import FoundationModels

@Generable
struct ImportantNotesStage: CustomStage {
    static let name = "importantNotes"
    static let description = "Scores notebook entries by app-defined importance"
    static let inputTypes: [SearchPipelineDataType] = [.items]
    static let outputTypes: [SearchPipelineDataType] = [.scoredItems]

    @Guide(description: "Minimum importance score from 0.0 to 1.0")
    var minimumScore: Double?

    func execute(items: [CSSearchableItem]) async throws -> SearchPipelineData {
        let threshold = minimumScore ?? 0.6
        let scored = scoreItems(items, threshold: threshold)
        return .scoredItems(scored)
    }
}

The scoreItems helper there is app code, not a framework call. That is not something I would add on day one. Reach for a pipeline stage when it reduces context size, uses app-owned computation, or makes a large query practical. Do not add one just because the model could call it.

Evaluate retrieval quality

Conversational search needs evaluation before it needs clever prompting.

At minimum, keep a small set of fixture data and questions:

  • The user’s natural-language question.
  • The identifiers you expect Spotlight to retrieve.
  • Whether a tool call should happen.
  • A rough expected answer or answer properties.

Apple’s WWDC26 session shows the Evaluations framework being used with ModelSampleProtocol, TrajectoryExpectation, and a custom result-coverage metric. The exact evaluation type, fixture loader, and coverage metric are app-specific, so treat this as a sketch that separates the official API shape from your own test harness:

import Evaluations
import Testing

struct NotebookQuestionSample: ModelSampleProtocol {
    typealias ExpectedValue = String
    typealias Expectation = TrajectoryExpectation

    var input: ModelSampleInput
    var output: ModelSampleOutput<String, TrajectoryExpectation>
    var expectedIdentifiers: [String]
}

// App-specific fixture data and evaluation wiring.
let expectation = TrajectoryExpectation(
    unordered: [
        ToolExpectation(
            "searchSpotlight",
            arguments: [.keyOnly(argumentName: "query")]
        )
    ]
)

@Test("Notebook search retrieves expected entries")
func notebookSearchEvaluation() async throws {
    let entries = try FixtureLoader.loadNotebookEntries()
    let samples = try FixtureLoader.loadQuestionSamples()

    let infrastructure = SearchInfrastructure()
    await infrastructure.store.replaceAll(entries)
    try await infrastructure.indexer.donate(entries)

    let evaluation = NotebookSearchEvaluation(
        tool: SpotlightSearchTool(),
        dataset: ArrayLoader(samples)
    )

    let result = try await evaluation.run()
    let coverage = result.aggregateValue(.mean(of: Metric("ResultCoverage")))

    #expect(coverage >= 0.7)
}

That test is not trying to prove the assistant is charming. It is asking a narrower and more valuable question: when a user asks about data we know exists, does the retrieval path find it?

Once retrieval coverage is visible, you can make useful changes:

  • Add missing metadata to the CSSearchableItemAttributeSet.
  • Change summaries so they contain the words users actually ask with.
  • Add or remove guidance profile capabilities.
  • Tune date, person, or project metadata.
  • Decide whether a custom pipeline stage is worth the extra surface area.

This is where SpotlightSearchTool feels less like a demo API and more like an app architecture API. Your app keeps owning its data, identifiers, indexing, and evaluation. The model gets a focused way to ask for context.

The practical shape

The version I would ship starts boring:

  • Donate high-quality Core Spotlight items whenever app data changes.
  • Keep a strong index delegate that can hydrate full items by identifier.
  • Add one SpotlightSearchTool to the LanguageModelSession.
  • Show the assistant response and the retrieved result batches.
  • Group batches by queryToken because one response can involve multiple searches.
  • Evaluate result coverage before spending time on custom guidance or pipeline stages.

That gives you a conversational feature without making the model the owner of search. It can ask for context. Spotlight can retrieve it. Your app can display and evaluate what happened.

That is a much healthier division of labor.

Further reading: