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 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
First search
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
| Provider | Env var | Search | Image | Read |
|---|---|---|---|---|
| Brave | BRAVE_API_KEY | yes | ||
| Context.dev | CONTEXT_DEV_API_KEY | yes | yes | |
| Exa | EXA_API_KEY | yes | ||
| Firecrawl | FIRECRAWL_API_KEY | yes | yes | |
| Jina | JINA_API_KEY | yes | yes, key optional | |
| Mojeek | MOJEEK_API_KEY | yes | ||
| SearXNG | none, self-hosted | yes | ||
| SerpAPI | SERPAPI_API_KEY | yes | yes | |
| SerpBase | SERPBASE_API_KEY | yes | ||
| Tavily | TAVILY_API_KEY | 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.
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.