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 assistant and agent tool pickers, and enabled on the default assistant. In the chat composer it is not offered under that name — see In the tool picker it is one entry. 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 node type's display name plus the field, kebab-cased

Values are relabelled to what a user sees in the interface, not to what is stored. A file's content type reads as its kind — a PowerPoint deck is Slides, not application/vnd.…presentation — and a file's language reads as the language rather than its code, matching the Kind and Language facets in the search interface. Counts of every raw value behind a label are summed into one, and filtering on the label 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. The two lists also travel with the workspace definitions export — see LLM Search: what the assistant may search for the page itself.

In the tool picker it is one entry, named after the workspace

A user does not pick "search" and "consult" in the chat composer. Reaching into the workspace — finding things in it, reading what the conversation was handed — is one capability from their side, so the two tools are offered as a single entry named after the workspace, described as "Search the workspace and read the items added to this conversation". It starts selected: an assistant that cannot reach the workspace is the surprising case, not the default. The entry is left out only where neither of the two tools is available to that assistant and user, and search and consult are not repeated separately in the list.

To call it something other than the workspace name, set Workspace Tool Name under Settings → Interface → Branding, directly below Workspace Name. Leave it empty to use the workspace name.

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.

It also tells the model to search plainly first: no "exact phrase", no AND / OR / NOT, unless the user's own wording asks for them. Those operators narrow a search that has not yet been shown to return too much, and picking them is the interpreter's job, not the calling model's.

© 2026 Curiosity. All rights reserved.