---
title: "ai.oz0/web"
description: "Search the web with the backend you choose, and fetch pages as Markdown inside the egress allowlist."
canonical_url: "https://docs.oz0.ai/build/plugins/verified/web"
---
# ai.oz0/web

> Search the web with the backend you choose, and fetch pages as Markdown inside the egress allowlist.

For: tenant administrators and plugin users.

`ai.oz0/web` provides web search and page retrieval through `oz0_web`. Both functions are read-only and open-world. Its `oz0-web-research` agent uses them to answer questions and cite the sources it read.

<table>
<thead>
  <tr>
    <th>
      Function
    </th>
    
    <th>
      Arguments
    </th>
    
    <th>
      Returns
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        oz0_web.search
      </code>
    </td>
    
    <td>
      <code>
        query
      </code>
      
      , <code>
        count
      </code>
      
       (1 to 20, default 8), <code>
        page
      </code>
    </td>
    
    <td>
      The backend's name and each result's title, URL and excerpt
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        oz0_web.fetch
      </code>
    </td>
    
    <td>
      <code>
        url
      </code>
      
      , <code>
        output
      </code>
      
       (<code>
        markdown
      </code>
      
      , <code>
        text
      </code>
      
       or <code>
        raw
      </code>
      
      ), <code>
        max_chars
      </code>
      
       (default 20 000), <code>
        max_bytes
      </code>
      
       (default 2 MB, at most 10 MB), and from 1.2.0 <code>
        view
      </code>
      
      , <code>
        budget
      </code>
      
       and <code>
        query
      </code>
    </td>
    
    <td>
      The final URL, status, type, title and content, whether it was cut short, and with a budget the fit note
    </td>
  </tr>
</tbody>
</table>

## Install

Its agent `oz0-web-research` asks for the tenant's `default` [model alias](https://docs.oz0.ai/spend/models-and-keys/aliases#the-standard-aliases) with tools, never a vendor's model, so it runs on whichever provider the tenant set up first; map it elsewhere with `oz0 model alias set default <provider>/<model>`.

```bash [Terminal]
oz0 plugin install hub:ai.oz0/web
```

Ask the agent:

```bash [Terminal]
oz0 run oz0-web-research "What changed in Python 3.13's free-threaded build?"
```

It answers with Answer, Sources (each address it used) and Not reached (each address a fetch refused, with
the reason and the command that allows it). Or give your own agent the functions in its definition; from v1.0 its
plugin also lists them under `uses:` in its manifest ([plugin manifest](https://docs.oz0.ai/reference/plugin-manifest)):

```yaml [agents/researcher.yaml]
tools: [web/oz0_web.search, web/oz0_web.fetch]   # web's functions, by their address
```

## Choose a search backend

The backend is two [settings](https://docs.oz0.ai/admin/secrets/settings#settings-are-not-secrets) of the tenant and, for a backend that takes one,
a key kept as an optional secret: the plugin starts without them, and `search` says what to set when none is.

<table>
<thead>
  <tr>
    <th>
      Name
    </th>
    
    <th>
      Kind
    </th>
    
    <th>
      Value
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        WEB_SEARCH_BACKEND
      </code>
    </td>
    
    <td>
      setting
    </td>
    
    <td>
      <code>
        searxng
      </code>
      
       or <code>
        brave
      </code>
      
      . Without it: <code>
        searxng
      </code>
      
       when only <code>
        WEB_SEARCH_URL
      </code>
      
       is set, <code>
        brave
      </code>
      
       when <code>
        WEB_SEARCH_KEY
      </code>
      
       is
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        WEB_SEARCH_URL
      </code>
    </td>
    
    <td>
      setting
    </td>
    
    <td>
      A <a href="https://docs.searxng.org/dev/search_api.html" rel="nofollow">
        SearXNG
      </a>
      
       instance's address; for Brave, another API address than <code>
        https://api.search.brave.com
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        WEB_SEARCH_KEY
      </code>
    </td>
    
    <td>
      secret
    </td>
    
    <td>
      Brave's subscription token (<a href="https://api-dashboard.search.brave.com/app/documentation/web-search/get-started" rel="nofollow">
        Brave Search API
      </a>
      
      ), or a bearer token for a SearXNG behind one
    </td>
  </tr>
</tbody>
</table>

```bash [Terminal]
oz0 setting set WEB_SEARCH_URL https://searx.internal.example
# or
oz0 setting set WEB_SEARCH_BACKEND brave
printf '%s' "$BRAVE_API_KEY" | oz0 secret set WEB_SEARCH_KEY
```

Settings are shown and never scanned for as secrets, so `fetch` reaches a page on the same address as the search
backend. Version 1.0.0 read the backend and its address as optional secrets; with 1.1.0 a tenant's old secrets of those
names become these settings by themselves, and stay secrets until you delete them (`oz0 setting list` notes which).

A SearXNG instance answers JSON only when `search.formats` in its `settings.yml` lists `json`. Changing a setting
restarts the plugin's server on the nodes; nothing else changes. The backend's address is the tenant's own setting, so
`search` does not check it against the egress allowlist; what it returns are links, and fetching them does.

## What fetch reaches

- Only hosts on the egress allowlist: the tenant's (`oz0 tenant egress`) and the plugin's, which
names only Brave's API. A fresh tenant's list is empty, so allow the sites agents may read:
`oz0 tenant egress default '*.wikipedia.org' docs.python.org`.
- Never a private network by name. A name that resolves to a private, loopback, link-local or metadata address is
refused; only an address the tenant's list names is reached, such as an intranet server at `10.0.0.7:8080`. The
connection goes to the address that was checked, so a name cannot resolve differently in between.
- Every redirect is checked again, up to five.
- robots.txt is read per site and kept for ten minutes; a path it disallows for `oz0-web` is not fetched. As
[RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html) says, a robots.txt that answers 4xx allows everything, and
one that answers 5xx or not at all disallows everything.
- Only text: HTML (converted to Markdown or text, without scripts, styles, navigation, headers, footers and hidden
elements), plain text, Markdown, CSV, JSON and XML. Anything else is refused by its type.

Inside a [sandbox](https://docs.oz0.ai/concepts/security/sandbox-tiers#sandboxes) (a job an outside event started) the tool server has no resolver:
fetch matches the name against the lists and sends the request, by name, through the node's egress proxy, which
applies the same lists and refuses the node's own, link-local and metadata addresses.

Every request asks the proxy for
public addresses only (the header `Oz0-Egress-Public-Only`), so there too a name that resolves to a private address is
refused, the tenant's names included, and only an address the tenant's list names is reached. Every refusal is a tool
error that names the reason, so the agent can say so.

## Long pages and a budget

From version 1.2.0 `fetch` follows the convention of [results that fit a budget](https://docs.oz0.ai/build/parts/tools/results-and-files#results-that-fit-a-budget):
with `budget` (tokens), `view` or `query`, it reads the whole page (up to `max_bytes`) and answers with the biggest
view of its Markdown that fits:

<table>
<thead>
  <tr>
    <th>
      View
    </th>
    
    <th>
      What comes back
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        full
      </code>
    </td>
    
    <td>
      The whole page as Markdown
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        section
      </code>
    </td>
    
    <td>
      The part under the heading <code>
        query
      </code>
      
       names, with the headings below it, or the lines of a range such as <code>
        40-90
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        summary
      </code>
    </td>
    
    <td>
      The address and title, then each heading of the page with its first sentence
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        stub
      </code>
    </td>
    
    <td>
      The address, title and size, and the page's headings; when nothing else fits, with how to ask for more
    </td>
  </tr>
</tbody>
</table>

The answer's `fit` field (and the result's `_meta` under `oz0/fit`) names the view, the budget, the page's size and the answer's in tokens, and what was left out. The runtime gives every call of an agent's model a budget when the model leaves it out, so an agent gets the biggest view that fits what is left of its context, at most 8,000 tokens. A page without headings (plain text, JSON) is cut into ranges of lines. `max_chars`, when given, is the bound the view is chosen within, so the fit note describes what came back.

A fitted answer holds at most 50,000 tokens and 200,000 characters whatever the budget, and at most 8 characters for each token of the budget, so a page made of white space is not mistaken for a short one: past a bound, `truncated` is set and the answer is a smaller view. A call without `view`, `budget` or `query` (a flow step, or the tool called by hand) answers as 1.1.0 did: the first `max_chars` characters.

```json
{"url": "https://docs.example.com/guide", "budget": 2000, "query": "Install"}
```


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
