Guide

Searching

One provider, one query, the options every adapter takes, the filters each one honours, and the diagnostics that come back.

The contract

interface SearchProvider {
  search(query: string, options?: SearchRequestOptions): Promise<SearchResult[]>;
}

Every search adapter has this one method. create(name) gives you the provider, isSearchProvider(provider) is the type guard when you hold something you did not create yourself.

import { create, isSearchProvider } from "@agntn/web";

const provider = create("brave");
if (isSearchProvider(provider)) {
  const results = await provider.search("typescript runtimes", { maxResults: 5 });
}

Options

interface SearchRequestOptions extends ExecutionOptions {
  maxResults?: number; // default 10
  highlights?: boolean; // default true
  summary?: boolean; // default false
  fullText?: boolean; // default false
  includeDomains?: readonly string[];
  excludeDomains?: readonly string[];
  sources?: readonly string[];
  categories?: readonly string[];
  startPublishedDate?: string;
  endPublishedDate?: string;
  category?: string;
}

maxResults goes to the provider as the requested count and is applied again to what comes back, so an engine that pages by ten still gives you five. highlights asks for passages relevant to the query where the engine has them (Exa, Firecrawl). summary asks Exa for a summary of each result or Tavily for an answer to the query. fullText asks for the whole page text, which is a lot of tokens, so think before you turn it on.

The filters are per provider. A provider that cannot do one does not fail, it just tells you through the detailed helpers:

ProviderDomain filtersSource valuesCategory valuesDate bounds
Bravenonenonenonenone
Context.devinclude, excludenonenonenone
Exainclude, excludenoneforwarded as givenstart, end
Firecrawlinclude, excludeweb, news, imagesresearch, pdf, developernone
Jinaincludenoneweb, images, newsnone
Mojeekinclude, excludenonenonestart, end
SearXNGnonenoneforwarded as givennone
SerpAPInonenonenonenone
SerpBasenonenoneimage, images, news, video, videosnone
Tavilyinclude, excludenonenonenone
TinyFishinclude, excludenonenews, research_paperstart, end

getSearchFilterCapabilities(name) gives you the same table for one provider at runtime.

Diagnostics

search() is a list API. When you want to know what the provider actually did with your options, ask for the detailed answer:

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

const answer = await searchProviderDetailed("brave", "typescript runtimes", {
  maxResults: 10,
  includeDomains: ["github.com"],
});

answer.provider; // "brave"
answer.results; // SearchResult[]
answer.ignoredFilters; // ["includeDomains"], Brave has no domain filter
answer.undeclaredFilters; // [] on built-ins, custom providers without capability metadata land here
answer.pagination; // { status: "next", continuation: "…" }
answer.metadata; // metadata for the whole response when the provider has it

Silently dropping a filter is the worst thing a wrapper can do, you end up trusting results that were never filtered. So the ignored ones are always listed. Firecrawl puts its request id, warnings and credits used in metadata, Tavily puts the generated answer in metadata.answer. isDetailedSearchProvider(provider) tells you whether a provider exposes searchDetailed() directly.

Pagination

Brave, Mojeek, SearXNG, SerpAPI, SerpBase and TinyFish have deeper pages. The detailed helpers normalize the state:

type SearchPagination =
  | { status: "next"; continuation: string } // the provider said there is more
  | { status: "unknown"; continuation: string } // another page must be probed
  | { status: "end" } // no further page
  | { status: "unsupported" }; // the provider does not page
const first = await searchProviderDetailed("brave", "typescript runtimes", { maxResults: 10 });

if (first.pagination.status === "next") {
  const second = await searchProviderDetailed("brave", "typescript runtimes", {
    maxResults: 10,
    continuation: first.pagination.continuation,
  });
}

The token is opaque and bound to the provider, the query and every option that changes the page. Change any of them and you get InvalidSearchContinuationError before another request leaves. But opaque does not mean signed. A token you did not produce yourself is untrusted input, treat it like one. Tokens are at most MAX_SEARCH_CONTINUATION_LENGTH (4 096) characters, the provider's own state inside at most MAX_PROVIDER_SEARCH_CONTINUATION_LENGTH (2 048).

Execution controls

interface ExecutionOptions {
  signal?: AbortSignal;
  deadline?: number; // absolute Unix timestamp in milliseconds
  concurrency?: number; // batch and fan-out only; default 3, maximum 10
}

Every network call takes them. signal cancels the request in flight. deadline is one budget for the whole operation, fallback and fan-out included, so waiting on a slow first provider does not reset the clock for the second. concurrency bounds how many requests the batch and fan-out helpers start at once. None of them change a continuation token.

Errors

ErrorWhen
EmptyQueryErrorthe query is blank
UnknownProviderErrorthe name is not registered
SearchNotSupportedErrorthe provider has no search()
InvalidDateFilterErrora date bound is not an ISO date, or the end is before the start
InvalidSearchContinuationErrorthe token does not belong to this provider, query and options
AuthErrorthe provider answered 401, or the key is missing
RateLimitErrorthe provider answered 429, retryAfter in seconds
HTTPErrorany other non-2xx, with statusCode, redacted url and body

normalizeError(error, provider) is what the adapters use to map a raw HTTP failure onto this list. It is exported, custom providers should use it too.

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