Custom Providers
The contract
abstract class Provider {
static readonly providerName: string;
static readonly defaultBaseURL: string;
static readonly apiKeyEnvVar?: string | null;
static readonly capabilities?: readonly ("search" | "searchImage" | "read")[];
static readonly capabilityDetails?: ProviderCapabilityDetails;
static readonly searchFilterCapabilities?: SearchFilterCapabilities;
protected readonly client: Client;
protected readonly baseURL: string;
get name(): string;
}
interface SearchProvider {
search(query: string, options?: SearchRequestOptions): Promise<SearchResult[]>;
}
interface ImageSearchProvider {
searchByImage(url: string, options?: ImageSearchRequestOptions): Promise<ImageSearchResult[]>;
}
interface ReadProvider {
read(url: string, options?: Readonly<ReadOptions>): Promise<ReadResult>;
}
A provider is a class that extends Provider and implements one or more of the three interfaces. Capabilities are read off the prototype: a class with a search method is a search provider, done. Methods declared as class fields are invisible to that check, so a class that uses them declares a static capabilities array instead.
A minimal search provider
import {
Provider,
register,
searchProviders,
type ProviderCapabilityDetails,
type ProviderConfig,
type SearchResult,
} from "@agntn/web";
class InternalSearch extends Provider {
static readonly providerName = "internal-search";
static readonly defaultBaseURL = "https://search.example.com";
static readonly apiKeyEnvVar = null;
static readonly capabilityDetails = {
search: {
contentOptions: [],
resultLimit: { default: 10, maximum: 50 },
resultFields: [],
},
} as const satisfies ProviderCapabilityDetails;
constructor(config: Readonly<ProviderConfig>) {
super(config, InternalSearch);
}
async search(query: string): Promise<SearchResult[]> {
return [{ url: "https://example.com", title: query, snippet: "Internal result" }];
}
}
register(InternalSearch);
searchProviders(); // [..., "internal-search"]
From here create("internal-search"), searchAll, searchWithFallback, the CLI, the AI SDK tools, the MCP server and the Pi and OMP extensions all know about it. One catch: tool descriptions still advertise the built-in names, because they are frozen when a session starts. Execution accepts any registered name, the description just does not brag about yours until the next session.
Names and keys
Provider names are lowercase ASCII letters, digits and single hyphens inside. apiKeyEnvVar: null means registering is enough to count as configured. Leave it out and automatic selection expects the derived variable, INTERNAL_SEARCH_API_KEY for the class above. An explicit create() can always pass apiKey.
this.client is the shared HTTP client, getJSON, postJSON and friends, with the redaction that keeps keys out of error messages. this.baseURL is the constructor's baseURL or the class default, checked to be http or https.
Declare what you can do
capabilityDetails is what listProviders(), web providers and web_providers report: content options, result limits, the result fields you fill, read formats and options. searchFilterCapabilities lists the filters you honour and the categories you accept. Skip it and requested filters come back as undeclaredFilters instead of being guessed. Guessing would be worse.
static readonly searchFilterCapabilities = {
filters: ["includeDomains", "startPublishedDate", "endPublishedDate"],
categories: ["news", "docs"],
} as const satisfies SearchFilterCapabilities;
Pagination
A search provider can also implement searchPage(query, options, continuation?) and return a ProviderSearchPage with its own continuation and a continuationStatus of next or unknown. That state belongs to the provider, at most 2 048 characters, and only the same adapter ever reads it. The core helpers wrap it in a public token bound to the provider, query and options. No returned continuation means last page, and capabilities.search.pagination turns true as soon as the method exists.
Errors
Map raw failures with normalizeError(error, this.name): a 401 becomes AuthError, a 429 RateLimitError with the Retry-After value, anything else stays HTTPError. Throw the typed errors yourself for the validation you do before the request. Automatic fallback reads the type to decide whether the next provider is worth trying, so a wrong type means either a wasted quota or a chain that stops too early.
Reachability
Implement isAvailable(signal?) when your endpoint can be down without being misconfigured, the way a self-hosted SearXNG can. listProvidersAsync, detectAvailableProvidersAsync and resolveDefaultProviderAsync call it and skip a provider whose probe fails.
Where the built-ins live
src/providers/<name>.ts, one file per provider, each ending in register(). Brave is the smallest search adapter with pagination, Jina is the template for a reader, SerpAPI is the one with reverse image search.