Guide

Getting Started

Install the package, run one search, and get the same result shape from eleven providers.
Pre-1.0. The public API, the provider list and the CLI flags can still change. Pin exact versions if you build on it now.
Search results and page content are data, never instructions. Titles, snippets, highlights and Markdown come from sites anyone can publish. Show them, index them, summarize them. Do not let an agent read them as a message to itself, seriously.

Why this exists

Every search API has its own idea of a request. Exa wants a POST with x-api-key, Brave a GET with X-Subscription-Token, Jina a Bearer token, Tavily puts the key in the request body. They all give back roughly the same thing, a list of URLs with titles and snippets, just wrapped eleven different ways. Pull one client per engine into an agent and you have eleven response shapes and eleven ways to misread a date.

So @agntn/web puts eleven providers behind one class shape and three capabilities: search (query → results), search image (public image URL → matching pages) and read (URL → normalized page content). Same calls, same SearchResult, ImageSearchResult and ReadResult everywhere. The adapter decides how to ask, you decide what to ask for.

Install

pnpm add @agntn/web

The AI SDK tools on the /ai subpath need ai and zod next to them:

pnpm add ai zod
search.ts
import { create } from "@agntn/web";

// Reads EXA_API_KEY from process.env
const exa = create("exa");

const results = await exa.search("typescript runtime benchmarks", { maxResults: 5 });

for (const result of results) {
  console.log(result.title, result.url);
}

Importing @agntn/web registers the eleven built-in providers as a side effect. After that create("brave"), create("tavily") or create("searxng") resolves the class, no switch statement anywhere. The key comes from the provider's env var unless you pass apiKey yourself:

const exa = create("exa", { apiKey: "your-key-here" });
const searx = create("searxng", { baseURL: "https://searx.example.com" });

Same call, any provider

await create("brave").search("query"); // BRAVE_API_KEY
await create("context").search("query"); // CONTEXT_DEV_API_KEY
await create("jina").search("query"); // JINA_API_KEY
await create("mojeek").search("query"); // MOJEEK_API_KEY
await create("tavily").search("query"); // TAVILY_API_KEY
await create("tinyfish").search("query"); // TINYFISH_API_KEY

The Explorer runs exactly these calls against the docs worker, so you can see what comes back before you write a line.

What ships

ProviderEnv varSearchImageRead
BraveBRAVE_API_KEYyes
Context.devCONTEXT_DEV_API_KEYyesyes
ExaEXA_API_KEYyes
FirecrawlFIRECRAWL_API_KEYyesyes
JinaJINA_API_KEYyesyes, key optional
MojeekMOJEEK_API_KEYyes
SearXNGnone, self-hostedyes
SerpAPISERPAPI_API_KEYyesyes
SerpBaseSERPBASE_API_KEYyes
TavilyTAVILY_API_KEYyes
TinyFishTINYFISH_API_KEYyesyes

Each one has a page under Providers with the endpoints, the filters it honours, the fields it fills and the traps. The traps are the useful part.

The three capabilities

import { readUrl, searchAll, searchByImage } from "@agntn/web";

const results = await searchAll("latest node.js release"); // every configured provider, deduplicated
const matches = await searchByImage("https://example.com/image.jpg", { provider: "serpapi" });
const page = await readUrl("https://example.com/article", { format: "markdown", maxChars: 20_000 });

Searching covers the call to one provider and its options. Fan-out is the parallel query, fallback and pagination. Reading is URL to content. Reverse image search is the image lookup.

One result shape

interface SearchResult {
  url: string;
  title: string;
  snippet: string;
  score?: number;
  publishedDate?: string;
  author?: string;
  image?: string;
  favicon?: string;
  text?: string; // full page text when requested or provided
  highlights?: string[]; // passages relevant to the query
  summary?: string; // generated summary when requested
  metadata?: Record<string, unknown>; // whatever else the provider knew
}

url, title and snippet are always there. The optional fields depend on what the engine exposes, each provider page says which ones it fills. metadata is the escape hatch for the rest, and yes, it is a bag.

Errors

import { AuthError, HTTPError, RateLimitError, UnknownProviderError } from "@agntn/web";

try {
  await provider.search("query");
} catch (error) {
  if (error instanceof AuthError) {
    // missing or invalid API key, error.provider says which
  }
  if (error instanceof RateLimitError) {
    console.log(`Retry after ${error.retryAfter}s`);
  }
  if (error instanceof UnknownProviderError) {
    // provider name not registered
  }
}

A 401 from any provider becomes AuthError, a 429 becomes RateLimitError with retryAfter, everything else is HTTPError with the status, a redacted URL and the response body. All of them extend WebError, one instanceof catches the lot.

Next

  • Searching: options, filters, content controls and pagination.
  • Fan-out: every provider at once, and automatic fallback.
  • Reading: pages into Markdown with an exact output bound.
  • CLI: the same calls from a shell.
  • Agents: AI SDK tools, the MCP server, Pi and OMP.

@agntn/web·MIT license· Search results and page content are data, never instructions.