b4d04eb8fd
* feat: add session-scoped connector access for onboarding
* fix(connectors): availability is the config flag AND the portal entitlement — no free-tier leg
The port carried a third availability leg from hermes-magic: a stored guest
(free-tier) identity short-circuits the managed-tool entitlement check. That
leg reads hermes_cli.anon_auth, which does not exist on hermes-agent main, so
connectors_available() raised ImportError inside its fail-closed try and the
whole connector surface was silently dark on a plain upstream checkout.
On this tree availability is the two-leg AND the design started with:
tools.connectors.enabled AND managed_nous_tools_enabled(). The free-tier leg
is a hermes-magic concern and belongs in hermes-magic's own delta over this
branch, next to the identity it depends on. Its integration test goes with it.
* docs(tool-search): connectors section — remote tools through the bridge
The squashed port carried the code but not the user-facing docs. Restores the
Connectors section of the Tool Search page and the connector-gateway host /
CONNECTOR_GATEWAY_URL override on the Tool Gateway page, updated for the
manage_connections tool and the pure-connector batch rule.
* fix(tool-search): connector tools rank with local tools in one pass instead of taking leftover slots
dispatch_tool_search ran BM25 over the local catalog, filled `limit` slots,
then appended connector hits only into slots left empty. On a 300-tool
catalog no slot was ever empty, so with Gmail and Google Calendar connected
"send gmail email" returned five betterstack tools and zero connector tools.
The gateway's hits for a query now become catalog entries (connector name,
slug words, description as the search text) and join the local catalog for
that query's BM25 pass. One ranking, one rarest-token admission rule for both
sources, `limit` as the total per query. The merge loop and the separate
record builder for connector hits are gone; `_shared_tool_record` serves both
sources.
The gateway search timeout rises from 8 s to 30 s. One request with six
use_cases measured 7 s, so 8 s sat on the edge and cut real answers off; the
failure path is unchanged (local-only results, no error to the model).
Live, 311 local tools + gateway, before -> after:
"send gmail email": 5 betterstack tools -> gmail SEND_EMAIL, CREATE_EMAIL_DRAFT
"read google calendar events": 5 betterstack tools -> googlecalendar EVENTS_LIST_ALL_CALENDARS
"linear create issue", "betterstack incident": unchanged
Benchmark (25 labelled queries): connector recall 0.09 -> 0.82, precision@5
0.18 -> 0.59, false positives on absent intents 17 -> 2.
* refactor(tool-search): connector leg into tools/connector_search.py
tools/tool_search.py is a facade. The connector leg (gateway hits as catalog
entries for tool_search, remote schemas for tool_describe, the
connections_in_scope gate) was appended to it by the port. It now lives in
its own sibling, tools/connector_search.py, and the facade imports the three
entry points: connections_in_scope, connector_entries_by_group,
remote_schemas_for.
No behaviour change. The tool_describe remote block became
remote_schemas_for(names, current_tool_defs, connector_describe) with the
same inputs, the same silent-degradation contract and the same injection
seam the tests already use.
* fix(tool-search): at most 7 queries per call, the gateway's search limit
One tool_search call sends all its queries to the connector gateway as one
search request. The gateway answers 7 use_cases per request and returns
HTTP 502 for 8 or more (measured 2026-09-09, re-measured with one-word
use_cases: it is a count limit, not a size limit). With the client cap at
10, a model sending 8 to 10 queries lost every connector hit for that call
and saw local-only results with no error.
The shared constant splits: _MAX_QUERIES_PER_CALL = 7 for search,
_MAX_DESCRIBE_NAMES_PER_CALL = 10 for describe, which has no remote count
limit. Eight or more queries now get the existing "too many queries" retry
hint before any request is made. No chunking: one call, one request.
* fix(tool-search): the model is told that connectors__ names are manage_connections accounts
tool_search results carry names like connectors__gmail__CREATE_EMAIL_DRAFT and
manage_connections is the tool that checks and connects those accounts, but
nothing told the model the two are the same thing. A model that hit
CONNECTION_REQUIRED had to infer the fix on its own.
The tool_search description gains one sentence making the link, added at
assembly only when manage_connections is in the session's tools. Signed out
or with connectors off the tool is absent and the description is unchanged,
so it never names a tool the model cannot call. This follows the existing
rule for cross-tool references (tools/AGENTS.md): they are added dynamically
from the session's actual tool set, never hardcoded in a schema.
Tool defs are fixed for the life of a conversation, so the description is
byte-stable per conversation; this is a one-time prefix change.
Live, real get_tool_definitions() against a signed-in home: sentence present.
Same home with auth.json removed: manage_connections absent, sentence absent.
* fix(connectors): /stop halts a connector batch before the next remote call
dispatch_connector_batch runs every remote entry of a tool_call batch in
sequence. The executor only checks the interrupt flag between tools, and
the whole batch is one tool to it, so a /stop landing during entry 1 of
20 still sent the other 19 to the gateway.
The loop now reads tools.interrupt.is_interrupted before each dispatch.
Once set, it stops calling handle_function_call and fills every unstarted
slot with the loop's existing error-slot shape, code INTERRUPTED and the
message "Stopped by the user before this call was made.", so the result
envelope stays valid and the counts stay honest. Entries already
dispatched keep their real results.
Test: three connector calls where the fake client sets the interrupt on
the first execute. The client sees exactly one call and slots 2 and 3
carry INTERRUPTED. Red on the base branch, green with the fix.
* test(connections): schema assertions become dispatch contracts
test_schema_documents_wait_and_its_timeout froze description fragments
("REQUIRED", "can NOT disconnect", "Nous Portal"). A wording edit fails
it while a real regression (a disconnect that reaches the gateway) does
not. That is a snapshot of prose, not a behaviour contract.
Delete it. The requirement that wait needs connectors is already covered
by test_wait_requires_connectors. The user-only disconnect boundary is
now asserted as behaviour: action disconnect with a connector returns an
error and the fake client records no call. That replaces the earlier
de-authenticate test, which only checked that the word "dashboard"
appeared in the error text.
Test count in the file goes from 26 to 25.
* docs(tool-search): connector batches are one gateway request per entry
The user guide said a connector batch travels as one gateway request. It
does not: model_tools_connectors.dispatch_connector_batch re-enters core
dispatch per entry, and each entry becomes its own execute request in
bridge._run_remote (plus at most one literal-slug retry when the gateway
reports TOOL_NOT_FOUND under the conventional slug). The docstrings in
tools/tool_gateway/bridge.py and tools/tool_gateway/__init__.py still
described the abandoned V1 plan and claimed nothing outside the package
imports it.
Rewrite those sentences to match the code: one request per entry, in
input order, dispatched from model_tools_connectors.py, with the per-entry
approval and interrupt behaviour that motivated the split. The guide also
still showed the single-call shape tool_call(name, arguments); both
places now show the `calls: [{name, arguments}]` array the schema
advertises and note that a single local call is an array of one.
Docs only, no test.
* fix(tools): the between-turns refresh never rewrites the bridge tools
The per-turn MCP refresh folds a fresh tool snapshot into the live array
with preserve_prefix: order and membership stay, but a name present in both
takes the fresh schema. That is right for ordinary tools, whose schema is a
constant. tool_search is the one tool whose description is derived from the
session: the deferred-tool count, the embedded listing, and, on this branch,
whether manage_connections was present. A late MCP server or one failed
portal lookup (manage_connections' check_fn fails closed) changed those bytes
on the next turn, and every byte after tool_search in the cached prefix was
re-prefilled. The array also contradicted itself in that case: the flapping
manage_connections was carried forward while the description lost its hint.
The bridge entries now keep the bytes they were built with for the life of
the conversation. Nothing is lost: tool_search reads the live catalog at
dispatch, so tools that arrived late are still found; connector availability
is checked at dispatch too. The compaction-boundary rebuild (content_aware,
the one sanctioned cache break) still refreshes the description.
Consequence: connector exposure in the prompt is decided once, at agent
build, by whether the user was signed in then. That is the intended
contract.
* refactor(tool-search): normalize_tool_call_entries lives with the other argument validation
The port appended the tool_call argument parser to the tool_search facade.
The family already has tools/tool_search_validation.py for exactly this
work (schema validation of deferred call arguments), so the parser moves
there and the facade imports it. No behaviour change; the one test that
imported it now imports from the defining module.
* refactor(connectors): delete the unused batch dispatcher; _run_remote becomes run_remote
bridge.dispatch_calls and its helpers (_dispatch_calls_inner, _run_pre_dispatch,
_run_local, _error_slot, _maybe_parse_json) and the LocalDispatch / PreDispatch
seams had no production caller. Connector dispatch runs through
model_tools_connectors: dispatch_connector_batch re-enters handle_function_call
once per entry, so scope, hook, approval and middleware policy fire against each
composed name inside core dispatch, and dispatch_connector_call hands the single
planned entry to the bridge's transport function. Only tests called the batch
dispatcher, and they exercised policy seams that production never wires.
The transport function is the module's real entry point, so it drops the
underscore: _run_remote becomes run_remote, body unchanged. The module
docstring now describes the two legs that exist (availability with D32 silent
degradation, and run_remote) instead of the injected seams. Imports that only
the deleted code used are gone; merge.py is untouched because every export
still has a caller.
Tests that drove dispatch_calls are deleted where they covered the removed
seams (pre_dispatch blocks and rewrites, local_dispatch classification, mixed
batches). The literal-slug fallback, the per-entry transport failure, and the
hook rewrite reaching the gateway request body are re-targeted at
handle_function_call('tool_call', ...) with the fake client swapped in at
bridge._default_client_factory, the same seam test_connector_dispatch_policy
uses. Each re-targeted test fails when the retry is disabled in run_remote.
* fix(connectors): search keeps the twin a colliding name reaches, and says so
format_connector_name strips the toolkit prefix, so GMAIL_FETCH_PROFILE and a
literal FETCH_PROFILE on gmail both compose to connectors__gmail__FETCH_PROFILE.
describe and execute decode that name to the prefixed slug first, so the
literal twin is unreachable under it. If a vendor ever shipped both, search
could describe the literal under a name that runs the prefixed tool.
Search is the one place that sees both twins in one response. It now keeps
the twin the name reaches and drops the other with a WARNING that names both
slugs, whichever the gateway listed first. Short names stay; no marker, no
per-process map, no change to describe or execute. No such pair exists in the
live catalog today; the guard turns a silent alias into a logged one.
267 lines
14 KiB
Markdown
267 lines
14 KiB
Markdown
---
|
||
title: Tool Search
|
||
sidebar_position: 95
|
||
---
|
||
|
||
# Tool Search
|
||
|
||
When you have many MCP servers or non-core plugin tools attached to a
|
||
session, their JSON schemas can consume a substantial fraction of the
|
||
context window on every turn — even when only a few of them are relevant
|
||
to what the user actually asked for.
|
||
|
||
**Tool Search** is Hermes' opt-in progressive-disclosure layer for that
|
||
problem. When activated, MCP and plugin tools are replaced in the
|
||
model-visible tools array by three bridge tools, and the model loads each
|
||
specific tool's schema on demand.
|
||
|
||
:::info Built-in Hermes tools never defer
|
||
The tools that make up Hermes' core capability set (`terminal`,
|
||
`read_file`, `write_file`, `patch`, `search_files`, `todo`, `memory`,
|
||
`browser_*`, `web_search`, `web_extract`, `clarify`, `execute_code`,
|
||
`delegate_task`, `session_search`, and the rest of
|
||
`_HERMES_CORE_TOOLS`) are *always* loaded directly. Only MCP tools and
|
||
non-core plugin tools are eligible for deferral.
|
||
:::
|
||
|
||
## How it works
|
||
|
||
When Tool Search activates for a turn, the model sees three new tools in
|
||
place of the deferred ones:
|
||
|
||
```
|
||
tool_search(queries, limit?) search the deferred-tool catalog (one or more queries)
|
||
tool_describe(names) load the full schemas for one or more tools
|
||
tool_call(calls) invoke deferred tools; `calls` is an array of {name, arguments}
|
||
```
|
||
|
||
`calls` takes one entry per invocation; a single local call is an array of
|
||
one. Only `connectors__` names may be batched together; mixed and
|
||
multi-local batches are rejected.
|
||
|
||
A typical interaction looks like:
|
||
|
||
```
|
||
Model: tool_search(["create a github issue", "send a slack message"])
|
||
→ { results: [ { query: "create a github issue",
|
||
matches: ["mcp_github_create_issue", ...] },
|
||
{ query: "send a slack message",
|
||
matches: ["mcp_slack_post_message", ...] } ],
|
||
tools: { mcp_github_create_issue: { description: "...",
|
||
required: ["title"], ... },
|
||
mcp_slack_post_message: { ... } } }
|
||
Model: tool_describe(["mcp_github_create_issue", "mcp_slack_post_message"])
|
||
→ { tools: { mcp_github_create_issue: { parameters: { ... } },
|
||
mcp_slack_post_message: { parameters: { ... } } } }
|
||
Model: tool_call({ calls: [{ name: "mcp_github_create_issue",
|
||
arguments: { title: "...", body: "..." } }] })
|
||
→ { ok: true, issue_number: 42 }
|
||
```
|
||
|
||
Each query in a `tool_search` call is searched independently against the
|
||
same catalog (`limit` applies per query); the per-query groups carry tool
|
||
names only, while the shared `tools` map holds each matched tool's
|
||
description and required parameter names once. Queries are stemmed, so
|
||
"issues" finds `create_issue`. Each query group that returns no matches
|
||
includes an `available_sources` summary of the connected servers so a lexical
|
||
miss is not mistaken for a missing capability.
|
||
`tool_describe` resolves every requested name in one call; unknown names
|
||
are reported in `not_found` without failing the rest of the batch.
|
||
|
||
When the model invokes `tool_call`, Hermes **unwraps the bridge** and
|
||
dispatches the underlying tool exactly as if the model had called it
|
||
directly. Pre-tool-call hooks, guardrails, approval prompts, and
|
||
post-tool-call hooks all run against the real tool name — not against
|
||
`tool_call`. The activity feed in the CLI and gateway also unwraps so you
|
||
see the underlying tool, not the bridge.
|
||
|
||
## When does it activate?
|
||
|
||
Tool Search uses **tiered disclosure**: the presence of *any* deferrable
|
||
(MCP/plugin) tool activates the bridge; what scales with catalog size is
|
||
how much of the catalog stays visible, not whether schemas defer.
|
||
|
||
| Tier | Condition | What the model sees |
|
||
| --- | --- | --- |
|
||
| **0** | No MCP/plugin tools | Every tool eager, no bridge. Pass-through. |
|
||
| **1** | Deferred catalog's listing fits the budget | Bridge + a skills-style manifest of every deferred tool (name + short description, degrading to names-only when over budget). Degradation is **per server**: when one oversized server (Cloudflare) is attached alongside small ones (Linear), the small servers keep their per-tool listings and only the oversized server collapses to a summary line. |
|
||
| **2** | Per-tool listing exceeds the budget even names-only for every server (e.g. Cloudflare's flat API surface alone: ~3,300 tools whose names are ~32K tokens) | Bare bridge + a one-line-per-server summary (server name + tool count), so the model knows which domains are reachable; individual tools are discoverable only through `tool_search`. |
|
||
|
||
The listing budget is `min(threshold_pct% of context, listing_max_tokens)`.
|
||
The decision is re-evaluated every time the tools array is built, so
|
||
adding or removing MCP servers mid-session moves the session between
|
||
tiers on the next assembly.
|
||
|
||
## Configuration
|
||
|
||
```yaml
|
||
tools:
|
||
tool_search:
|
||
enabled: auto # auto (default), on, or off
|
||
threshold_pct: 5 # listing budget as a percentage of context
|
||
search_default_limit: 5
|
||
max_search_limit: 25
|
||
listing: auto # embed a grouped name+description catalog manifest
|
||
listing_max_tokens: 4000
|
||
```
|
||
|
||
| Key | Default | Meaning |
|
||
| --- | --- | --- |
|
||
| `enabled` | `auto` | `auto`/`on` activate whenever at least one deferrable tool exists; `off` disables entirely (everything stays eager). `auto` is currently an alias of `on` — it is reserved for a future mode that inlines schemas when they fit the context and defers only when they don't. Pin `on` or `off` if you want today's behavior guaranteed across upgrades. |
|
||
| `threshold_pct` | `5` | Listing budget as a percentage of the active model's context length. Range 0–100. |
|
||
| `search_default_limit` | `5` | Hits returned per query when the model calls `tool_search` without a `limit`. |
|
||
| `max_search_limit` | `25` | Hard upper bound the model can request via `limit` (per query). Range 1–50. |
|
||
| `listing` | `auto` | Embed a skills-style manifest of every deferred tool (name + first sentence of its description, ≤60 chars, grouped by MCP server) in the `tool_search` bridge description. `auto` includes it when it fits the budget (falling back to names-only, then to the tier-2 server summary); `on`/`off` force either way. |
|
||
| `listing_max_tokens` | `4000` | Absolute cap on the embedded listing, regardless of context size. Range 200–60000. Large catalogs degrade to names-only or per-server summaries, keeping full schemas available through search. |
|
||
|
||
Per-call array caps are internal safety bounds, not configuration. Over-cap
|
||
calls return an error so the model can retry with a smaller batch.
|
||
|
||
### Why the listing exists
|
||
|
||
Without it, deferred capabilities are *invisible* — live benchmarking showed
|
||
models substituting visible core tools (running `gh` in the terminal instead
|
||
of searching for the deferred GitHub tool) or declaring a capability
|
||
nonexistent instead of calling `tool_search`. The listing applies the skills
|
||
pattern to tools: every capability stays discoverable by name at all times,
|
||
while full parameter schemas remain deferred. If the model sees the exact
|
||
tool name in the listing, it can skip `tool_search` and go straight to
|
||
`tool_describe`, saving a round trip.
|
||
|
||
You can also flip the legacy boolean shape:
|
||
|
||
```yaml
|
||
tools:
|
||
tool_search: true # equivalent to {enabled: auto}
|
||
```
|
||
|
||
## Connectors (remote tools)
|
||
|
||
When you are signed in to the Nous Portal, the bridge additionally reaches
|
||
**connectors** — remote tools served by the managed tool gateway. They are
|
||
never registered locally: `tool_search` sends each query to the gateway, adds
|
||
the gateway's hits to the local catalog as documents (tagged
|
||
`source: "connectors"`, named `connectors__<connector>__<tool>`), and ranks
|
||
both with the same BM25 pass and the same rarest-token rule, so `limit`
|
||
caps the group as a whole and a connector tool that answers the query is
|
||
never pushed out by local tools that share one word with it. The gateway
|
||
call is bounded at 30 seconds; a slow or dark gateway degrades to local
|
||
results only. `tool_describe` fetches connector schemas from the gateway,
|
||
and `tool_call` sends each connector entry in a batch as its own gateway
|
||
request, in input order (a tool name the gateway does not know under its
|
||
conventional slug is retried once under the literal slug, so an entry can
|
||
cost two requests). If a connector ever shipped both `GMAIL_X` and a literal
|
||
`X`, both would compose to `connectors__gmail__X`, which runs `GMAIL_X`;
|
||
search keeps that twin, drops the other, and logs a warning. Results splice back into the batch's original order
|
||
with recomputed counts.
|
||
|
||
```yaml
|
||
tools:
|
||
connectors:
|
||
enabled: true # false — never touch connector routes; the bridge
|
||
# behaves exactly as if the feature didn't exist
|
||
```
|
||
|
||
Signed out (or when the gateway does not serve connectors for your
|
||
account), everything above is invisible: local search behaves exactly as
|
||
described in the rest of this page, with no errors shown to the model.
|
||
|
||
A connector call that needs an account you haven't linked returns a
|
||
`CONNECTION_REQUIRED` error carrying a connect link. The `manage_connections`
|
||
tool (available on the same condition as the connector bridge) lists
|
||
connectors and their connection state, starts an authorization, and can wait
|
||
for the user to finish it; disconnecting an account is done by the user in
|
||
the Portal.
|
||
|
||
`tool_call` accepts a batch: `calls` is an array of `{name, arguments}`
|
||
entries (a single call is an array of one). Each connector entry in a batch
|
||
is dispatched as its own gateway request, one after another; local deferred
|
||
tools stay one entry per `tool_call`. Approvals settle per entry before
|
||
dispatch, and a `/stop` between entries leaves the unstarted ones unsent
|
||
(their slots report `INTERRUPTED`).
|
||
|
||
## When NOT to use it
|
||
|
||
Tool Search trades a fixed per-turn token cost (the three bridge tool
|
||
schemas plus the catalog listing) and at least one extra round trip on
|
||
cold tools (describe → call) for the savings on the deferred schemas.
|
||
At tier 1 the listing keeps every capability visible, so the discovery
|
||
round trip usually disappears — the model goes straight to
|
||
`tool_describe`. Live benchmarking showed the listing mode matching
|
||
eager loading's task success while costing less than the bare bridge.
|
||
|
||
If you want the old always-eager behavior for a small toolset, set
|
||
`enabled: off`.
|
||
|
||
## Trade-offs that don't go away
|
||
|
||
These come from the prompt-cache integrity invariant — they are inherent
|
||
to any progressive-disclosure design, not specific to this implementation:
|
||
|
||
- **One extra round trip on cold tools.** The first time the model needs
|
||
a deferred tool, it spends one or two extra model calls to find and
|
||
load the schema. The token savings on the static side are real, but a
|
||
portion is paid back at runtime.
|
||
- **No cache benefit on deferred schemas.** A loaded `tool_describe`
|
||
result enters the conversation history (so it does get cached on
|
||
subsequent turns) but it never benefits from the system-prompt cache
|
||
prefix.
|
||
- **No provider-native validation for deferred schemas.** `tool_describe`
|
||
lets the model read a deferred tool's schema, but the provider still sees
|
||
only the generic `tool_call.arguments` object. Hermes therefore coerces and
|
||
validates the underlying arguments locally before dispatch; the concrete
|
||
tool or MCP server remains responsible for schemas Hermes cannot safely
|
||
validate, such as malformed schemas or external references.
|
||
- **Model-quality dependence.** Tool Search assumes the model can write a
|
||
reasonable search query for the tool it wants. Smaller models do this
|
||
less well; the published Anthropic numbers (49% → 74% on Opus 4 with
|
||
vs. without tool search) show the upside but also that ~26 points of
|
||
accuracy is still retrieval failure.
|
||
- **Toolset edits invalidate cache.** Adding or removing a tool mid-
|
||
session changes the bridge tools' descriptions (which include the
|
||
count of deferred tools) and the catalog, so the prompt cache is
|
||
invalidated. This is the same trade-off as any toolset edit.
|
||
|
||
## Implementation details
|
||
|
||
- **Retrieval:** BM25 over tokenized tool name, source name (the MCP
|
||
server or plugin toolset the tool belongs to, so searching `"linear"`
|
||
finds that server's tools even when a tool's own name doesn't carry
|
||
the service), description, and parameter names, with Snowball
|
||
stemming (English) applied to both the index and the query so
|
||
morphological variants match ("issues" finds `create_issue`). A tool is
|
||
a result only if it contains the query's rarest token (the one in the
|
||
fewest tool documents, so the word that names the intent: `gmail`,
|
||
`github`, `incident`, not `send` or `create`). A query whose rarest
|
||
token appears in no tool returns an empty group with the connected
|
||
sources and a retry hint, instead of `limit` tools that share one
|
||
common word.
|
||
- **Parallel execution unwraps the bridge.** The batch planner decides
|
||
concurrency on the *underlying* tool of a `tool_call`, not on the
|
||
literal bridge name — so an MCP server opted in via
|
||
`supports_parallel_tool_calls: true` keeps its concurrency when its
|
||
tools are called through the bridge, and `tool_search` /
|
||
`tool_describe` lookups batch concurrently like any read-only tool.
|
||
- **Catalog is stateless across turns.** It rebuilds from the current
|
||
tool-defs list every assembly — no session-keyed `Map`. This avoids
|
||
the class of bug where a stored catalog drifts out of sync with the
|
||
live tool registry.
|
||
- **The catalog is scoped to the session's toolsets.** `tool_search`,
|
||
`tool_describe`, and `tool_call` only ever see and invoke tools the
|
||
session was actually granted. A subagent, kanban worker, or gateway
|
||
session restricted to a subset of toolsets cannot use the bridge to
|
||
discover or call a tool outside that subset — the deferred catalog is
|
||
the deferrable slice of the session's own enabled/disabled toolsets,
|
||
not the whole process registry.
|
||
- **No JS sandbox.** Hermes uses the simpler "structured tools" mode
|
||
(search / describe / call as plain functions). The JS-sandbox "code
|
||
mode" some other implementations offer is a large surface area; we
|
||
skip it.
|
||
|
||
## See also
|
||
|
||
- `tools/tool_search.py` — the implementation
|
||
- `tests/tools/test_tool_search.py` — the regression suite
|
||
- The `openclaw-tool-search-report` PDF in the original implementation
|
||
PR for the research that shaped the design
|