Getting Started
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
pnpm add @agntn/web
The AI SDK tools on the /ai subpath need ai 7 next to them:
pnpm add ai
First search
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:
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
| Provider | Env var | Search | Image | Read |
|---|---|---|---|---|
| Brave | BRAVE_API_KEY | yes | ||
| Context.dev | CONTEXT_DEV_API_KEY | yes | yes | |
| Exa | EXA_API_KEY | yes | yes | |
| Firecrawl | FIRECRAWL_API_KEY | yes | yes | |
| Jina | JINA_API_KEY | yes | yes, key optional | |
| Marginalia | MARGINALIA_API_KEY, or the shared public key when named | yes | ||
| Mojeek | MOJEEK_API_KEY | yes | ||
| OpenAI Codex | Existing login; optional OPENAI_CODEX_ACCESS_TOKEN | yes | ||
| SearXNG | none, self-hosted | yes | ||
| SerpAPI | SERPAPI_API_KEY | yes | yes | |
| SerpBase | SERPBASE_API_KEY | yes | ||
| Tavily | TAVILY_API_KEY | yes | yes | |
| TinyFish | TINYFISH_API_KEY | yes | yes |
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. And Reveal needs no provider at all, it lists what a page hides from all of them.
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, 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.