Guide

Getting Started

Install the package, run one search, and get the same result shape from thirteen providers.
The public API, the provider list and the CLI flags can still change. Pin exact versions if you build on it now.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 thirteen different ways. Pull one client per engine into an agent and you have thirteen response shapes and thirteen ways to misread a date.

So @agntn/web puts thirteen 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

shell
pnpm add @agntn/web

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

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

// Reads EXA_API_KEY from process.env and imports the Exa adapter
const exa = await 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 loads none of the thirteen built-in providers. create("brave"), create("tavily") or create("searxng") imports that one adapter on the first call and resolves the class, no switch statement anywhere, which is why create() is async. The key comes from the provider's env var unless you pass apiKey yourself:

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

Same call, any provider

ts
const brave = await create("brave"); // BRAVE_API_KEY
const context = await create("context"); // CONTEXT_DEV_API_KEY
const jina = await create("jina"); // JINA_API_KEY
const mojeek = await create("mojeek"); // MOJEEK_API_KEY
const tavily = await create("tavily"); // TAVILY_API_KEY
const tinyfish = await create("tinyfish"); // TINYFISH_API_KEY

await brave.search("query"); // the same call on every one of them

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

Codex search reuses existing Pi, OMP, Codex and OpenCode OAuth logins rather than an API key. See OpenAI Codex for login discovery, refresh ownership and explicit overrides.

What ships

ProviderEnv varSearchImageRead
BraveBRAVE_API_KEYyes
Context.devCONTEXT_DEV_API_KEYyesyes
ExaEXA_API_KEYyesyes
FirecrawlFIRECRAWL_API_KEYyesyes
JinaJINA_API_KEYyesyes, key optional
MarginaliaMARGINALIA_API_KEY, or the shared public key when namedyes
MojeekMOJEEK_API_KEYyes
OpenAI CodexExisting login; optional OPENAI_CODEX_ACCESS_TOKENyes
SearXNGnone, self-hostedyes
SerpAPISERPAPI_API_KEYyesyes
SerpBaseSERPBASE_API_KEYyes
TavilyTAVILY_API_KEYyesyes
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

ts
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. And Reveal needs no provider at all, it lists what a page hides from all of them.

One result shape

ts
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

ts
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 normally becomes AuthError, and its message names the provider, since with automatic selection an agent sees nothing else that says which key was rejected. Spent credits are the exception, and every provider spells them differently: Context.dev's USAGE_EXCEEDED on a 401, Tavily's 432 and 433 usage limits, SerpBase's status: 1020 inside a 200. All of them become PaymentError, an HTTPError subclass that preserves the original status, redacted URL and body and allows automatic fallback. A 429 becomes RateLimitError with retryAfter and provider, and its message names the provider the same way; other failures use HTTPError or WebError. No response at all, DNS failure, refused connection or a timeout, is HTTPError with statusCode 0. body names the transport cause and cause keeps the original error. The message quotes at most a thousand characters of body, so a proxy's HTML error page never lands whole in failures or in what an agent reads. 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.