Searching
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:
| Provider | Domain filters | Source values | Category values | Date bounds |
|---|---|---|---|---|
| Brave | none | none | none | none |
| Context.dev | include, exclude | none | none | none |
| Exa | include, exclude | none | forwarded as given | start, end |
| Firecrawl | include, exclude | web, news, images | research, pdf, developer | none |
| Jina | include | none | web, images, news | none |
| Mojeek | include, exclude | none | none | start, end |
| SearXNG | none | none | forwarded as given | none |
| SerpAPI | none | none | none | none |
| SerpBase | none | none | image, images, news, video, videos | none |
| Tavily | include, exclude | none | none | none |
| TinyFish | include, exclude | none | news, research_paper | start, 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
| Error | When |
|---|---|
EmptyQueryError | the query is blank |
UnknownProviderError | the name is not registered |
SearchNotSupportedError | the provider has no search() |
InvalidDateFilterError | a date bound is not an ISO date, or the end is before the start |
InvalidSearchContinuationError | the token does not belong to this provider, query and options |
AuthError | the provider answered 401, or the key is missing |
RateLimitError | the provider answered 429, retryAfter in seconds |
HTTPError | any 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.