Agents
Four tools, every host
| Tool | Does | Returns |
|---|---|---|
web_search | one query or a batch, one provider, automatic selection, or all for fan-out | results with the provider diagnostics |
web_search_image | 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_providers | the running build, configuration, reachability and the complete capability matrix | listProviders() plus packageCapabilities |
Same four on every surface. 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.
AI SDK
import { generateText } from "ai";
import { readTool, searchImageTool, searchTool } from "@agntn/web/ai";
const { text } = await generateText({
model: yourModel,
tools: {
web_search: searchTool,
web_search_image: searchImageTool,
web_read: readTool,
},
prompt: "Find the latest TypeScript release notes",
});
ai and zod are optional peers. The main entry never imports them, only the /ai subpath does. providersTool is the fourth export.
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;
includeDomains?: string[];
excludeDomains?: string[];
sources?: string[];
categories?: string[];
category?: string;
startPublishedDate?: string;
endPublishedDate?: string;
};
A scalar search returns { provider, results, ignoredFilters, undeclaredFilters, pagination, metadata? }, automatic ones add attempts and failures, 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.
MCP
web mcp
claude mcp add web --scope user -- web mcp
The server uses the SDK's Server class with TypeBox schemas. Every tool advertises an output schema and returns its result under structuredContent.result, compact JSON stays in content for clients that only render text. 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
pi install git:github.com/agntn/web
The package ships the four 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 tools return the normalized objects, not prose. 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? The four tools are 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 "search_image":
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();
}
}