Capabilities

A capability is a shared shape that many different plugins can implement behind one interface. It is the thing your application code talks to; the plugin is whichever specific implementation you happen to have installed. When you switch from one mailer to another, the code that calls mailer.send(...) does not change — only the plugin behind the capability does.

This page explains what capabilities are, why bext has them, how they graduate from experimental ideas to defaults, and how you will install one once the foundational set lands.

Capability vs. plugin

The distinction matters, because the words are easy to confuse:

- A plugin is a single concrete implementation. @bext/mailer-smtp is a plugin. It knows how to open an SMTP connection, authenticate, and send a message.

- A capability is the trait — the shape — that any mailer plugin must implement. Mailer is a capability. It says "send a message, get back an id, here is the error type."

One capability, many plugins. You pick the plugin; your code only ever sees the capability.

your code
   |
   v
  Mailer (capability — stable surface)
   |
   +-- @bext/mailer-smtp       (plugin)
   +-- @bext/mailer-ses        (plugin)
   +-- @bext/mailer-resend     (plugin)
   +-- @bext/mailer-postmark   (plugin)

If a shape is worth turning into a capability, there are already two or more plugins that want to implement it. A shape with only one backend is just a plugin with a good config surface — not a capability.

Every plugin declares, in its manifest, which capabilities it provides and which it depends on:

# plugin.toml
[manifest]
name = "auth-jwt"
version = "1.0.0"
provides_capabilities = ["auth"]
requires_capabilities = ["session"]

The registry uses these fields to resolve the dependency graph when you install a plugin, so you cannot end up with an Auth plugin that has no Session backend behind it.

Why capabilities exist

The short version: your application code should not have to care who the mailer is. You write mailer.send(...), and somebody else — the operator, the ops team, the person running the production instance — picks the backend in config. Swap SMTP for SES, or SES for Resend, and nothing in your code changes. The capability is the contract that makes that swap safe.

The longer version is that bext treats the capability surface as a commitment to everyone who depends on it. Once a capability is stable, plugins build against it, configs reference it, and the registry indexes it. Reshaping the capability later would break every one of those plugins at once. So the rule is: a stable capability surface is effectively permanent. We would rather ship no capability than one that has to be redesigned six months in.

That is also why the bar for adding a new capability is high. Bext needs to see at least two real providers, at least one plugin that wants to depend on the capability, a trait surface that does not leak any one vendor's quirks, and a reference implementation that proves the shape holds up end-to-end. When in doubt, it stays a plugin.

Anti-fragmentation

Adding capabilities is also subject to explicit anti-fragmentation rules, because the worst outcome for an ecosystem is two capabilities that almost do the same thing. In practice that means:

- One name, one thing. Two capabilities that overlap — for example, a split between "in-process cache" and "distributed cache" — get unified or one is rejected.

- No vendor-coupled shapes. If a trait has a field named after one vendor's API (postmark_message_id, for example), it is not a shared shape, it is a private interface wearing a hat. Back to the drawing board.

- No capability inheritance. A plugin that implements Auth does not automatically also implement Session. Each capability stands alone, and a plugin that wants both declares both.

The goal is that every capability earns its name by being distinct, useful, and stable on its own terms.

The promotion ladder

A capability does not appear as "first-party stable" on day one. It travels a ladder, and each rung carries a specific commitment about what bext will and will not break.

Step 1 — Experimental

The capability exists as a proof of concept, usually in someone's personal repo. The bext repo does not know about it. A small group of users may be running it. There is no conformance testing, no signing, no registry listing. Everything can change without warning.

Step 2 — Preview

The capability is published to the registry under the @preview/ namespace. You can install it, but the install path warns that you are opting into preview code. There are no backwards-compatibility guarantees — a preview capability can be reshaped on the next release. Preview is where serious users trade stability for early access, and their bug reports are how the shape gets hardened.

Step 3 — First-party stable

The capability lives under the @bext/ namespace. Its trait surface is locked, the plugin harness runs a conformance suite against every provider, documentation lives on this site, and the shape is supported indefinitely. Removing or renaming it requires a named successor and a migration window long enough for the ecosystem to follow.

Step 4 — Default

The capability is installed by default on new sites scaffolded by bext new. This is the highest rung: the capability is so universally useful that not having it is the surprise. Very few capabilities reach this level.

Stability commitments

Each rung carries an explicit API-stability window:

Level API stability Backwards-compat window
Experimental None None
Preview Patch-version stable 60 days
First-party Major-version stable 18 months
Default Locked; deprecation requires a named successor 24 months

Plugin manifests declare their own stability level, so a plugin that depends on a preview capability inherits the preview guarantees, and a plugin on a first-party capability inherits the long window. If you are shipping production, target first-party or default.

The five foundational capabilities

Five capabilities form the foundation that most of the rest depend on. They are coming soon as the first wave of stable, first-party capabilities, each with a real trait surface, two or more reference plugins, and a conformance test suite.

Auth

Who the caller is. An Auth plugin resolves a request to an authenticated user, runs the login and callback flows, and issues or refreshes a session. It does not store the session itself — that is the job of the Session capability — which means you can mix a JWT auth strategy with a Redis session store, or magic-link auth with cookie-only sessions.

Session

Where the proof of identity lives. A Session plugin is a store: create a session, read it by id, update it, delete it, touch it to keep it alive. Backends range from stateless encrypted cookies to Redis and Postgres. Auth plugins depend on Session, which is why the two ship together.

Mailer

Send a transactional email. A Mailer plugin takes a message and returns a message id. SMTP, SES, Resend, Postmark — every real project picks one, and the capability lets it pick in config. Your code calls mailer.send(...) and never thinks about the backend again.

Tracer

Emit a span. A Tracer plugin is a tracing exporter: start a span, end a span, attach attributes, flush to the backend. OTLP, Datadog, Honeycomb, and stdout (for dev) are all valid targets. Bext's internal observability hooks produce spans; the active Tracer decides where they go.

Scheduled

Run a job on a schedule. A Scheduled plugin declares one or more schedules — cron expression, jitter, missed-run policy — and bext's runtime calls them at the right time. On multi-instance deployments, the scheduler uses the Locking capability to make sure a given run fires once across the fleet, not once per instance.

How you install a capability

Capabilities show up in your project through the CLI. Installing a plugin that provides or requires a capability is a single command:

bext plugin add @bext/auth-jwt

The CLI reads the plugin's manifest, sees that it requires the Session capability, and walks you through picking a session backend if none is configured yet. The same is true for any capability dependency: the CLI surfaces what is needed and prompts you to resolve it, so you never end up with a broken dependency graph at runtime.

Non-interactive installs

If stdin is not a TTY, or you pass --yes / -y, or you export BEXT_NONINTERACTIVE=1, the install runs without prompting and auto-selects the first candidate for each unsatisfied capability. This lets you script installs in CI or Dockerfiles:

BEXT_NONINTERACTIVE=1 bext plugin install ./auth-jwt.wasm
# or
bext plugin install ./auth-jwt.wasm --yes

The defaults are intentional: @bext/session-cookie for session, @bext/auth-jwt for auth, @bext/mailer-smtp for mailer, @bext/tracer-otlp for tracer, and @bext/cron for scheduled. If you need a different backend, install it first, then install the dependent plugin — the CLI will see the capability already satisfied and will not prompt.

Once installed, the capability is wired up automatically. Your code uses it via the host-function API exposed to plugins (auth.user(), mailer.send(...), trace.span(...)) or via configuration in bext.config.toml:

[auth]
provider = "jwt"

[session]
backend = "cookie"

[mailer]
backend = "ses"

[tracer]
exporter = "otlp"

Swapping the backend is a config change. The code that calls into the capability stays exactly the same.

Where to go next

- The plugin system overview covers the sandbox tiers — QuickJS, WASM, nsjail — that capabilities run inside.

- Building a plugin shows how to scaffold a plugin; implementing a capability is the same flow plus declaring provides_capabilities in the manifest.

- Publishing walks through getting a plugin into the @preview/ registry namespace, which is the first real rung on the promotion ladder.