Skip to content
Chock
Web search (14 of 23)

Web search

An agent that can read a page it was given a link to still cannot find the link. The web_search tool is how it looks something up, and this page is how you say which engine answers.

The engine is yours, so it is named in your own file

The search block goes in ~/.config/chock/config.zon, beside your provider instances, and never in a project’s chock.zon.

.{
    .providers = .{
        .{ .name = "local", .kind = "openai-compat", .base_url = "http://127.0.0.1:5000/v1" },
    },
    .search = .{ .kind = "self_hosted", .base_url = "https://searx.example.org" },
}

The split is the same one the providers already keep. configuration.md has it in full: your file says where Chock talks to, and the project’s file says what an agent may do there. A search engine is your own infrastructure, so a repository you cloned this morning does not get to name it.

There is no project layer to fold in. A search block in a chock.zon is not read.

The kinds

Kind What it talks to State
self_hosted a SearXNG instance of your own built
api a keyed vendor built, Brave, Kagi and Exa
scrape a results page, read as HTML built, DuckDuckGo

Leave the block out and the tool is still offered. It answers that no engine is configured and says which file sets one, because “no engine” and “no results” must not read the same to an agent.

self_hosted

.search = .{ .kind = "self_hosted", .base_url = "https://searx.example.org" }

Chock calls GET {base_url}/search?q=...&format=json.

SearXNG answers 403 to that until you turn JSON on. The format is off by default. Add it to settings.yml:

search:
  formats:
    - html
    - json

Chock names this setting whenever a reply does not parse, because an instance that will not speak JSON and a query that found nothing otherwise read alike.

An http base URL is refused unless the host is loopback. A search query says what somebody is working on, and in the clear it says it to every hop on the way.

api

.search = .{
    .kind = "api",
    .provider = "brave",
    .base_url = "https://api.search.brave.com",
    .credential = "brave",
}

provider names the vendor. Each one answers in its own shape under its own field names, so the kind alone does not say enough to read a reply, and a reader that guessed would hand the agent zero results rather than an error.

A provider belongs to exactly one kind. brave, kagi and exa are api, and duckduckgo is scrape. Naming one beside a kind it does not belong to is refused, and the message says which kind it is for. self_hosted takes no provider at all: it means SearXNG, so its shape is already known.

credential is a name in the credential store, never the key itself. A key, token, api_key or secret field in this block is refused by name.

Only the api kind reads a credential. Naming one on self_hosted or scrape is refused rather than read and dropped, because a value that goes nowhere is how somebody comes to believe they configured something.

Put the key in the store with:

chock login --search brave

The name you pass is the name the block reads it under. There is no option that takes the key on the command line, for the reason configuration.md gives for a provider: a command line is visible through ps and it lands in your shell history.

Unlike a provider login, this one does not ask the engine whether the key works. A provider credential that does not work ends a session before it starts. A search key that does not work costs one tool call, which comes back naming the status the engine answered with.

Brave bounds a query to 600 characters and 75 words. Chock refuses a longer one before the request, and says which bound was passed, so the agent can shorten the query rather than read a 422.

Kagi is configured the same way, with its own base URL:

.search = .{
    .kind = "api",
    .provider = "kagi",
    .base_url = "https://kagi.com/api/v1",
    .credential = "kagi",
}

Kagi’s own documentation disagrees with itself about the credential header. Its API specification says Authorization: Bearer, and two of its help pages say the literal word Bot. This build sends Bearer, which the specification and the quick-start page both show. If Kagi answers 401 on a key you know is good, that disagreement is the first thing to suspect, and the refusal message says so.

Kagi bills per search, and a spent balance answers with the same 429 a rate limit does, so the refusal names both causes.

A title or snippet from Kagi can hold HTML entities such as ', because Kagi sends them and documents no way to turn them off. They reach the agent as written. Chock does not decode them: undoing markup that may not be there would corrupt a snippet that legitimately holds one.

Exa is the third, and it searches differently: it retrieves by meaning rather than by keyword, so a query that describes the kind of page you want often finds one that a keyword search would miss.

.search = .{
    .kind = "api",
    .provider = "exa",
    .base_url = "https://api.exa.ai",
    .credential = "exa",
}

Three things Chock asks Exa for, and three it refuses to.

Chock asks for highlights, which are extracts of the page in the page’s own words. That is Exa’s equivalent of the snippet Brave and Kagi return, and a result with no extract would be a bare link.

Chock never asks for text, which is the whole page. A search is approved under web.search; reading a page is approved under net.fetch, for the one host the agent named. Full page text arriving inside a search result would put a stranger’s page in front of the model under an approval that was given for something else. actions.md has both actions.

Chock never asks for summary, which is written by a model at Exa. It would be text with no source to attribute it to, and everything the model reads from a search has to be something a person can go and check.

Chock never asks for the deep search modes. Exa’s type accepts deep and deep-reasoning, each of which is Exa running an agent of its own: unbounded work outside this session’s budget, its policy table and its log. The mode is a constant in the request builder, so there is no way to name one.

Exa bills each search and answers 402 when an account is out of credit, which is a different fact from a bad key or a rate limit, so the refusal says which.

Its reply has two shapes: results, or a synthesis. Chock pins the request so the results shape is what comes back, and refuses a synthesis body rather than reading it as no results found.

scrape

.search = .{
    .kind = "scrape",
    .provider = "duckduckgo",
    .base_url = "https://html.duckduckgo.com",
}

No key, so no credential. Chock posts the query to DuckDuckGo’s own no-JavaScript results page and reads the result blocks out of the HTML.

It will never be the default, and not on principle. A results page changes shape without notice, so a scraper works until it quietly does not. Of the four harnesses read while this was designed, the one that scrapes needed a headless browser with stealth patches to keep it working.

What makes it usable anyway is that it tells you which way it failed. Three answers come back, and they do not look alike:

What happened What the agent is told
a bot challenge instead of results refused, naming the challenge
the page is not the shape this build reads refused, naming a shape change
the page loaded and matched nothing zero results, and no error

That third case is the one a scraper usually gets wrong. “The web has nothing about this” and “my parser broke” are the same sentence unless something tells them apart, so Chock checks for the marker DuckDuckGo puts on a genuine no-results page and treats its absence, with nothing parsed, as a fault.

Chock sends its own user agent and does not pretend to be a browser. If DuckDuckGo refuses that, you get the first row of the table, which is the honest answer. Claiming to be a browser you are not is the road that ends in stealth patches.

Expect this kind to need attention that self_hosted and api do not. If you want search that keeps working without you watching it, run SearXNG or buy a key.

Asking before it searches

web.search ships as ask, and the question reaches you at the tool call, while the agent waits. actions.md has every action name and what ships with it.

That timing is the whole reason it can be ask at all. Three actions are read before the work they govern, when nobody is waiting, and for those an ask means “never”. web.search is not one of them.

Allow it outright in a project that does a lot of reading:

.{ .policy = .{ .rules = .{ .{ .action = "web.search", .decision = "allow" } } } }

The engine’s own host is not gated separately. You chose the engine, so a project does not have to name it in net.fetch.

Reading a result

A search gives back a title, a URL, and a bounded snippet, at most 8 results. It never gives back page text: reading a page stays fetch_url’s job, which is what keeps reachability a decision made outside the sandbox.

Results are marked as text a stranger wrote, and they go through the same cleaning a fetched page does. A ranked list is more attacker shaped than an ordinary page, because whoever ranks decides what the agent reads first. That you chose the engine says something about where the bytes come from and nothing about who wrote them.

A result the agent then wants to read is a net.fetch on a host your policy probably does not name. Chock asks you about that host, once, for the host the agent named. Every redirect after it keeps the ordinary refusal. approvals.md says why the scope stops there.

Pinning it for an installation

An org policy bundle narrows what a user may choose, and never widens it:

.{
    .search = .{
        .kinds = .{ .self_hosted, .api },
        .base_url = "https://searx.corp.example",
    },
}

kinds is the set a user may pick from, so the bundle above forbids scrape. base_url pins the exact address. Either one is a refusal and never a silent narrowing: a kind and an address are categorical, so a user config that names one the bundle excludes is refused when the session starts, with the reason. org.md has the rest of the bundle.