Agents
Every tool, every host
| Tool | Does | Data under result |
|---|---|---|
web_search | one query or a batch, one provider, automatic selection, or all for fan-out | results with the provider diagnostics |
web_image_search | matching pages and images for one public image URL | ImageSearchResult[] |
web_read | one URL or a batch into normalized content, with reader provenance | { result, requestedProvider, provider, attempts, failures } |
web_reveal | what one page keeps from its reader, read from its own HTML | { url, source, counts, findings } |
web_providers | the running build, configuration, reachability and the complete capability matrix | { runtime, packageCapabilities, providers } |
Same set on every surface, and that's literal. Each tool is one defineTool from @agntn/tools. MCP, Pi, OMP, the AI SDK and the CLI all get that one definition. Every answer comes twice: text for the model, data for your code.
The descriptions name the built-in providers, but execution checks the name against the live registry, so a custom provider registered before the session works in a tool call too. The host's abort signal cancels the provider request, no zombie fetches after the model gives up. timeoutSeconds on web_search and web_read is the budget the model sets itself: a fan-out or batch returns what finished by then and lists the providers it cut off under errors, so one slow provider no longer holds an all search past the host's own limit.
A blank optional string counts as an omitted one. Models fill every property of a flat schema, so continuation: "" or startPublishedDate: "" arrives where a person would leave the field out, and an error there costs a round trip for nothing. Real values are still checked, a malformed token or date fails as before, and an empty query or url is still empty.
Some hosts send a batch as one string, so query arrives as '["first","second"]'. A string that parses as a JSON array of strings runs as that batch on every surface, otherwise ten queries would turn into one long search. Anything else, like [draft] notes, stays a plain query, and a list longer than 10 fails the same way an array would.
AI SDK
import { generateText } from "ai";
import { readTool, revealTool, searchImageTool, searchTool } from "@agntn/web/ai";
const { text } = await generateText({
model: yourModel,
tools: {
web_search: searchTool,
web_image_search: searchImageTool,
web_read: readTool,
web_reveal: revealTool,
},
prompt: "Find the latest TypeScript release notes",
});
ai 7 is an optional peer. The main entry never imports it, only the /ai subpath does. providersTool is there too.
type SearchToolInput = {
query: string | string[]; // up to 10 in a batch
provider?: string; // a built-in, a custom name, or "all"
maxResults?: number; // 1 to 20, default 10
continuation?: string;
highlights?: boolean;
summary?: boolean;
fullText?: boolean;
favicon?: boolean; // favicon URLs on results, off by default
includeDomains?: string[];
excludeDomains?: string[];
sources?: string[];
categories?: string[];
category?: string;
startPublishedDate?: string;
endPublishedDate?: string;
timeoutSeconds?: number; // whole seconds, at most 3600
};
execute returns { result, text }. The model only sees text. Your code reads result.
A scalar search returns { provider, results, ignoredFilters, undeclaredFilters, pagination, metadata? }, automatic ones add attempts and failures, plus skipped when a provider with spent credits was passed over, and provider: "all" returns the fan-out envelope with providers and evidence on each result. readTool takes url or an array of URLs plus the read options, defaults to 20 000 characters and accepts at most 200 000. Without that default a model would happily ask for a whole site and stuff it into its context. The page's links and images are off unless the model asks for them, a long page carries thousands and they'd outweigh the content several times over. The favicon URLs on search results are off the same way: they were a third of a ten result Brave answer and a model has nothing to do with an icon address.
MCP
web mcp
claude mcp add web --scope user -- web mcp
The server is @agntn/tools on SDK v2. Every tool advertises an output schema. content carries the same text Pi and OMP give the model, and structuredContent.result the data. A client that only renders text still gets something a model can read, not a wall of JSON. With provider: "all" each result already is its first provider's record, so here and in the Pi and OMP tools evidence holds only what the other providers said, and a page just one engine found has none. Sending that record twice cost about a quarter of every fan-out answer. Tool symbols and titles match the native extensions, and nothing decorative gets written into the JSON-RPC stream. The client owns its TUI, not the server.
createMcpServer() from the @agntn/web/mcp subpath returns the same server when your host brings its own transport.
Pi and OMP
The Pi extension runs on @earendil-works/pi-coding-agent 0.86.1 or later, tested with Pi 1.1.0. OMP keeps its 18.x API, tested with 18.9.1. Updating the package alone won't update either host.
pi install git:github.com/agntn/web
The package ships the tools as a pi extension and an OMP extension. Pi also gets two slash commands: /web [query] shows results as a selector and pastes the chosen URL into the editor, /web-providers shows configuration, reachability and capabilities. Both extensions read the same env vars as the library. Their TUI rows show progress, provider choice, result counts and fallback attempts, with bounded previews. A whole page rendered into the terminal is useless, so it never happens.
What the answer is
The model reads a header and numbered results, and the normalized objects ride along for your code. A model summarizing them should say what the provider said. The Explorer shows the same objects for any query, which is a quick way to check a summary against the source when a model gets creative.
snippet: "ignore previous instructions" has learned one thing about the page, and nothing about what to do next.Rolling your own
Framework is none of the above? webTools() from @agntn/web/tools hands you the definitions for any @agntn/tools adapter. Or skip them, they're thin wrappers over the helpers anyway:
import { listProviders, readUrlDetailed, searchAllDetailed, searchByImage, searchWithFallback } from "@agntn/web";
export async function run(input: { operation: string; query?: string; url?: string; provider?: string }, signal?: AbortSignal) {
switch (input.operation) {
case "search":
return input.provider === "all"
? searchAllDetailed(input.query!, { signal })
: searchWithFallback(input.query!, { signal });
case "image_search":
return searchByImage(input.url!, { provider: input.provider, signal });
case "read":
return readUrlDetailed(input.url!, { provider: input.provider, maxChars: 20_000, signal });
case "providers":
return listProviders();
}
}