The search tool

An assistant does not talk to the search engine directly. It calls one built-in tool, search, with the user's request in their own words — no query syntax, no filter names, no field names. The translation into a real SearchRequest happens inside the tool, through a dedicated interpreter agent that first asks the engine what can actually be filtered in this workspace, for this user.

search is an in-code built-in: available to all users, selectable in the chat, assistant and agent tool pickers, and enabled on the default assistant. It is not editable.

What one call does

flowchart TD Model["Assistant<br/>search(request, page)"] --> Tool["search tool"] Tool --> Agent["InterpretSearch agent"] Agent --> Filters["consult-filters<br/>(empty search as the user)"] Filters --> Agent Agent -->|"{ query, filters[], explanation }"| Tool Tool --> Engine["SearchRequest<br/>(ACL-filtered)"] Engine --> Tool Tool -->|"interpretation + 10 results + facet breakdown"| Model
  1. Interpret. The tool runs the built-in InterpretSearch agent. Its system prompt carries the query syntax and filter examples, with ${VISIBLENODETYPES} and ${TODAY} substituted per run — which data types are searchable depends on the calling user's access rights, and without today's date "last quarter" cannot become a range.
  2. Consult the real filters. The agent's only tool, consult-filters, runs an empty search as that user and reports the filters that really exist, with their values and counts. Nothing is invented: the agent may only use names and values it saw there. It may call this once — a second call in the same interpretation is refused outright, because a model handed the same list again reads it as progress and loops.
  3. Answer under a schema. The agent replies under a strongly-typed output schema — { query, filters[], explanation }.
  4. Search. The tool reverses the filter names back into a real SearchRequest (every LLM filter is a pre-filter, so the facet counts describe the filtered result set), runs it as the user, and returns the interpretation, the first page of results and the facet breakdown.

If the interpreter is unavailable or its run fails, the search still happens — on the user's own wording, with no filters — and the reason comes back as a hint. A failed interpretation is never a failed search.

What the model gets back

{
  "ok": true,
  "interpretation": { "query": "…", "filters": ["file-kind: PDFs"], "explanation": "…" },
  "totalResults": 128,
  "page": 1,
  "pageSize": 10,
  "totalPages": 13,
  "results": [ { "uid": "…", "title": "…", "type": "…", "date": "…", "source": "…", "url": "…" } ],
  "filters": [ { "name": "file-kind", "kind": "value", "appliesTo": ["File"], "values": [ { "value": "PDFs", "count": 42 } ] } ],
  "hint": "…"
}
  • interpretation is what tells the user how their request was read. Surface it when the results look wrong — that is more useful than paging.
  • filters (page 1 only) is the breakdown of the matches, useful to say how results are distributed or to suggest a narrower search.
  • Each result uid is what the reading tools take: an item's content is read with consult, not guessed from its title.

Pagination is 10 results per page, page is 1-based, and only the first 1000 results (100 pages) of a search are reachable. Pages 2..N are served from a per-conversation cache of the result UIDs, which lives for a day: re-running the interpretation would spend another agent run and could return a different result set under the same page numbers. Asking for a later page without a cached page 1 is refused with a message saying to read page 1 first.

The model never sees a facet key or a UID

Filters are renamed for the model and values are turned into labels:

Back-end facet key What the model sees
Node.Timestamp time — values are date buckets written YYYY-MM-DD..YYYY-MM-DD
Node.Source source
Type node-type — values are data type display names
_FileEntry.ContentType file-kind — MIME types grouped the way the "Kind" facet groups them ("PDFs", "Slides", …)
_FileEntry.Extension file-extension
Related._Contact the node type's display name, Related. dropped — values are node labels, not UIDs
anything else the key, kebab-cased

Counts of every MIME type behind a kind are summed into one, and filtering on the kind filters on all of them. Two keys that map to the same name are grouped rather than shadowing each other.

Configuring what is searchable

Settings → Search → LLM Search decides which data types the tool may search and which of the engine's filters the interpreter may apply. Each filter is listed under the name the model will see.

Defaults, applied while nothing is configured:

Default
Node types _FileEntry, _WebPage
Filters file-kind, file-extension, source, time

Keep the list small and meaningful. Every extra filter is more for the model to reason about; a missing one means it has to fall back to words alone.

Access control is not configuration

The node types a user can search are the configured ones intersected with what that user may see — the empty search behind consult-filters runs as the calling user, so a filter value that only occurs on items they cannot access never reaches the model. A user with nothing visible gets an explicit "no data is visible to you" answer instead of an empty search over everything.

The same settings are readable and writable over HTTP for configuration sync:

Method Route
GET /api/search/settings/llm
PUT /api/search/settings/llm

Both require a system administrator; PUT requires write access. Storing an empty set restores the built-in defaults.

What the tool tells the assistant to do

search carries a [ToolSystemPrompt] excerpt, so every chat that offers it is told — for the whole conversation, not per call — that the model cannot see the workspace's contents, that it must pass the user's own words instead of pre-encoding filters, that it should show the interpretation when results look wrong, and that it must cite what it used. You do not have to repeat any of that in an assistant's prompt.

© 2026 Curiosity. All rights reserved.