Guide

Getting Started

Install the package, run one search, and get the same result shape from twelve 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 twelve different ways. Pull one client per engine into an agent and you have twelve response shapes and twelve ways to misread a date.

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

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

Same call, any provider

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_KEYyes
FirecrawlFIRECRAWL_API_KEYyesyes
JinaJINA_API_KEYyesyes, key optional
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

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 normally becomes AuthError. 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; 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. 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.