npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

agent-web-search

v0.1.0

Published

Client-first TypeScript search aggregation library for multiple web search providers.

Readme

agent-web-search

Client-first TypeScript search aggregation library for multiple web search providers.

Query several web search APIs through a single, normalized interface. One call fans out to every configured engine in parallel, and you get back a consistent result shape per engine — including per-engine errors, warnings, rate-limit info, and optional raw payloads — without one slow or failing provider blocking the rest.

  • One shape for every providerSearchResult, Answer, and metadata are normalized across engines.
  • Per-engine isolation — each engine returns its own ok: true | false result; a failure in one never rejects the others.
  • Streaming — consume results and answer deltas as they arrive via async iterables.
  • Capability-aware — unsupported query params are surfaced as warnings (or hard errors) instead of being silently dropped.
  • Bring your own engines — register custom adapters alongside the built-ins.
  • Typed and validated — runtime-validated with Zod; ESM + CJS builds with full type declarations.
  • Zero runtime deps beyond Zod, and a CLI for quick searches from the terminal.

Supported engines

| Engine | id | API key env var | | ----------- | ----------- | --------------------- | | Brave | brave | BRAVE_API_KEY | | Ceramic | ceramic | CERAMIC_API_KEY | | Exa | exa | EXA_API_KEY | | Parallel | parallel | PARALLEL_API_KEY | | Firecrawl | firecrawl | FIRECRAWL_API_KEY | | Perplexity Sonar | sonar | PERPLEXITY_API_KEY | | You.com | you | YOU_API_KEY |

Installation

npm install agent-web-search
# or
pnpm add agent-web-search

Requires Node.js >= 18.

Quick start

import { search } from "agent-web-search";

const response = await search(
  { query: "best espresso machines 2026", count: 5 },
  {
    brave: { apiKey: process.env.BRAVE_API_KEY! },
    exa: { apiKey: process.env.EXA_API_KEY! },
  },
);

for (const [engine, result] of Object.entries(response)) {
  if (result.ok) {
    console.log(`${engine}: ${result.results.length} results`);
    for (const item of result.results) {
      console.log(`  ${item.title} — ${item.url}`);
    }
  } else {
    console.error(`${engine} failed: ${result.error.kind} — ${result.error.message}`);
  }
}

search() returns a SearchResponse — a record keyed by engine id, where each value is either an EngineSuccess (ok: true) or an EngineFailure (ok: false).

Usage

Reusable client

Create a client once and reuse it for many searches. Configuration is validated up front.

import { createSearchClient } from "agent-web-search";

const client = createSearchClient({
  brave: { apiKey: process.env.BRAVE_API_KEY! },
  sonar: { apiKey: process.env.PERPLEXITY_API_KEY! },
});

const response = await client.search({ query: "what is a vector database" });

search() and searchStream() are one-shot convenience wrappers that build a client per call. Prefer createSearchClient when you issue more than one search.

Streaming

searchStream yields events as each engine produces them. Engines that support native streaming (e.g. Sonar) emit answer_delta events; non-streaming engines emit their terminal events when they complete.

import { searchStream } from "agent-web-search";

const stream = searchStream(
  { query: "summarize the latest in fusion energy" },
  { sonar: { apiKey: process.env.PERPLEXITY_API_KEY! } },
);

for await (const event of stream) {
  switch (event.type) {
    case "answer_delta":
      process.stdout.write(event.text);
      break;
    case "results":
      console.log(`\n${event.engine}: ${event.results.length} results`);
      break;
    case "metadata":
      console.log(`${event.engine} metadata:`, event.metadata);
      break;
    case "answer_done":
      // event.result is the final EngineResult for this engine
      break;
    case "error":
      console.error(`${event.engine} error: ${event.error.message}`);
      break;
    case "done":
      // event.result is the full EngineResult for this engine
      break;
  }
}

Stream event types: answer_delta, answer_done, results, metadata, error, done.

Query options

The query object is shared across all engines; each engine maps the parts it supports.

await client.search({
  query: "climate policy",            // string, or string[] for multi-query engines
  count: 10,                          // desired results per engine
  freshness: "month",                 // "day" | "week" | "month" | "year"
  dateRange: { start: "2026-01-01", end: "2026-06-01" },
  includeDomains: ["nature.com"],
  excludeDomains: ["example.com"],
  country: "US",                      // ISO country code
  language: "en",                     // ISO language code
  safeSearch: "moderate",            // "off" | "moderate" | "strict"
  includeContent: { markdown: true, summary: true }, // or `true`
  overrides: { exa: { someRawParam: 1 } }, // per-engine raw param passthrough
});

Handling unsupported parameters

Not every engine supports every parameter (see the capability matrix). When you pass a parameter an engine can't honor, the default behavior is to attach a Warning to that engine's metadata and continue. Control this per engine with onUnsupportedParam:

const client = createSearchClient({
  ceramic: {
    apiKey: process.env.CERAMIC_API_KEY!,
    onUnsupportedParam: "error", // "warn" (default) | "ignore" | "error"
  },
});
  • "warn" — record a warning in metadata.warnings, run the search anyway.
  • "ignore" — drop the parameter silently.
  • "error" — fail that engine with an unsupported error.

Per-engine configuration

Every engine accepts the same configuration shape:

{
  apiKey: string;              // required
  baseUrl?: string;            // override the provider base URL
  timeoutMs?: number;          // total network deadline per attempt (default 30000)
  maxRetries?: number;         // default 2
  retry?: {                    // exponential backoff policy
    initialDelayMs?: number;   // default 250
    maxDelayMs?: number;       // default 5000
    factor?: number;           // default 2
    jitter?: boolean;
    retryStatuses?: number[];  // additional HTTP statuses to retry
  };
  includeRaw?: boolean;        // include the raw provider payload in results/metadata
  onUnsupportedParam?: "warn" | "ignore" | "error";
  defaults?: Record<string, unknown>; // engine-specific default params
  hooks?: TelemetryHooks;      // per-engine telemetry
  fetch?: typeof fetch;        // per-engine fetch override
}

Retries apply to transient failures (network errors, timeouts, and 5xx/429 responses); auth and bad-request errors are not retried.

Telemetry hooks

Observe requests, responses, retries, and errors. Hooks can be set globally (client options), per request, or per engine — they are merged and all fire.

const client = createSearchClient(engines, {
  hooks: {
    onRequest: ({ engine, url, attempt }) => log(`→ ${engine} #${attempt} ${url}`),
    onResponse: ({ engine, status, latencyMs }) => log(`← ${engine} ${status} ${latencyMs}ms`),
    onRetry: ({ engine, attempt, delayMs }) => log(`↻ ${engine} retry in ${delayMs}ms`),
    onError: ({ engine, error }) => log(`✗ ${engine} ${error.kind}`),
    onSettled: ({ engine, result }) => log(`✓ ${engine} ok=${result.ok}`),
  },
});

Cancellation

Pass an AbortSignal to cancel in-flight requests (and the stream).

const controller = new AbortController();
const promise = client.search({ query: "..." }, { signal: controller.signal });
controller.abort();

Custom engines

Implement an EngineAdapter and register it via options.adapters. defineEngine is an identity helper that preserves config types.

import { createSearchClient, defineEngine } from "agent-web-search";
import { z } from "zod";

const myAdapter = defineEngine({
  id: "my-engine",
  capabilities: { /* ... */ },
  configSchema: z.object({ apiKey: z.string() }).passthrough(),
  buildRequest(query, config, warnings) {
    return { method: "GET", url: "https://api.example.com/search", query: { q: query.query } };
  },
  parseResponse(res, ctx) {
    /* return an EngineResult */
  },
});

const client = createSearchClient(
  { "my-engine": { apiKey: "..." } },
  { adapters: [myAdapter] },
);

You can also import the built-in adapters individually:

import { braveAdapter } from "agent-web-search/adapters/brave";
import { exaAdapter, sonarAdapter } from "agent-web-search/adapters";

Capability matrix

| Engine | answer | content | streaming | multi-query | count | dateRange | freshness | includeDomains | excludeDomains | country | language | safeSearch | verticals | | --------- | :----: | :-----: | :-------: | :---------: | :---: | :-------: | :-------: | :------------: | :------------: | :-----: | :------: | :--------: | -------------------------- | | brave | — | — | — | — | ✓ | ✓ | ✓ | emulated | emulated | ✓ | ✓ | ✓ | web, news, images, video | | ceramic | — | — | — | — | — | — | — | — | — | — | — | — | web | | exa | — | ✓ | — | — | ✓ | ✓ | ✓ | native | native | ✓ | — | — | web, news | | parallel | — | — | — | ✓ | ✓ | ✓ | ✓ | native | native | ✓ | — | — | web | | firecrawl | — | ✓ | — | — | ✓ | ✓ | ✓ | native | native | ✓ | — | — | web, news, images | | sonar | ✓ | — | ✓ | — | — | ✓ | ✓ | native | emulated | — | ✓ | — | web | | you | — | ✓ | — | — | ✓ | ✓ | ✓ | native | native | ✓ | ✓ | ✓ | web, news |

native = handled by the provider's API; emulated = approximated by the adapter (e.g. via query operators).

CLI

The package ships an agent-web-search binary. Set the relevant API key env vars, then:

agent-web-search --query "best espresso machines" --engine brave --engine exa --count 5

By default it queries every engine that has a matching API key set in the environment.

Options
  -q, --query <text>            Search query. Positional text is also accepted.
  -e, --engine <id>             Engine id. Repeat or comma-separate.
      --count <number>          Desired result count per engine.
      --freshness <range>       day, week, month, or year.
      --country <code>          ISO country code.
      --language <code>         ISO language code.
      --safe-search <mode>      off, moderate, or strict.
      --include-domain <host>   Domain allowlist. Repeat or comma-separate.
      --exclude-domain <host>   Domain blocklist. Repeat or comma-separate.
      --content <fields>        true or comma list: markdown,html,text,summary.
      --raw                     Include top-level raw provider payloads.
      --stream                  Emit stream events as NDJSON.
      --ndjson                  Emit one-line JSON for non-streaming output.
  -h, --help                    Show help.
  -v, --version                 Show version.

Result shapes

type SearchResponse = Record<string, EngineResult>;
type EngineResult = EngineSuccess | EngineFailure;

A successful engine result (ok: true) contains results: SearchResult[], an optional answer, and metadata. A normalized SearchResult includes url, title, snippet/snippets, publishedDate, author, score, source, optional content/highlights/image/favicon, and the provider's raw payload.

A failed engine result (ok: false) contains an error whose kind is one of: auth, rate_limit, quota, bad_request, unsupported, timeout, network, upstream, or parse, along with metadata.

Both Zod schemas (SearchResponseSchema, SearchResultSchema, AnswerSchema, …) and TypeScript types are exported from the package root.

Development

pnpm install
pnpm build       # emit ESM + CJS builds to dist/
pnpm test        # run the vitest suite with coverage
pnpm typecheck   # tsc --noEmit
pnpm lint        # biome check
pnpm format      # biome check --write

This repo uses pnpm (pinned to [email protected]), Vitest for tests, and Biome for linting and formatting.

License

MIT © Harold Martin