Custom providers
The built-ins are classes behind one interface, BrowserProvider, and yours is the same shape. Nothing about a built-in is special. It has a manifest entry so the package can import it lazily, that's the whole difference.
A provider in one file
This one puts Jina Reader behind scrape(). It renders a page and answers with markdown, one GET per URL, no session, and it answered us without a key.
import { create, notSupportedViaRest, register } from "@agntn/browsers";
import type { BrowserProvider, ProviderCapabilities, ProviderConfig, ScrapeResult } from "@agntn/browsers";
class Reader implements BrowserProvider {
private readonly baseURL: string;
constructor(config: ProviderConfig) {
this.baseURL = config.baseURL!;
}
name(): string {
return "reader";
}
capabilities(): ProviderCapabilities {
return {
scrape: true,
statelessScrape: true,
screenshot: false,
statelessScreenshot: false,
navigate: false,
evaluate: false,
sessions: false,
cdp: false,
crawl: false,
pdf: false,
links: false,
search: false,
extract: false,
};
}
async scrape(url: string): Promise<ScrapeResult> {
const response = await fetch(`${this.baseURL}/${url}`, { headers: { "X-No-Cache": "true" } });
return { url, markdown: await response.text(), statusCode: response.status };
}
async createSession(): Promise<never> {
return notSupportedViaRest("reader", "sessions");
}
// getSession, listSessions, releaseSession, screenshot, navigate and evaluate the same way
}
register("reader", "https://r.jina.ai", (config) => new Reader(config));
const page = await (await create("reader")).scrape("https://example.com");
The version on the home page has every method written out, and that one compiles under strict and ran against the real service before it went on the page.
What each part does
capabilities()is what callers read to pick a provider, not a gate. A method flaggedfalsecan still be called, a tool with your provider named does exactly that, so the method itself has to refuse. Saytrueonly when it works.notSupportedViaRest(name, operation)throwsUnsupportedOperationErrorwith a message that points at CDP. Every method you can't do should end there, not in a silent empty result.register(name, defaultURL, factory)adds it to the registry. The factory gets{ apiKey, baseURL, … }, withapiKeyread fromREADER_API_KEYif you didn't pass one andbaseURLdefaulting to what you registered.X-No-Cacheis Jina's, not ours. Without it Jina served us a cached page that wasn't example.com at all.
Agents and the CLI
create("reader") works right away. The CLI and the agent tools also check for a key before they use a provider, READER_API_KEY here, so a keyless provider of your own stays in the library until you set that variable. Playwright is the one built-in exception to that rule, and it's hardcoded.
Shipping it in the package
For a provider that belongs in @agntn/browsers itself: a file in src/providers/ that exports factory, a manifest entry in src/providers/index.ts, the key in browserProviderNames in src/tool-contract.ts, and any unusual environment key in src/core/resolve.ts. The tests fail when those disagree, which is the point. Then a page here, next to the other providers.