feat(web): say when the agent can search, and mean it - #81
Merged
Conversation
The browser had no way to know whether `web_search` was there, and it never was: `zorp-web` depended on `zorp-agent` with no features, so the tool could not register under the browser however the environment was set up. An indicator reporting the state as it stood would have been permanently off. So three pieces. `zorp-web` gets its own opt-in `search` feature, off by default for the reason it is off on `zorp-agent`: starting a local web UI should not acquire an egress path by side effect. `GET /api/capabilities` reports whether the tool is really there. And the chat UI draws a small pill in the topbar when the answer is yes, and nothing at all when it is no. The answer is observed rather than re-derived. Registration and the availability query share one function in `zorp-agent`, the policy is read from the real `Policy` rather than from a copy of its reasoning, and the test that matters asserts the reported answer against `tool_names()`, which is registration itself. A hand-written copy of the three conditions would have been right the day it was written and silently wrong after that, and the failure mode is the worst one available here: a page saying nothing leaves this machine while something does. What it does not claim is that searching will work. Whether Tavily accepts the key is only knowable by spending a search, and this question gets asked on page loads. A separate route rather than another field on `/api/settings`, which is a PUT-able resource of things a person chose. Nothing here is choosable from a browser. The pill is a report and not a switch for the same reason. Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A read-only indicator in the chat UI that says when the agent can search
the web, plus the two things that had to exist first for it to ever say
yes.
Before this,
zorp-webdepended onzorp-agentwith no features, soweb_searchcould not register under the browser however the environmentwas set up. An indicator reporting the state as it stood would have been
permanently off.
Three pieces:
zorp-webgets an opt-insearchfeature(
search = ["zorp-agent/search"]), not on by default.searchis theonly built-in that sends anything off the machine, it is opt-in on
zorp-agentfor that reason, andresearchdeliberately does not pullit in either. Starting a local web UI should not acquire an egress path
by side effect. Run
cargo run -p zorp-web --features searchwhen youwant it.
GET /api/capabilitiesanswers{"web_search": {"available": bool, "detail": string}}.when it is no. The detail is its tooltip.
How availability is observed rather than guessed
Three separate things decide whether the tool exists, and the browser can
see none of them: the compile-time feature, the policy, and whether the
search provider found
ZORP_TAVILY_API_KEY. The last of those can changewithout a restart, so the question is answered per request rather than
cached at startup.
The answer is not a hand-written copy of those conditions:
register_builtins_filterednow callsweb_search_tool(), andweb_search_availability()calls the same function and throws the toolaway. They cannot drift.
Policy::decide, and the policyit reads is the one a turn uses:
turn::policy(own_port)was factoredout of
run_agentso the endpoint and a real turn ask the same one.zorp-agent/src/agent.rs, asserts thereported answer against
tool_names(), which is registration itself,and against the real
Policyrather than a copy of its reasoning. Itpasses in a build with the feature and in one without.
A hand-written copy would have been right the day it was written and
silently wrong afterwards, and the failure mode is the worst one
available here: a page saying nothing leaves this machine while something
does.
What it does not claim. That the tool is registered, not that
searching will work. Whether Tavily accepts the key is only knowable by
spending a search, and this question gets asked on page loads. The tests
say so out loud.
Why a separate route, not a field on
/api/settingsSettings are things a person chose and can PUT back. Nothing here is
choosable from a browser: it is a fact about the build, the code, and the
server's environment. Putting it beside the settings would invite an
attempt to set it. The pill is read-only for the same reason, and it is a
divrather than abuttonso nothing invites clicking it. There is atest asserting that.
The route sits behind the same token gate as everything else, with a test
for it: it reports what the build has and what the environment holds,
which is not for an unauthenticated caller when the server is reachable
off this machine.
Tests added
Rust,
zorp-web/tests/capabilities.rs:without_the_search_feature_web_search_is_unavailable(default build,and the reason names the feature)
with_the_feature_but_no_key_web_search_is_unavailable(the reasonnames
ZORP_TAVILY_API_KEY)with_the_feature_and_a_key_web_search_is_availablecapabilities_is_behind_the_token_gateRust,
zorp-agent/src/agent.rs:web_search_availability_agrees_with_the_gates_it_reports_onTypeScript,
web/test/search-indicator.test.ts, 8 cases: nothing drawnwith no answer, nothing drawn when unavailable, drawn with the label and
the detail when available, taken back down when availability goes away, a
hostile detail cannot become markup, the
[hidden]stylesheet rule exists(the
display: inline-flextrap the context meter documents), the markupis in
index.htmland starts hidden, and the indicator is not a control.Written failing first in both languages, and watched fail: the Rust one
on a 404 from the missing route, the TypeScript one on the missing module.
Commands run
cargo build --workspacecargo test --workspacecargo test -p zorp-web --features searchcargo test -p zorp-agent --features searchcargo test -p zorp-agent --features researchcargo clippy --workspace --exclude zorp-track --all-targets --lockedcargo clippy -p zorp-web --features search --all-targets --lockedcargo fmt --all --checkweb/:npm run checkweb/:npm testweb/:npm run buildAlso checked by hand, on spare ports, against a temporary config file:
--features searchwith a key set: the endpoint answersavailable: true, and the page draws the pill with its tooltip.available: falsenaming themissing feature, and the topbar has no pill at all.
Also in here
docs/DECISIONS.mdgets an entry (2026-08-21), and thezorp-searchbullet in
CLAUDE.mdandAGENTS.mdgains the browser half.README.mdand
web/README.mddocument the feature and the route.https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4