Verified 2026-06-13 (macOS 27 beta, M4 Max). WWDC26 246 "LLM search using Core Spotlight". Apple's
SpotlightSearchToolturns the Core Spotlight index into a retrieval tool for aLanguageModelSession. It is a plainFoundationModels.Tool, so it works behind ANYLanguageModel— we ran it behind a Core AI zoo bundle (KitLanguageModel), not the system model. Full round trip (model writes a query → Spotlight searches → grounded answer) passes on the zoo qwen3.5-0.8B and on qwen3-4B. Working example:coreai-kit/Examples/SpotlightChat.
SpotlightSearchTool lives in the _CoreSpotlight_FoundationModels overlay — it materializes
when a file imports BOTH CoreSpotlight and FoundationModels. Shape:
import CoreSpotlight
import FoundationModels
let tool = SpotlightSearchTool(configuration: .init(
sources: [.coreSpotlight(CoreSpotlightSource(searchableIndexDelegate: delegate))],
guide: SpotlightSearchTool.Guide(level: .focused(.items), format: .compact),
contactResolver: nil,
customStages: []))
// Behind YOUR model instead of the system one:
let session = LanguageModelSession(model: kitModel, tools: [tool], instructions: …)
let answer = try await session.respond(to: "What did I write about the night hike?")Configuration.sources:.coreSpotlight(your app's index) and/or.files(indexed files).Guide.level:.complete|.focused(ContentDomain = .items)|.dynamic(GuidanceProfile)..format:.structured|.compact.GuidanceProfile(textMatch:similarityMatch:numericMatch:dates:people:contentType:attributes:).tool.searchResultsis anAsyncSequence<SearchReply, Never>— observe results live (items/scoredItems/groupedItems/count/table/statistic/text + label + queryToken + status).CustomStage: Generable & Codable & Sendable— pipeline stages withinputTypes/outputTypesandexecute(items:/scoredItems:/count:/table:/text:…).
The only capability required is .toolCalling — declared by KitLanguageModel for ChatML
tokenizers (qwen3 family). The tool's query GenerationSchema is rendered into the tool prompt;
the model emits a parseable tool call; the framework runs the tool and feeds results back.
.guidedGeneration is NOT required (the tool does not constrain decoding on the model side),
so this works on the GPU-pipelined engine that cannot expose logits. Transcript:
prompt → reasoning → toolCall spotlight_search({"searchTerms":["night hike"]})
→ toolOutput (items) → toolCall fetch_note({"id":"note-003"})
→ toolOutput (body) → grounded answer
Even with CoreSpotlightSource(fetchAttributes: [.title, .contentDescription, .keywords]), the
toolOutput handed to the model carries only identity attributes — uniqueIdentifier, title,
contentType, contentCreationDate, domainIdentifier. contentDescription and keywords
do not appear (in .compact or .structured). This is not a Spotlight limitation: a raw
CSSearchQuery with the same fetchAttributes returns contentDescription (full body) fine
(textContent is index-only — write-only for full-text search, returns nil on read).
Consequence: a model answering from search results alone sees only TITLES and will hallucinate bodies (the system model, asked about a night hike, invented "rained heavily / pack a waterproof jacket"; the real note said the headlamp died — pack spare batteries).
Give the model a second plain Tool that reads the full content from your store by identifier:
struct FetchNoteTool: Tool {
let name = "fetch_note"
let description = "Read the full saved text of a note by its identifier."
@Generable struct Arguments {
@Guide(description: "The note id from spotlight_search, like note-002.") var id: String
}
func call(arguments: Arguments) async throws -> String { store[arguments.id] ?? "not found" }
}
let session = LanguageModelSession(model: kitModel, tools: [spotlightTool, FetchNoteTool()], …)The model chains spotlight_search → ids/titles → fetch_note(id) → body → grounded answer.
This mirrors a real app (Spotlight index = lightweight finding aid; full content = your store)
and doubles as a multi-tool-orchestration demo on a third-party model. Verified on the system
model, zoo qwen3.5-0.8B, and qwen3-4B.
.complete guidance injects ~13 k tokens of tool instructions → instant contextSizeExceeded
on any 4 k-context model (system or zoo). Ship .focused(.items) + format: .compact for local
models. .dynamic(GuidanceProfile) was prompt-sensitive in testing (a model skipped the search
and hallucinated) — use deliberately.
- Tool calling via the kit needs a ChatML tokenizer (
<|im_start|>). In the public catalog that is qwen3-0.6b / qwen3-4b; mistral ([INST]) and gemma do not get.toolCalling. - qwen3-0.6b is too small for the rich SpotlightSearchTool schema (loops on
<think>→ framework reports "ended without producing a response"). Use qwen3-4B or larger. - qwen3 is a thinking model; with this big tool schema its reasoning can run to the token cap
→ intermittent "ended without producing a response" on the stock engine. Append
/no_thinkto the instructions to disable qwen3 reasoning — the search→fetch chain then completes reliably (5/5 on stock qwen3-4B) and is ignored harmlessly by non-qwen models. (This is the D1 EOS-overshoot interaction surfacing at the app level; the engine-side fix is the pipelined yield-check patch.) - Hybrid zoo bundles (qwen3.5/3.6, LFM2.5, granite) need a
coreai-modelsengine with hybrid KV-state support; the stock public engine asserts "Expected 2 states, got 4".
- A
CustomStageconforms and is accepted inConfiguration.customStages(the session builds and the tool round trip still passes), but neither anitems→textnoritems→scoredItemsstage was routed through by the 27.0-beta pipeline for our queries — including under SystemLanguageModel, so it is a tool/beta behavior, not a third-party-model limitation. Docs note stages "run independently" (isolated execution). Prefer the companion-tool hydration above. CSSearchableIndexDelegateconforms and wires viaCoreSpotlightSource(searchableIndexDelegate:);searchableItems(forIdentifiers:)(macOS 15.4+, with a new protectionClass overload in 27.0) is the index-recovery hydration API — not the search-time body path.