# Websearch

URL: https://docs.hanzo.ai/docs/openapi/websearch

> A web search and a page fetch your agents can call.

Also for this capability: **API** · [CLI](https://docs.hanzo.ai/docs/cli/websearch) · [MCP](https://docs.hanzo.ai/docs/mcp-tools/websearch) · [SDKs](https://docs.hanzo.ai/docs/sdks)

A web search and a page fetch your agents can call.

> [All API references →](https://docs.hanzo.ai/docs/openapi) · [Get started →](https://docs.hanzo.ai/docs/guides)

|                |                                        |
| -------------- | -------------------------------------- |
| **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 →](https://github.com/hanzoai/hips/blob/7ed737180339cd05b5f2935eafe7f7b83d2449a2/HIPs/hip-1251-websearch-the-live-web.md)

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` | 1 operation  |

## Quickstart

```bash
export HANZO_API_KEY=sk-...   # console.hanzo.ai → API keys
```

Then the first call — a read that needs nothing but the key. `GET /v1/websearch/search`, operation `get_websearch_search`:

**CLI**

```bash
hanzo websearch search get
```

**SDK**

**.ts**

```ts
import { Configuration, WebsearchApi } from 'hanzoai';

const api = new WebsearchApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getWebsearchSearch();
```

**.py**

```python
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()
```

**.go**

```go
cfg := hanzoai.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := hanzoai.NewAPIClient(cfg)

resp, _, err := client.WebsearchAPI.GetWebsearchSearch(context.Background()).Execute()
if err != nil {
	return err
}
```

**.rs**

```rust
use hanzo_client::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?;
```

**.java**

```java
import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.WebsearchApi;

ApiClient client = new ApiClient();
client.setBearerToken(System.getenv("HANZO_API_KEY"));

var result = new WebsearchApi(client).getWebsearchSearch();
```

**HTTP**

```bash
curl https://api.hanzo.ai/v1/websearch/search \
  -H "Authorization: Bearer $HANZO_API_KEY"
```

**MCP**

MCP declares no tool for **websearch** — `tools/list` on `https://api.hanzo.ai/v1/mcp` names the products it does reach. Use HTTP or an SDK.

## Endpoints

| Endpoint                                                                                              | What it does                                                              |
| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [`POST /v1/websearch/scrape`](https://docs.hanzo.ai/docs/openapi/websearch/post-websearch-scrape)     | Fetch one page and get its extracted markdown, in the firecrawl envelope. |
| [`GET /v1/websearch/search`](https://docs.hanzo.ai/docs/openapi/websearch/get-websearch-search)       | Keyless web meta-search, in the SearXNG JSON envelope.                    |
| [`POST /v1/websearch/search`](https://docs.hanzo.ai/docs/openapi/websearch/post-websearch-search)     | Keyless web meta-search, in the SearXNG JSON envelope.                    |
| [`PUT /v1/websearch/search`](https://docs.hanzo.ai/docs/openapi/websearch/put-websearch-search)       | Keyless web meta-search, in the SearXNG JSON envelope.                    |
| [`PATCH /v1/websearch/search`](https://docs.hanzo.ai/docs/openapi/websearch/patch-websearch-search)   | Keyless web meta-search, in the SearXNG JSON envelope.                    |
| [`DELETE /v1/websearch/search`](https://docs.hanzo.ai/docs/openapi/websearch/delete-websearch-search) | Keyless web meta-search, in the SearXNG JSON envelope.                    |
| [`POST /v1/websearch`](https://docs.hanzo.ai/docs/openapi/websearch/search-web)                       | Search the live web                                                       |

---

[All Hanzo APIs](https://docs.hanzo.ai/docs/openapi) · [Interactive reference](https://docs.hanzo.ai/docs/openapi/reference)
