Guide

Agents

The same tools for the Vercel AI SDK, the MCP server, Pi and OMP, and what an agent should and should not do with the answer.

Every tool, every host

ToolDoesData under result
web_searchone query or a batch, one provider, automatic selection, or all for fan-outresults with the provider diagnostics
web_image_searchmatching pages and images for one public image URLImageSearchResult[]
web_readone URL or a batch into normalized content, with reader provenance{ result, requestedProvider, provider, attempts, failures }
web_revealwhat one page keeps from its reader, read from its own HTML{ url, source, counts, findings }
web_providersthe 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

ts
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.

ts
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

shell
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.

shell
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.

Titles, snippets, highlights and page content are published by whoever owns the page. They are data to report, not instructions to follow. An agent that reads 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:

ts
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();
  }
}