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

@stratusagent/search

v0.11.4

Published

The Stratus Agent web.search contract: one tool shape, one option meaning, and one result envelope that every search backend obeys

Downloads

821

Readme

@stratusagent/search

web.search: the contract, not a backend. One tool name, one option meaning, one result shape — so two independently written search plugins are interchangeable behind one soul allowlist, and swapping vendors changes no soul, no skill, and no result shape.

This package ships no backend and reaches no vendor. Every backend needs an API key and a commercial relationship, so core ships web.fetch and the ecosystem ships web.search (plugins.md). What has to be first-party is the half that makes the other half worth anything.

For an operator

You need a backend plugin. Install it, enable it in a trusted config, and store the key:

npm install -g stratus-plugin-somesearch
printf %s "$SEARCH_KEY" | stratus credential set search.apiKey
// ~/.stratus/config.json — a trusted config only
{
  "plugins": {
    "stratus-plugin-somesearch": { "enabled": true, "maxSearchesPerDay": 200 }
  }
}

Then each agent's soul opts in, on both gates — the tool and the credential:

---
id: ava
tools: [web.fetch, web.search]
credentials: [search.apiKey]
---

tools: [web.*] picks up search too, with no soul edit, which is the reason the glob exists. The credentials: line is separate and is not optional: without it every call answers "Agent ava is not allowed to access credential: search.apiKey".

Two backends cannot be enabled at once. Both contribute web.search, and a tool name is unique per install, so that is a load-time error naming both packages. A deployment that genuinely needs two backends wants two daemons.

Settings every backend accepts

A backend's manifest must declare these in its config schema, because they are read by the shared tool rather than by the backend's own code. All of them take a per-agent override in the agents sub-block.

| Key | Default | What | | --- | --- | --- | | maxSearchesPerDay | 200 | Calls one agent may make in a UTC day. A query acts on nothing, but a metered API costs money and an agent in a loop can spend it. 0 refuses every call, which is how you turn one agent's search off without editing its soul. There is deliberately no "uncapped" — set a large number if that is what you mean. | | allowedHosts | none | Hosts exempt from the address check. What a self-hosted backend on localhost needs. | | allowPrivateAddresses | false | Reach any non-global address. Turns the SSRF protection off rather than adjusting it. |

The cap is kept in the daemon's process, so a restart forgives the count. That is stated rather than hidden: its job is to stop a runaway loop, and a loop lives inside one daemon's lifetime.

The credential is one name, whatever the vendor

search.apiKey. Always. A Brave adapter asking for BRAVE_API_KEY and a Tavily adapter asking for TAVILY_API_KEY would mean swapping backends edits every soul in the fleet, even though the web.search allowlist entry never moved.

Two corollaries. Something that is not secret — a Google CSE engine id, a SearXNG base URL — goes in the plugin's config block, which is not soul-scoped. And a backend needing no credential is legitimate: a self-hosted instance never calls get, and is conforming rather than broken.

A key belongs in the credential store, never as a literal in the config block — a key in stratus.config.json is a key in a file people commit.

For a backend author

Implement one method. Everything else — the tool name, the schema, the option parsing, the filtering, the envelope, the credential seam, the address policy — is this package's, which is what makes your backend swappable with somebody else's.

import {
  createWebSearchTool,
  defineSearchProvider,
  SEARCH_CREDENTIAL_NAME,
} from '@stratusagent/search';
import { requestThroughPolicy } from '@stratusagent/egress';
import type { JsonObject, Plugin } from '@stratusagent/core';

const createBackend = (config: JsonObject) => defineSearchProvider({
  name: 'somesearch',
  async search(query, options, context) {
    const key = await context.credentials.get(SEARCH_CREDENTIAL_NAME);
    const url = new URL('https://api.somesearch.example/v1/search');
    url.searchParams.set('q', query);          // literal text — see below
    url.searchParams.set('count', String(options.count));
    if (options.site) url.searchParams.set('host', options.site);
    if (options.freshness) url.searchParams.set('since', options.freshness.since);

    // Every request through the policy you were handed. Never derive one.
    const response = await requestThroughPolicy(url.href, {
      policy: context.policy,
      headers: { 'x-api-key': key },
      ...(context.signal ? { signal: context.signal } : {}),
    });
    if (response.status !== 200) {
      throw new Error(`The somesearch API answered ${response.status}.`);
    }
    return (JSON.parse(response.body).results ?? []).map((row) => ({
      title: row.title,
      url: row.url,
      snippet: row.description,
      publishedAt: row.published,
    }));
  },
});

export const createPlugin = (config: JsonObject): Plugin => ({
  name: 'stratus-plugin-somesearch',
  setup(context) {
    context.tools.register(createWebSearchTool({
      provider: createBackend(config),
      config,
      ...(context.credentials ? { credentials: context.credentials } : {}),
      // The daemon's log records that a search ran and against which
      // provider — never the query, which is user content.
      onSearch: (record) => context.log?.(`web.search via ${record.provider} — ${record.results} result(s)`),
    }));
  },
});

Your package.json manifest declares the tool and the credential:

"stratus": {
  "pluginVersion": 1,
  "contributes": { "tools": [{ "name": "web.search", "risk": "safe" }] },
  "credentials": ["search.apiKey"],
  "config": {
    "type": "object",
    "properties": {
      "maxSearchesPerDay": { "type": "integer" },
      "allowedHosts": { "type": "array", "items": { "type": "string" } },
      "allowPrivateAddresses": { "type": "boolean" }
    },
    "additionalProperties": false
  }
}

Declare risk: "safe" if you like — it will register gated anyway. safe means "run this unattended, with nobody watching", and that is not a claim the code being judged gets to make about itself; the third-party floor applies to web.* exactly as it does everywhere else.

The options, pinned

| Field | Type | Meaning | | --- | --- | --- | | query | string, required, non-empty | Literal text. Do whatever your provider requires — escaping, quoting, a literal-search parameter — so the upstream searches for the characters given rather than parsing operators out of them. An unbalanced quote is a search for a quote character, not a syntax error. | | count | integer, 1–50, default 10 | A maximum. Returning fewer is normal; returning more is a contract violation, and this package truncates. | | site | one normalized hostname | Matches that host and anything under it, on label boundaries: example.com matches docs.example.com and not notexample.com. Already lower-cased, trailing dot removed, IDN in A-label form. Not a query operator — never splice site: into the query. | | freshness | { duration, ms, since } | An age, resolved to an instant once per call. since is the oldest a result may be. |

A call carrying any other field is refused naming it. The caller is a model writing JSON, so freshnes: "P7D" is a realistic mistake — and read as "no freshness filter" it runs an unrestricted search that succeeds, after which the agent says the answer is recent. A dropped option is a silently ignored one arriving a step earlier, and this table is strict for that reason throughout.

What publishedAt may be

An ISO 8601 instant, and the shape is checked before anything is parsed — because new Date(value) is not a validator. It reads 03/04/2026 as 4 March without ever saying whether the backend meant March or April, and it silently corrects 2026-02-30 into 2 March. A lenient parse therefore does not fail to date a result, it invents a date, and a freshness filter then keeps or drops the result on the strength of it.

| Accepted | Read as | | --- | --- | | 2026-09-01T09:00:00Z | itself | | 2026-09-01T11:00:00+02:00, …+0200 | the same instant, in UTC | | 2026-09-01 | UTC midnight |

Everything else becomes absent, which under freshness means the result is dropped: a locale-formatted date, a day that does not exist, and a zone-less 2026-09-01T10:00:00 — that last one is local time to whatever machine parses it, so the same string is two different instants on two daemons, which is the disagreement this package exists to remove. Convert your vendor's format; that is the one job a backend cannot hand back.

A date with no time is accepted rather than refused because plenty of backends only have a day, and refusing them would silently empty every freshness search against such a backend. Reading it as midnight errs towards calling a page slightly older than it is, which is the safe direction.

Results older than freshness are excluded, and so are results with no known date — which is the case every backend produces without being asked. Leaving that unsaid would let two conforming adapters return different sets for identical calls. The agent asked for recent results and will say the answer is recent, and "we could not determine a date" is not evidence that it is. The exclusion applies only when freshness is set.

Refuse rather than approximate. A backend that silently ignores an option is worse than one that fails. Declare what you cannot honor and the call is refused naming it, before a request is spent:

defineSearchProvider({ name: 'somesearch', unsupported: ['freshness'], search })

Omitting unsupported means you honor everything, which fails towards the contract: you opt out deliberately rather than being quietly excused.

What you do not have to get right

The shared tool applies count, site, and freshness to whatever you return, validates every result URL against the address policy, strips markup from snippets, and turns publishedAt into a UTC instant (dropping anything that will not parse, rather than guessing).

Pass your vendor's decorated snippet straight through — highlight markup is expected. Inline formatting is removed, not replaced with a space, because vendors highlight the matched substring: un<strong>expected</strong> reads back as unexpected, and <strong>kettle</strong>. keeps its full stop attached. Anything that is not inline formatting becomes a space instead, so two table cells never fuse into AlphaBeta. Send the options upstream anyway where your vendor supports them — native filtering ranks better than a filter applied afterwards — but the guarantee does not rest on your having done so.

Freshness is fixed-length only: P7D, P4W, PT12H, PT30M. P1M and P1Y are refused, because a month from 31 March and a year from 29 February each have more than one defensible answer, and an adapter that converted to 30 or 365 days would disagree with one that did not — at boundaries, intermittently. The rejection is positional: PT30M is thirty minutes and is fine.

The result envelope

{
  "query": "kettles",
  "provider": "somesearch",
  "results": [
    { "title": "…", "url": "https://…", "snippet": "…", "publishedAt": "2026-09-01T09:00:00.000Z" }
  ],
  "untrusted": true,
  "untrustedNote": "Titles and snippets below were written by the pages that own them…"
}

Snippets are attacker-controlled text: written by whoever owns the page, selected by a third-party ranker, and handed to a model that is about to decide what to do next. This does not solve prompt injection and does not claim to — it marks the boundary, because doing so is nearly free here and a retrofit across every tool that returns third-party text is not. The marking is now also a kernel concept: the tool declares outputTrust: 'external', so the session that read a result carries the label forward into everything it remembers — the same label web.fetch, the browser, and the MCP bridge carry. The envelope's note stays, because it is the model, not the kernel, that reads it (Memory).