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

@agentback/plugin

v0.12.0

Published

Discover, gate, and mount Component-contributing plugins into an AgentBack Application

Downloads

302

Readme

@agentback/plugin

Discover, gate, and mount Component-contributing plugins into an AgentBack Application.

import {Application} from '@agentback/core';
import {loadPlugins} from '@agentback/plugin';

const app = new Application(config);
await loadPlugins(app); // discover (deps + dirs) -> gate -> mount -> report
await app.start();

loadPlugins is a standalone async bootstrapper: call it once, after constructing the app and before app.start(). It does not subclass or wrap the app — it reads a manifest, discovers plugins, mounts each plugin's Component via the normal app.component(), and returns an auditable report.

Imperative: loadPlugin(app, specifier)

When you want to mount one specific plugin — by npm package name or filesystem path — rather than discover from the dependency graph, use the singular loadPlugin. Unlike loadPlugins, the target need not be a declared dependency and need not carry an agentback marker:

import {loadPlugin} from '@agentback/plugin';

// A marked package (npm name) — marker names the component export:
await loadPlugin(app, '@acme/foo');

// A local directory (relative to cwd) — marker or {component}:
await loadPlugin(app, './plugins/foo');

// An UNMARKED package or bare file — name the export explicitly:
await loadPlugin(app, './plugins/bare.js', {component: 'MyComponent'});

loadPlugin returns the mounted PluginInfo and throws on failure (unresolvable specifier, missing named export, or a DI-key collision). It shares the exact governance of loadPlugins: re-binding a key the app already owns throws unless you pass it in options.allowOverride.

interface LoadPluginOptions {
  component?: string; // required for unmarked targets; overrides the marker
  allowOverride?: string[]; // DI keys this mount may intentionally re-bind
  cwd?: string; // base dir for relative paths / bare-specifier resolution
}

Use loadPlugins (plural) for the declarative manifest path; use loadPlugin (singular) for an explicit, code-driven mount of a known target.

loadPlugin returns an Installed — see Unmounting. Mounting the same plugin twice is refcounted, not an error: app.component() early-returns for a component already bound to the same class, so the second mount binds nothing new and simply takes a second reference. Each call returns its own handle, and the plugin is retracted when the last one uninstalls.

Mounting into a running app

Both entry points also work after app.start(), and a handle they return means the plugin is bound, lifecycle-started, and served. Anything less fails and unwinds — a success value that means "probably serving" would push a check onto every caller that no caller writes.

Three things happen that do not happen before start():

  • Lifecycle. The app-wide passes are over, so the mount notifies the observers it just bound. What is owed depends on the phase: from started, init + start; from initialized, init only, because the pending app.start() still notifies every bound observer and would otherwise double-start them. The mount phase therefore never changes the lifecycle a plugin receives.
  • Served surfaces. Routes and MCP capabilities are derived, not stored, so the entry point asks every bound server to re-derive once per call (refreshSurfaces, @agentback/core). Once per call, not per mount — a refresh is a global reconcile, so loadPlugins does it once for the batch.
  • Rollback covers all three. A failing observer start() is transactional at the registry: anything that did start is stopped before the mount's bindings are unbound. A server that cannot re-derive fails the mount (surface-refresh) and the whole thing is retracted.

Two properties worth knowing:

  • Servers validate before they commit. RestServer.refreshSurface() on the native host builds the candidate route table and runs the same checks start() does (duplicate routes, Express-coupled routes) before touching anything served, so a bad route fails its own mount instead of the next request. On Express there is no dry run — layers cannot be unmounted — but a partially-mounted route is harmless because the rollback unbinds the controller and the per-request liveness gate 404s it.
  • refreshSurfaces no-ops unless the app is started. getSync resolves a binding rather than reading a cached one, so refreshing earlier would construct every server as a side effect; and before start() the normal collection pass has yet to run.

MCP has no carve-out here: tools, resources and prompts are all derived per request, so a runtime-mounted @tool({ui}) widget and the ui:// resource it names both go live together.

Unmounting

Both entry points return their inverse, per the revertible-install contract:

const report = await loadPlugins(app);
await report.uninstall(); // retracts every plugin that mounted, LIFO

const one = await loadPlugin(app, '@acme/foo');
await one.uninstall(); // PluginInfo & Installed

uninstall() is idempotent, and retracts:

  • bindings the mount added — and restores any it displaced under allowOverride;
  • lifecycle observers, through the lifecycle registry (so group order, disabledGroups and the parallel setting all hold), and only while the app is started/initialized;
  • routes, as a consequence: unbinding a controller makes its routes 404 on both hosts.

Six behaviors worth knowing, each of which exists because the naive version is wrong:

  • Ownership, not key possession. If something else re-bound one of the plugin's keys after it mounted, uninstall() touches neither the unbind nor the restore. An unguarded restore would clobber that third party exactly as an unguarded unbind would delete it.
  • A provider outlives its consumers. Retracting a plugin whose provides key another live plugin still declares in inject is refused, naming both. A consumer's own teardown routinely needs the dependency it is losing, so this is not tidiness. LIFO composition already gives the ordering inside one report; the guard is what covers independent handles. It reads the same advisory declarations as the graph, so an under-declared inject is invisible to it.
  • Idempotent means idempotent. A handle memoizes its inverse, so repeat calls change nothing. This is load-bearing rather than cosmetic: a rebuilt teardown would decrement a shared component's refcount a second time and retract it under a plugin that still holds it.
  • A failing observer still gets you a clean retraction. If a plugin's stop() rejects, the bindings are still reverted and the error surfaces as an AggregateError. Bailing out early would leave them mounted with no record of who owns them — worse than the original failure.
  • A rejected mount leaves nothing behind. app.component() runs its side effects before a collision is detectable, so a losing plugin is rolled back rather than left bound and absent from report.mounted.
  • Shared nested components are refcounted, per application. Two plugins listing the same nested Component both keep it alive; it is retracted by whichever uninstalls last, not by whichever mounted it first.
  • A partial strict load is still retractable. The thrown error carries the report, and that report's uninstall() retracts the mounts that did succeed.

Not retracted: side effects that already left the process, and anything a component's constructor did beyond binding.

Plugins are trusted code

This package governs collisions, ordering, and lifecycle. It does not sandbox. A mounted plugin runs with the app's full authority: it can bind any key it is allowed to, reach any port in the container, and do anything the process can. There is no per-plugin capability restriction and none is planned.

That is a scope statement, not an unfinished feature. Language-level access control does not hold against a component that is actively hostile, a point the Spatiotemporal Composability preprint makes about its own design: real isolation needs an execution boundary outside the language. If you need to run code you do not trust, the boundary is a process or a container, not a DI container.

What you can do inside this model is narrow what a plugin resolves, by binding a restricted implementation of a port into a child context before mounting it. That limits honest mistakes. It is not a security control.

The assumption, so you can check whether it still holds. This scope depends on plugins being first-party or vendored, which is true of every AgentBack app today. What would invalidate it is a real third-party ecosystem: many plugins, installed on reputation, whose source nobody on your team has read. If you get there, the missing piece is per-plugin capability restriction (dependency resolution carrying cross-cutting metadata, so one plugin resolves a port to a narrowed view while first-party code does not), and this decision should be reopened rather than inherited. Recorded 2026-08-15 against agent-authored-plugins.md.

Mounting a class you wrote at runtime

loadPlugin resolves a specifier against disk or npm, which assumes a human with a filesystem. mountComponent takes the class directly and applies the same governance:

import {mountComponent} from '@agentback/plugin';

const handle = await mountComponent(app, MyComponent, {
  name: 'agent:scratch-1', // identity in the ledger and the registry
  allowOverride: ['services.Cache'],
});
await handle.uninstall();

Collision detection, rollback on rejection, component refcounting and retraction are the same code paths a package on disk takes, because everything that makes a mount safe lives after the import and both entry points share it. name is required rather than derived from the class: two components authored in different turns can share a class name, and the owners ledger keys on it.

Mounting is a write, so this is deliberately not on the read-only introspection surface. Whatever exposes it as an agent-callable tool owns the trust gate.

Exposing it to an agent is your call, not ours

There is deliberately no built-in tool that lets an agent mount a component. Shipping one would ship a default answer to "who may rewrite this process", and that is the decision least suited to having a default. Wire it yourself, with your own scope:

@mcpServer()
class PluginAdminTools {
  constructor(
    @inject(CoreBindings.APPLICATION_INSTANCE) private app: Application,
  ) {}

  @authorize({scopes: ['plugins:write']})
  @tool('mount', {input: MountIn, output: MountOut})
  async mount(input: z.infer<typeof MountIn>) {
    const ctor = KNOWN_COMPONENTS[input.component]; // an allow-list you own
    if (!ctor) throw new AgentError(`Unknown component ${input.component}.`);
    const handle = await mountComponent(this.app, ctor, {name: input.name});
    this.handles.set(input.name, handle);
    return {mounted: input.name};
  }
}

Note what that example does not do: turn agent-written text into a class. mountComponent takes a constructor, so something has to compile or evaluate source to produce one, and AgentBack does not do that and should not. An allow-list of components your app already ships is the version with a sane trust story. Going further means an evaluation step you own, and its trust level is the trust level of the whole process.

What is mounted right now

PluginBindings.REGISTRY is live state, where a PluginLoadReport is the record of one discovery run:

const registry = app.getSync(PluginBindings.REGISTRY);
registry.mounted(); // MountedPlugin[] — name, version, component, source, provides, inject
registry.componentRefs(); // components.* key -> live reference count

source is deps, dir, or memory. Entries disappear as plugins retract. @agentback/introspection exposes the same data as its plugin kind, so an agent can inspect the tree before changing it.

Declaring dependencies

A plugin can declare which DI keys it contributes and which it needs:

"agentback": {
  "plugin": true,
  "component": "MyComponent",
  "provides": ["services.Catalog"],
  "inject": ["services.Auth"]
}

Mount order is then a topological sort over inject → provides, and order: becomes a tiebreaker among plugins that are otherwise independent. With no declarations anywhere there are no edges, so order: alone governs — exactly as before this existed.

Because discovery reads the stanza off disk without importing anything, three checks run before any plugin code executes: a duplicate provides (unless the key is in allowOverride), an inject nothing provides, and a cycle. Each fails with the plugins named.

A typo'd marker key is reported, not swallowed — provide for provides lands in report.warnings naming the key, because a silently-dropped declaration leaves you debugging an ordering problem with no visible cause. Print report.warnings.

When several plugins declare the same provides key under allowOverride, a consumer injecting it is ordered after every one of them — edging only to the last declarer would let an earlier provider mount afterwards and overwrite the binding the consumer was ordered to wait for.

Declarations are advisory. They govern ordering and early detection; the DI container remains the authority at resolution time, so under-declaring inject costs you ordering guarantees, not correctness. Two consequences: a key the application itself binds satisfies an inject with no edge (not every dependency comes from a plugin), and the graph cannot express "I need the overridden binding" when the app holds a default — a consumer that needs the override should inject a key the overriding plugin uniquely provides.

A runnable end-to-end demo of both entry points lives in examples/hello-plugin.

Making a package a plugin

Add one stanza to the package's package.json. The named export must be a Component on the package's main module (it already is, if you export your component from the package root):

"agentback": {"plugin": true, "component": "MyComponent"}

Discovery reads this stanza off disk, so it never imports a package just to learn whether it is a plugin.

The marker makes a plugin loadable. To make it findable, add the agentback-plugin topic to its GitHub repository. That is the convention this project uses, and it costs nothing to adopt.

Manifest

Populate PluginBindings.CONFIG on the app, or pass options.config to loadPlugins. Both are validated by the PluginsConfig Zod schema.

{
  "scan": true, // discover from declared npm deps (default true)
  "dirs": ["./plugins"], // also scan these dirs for marked packages (default [])
  "enable": ["@acme/foo"], // allowlist - if present, ONLY these mount
  "disable": ["@acme/bar"], // subtract from the discovered set
  "order": ["@acme/foo"], // mount-order prefix; the rest follow discovery order
  "allowOverride": ["services.X"], // DI keys a plugin may intentionally re-bind
  "strict": true, // fail-closed (default): a broken plugin or DI-key
  // collision HALTS startup
}

Two discovery sources, one gate

  • scan resolves each declared dependency's package directory and reads its package.json marker off disk.
  • dirs scans each directory's immediate subdirectories for marked packages (local / dropped-in plugins that are not npm dependencies).

Both feed one candidate set, which enable / disable / order then filter and order.

Fail-closed by default

strict defaults to true. A plugin that fails to import, is missing its named export, or re-binds a DI key already owned by another plugin (and not listed in allowOverride) halts startup. The thrown error still carries the populated report. Set strict: false to collect every failure into the report and keep mounting the rest — useful for development or lenient third-party hosting.

Why DI-key collisions are first-class

A third-party plugin silently overriding a first-party binding (an auth strategy, an enforcement point) is the failure a governance substrate cannot have. The loader snapshots the context's bindings around each mount and flags any key a later plugin re-binds, so an override is never silent. This protects keys bound by the application itself (before loadPlugins) as well as keys bound by an earlier plugin — to re-bind either on purpose, list the key in allowOverride.

The report

loadPlugins returns a PluginLoadReport — the synchronous, testable record of what happened:

interface PluginLoadReport {
  discovered: PluginInfo[]; // everything found by either source
  mounted: PluginInfo[]; // actually mounted, in mount order
  skipped: Array<PluginInfo & {reason: 'disabled' | 'not-enabled'}>;
  warnings: string[]; // non-fatal: undiscovered enable/order name, missing dir
  errors: PluginLoadError[]; // import / missing-export / key-collision
}

The discover scanner is also exported on its own, so a console or control plane can list what would mount without mounting anything.