Batteries included.
One contract, 40+ plugins.

The stack a real SaaS needs — billing, storage, mail, notifications, search, analytics, rate-limiting — shipped as first-party plugins over a single contract. Each can intercept handlers, extend the schema, add routes or run lifecycle hooks. Install only what you need; they compose.

plugins.ts
TypeScript
// definePlugin — one contract for every extension
import { definePlugin } from '@voltro/protocol'

export const billing = () => definePlugin({
  name: 'billing',
  extendSchema:      { tables: [subscriptions, plans] },
  interceptMutation: guardEntitlements,
  routes:            [checkoutRoute],
})

// install only what you need — they compose
plugins: [billing(), storage(), mail(), notifications()]

The ecosystem, by job.

One plugin contract

definePlugin lets a plugin intercept mutations / queries / actions, extend the schema (tables + migrations or a mixin), provide Effect service layers, register routes and inspect endpoints, and run install / activate lifecycle hooks. Order is explicit; plugins compose outer→inner.

Commerce & billing

plugin-billing models subscriptions, plans, entitlements and usage metering over a provider (Stripe + mock) with seats, proration, dunning and a requireEntitlement() guard. plugin-licensing verifies offline EdDSA license keys.

Storage, mail & notifications

plugin-storage (S3 — R2/GCS via S3 interop — Azure / database / filesystem) with presigned URLs and image transforms; plugin-mail (Resend / Postmark / SES / SMTP) with React-Email templates; plugin-notifications across email / Slack / SMS / mobile push / web push (VAPID) / in-app.

Data & schema mixins

audit(), softDelete(), deactivation(), row history + time-travel, governance (retention, GDPR export/erasure, field encryption) and multitenancy — cross-cutting concerns as composable mixins.

Search, analytics & webhooks

plugin-search syncs to Typesense / Meilisearch / Algolia; five analytics sinks (Postgres, DuckDB, ClickHouse, Tinybird, PostHog) behind one ctx.analytics; plugin-webhooks does durable outgoing + verified incoming (Stripe / GitHub / Slack presets).

Control & operations

plugin-ratelimit (fixed / sliding / token-bucket), plugin-flags (targeting + % rollout + kill-switch), plugin-moderation, plugin-rbac, plugin-openapi, and the observability exporters (Prometheus, Datadog, Sentry, log-ship).

Comment threads

plugin-comments hangs threads on any anchor your app can name — an order, a document, a section. Access delegates to your own guard and fails closed when none is declared; mentions are re-filtered to the caller's tenant on autocomplete and re-validated at create, so no notification crosses the boundary. Reactions, per-subject unread and resolve/reopen ride the same reactive channel: useComments underneath an ejectable CommentsThread.

Add only what you need.

Composable, not a monolith

A Voltro app installs the plugins it uses and nothing else — each is a package over the same contract, so they share the runtime, the change-feed and the migration planner. A few plugins are still filling out: plugin-cdc-out ships its engine plus memory, webhook and — via plugin-queue — Kafka sinks; a warehouse is a CdcSink you implement yourself, not turnkey. And plugin-queue itself is the Kafka interop door: Schema-decoded consumers against your existing topics, transactional producing through the outbox.

The plugin surface, counted.

45

first-party plugins, each one opt-in and installed only when a requirement arrives

80

published packages in total — a plugin is an ordinary one of them, with no privileged API

What installing a plugin looks like.

app.config.ts
ts
// app.config.ts
import { defineApp } from '@voltro/runtime'
import { authPlugin } from '@voltro/plugin-auth'
import { storagePlugin } from '@voltro/plugin-storage'

export default defineApp({
  name: 'acme',
  plugins: [
    authPlugin({ mfa: true, passkeys: true }),
    storagePlugin({ provider: 's3', bucket: 'acme-uploads' }),
  ],
})

A plugin declares what it needs and what it contributes; the boot audit refuses one that reaches past what it declared. Writing your own is the same shape as consuming one — there is no separate authoring API to learn.

The plugin contract, in depth.

What can a Voltro plugin actually do?

A plugin is a server-side extension that composes into the runtime rather than sitting beside it. It can intercept every mutation, query and action; contribute tables, schema mixins and migrations; mount rpc routes and raw-HTTP endpoints; provide an Effect service layer that any handler can reach; and run install, activate, deactivate and uninstall lifecycles.

That list is deliberately finite. A plugin cannot modify the core schema DSL or the query builder, cannot bypass tenant scoping, and cannot read secrets it was not given — its configuration arrives through its own factory function, so the surface it touches is the surface it declared.

The framework ships more than forty first-party plugins over that one contract: auth, billing, storage, mail, search, moderation, governance, analytics sinks, observability exporters, identity adapters. They are not privileged. Anything they do, a plugin you write can do, which is the property that makes the contract worth learning once.

How do interceptors compose, and does order matter?

Interceptors wrap the procedure outer-to-inner in the order you list the plugins. Order matters and is visible: put a rate limiter before an audit log and a rejected request never reaches the audit trail — which is usually what you want, and always something you should be able to see in one place rather than infer from behaviour.

An interceptor sits inside the framework's own guarantees rather than around them. It runs after the runtime has merged the tenant predicate, so a mutation interceptor cannot read another tenant's rows by rewriting the subject. Access control is not something a plugin can loosen by accident.

Because interception is a wrap rather than an event, a plugin can transform input, transform output, short-circuit with a typed error, or add telemetry around the call — without the handler knowing a plugin exists.

How do plugin tables relate to my own?

A table-carrying plugin exports its table handles, so a column in your schema can reference one exactly like it references your own: `reference(() => aiFlowsTable, …)` produces a real foreign key with the same on-delete semantics and the same index defaults. The database enforces the link, so there is no cleanup subscriber to forget.

Referencing the table as a VALUE rather than its name as a string is what makes this survive the plugin's own migrations. When ten plugin tables moved into the framework namespace, a `reference(() => table)` followed the rename because the constraint travels with the table; a hand-written text column holding ids would not have told you anything had changed.

When you deliberately want the app schema independent of the plugin's, `pluginRef` gives you the rule without the key: a plain typed id column, no cross-schema constraint, plus a declared orphan policy the framework runs on the post-commit change channel. That closes the usual gap, where an unenforced id column plus a hand-written cleanup subscriber is referential integrity re-implemented per app — and silently wrong the first time somebody forgets it.

When should I NOT write a plugin?

For a one-off side effect, call it from the mutation. For behaviour specific to one application, keep it in application code. For something cross-cutting that affects exactly one mutation, a single call in that mutation beats a plugin's hook — a plugin adds an indirection that only pays off when it applies broadly.

Plugins are for concerns that cut across the whole surface: audit every write, rate-limit every mutation, emit an event on every sign-up, mirror every change into a warehouse. If you cannot name the rule without saying "every", it probably belongs in a handler.

You can also share a namespace with a plugin instead of replacing it. The collision check compares full tags, so your `notifications.archive` sits beside the plugin's `notifications.inbox` without a clash; replacing one of the plugin's own routes is possible but must be declared, because letting your route win silently would mean a plugin upgrade could shadow one of yours with no diff to read.

What a plugin can hook into.

Plugin extension points and what each one lets you do
SurfaceWhat it lets you do
InterceptorsWrap every mutation, query-setup and action — gate, audit, transform input or output, fail with a typed error.
extendSchemaContribute tables and custom SQL migrations, tracked separately from the app's own schema history.
servicesProvide an Effect Layer whose tags every handler can reach — how MailService and StorageService are delivered.
routes / httpRoutesRegister rpc procedures, or raw HTTP endpoints on the framework listener for flows that cannot be rpc.
inspectEndpointsMount tooling the dashboards read, so a plugin can ship its own operational panel.
Lifecycle hooksonInstall, onActivate, onDeactivate, onUninstall — plus wrappers for cron firings, workflow steps and pre-auth requests.

Frequently asked questions

Do I have to use the shipped plugins?

No. Every plugin is opt-in: you list the ones you want in app.config.ts and the rest are never loaded. They are ordinary packages built on the same contract your own plugins use, with no privileged access to the runtime.

Can a plugin see another tenant's data?

No. Interceptors run after the runtime has merged the tenant predicate, so a mutation interceptor cannot reach rows outside the caller's tenant by manipulating the subject. The tenant boundary is enforced beneath the plugin layer, not by it.

How do plugin migrations interact with mine?

A plugin's tables and migrations are tracked separately from your schema's, so installing one does not entangle its history with yours. Its tables still ride the same declarative differ, which means a plugin's schema change is applied by the same code path as your own on every dialect.

What happens if two plugins register the same route?

It is refused rather than silently resolved — two handlers behind one tag is not something a caller can reason about. Sharing a namespace is fine because the check compares full tags; deliberately replacing a plugin's route requires declaring it, and the boot logs which routes were replaced.

Can I write a plugin for my own company's internal use?

Yes, and it is the intended path for cross-cutting internal concerns. The contract is small enough to learn in one read, plugins are ordinary workspace packages, and nothing about a first-party plugin is unavailable to yours.

Plugins compose with the core.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.