Guide

Custom providers

Put your own backend behind create(). One class that implements BrowserProvider and one register() call

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.

tsreader.ts
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 flagged false can still be called, a tool with your provider named does exactly that, so the method itself has to refuse. Say true only when it works.
  • notSupportedViaRest(name, operation) throws UnsupportedOperationError with 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, … }, with apiKey read from READER_API_KEY if you didn't pass one and baseURL defaulting to what you registered.
  • X-No-Cache is 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.