Websearch
A web search and a page fetch your agents can call.
Also for this capability: API · CLI · MCP · SDKs
A web search and a page fetch your agents can call.
| Base URL | https://api.hanzo.ai |
| Operations | 7 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1251 · Websearch — The Live Web — Draft · read the specification →
Websearch is a web search and a page fetch an agent can call: a meta-search
over public engines — keyless by default, with paid vendor engines joining the
blend only where the operator holds their keys — and a fetch-and-extract that
returns a page as markdown, both in-process Go with no search SaaS and no
crawler pod. hanzoai/cloud apps/websearch is the implementation and
/v1/websearch is its address.
This HIP declares the capability: what it stores (nothing), the target surface, which operations are typed and why two cannot be, and the two gates that keep it from being an open proxy.
Motivation
This surface is the fleet's only path to the live internet, and it exists twice over in compatibility: the chat server's search pipeline accepts only two self-hostable provider contracts — a SearXNG-shaped search and a Firecrawl-shaped scrape — so the capability serves both wire shapes natively rather than deploying either upstream. The predecessor design proxied to services that were down or never existed and answered 200 anyway; in-process is the shape with no pod to be down.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 The store
The capability owns none. Its only state is an in-process bounded cache
(apps/websearch/cache.go:31). A scraped page that persists does so under the
caller's crawl scope, in crawl's storage — never here.
§2 The address
The target surface is /v1/websearch: the typed native door at the root, the
SearXNG-shaped search at /v1/websearch/search, and the Firecrawl-shaped
scrape at /v1/websearch/scrape. Today scrape answers at a second root,
/v1/scrape; the pair is carried by cloud's openapi/misfiled.txt:93 and
closes by fold — no store, so no boundary to split on, and Firecrawl is not in
HIP-0139 §3.2's closed exemption list. The fold is a compat-wire break, not a
client edit: Firecrawl clients compose {base}/v1/scrape
(apps/websearch/websearch.go:13), so no base-URL setting reaches the folded
address, and the chat server's scraper provider MUST be re-pointed in the same
release or lose scraping. A survival of the /v1/scrape spelling would be a
HIP-0139 amendment argued here; this HIP does not argue it.
§3 Operations
POST /v1/websearch is the one typed operation (search_web,
apps/websearch/websearch.go:338) — the tool, client method and command every
projection carries. The two compat doors are declared with prose beside the
route and cannot be values, each for a measured reason
(apps/websearch/typed_wire_test.go): the search door answers every method the
router knows and its write arms read the query string while ignoring the body,
which a typed operation refuses; the scrape door deliberately answers
200 {"success":false} to a malformed body and caps the read at 1 MiB, because
Firecrawl clients read data.success, not the status line. Both run the same
search and the same fetch as the typed door — the adapter's frozen contract
binds the adapter, never the capability.
§4 Tenancy and the two gates
Search admits either a validated principal (HIP-0026) or the shared service
key WEBSEARCH_API_KEY as X-API-Key — the chat server's service-to-service
path. Scrape requires the key as a Bearer. An unset key MUST answer 503 and a
missing or mismatched key 401; a request with a validated principal never needs
the key. Neither door is ever an open proxy.
§5 Metering, events, telemetry, stage
Metered, and the unit is one search answered by a paid engine
(plugin/websearch/main.go, cloud.Metered): most engines scrape public
result pages and cost nothing, but Brave and Mojeek spend a vendor's money per
query, so a search that reaches them carries a fee —
WEBSEARCH_FEE_CENTS_BRAVE / _MOJEEK over WEBSEARCH_FEE_CENTS, defaulting
to one cent (apps/websearch/meter.go). A credential is what makes an engine
paid: a keyless deployment is byte-for-byte the free tier, and an org out of
funds loses the paid engines from the blend rather than the search — the
failure closes on spend, never on answer. It publishes no events on the bus.
Beyond the request span, it logs each engine's outcome as one of three states — answered, blind,
down (apps/websearch/outcome.go) — because a metasearch whose engine fails
soft is indistinguishable from calm, and blindness must be a fact an operator
can read. Stage: ga.
§6 Upstream
The capability derives from none. It implements two wire shapes it does not
own — SearXNG's /search?format=json envelope and Firecrawl's scrape
envelope — as compatibility contracts, forking neither project. The engines it
queries are public search engines over native Go HTTP
(apps/websearch/search.go), keyless by default; a Brave or Mojeek credential
the operator holds adds that vendor's API to the blend
(apps/websearch/brave.go, apps/websearch/mojeek_api.go). No search SaaS is
embedded or forked.
Rationale
The alternative was the one already tried: proxy to a SearXNG pod and a crawler
service. It cost a deployment per contract and failed silently — the crawler's
DNS name did not resolve while the surface answered 200 with success:false
inside. In-process serving keeps one binary answerable for the whole path, and
the three-state outcome log is what makes its failures visible instead of soft.
Security Considerations
The wrong implementation is an open proxy: an unauthenticated scrape door is server-side request forgery against anything the cluster egress can reach, paid for by us. The two gates close it — principal or key for search, key alone for scrape, 503 when the key is unset rather than open-when-unconfigured. The remaining exposures are the shared key itself, which is KMS-sourced and never logged, and silent engine blindness, which the outcome states exist to surface.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | websearch at its own prefix | 7 operations |
| CLI | hanzo websearch … | 7 of 7 |
| SDK | WebsearchApi in every published client | 7 methods |
| MCP | tool websearch on https://api.hanzo.ai/v1/mcp | 7 operations, 3 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/websearch/search, operation get_websearch_search:
hanzo websearch search getimport { Configuration, WebsearchApi } from 'hanzoai';
const api = new WebsearchApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getWebsearchSearch();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import WebsearchApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = WebsearchApi(client).get_websearch_search()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.WebsearchAPI.GetWebsearchSearch(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, websearch_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = websearch_api::get_websearch_search(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.WebsearchApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new WebsearchApi(client).getWebsearchSearch();curl https://api.hanzo.ai/v1/websearch/search \
-H "Authorization: Bearer $HANZO_API_KEY"Tool websearch, op get_websearch_search — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "websearch",
"arguments": {
"op": "get_websearch_search",
"input": {}
}
}
}'Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/websearch/scrape | Fetch one page and get its extracted markdown, in the firecrawl envelope. |
GET /v1/websearch/search | Keyless web meta-search, in the SearXNG JSON envelope. |
POST /v1/websearch/search | Keyless web meta-search, in the SearXNG JSON envelope. |
PUT /v1/websearch/search | Keyless web meta-search, in the SearXNG JSON envelope. |
PATCH /v1/websearch/search | Keyless web meta-search, in the SearXNG JSON envelope. |
DELETE /v1/websearch/search | Keyless web meta-search, in the SearXNG JSON envelope. |
POST /v1/websearch | Search the live web |
How is this guide?