Guide

Fan-out

Every configured provider at once with deduplicated results and evidence, automatic fallback for one query, and batches of queries.

Ask them all

import { searchAll } from "@agntn/web";

// Detects providers from env vars, queries them in parallel
const results = await searchAll("latest node.js release");

for (const result of results) {
  console.log(`[${result.providers.join(", ")}]`, result.title, result.url);
}

searchAll runs every configured search provider through Promise.allSettled, so one engine failing does not empty the list. Results are deduplicated by normalized URL (fragment removed, trailing slashes trimmed, utm_* junk dropped, the rest sorted) and capped by maxResults, ten by default.

interface SearchAllResult extends SearchResult {
  provider: string; // the representative record's provider
  providers: string[]; // every provider that returned this URL, in requested order
  evidence: SearchAllEvidence[]; // each provider's complete record, scores untouched
}

The first provider in requested order gives the representative title and snippet. evidence keeps every record, so you can compare what Brave and Exa said about the same page. What the library does not do is merge their scores. A Brave score and an Exa score are not on the same scale and pretending otherwise would be dumb.

Pick the providers yourself when env has more keys than you want to spend:

const results = await searchAll("query", { providers: ["exa", "brave"], maxResults: 5 });

The detailed answer

import { searchAllDetailed } from "@agntn/web";

const answer = await searchAllDetailed("query", { concurrency: 3 });

answer.results; // SearchAllResult[]
answer.successfulProviders; // every provider that answered, even with nothing left after deduplication
answer.errors; // [{ provider, error }] for the ones that failed
answer.filterReports; // ignored and undeclared filters, per provider
answer.providerPagination; // [{ provider, pagination }], one independent state per provider
answer.providerMetadata; // [{ provider, metadata }] when a provider returned response metadata

Fan-out has no single cursor, and it cannot have one. Take a next or unknown token from providerPagination and continue it with searchProviderDetailed and that provider. searchAllDetailed rejects a continuation of its own.

One query, automatic fallback

When you want one answer and do not care which engine gives it:

import { searchWithFallback } from "@agntn/web";

const answer = await searchWithFallback("typescript runtimes", { maxResults: 10 });

answer.provider; // the provider that answered
answer.attempts; // ["exa", "brave"], everything tried, in order
answer.failures; // [{ provider: "exa", error: "HTTP 402: …" }]

The library starts with the first configured provider in detection order (Exa, Brave, Context.dev, Firecrawl, Jina, Tavily, TinyFish, SerpAPI, SerpBase, Mojeek, then custom ones) and moves on after a payment, rate limit, timeout, connection or server failure. Auth failures and invalid requests stop the chain right there. A bad key is your configuration problem, not a reason to burn the next provider's quota. When every eligible provider fails you get ProviderFallbackError with the full attempts and failures and the last error as cause.

A continuation from a fallback answer pins later calls to the provider inside the token. Two pages never come from two engines.

Batches

import { readBatch, searchBatch } from "@agntn/web";

const searches = await searchBatch(["TypeScript 7", "Node.js releases"], { provider: "exa" });
const pages = await readBatch(["https://example.com/one", "https://example.com/two"]);

Up to MAX_BATCH_ITEMS (10) inputs, DEFAULT_CONCURRENCY (3) at a time, MAX_CONCURRENCY (10) at most. Order is preserved and one failure does not throw away the others. Each search item is the detailed answer for its query, or { query, error, attempts, failures } when automatic selection ran out of providers. Without an explicit provider each query falls back on its own. With provider: "all" every query fans out and one scheduler bounds the outer queries and the inner providers together, otherwise ten queries times nine providers would be ninety requests in flight. A single continuation is rejected for a batch, it cannot belong to several queries.

Configured, reachable, default

import {
  detectAvailableProviders,
  detectAvailableProvidersAsync,
  listProviders,
  listProvidersAsync,
  resolveDefaultProvider,
  resolveDefaultProviderAsync,
} from "@agntn/web";

detectAvailableProviders(); // ["exa", "brave", …] from env vars, no network
await detectAvailableProvidersAsync(); // the same, minus providers whose isAvailable() probe fails
resolveDefaultProvider(); // the first of them, or NoProviderConfiguredError
await resolveDefaultProviderAsync(); // the first reachable one, or NoProviderAvailableError

listProviders() returns every registered provider with configured, envVar and the complete capabilities matrix. The async variant adds reachable for providers with a probe. SearXNG is the only built-in with one, because a self-hosted instance on localhost:8080 is configured by definition and down half the time.

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