Plugin contract
definePlugin, lifecycle hooks, rpc interceptors, framework-version compatibility — the surface every Voltro plugin implements.
A plugin is the unit of cross-cutting framework extension. Examples in the wild:
@voltro/plugin-auditrecords every mutation invocation to a sink.@voltro/plugin-multitenancyadds thetenant()schema mixin + write-guard.@voltro/plugin-webhooksships incoming + outgoing webhook tables + workflow.@voltro/plugin-auth-workos(and 5 siblings) plug an external IdP into the auth chain.
Plugins live as npm packages, get listed in app.config.ts's plugins: array, and the framework wires their hooks at boot. The contract is intentionally narrow — a plugin is NOT a full app extension; it's a focused cross-cutting concern that pairs with the existing query / mutation / action / workflow primitives.
The shape
import { definePlugin } from '@voltro/protocol'
export const myPlugin = (options: MyOptions) =>
definePlugin({
name: '@vendor/my-plugin', // stable id — surfaced in boot logs + dashboard
description: 'rate-limits outbound HTTP per tenant',
framework: '^1.0.0', // semver range — soft-checked at boot
interceptMutation: async (next, ctx) => { /* wraps every mutation */ },
interceptAction: async (next, ctx) => { /* wraps every action */ },
onActivate: (lifecycle) => { /* one-shot boot: open pools, register metrics */ },
onDeactivate: (lifecycle) => { /* graceful shutdown */ },
})definePlugin is an identity function with type-level enforcement — it returns the input unchanged at runtime, but the type-checker catches missing required fields, misnamed hooks, and excess properties at the declaration site. Always use it over plain object literals.
Hooks
interceptMutation / interceptAction (Effect-native)
Wrap every mutation or action call respectively. Both are Effect-based — the framework is Effect end-to-end and the plugin boundary preserves that shape so tracing, interruption, and the typed error channel flow through without round-tripping to Promise:
type RpcInterceptor = (
next: Effect.Effect<unknown, unknown>,
context: {
readonly tag: string // 'todos.create', 'support.ping', …
readonly kind: 'mutation' | 'action' // discriminator
readonly input: unknown // the validated payload
readonly subject: Subject // resolved by AuthMiddleware
readonly traceId: string // matches OTel spans + logs
},
) => Effect.Effect<unknown, unknown>The interceptor MUST flow next through somehow — yield it, pipe through it,
or return it. Whatever the returned Effect produces becomes the final result
UNLESS the interceptor substitutes a different one (cache hits, etc).
Failing the returned Effect skips the rest of the chain and surfaces the
error to the rpc layer.
// Pre-only: short-circuit before the executor runs.
const guard: RpcInterceptor = (next, ctx) =>
ctx.tag.startsWith('admin.') && ctx.subject.type !== 'user'
? Effect.fail(new ScopeError({ required: 'user', message: ` is admin-only` }))
: next
// Post-only: tap the success/failure channels.
const log: RpcInterceptor = (next, ctx) =>
next.pipe(
Effect.tap((result) => recordAudit(ctx.tag, ctx.input, result, ctx.subject)),
Effect.tapError((err) => recordAuditError(ctx.tag, err)),
)
// Tracing: spans nest automatically.
const trace: RpcInterceptor = (next, ctx) =>
next.pipe(Effect.withSpan(`plugin.${ctx.tag}`))Composition. When multiple plugins each install interceptMutation, the framework composes them in declaration order: the first plugin in app.config.ts's plugins: array is the OUTERMOST wrapper, the last is closest to the executor. That mirrors how middleware composition normally reads top-to-bottom in user code.
Why three hooks instead of one. A plugin that only wants to govern outbound HTTP (rate limiting, tenant-scoped IO quotas) installs interceptAction without touching mutations. A plugin that records writes (audit log) installs interceptMutation without touching reads or actions. The split lets plugins opt in narrowly.
interceptQuery wraps subscription setup — the one-time call that produces the QueryDescriptor. Use it for pre-setup authz (deny before the subscription opens), per-tenant subscription-rate-limiting, or subscription-open audit logging. It does NOT wrap every snapshot/delta delivery — per-delta observability flows through the framework's OTel spans (subscription.<tag>.snapshot / .delta); wrapping every delivery would add per-event latency the streaming model is specifically designed to avoid.
interceptQuery: (next, ctx) =>
ctx.subject.type === 'anonymous' && ctx.tag.startsWith('admin.')
? Effect.fail(new Unauthenticated({ reason: 'admin queries require authentication' }))
: next,A failure inside an interceptQuery aborts the subscription open; the rpc layer surfaces it to the client as a subscription error. Substituting the return Effect is supported but rare — usually interceptors do pre-only or post-only side effects.
Per-plugin observability is automatic. Every interceptor is wrapped at compose time with Effect.withSpan('plugin.<name>.intercept-<kind>') + a plugin.* metric sample — no opt-in. The DevTools / cloud dashboard's Plugins panel filters /_voltro/inspect/metrics by the plugin.* prefix to render per-plugin latency + count buckets, and the trace waterfall shows the plugin layer as a nested span inside the handler boundary.
onChangeEvent — the post-commit ChangeEvent tap
onChangeEvent: (event: PluginChangeEvent) => Effect.Effect<void, unknown>
// PluginChangeEvent = {
// table, op: 'insert'|'update'|'delete', new: Row | null, old: Row | null,
// origin?: 'inline' | 'injected', changeScope: 'local' | 'fleet',
// }A tap on the store's post-commit change stream — the plugin sees every committed insert/update/delete. It returns an Effect<void, E> that the runtime supervises: it forks the Effect under the plugin's supervision scope (so the tap is non-blocking and can never back-pressure the change stream or the writing mutation) and routes the Effect's failure channel to the plugin-scoped logger. That gives a change-tap a real, typed error channel — Effect.retry, Effect.timeout, Effect.catchTag, a durable enqueue — instead of fire-and-forget glue.
onChangeEvent: (event) =>
event.table !== 'orders'
? Effect.void // not my table — no-op
: Effect.tryPromise(() => mirror(event)).pipe( // failure is logged by the runtime
Effect.retry({ times: 3 }),
)Runs under BOTH voltro dev and voltro serve (the prod serve path fans out the same way). Requires the 'store:changes:read' permission. It is NOT durable at the framework layer — a crash between commit and the fork loses the event; build durability INSIDE the Effect (insert into an outbox and retry against the typed error channel, the way @voltro/plugin-cdc-out does). Exactly-once / change-scope semantics are unchanged: read event.origin + event.changeScope inside the Effect to act once per change fleet-wide (skip origin: 'injected' on 'local' scope; elect one worker on 'fleet'). Used by @voltro/plugin-search to mirror rows into an external index. For in-transaction reactions use a mutation; for best-effort per-table reactions in app code prefer a *.subscribe.ts — onChangeEvent is the plugin-level equivalent.
onInstall / onUninstall / onActivate / onDeactivate lifecycle
Four lifecycle hooks, all accepting Effect / Promise / sync return values. The framework awaits each in turn — sync returns settle synchronously, Promises and Effects are awaited.
interface PluginLifecycleContext {
readonly app: {
readonly name: string
readonly type: 'api' | 'web'
readonly voltroVersion: string
}
readonly logger: { info; warn; error; debug } // scoped to the plugin's name
readonly env: NodeJS.ProcessEnv
readonly config: unknown // validated against configSchema if declared
}onInstallruns ONCE per process perplugin@version— the FIRST time the host sees this plugin. Use for schema migrations + resource provisioning that survive across deactivate/activate cycles. In v1 the install marker is in-memory; persisted state (_voltro_plugin_installstable) lands with the migration runner. Failure aborts boot.onUninstallis the teardown mirror ofonInstall— declare it to drop tables, delete queues, etc. when the plugin is removed from the app. Keep it idempotent (re-running is a no-op once torn down).onActivateruns every boot. Use for warming caches, opening connection pools, registering metrics sinks. The framework awaits each hook sequentially in declaration order. Throwing/failing aborts boot — better to surface a misconfigured plugin loudly than start the rpc server with half-wired plugins.onDeactivateruns in REVERSE declaration order (mirror of activate — the most-recently-activated plugin tears down first). Each hook has a 5-second per-plugin grace window and the whole sequence is capped at 30 seconds total. A plugin that throws or exceeds its grace window logs a warning + the sequence moves on; shutdown never blocks on a stuck plugin. Idempotent — repeated SIGTERM doesn't re-run hooks. Hookup covers SIGINT + SIGTERM in bothvoltro devandvoltro start.
framework compatibility
definePlugin({
name: '@vendor/x',
framework: '^1.0.0', // accepts 1.x, rejects 0.x and 2.x
// OR: '~1.2.0', '>=1.0.0 <2.0.0', '1.2.3' (exact), '*' (any)
})At boot the framework runs checkFrameworkCompat(plugin.name, plugin.framework, runningVoltroVersion). Mismatch logs a WARN with the constraint + the running version, and boot continues by default — the operator decides whether to pin a different version or upgrade. Set VOLTRO_STRICT_PLUGIN_COMPAT=1 to make a mismatch a hard boot-abort instead (the plugin throws at boot) — for CI or regulated deployments that must refuse to run a plugin built against a different framework version.
Absent framework field → no compat check. Suitable for in-tree plugins that ship lockstep with the framework.
What plugins CAN'T do (initial policy)
- Mutate other plugins' state. Plugins don't talk to each other directly. If two plugins need to coordinate, it's via the rpc layer (one plugin's interceptor sees the other's
subject.metadata, for example). - Bypass tenant scoping. Interceptors run AFTER the runtime's tenant predicate merge. A mutation interceptor can't query rows from another tenant by manipulating
subject.tenantId. - Access raw secrets directly. Plugins get config via their own factory function's options object.
lifecycle.envexposesprocess.envbut only the plugin's own factory chooses which env vars to read. - Modify the core schema DSL or query builder. Schema extension happens through schema mixins (the
*.mixin.tspattern in@voltro/database); not through plugin runtime hooks.
These boundaries hold for v1; some may relax for verified plugins once the marketplace ships.
Stacking
// app.config.ts
import { auditPlugin } from '@voltro/plugin-audit'
import { rateLimitPlugin } from '@vendor/rate-limit'
import { metricsPlugin } from '@vendor/metrics'
export default {
type: 'api' as const,
name: 'myApi',
plugins: [
metricsPlugin({ sink: 'datadog' }), // OUTERMOST — sees every request first
rateLimitPlugin({ perTenant: 100 }),
auditPlugin({ sink: 'console' }), // INNERMOST — closest to executor
],
}The first plugin's interceptMutation wraps the second's, which wraps the third's, which wraps the executor. Same composition for interceptAction. The chain runs in deterministic order independent of import order or filesystem walk — only plugins: array order matters.
services: Layer — contribute Tags into the per-request runtime
A plugin can provide an Effect Layer whose Tags become available to
EVERY handler in the app. Handlers yield* MyTag to read; plugins own
the implementation; consumers don't import the plugin directly.
import { Effect, Layer } from 'effect'
import { definePlugin, definePluginService } from '@voltro/protocol'
interface AuditService {
readonly record: (event: { tag: string; actor: string }) => Effect.Effect<void>
}
const { Tag: Audit, Live: AuditLive } = definePluginService<AuditService, AuditService>(
'@vendor/audit/Service',
{
record: (event) => Effect.sync(() => console.log('AUDIT', event)),
},
)
export const auditPlugin = (): VoltroPlugin =>
definePlugin({
name: '@vendor/audit',
services: AuditLive,
})
// In any handler:
// import { Audit } from '@vendor/audit'
// const audit = yield* Audit
// yield* audit.record({ tag: 'todos.create', actor: 'u_42' })Layer composition is independent of declaration order across plugins
(Effect's mergeAll is commutative on Tag identity); collisions on the
SAME Tag resolve to the LAST layer in the merge list, so a user layer
in apiConfig.layers can override a plugin layer with the same Tag.
routes: PluginRpcRoute[] — plugins ship their own RPC endpoints
Plugins can register their own queries / mutations / actions alongside user-authored queries. Same wire protocol, same dashboard surface, same interceptor + tracing wiring. Query tags carry a plugin-derived prefix so plugin queries never collide with user queries:
import { definePlugin, definePluginRoute } from '@voltro/protocol'
import { Effect, Schema } from 'effect'
export const auditPlugin = (): VoltroPlugin =>
definePlugin({
name: '@voltro/plugin-audit',
routes: [
definePluginRoute({
kind: 'query',
name: 'list', // tag = 'audit.list' (plugin alias prepended)
input: Schema.Struct({ limit: Schema.optional(Schema.Number) }),
output: Schema.Array(AuditEventSchema),
execute: (input) => Effect.gen(function* () {
const buffer = yield* AuditBuffer // service Tag from plugin's `services` layer
return buffer.read(input.limit ?? 100)
}),
}),
definePluginRoute({
kind: 'mutation',
name: 'clear', // tag = 'audit.clear'
input: Schema.Struct({}),
output: Schema.Struct({ cleared: Schema.Number }),
execute: () => Effect.sync(() => {
// clear logic — runs inside the framework's transactional wrap
return { cleared: 0 }
}),
}),
],
})Tag derivation: <plugin-alias>.<query.name> unless query.name contains
a dot (escape hatch). Plugin alias strips @scope/ + the plugin- prefix
and kebab→camelCase:
| Plugin name | Alias |
|---|---|
@voltro/plugin-audit |
audit |
@scope/plugin-rateLimit |
rateLimit |
@scope/plugin-rate-limit |
rateLimit |
plain-name |
plainName |
@voltro/audit |
audit (no plugin- to strip) |
Boot fails with a clear error on tag collisions (between two plugins, or with a user-authored tag).
Reactive plugin queries — source
A plugin query can be push-driven instead of poll-only. Add a source
table (or tables) to a kind: 'query' route and the framework re-runs the
executor and pushes a fresh result over the SAME subscription/WS transport
app reactive queries use — every time one of those tables changes. No new
push system: it reuses the framework's computed-reactive-query machinery, so
the client just subscribes and receives live updates.
definePluginRoute({
kind: 'query',
name: 'list',
source: '_voltro_presence', // ← reactive: re-run + push on any change to this table
input: Schema.Struct({ channel: Schema.String }),
output: Schema.Array(MemberSchema),
// The executor returns the query's VALUE (the same shape it returns when
// polled); the framework recomputes + pushes it on change.
execute: (input, ctx) => Effect.gen(function* () {
// read the table, shape the roster, return it
return roster
}),
})source must name a table declared .reactive(). Omit source for a plain
poll-only plugin query, a mutation, or an action. This is exactly how
@voltro/plugin-presence makes presence.list push-driven — the
usePresence roster updates live with no client polling.
permissions: PluginPermission[] + configSchema: Schema — manifest fields
definePlugin({
name: '@vendor/audit',
version: '1.2.3',
permissions: [
'rpc:intercept:mutation', // matches interceptMutation
'rpc:intercept:query', // matches interceptQuery
'secrets:read:audit:*', // pattern-perm (runtime check)
],
configSchema: Schema.Struct({
sink: Schema.Literal('console', 'memory', 'postgres'),
verbose: Schema.optional(Schema.Boolean),
}),
})permissions is a typed enum. The framework audits every plugin's
declared set against its hook surfaces at boot — a plugin that ships
onHttpRequest without declaring 'http:intercept' (or any other
hook-without-perm combination) fails boot with the specific scope name.
No silent strip; no advisory warnings; the operator either grants the
perm or the plugin doesn't run.
The static surface is a union of well-known scopes; pattern perms
('secrets:read:foo:*', 'network:outbound:api.stripe.com',
'plugin:hook:<other-plugin>') fall through the template-literal arm.
Runtime resource checks use permissionMatches(declared, required) —
a declared 'secrets:read:auth0:*' covers a required
'secrets:read:auth0:clientSecret'.
| Hook surface | Required permission |
|---|---|
interceptMutation |
'rpc:intercept:mutation' |
interceptQuery |
'rpc:intercept:query' |
interceptAction |
'rpc:intercept:action' |
onScheduleFire |
'schedule:fire' |
onWorkflowStep |
'workflow:step' |
onHttpRequest |
'http:intercept' |
onChangeEvent |
'store:changes:read' |
inspectEndpoints (non-empty) |
'inspect:read' |
dashboard (non-empty) |
'dashboard:mount' |
extendSchema.tables (non-empty) |
'store:write' |
extendSchema.migrations (non-empty) |
'migration:run' |
configSchema decodes the operator's user-supplied config payload at
boot. Decode failures abort boot with a typed error pointing at the
plugin. The decoded value lands in PluginLifecycleContext.config so
lifecycle hooks consume the already-validated shape.
Writing a plugin — minimal example
// packages/plugin-rate-limit/src/index.ts
import { Effect } from 'effect'
import { definePlugin, type VoltroPlugin } from '@voltro/protocol'
interface RateLimitOptions {
readonly perTenant: number // requests/minute
}
const buckets = new Map<string, { count: number; windowStart: number }>()
export const rateLimitPlugin = (options: RateLimitOptions): VoltroPlugin =>
definePlugin({
name: '@vendor/rate-limit',
framework: '^1.0.0',
interceptMutation: (next, ctx) => {
if (ctx.subject.type === 'anonymous') return next // no per-tenant limit
const key = ctx.subject.tenantId
const now = Date.now()
const bucket = buckets.get(key) ?? { count: 0, windowStart: now }
if (now - bucket.windowStart > 60_000) {
bucket.count = 0
bucket.windowStart = now
}
bucket.count++
buckets.set(key, bucket)
if (bucket.count > options.perTenant) {
return Effect.fail(new Error(`rate limit exceeded for tenant ${key}`))
}
return next
},
onActivate: ({ logger }) => {
logger.info('rate-limit plugin armed', { perTenant: options.perTenant })
},
})That's the whole plugin. Drop it in app.config.ts, voltro dev picks it up, every mutation goes through the rate limiter.
Schema mixins — the OTHER plugin shape
Not every cross-cutting concern needs runtime hooks. Schema extensions live in their own surface — defineMixin({...}) from @voltro/database — and don't go through the VoltroPlugin contract at all. See database/mixins for that pattern.
A package can ship BOTH a schema mixin AND a runtime plugin (@voltro/plugin-audit does — the audit() mixin adds columns; the auditPlugin() factory installs the mutation interceptor). They're independent — apps that want the schema but not the interceptor .with(audit()) without plugins: [auditPlugin()], and vice versa.
More extension surfaces
The plugin contract carries eight additional surfaces beyond the interceptors + lifecycle + manifest fields covered above:
inspectEndpoints: PluginInspectEndpoint[] — plugin-mounted HTTP endpoints
import { definePlugin } from '@voltro/protocol'
import { Effect } from 'effect'
export const auditDebugPlugin = (): VoltroPlugin =>
definePlugin({
name: '@voltro/plugin-audit-debug',
inspectEndpoints: [
{
method: 'GET',
path: 'buffer', // → /_voltro/inspect/plugins/auditDebug/buffer
description: 'Returns the last 1000 audit events',
handler: () => Effect.sync(() => ({
kind: 'json',
data: { events: readAuditBuffer() },
})),
},
{
method: 'POST',
path: 'clear',
handler: () => Effect.sync(() => {
clearAuditBuffer()
return { kind: 'json' as const, data: { cleared: true } }
}),
},
],
})Plugin endpoints inherit the framework's auth resolver — plugins can't
bypass VOLTRO_INSPECT_TOKEN. Path: /_voltro/inspect/plugins/<plugin-alias>/<path>.
Boot fails loudly on collisions (two plugins claiming the same path+method).
onScheduleFire: ScheduleFireInterceptor — wrap every cron firing
definePlugin({
name: '@vendor/schedule-gate',
onScheduleFire: async (next, ctx) => {
// Suppress all firings of `nightlyBilling` when the kill-switch is on.
if (ctx.name === 'nightlyBilling' && (await isKilled('billing'))) {
return // skip — handler doesn't run
}
await next()
},
})Composed across plugins in declaration order; runs INSIDE the schedule's
maxRuntimeMs watchdog. Per-plugin span (plugin.<name>.schedule-fire)
- metric auto-emitted.
onWorkflowStep: WorkflowStepInterceptor — wrap every step()
import { Effect } from 'effect'
definePlugin({
name: '@vendor/step-observer',
onWorkflowStep: (next, ctx) =>
next.pipe(
Effect.withSpan(`vendor.workflow.${ctx.stepName}`, {
attributes: { 'workflow.attempt': ctx.attempt },
}),
Effect.tap((output) => Effect.sync(() => recordStep(ctx, output))),
),
})Wraps every step() (== Activity.make) inside a workflow body. Effect-
native — the interceptor sees next as an Effect and can pipe through
tap/retry/withSpan. Bypassed on workflow REPLAY — the interceptor
only fires on the first execution per step; on resume after a crash, the
cached Activity output is replayed without re-running the user effect or
the plugin interceptor.
codegen: PluginCodegen — emit typed bindings into rpcGroup.generated.ts
definePlugin({
name: '@voltro/plugin-audit',
codegen: (ctx) => `
// Typed accessor for the audit plugin's inspect endpoint
export const useAuditBuffer = () =>
fetch('/_voltro/inspect/plugins/${ctx.pluginAlias}/buffer')
.then((r) => r.json() as Promise<{ events: ReadonlyArray<AuditEvent> }>)
`,
})The framework emits the returned string into rpcGroup.generated.ts
between // <plugin:@voltro/plugin-audit> / // </plugin:@voltro/plugin-audit>
markers. The plugin sees the api name + the list of discovered user-query
rpc tags so it can emit per-rpc bindings. Returning null contributes
nothing.
templates: PluginTemplate[] — ship voltro init templates
definePlugin({
name: '@vendor/plugin-stripe-webhooks',
templates: [
{
id: 'stripe-receiver',
title: 'Stripe webhook receiver',
description: 'Verified Stripe-webhook endpoint with replay protection',
kind: 'api',
sourcePath: 'templates/stripe-receiver',
postInstallSteps: [
'Set VOLTRO_WEBHOOK_SECRET_STRIPE in your env',
'Configure the endpoint URL in your Stripe dashboard',
],
},
],
})Templates surface under the plugin alias in voltro list-templates. The
registration shape + manifest are part of the public contract, and a
plugin-provided template is scaffolded exactly like a built-in one —
scaffoldFromTemplate copies the declared tree and substitutes the
{{…}} tokens in both file content AND file/directory names.
onHttpRequest: HttpRequestInterceptor — pre-auth HTTP-pipeline hook
Fires at the very top of every HTTP request — BEFORE auth resolution,
BEFORE rpc routing, BEFORE inspect. Composed across plugins in
declaration order (first listed = outermost). Returning a
HttpInterceptResponse short-circuits the pipeline; returning null
(or calling next() and returning its result) lets the request flow
through to the framework's normal handlers.
This is not an auth replacement — AuthMiddleware still runs on
the rpc path. Use this hook for concerns that need to fire BEFORE auth:
rate-limit, geo-block, bot detection, header injection for downstream
observability. Requires the 'http:intercept' permission.
import { definePlugin } from '@voltro/protocol'
export const rateLimitPlugin = (opts: { perMinute: number }) =>
definePlugin({
name: '@vendor/plugin-rate-limit',
permissions: ['http:intercept'],
onHttpRequest: async (next, ctx) => {
const remoteAddr = ctx.headers['x-forwarded-for'] ?? ctx.remoteAddr ?? 'unknown'
if (buckets.consume(remoteAddr, opts.perMinute) === 'exhausted') {
return {
status: 429,
headers: { 'retry-after': '60', 'content-type': 'application/json' },
body: JSON.stringify({ error: 'rate_limit_exceeded' }),
}
}
return next() // let the request continue
},
})A per-plugin plugin.<name>.http-intercept metric is auto-emitted so
the dashboard's Plugins panel surfaces HTTP-intercept latency next to
RPC-intercept latency.
extendSchema: { tables, migrations } — contribute schema + migrations
A plugin contributes BOTH declarative table descriptors AND custom SQL
migrations. Tables merge into the user's schema and flow through the
same idempotent applySchema() path. Migrations run after applySchema
against the live SqlClient and are tracked in
_voltro_plugin_migrations so each runs exactly once per app database.
The ledger key is <plugin-alias>__<migration.id> so two plugins can
each ship '001-init' without collision. Failure aborts boot;
re-runs are no-ops.
import { Effect, Schema } from 'effect'
import { definePlugin } from '@voltro/protocol'
import { table, id, text, timestamp } from '@voltro/database'
const auditLogs = table('audit_logs', {
id: id({ prefix: 'audit' }),
actorId: text(),
action: text(),
payload: text(),
createdAt: timestamp().default('now'),
})
export const auditPlugin = () =>
definePlugin({
name: '@voltro/plugin-audit',
permissions: ['store:write', 'migration:run', 'rpc:intercept:mutation'],
extendSchema: {
tables: [auditLogs],
migrations: [
{
id: '001-pgcrypto-extension',
description: 'enable pgcrypto for hashing actor ids',
up: (sql) => Effect.gen(function* () {
yield* sql`CREATE EXTENSION IF NOT EXISTS pgcrypto;`.pipe(Effect.orDie)
}),
},
],
},
})tables requires 'store:write'; migrations requires
'migration:run'. Either one missing → boot fails with the specific
permission name.
dashboard: PluginDashboardMount[] — remote-mounted dashboard surfaces
Plugins surface UI in the cloud / devtools dashboard by declaring remote-loaded ESM modules. The host runtime fetches the bundle URL at mount time, dynamic-imports it, and renders the exported component inline (NOT iframe — true in-process mount).
Trade-offs vs iframe:
- The plugin's bundle MUST be ESM with React/Effect as peerDeps; the host pins versions. Mismatch logs a warning at mount time but still mounts.
- No CSS isolation. Plugin authors are expected to use scoped Tailwind classes (the dashboard ships the framework token set) or CSS modules.
- Plugin code shares the host JS realm — honor-system sandboxing. The
dashboard:mountpermission gate keeps the operator audit trail explicit; signing/marketplace verification is the longer-term answer.
definePlugin({
name: '@vendor/plugin-audit',
permissions: ['dashboard:mount'],
dashboard: [
{
id: 'overview',
slot: 'page', // 'page' | 'widget' | 'nav'
route: 'overview', // mounted at /plugins/audit/overview
label: 'Audit log',
icon: 'shield', // optional lucide-react icon name
bundleUrl: 'https://cdn.example.com/audit/v1/dashboard.mjs',
exportName: 'AuditOverview', // optional; defaults to 'default'
dashboardVersion: '^1.0.0', // optional compat range
},
{
id: 'recent-events',
slot: 'widget', // card on the dashboard home
label: 'Recent audit events',
bundleUrl: 'https://cdn.example.com/audit/v1/widget.mjs',
},
],
})The registry surfaces at /_voltro/inspect/plugins/dashboard-mounts:
curl http://localhost:4000/_voltro/inspect/plugins/dashboard-mounts | jq
# { "mounts": [...], "capturedAt": 1717142400000 }Boot validates within-plugin id uniqueness AND that slot: 'page'
mounts carry a route. Cross-plugin ids are namespaced as
<plugin-alias>:<mount.id> (e.g. audit:overview).
The actual dashboard-host runtime — the React shell that dynamic-imports
- mounts the component — lives in
voltro-cloud-dashboard+voltro-devtools. The framework owns the registration shape + manifest; the consumers own the renderer.
bindDataStore(store, ctx) — bind DB-backed resources + framework handles
onActivate runs BEFORE the app's DataStore exists (the framework
activates plugins, THEN opens the store). bindDataStore is the
post-store hook: the framework calls it once, after the store + pool are
open, so a plugin can bind a resource it couldn't build from static
config — e.g. swap an in-memory ref store for a DataStore-backed one.
The second argument, ctx: PluginBindContext, hands the plugin the
framework's ALREADY-OPEN handles so it never rebuilds them:
ctx.sql— the framework's liveSqlClient(from@effect/sql), the SAME pool the app's store uses. Run raw SQL through it instead of standing up your OWNManagedRuntime+ pool from env.undefinedon the in-memory store (no SQL engine) — guard withif (ctx.sql).ctx.scheduleCoordinated(name, intervalMs, effect)— run a periodic task on ONLY ONE replica per tick, cluster-coordinated via the same claim-table exactly-once gate the cron scheduler uses. Replaces the hand-rolledsetIntervala plugin would otherwise run insidebindDataStore— which fires on EVERY replica, so a 10-pod deployment runs the same full-table sweep 10× per interval. On a single-process / memory / sqlite deployment it simply runs every tick locally (correct — one process needs no fan-out dedup). Returns a handle whosestop()cancels the task; the framework also stops every armed task at shutdown.
import { definePlugin } from '@voltro/protocol'
definePlugin({
name: '@vendor/plugin-presence',
permissions: ['store:write'],
bindDataStore: (store, ctx) => {
// Reuse the framework's open pool — no connFromEnv, no second pool.
if (ctx?.sql) {
// ctx.sql is the app's live SqlClient (Effect-native).
}
// One coordinated sweep fleet-wide, not one setInterval per replica.
ctx?.scheduleCoordinated('presence.sweep', 60_000, async () => {
await store.deleteMany('_voltro_presence', {
where: { column: 'lastSeen', op: 'lt', value: Date.now() - 120_000 },
})
})
},
})store is typed as the framework DataStore; ctx is optional in the
type (the framework always passes it) so an older single-argument
bindDataStore(store) still compiles. Requires no extra permission
beyond whatever the store operations themselves need (store:write for
writes).
Inspecting plugins at runtime
Every running app exposes its plugin manifest at /_voltro/inspect/plugins:
curl http://localhost:4000/_voltro/inspect/plugins | jqReturns one entry per plugin in app.config.ts's plugins: array with:
name,version,description,frameworksemver rangepermissions: string[]— declared scopeshooks— which interceptor + lifecycle hooks the plugin installedqueries—kind:nametuples for every plugin-contributed queryservices: boolean— whether the plugin contributes a service layerhasConfigSchema: boolean— whetherconfigSchemais declaredactivated,installed— runtime state
The DevTools + cloud dashboard's Plugins panel consumes this; AI agents grep it to answer "what is this deployment running, with what permissions".
Per-plugin metrics surface under the SAME /_voltro/inspect/metrics
endpoint that carries rpc.* / request.* buckets — plugin buckets
have kind: 'plugin' and tag plugin.<name>.intercept-<mutation|query|action>.
Filter client-side by the plugin. prefix to render the per-plugin
latency table.
Building integration plugins — @voltro/integration-http
A plugin that talks to a third-party REST API (Jira, GitHub, GitLab, …) needs
the same server-side transport concerns every time: token auth, transient
retry with backoff, per-request timeout, an SSRF host guard, and typed errors.
@voltro/integration-http is that transport core — one implementation shared
by @voltro/plugin-atlassian and any integration plugin you write, so you
don't re-invent it.
makeHttpClient binds a base URL, an auth strategy, a retry/timeout policy,
and a fetch impl once, then hands you typed request methods. Every method
returns Effect.Effect<A, YourError> — failures land on your error
channel via the makeError you supply, so each integration keeps its own
Schema.TaggedError:
import { makeHttpClient, type FetchLike } from '@voltro/integration-http'
import { Schema } from 'effect'
class GithubError extends Schema.TaggedError<GithubError>()('GithubError', {
message: Schema.String,
transient: Schema.Boolean,
status: Schema.optional(Schema.Number),
code: Schema.optional(Schema.Literal('session_expired')),
}) {}
const github = makeHttpClient<GithubError>({
baseUrl: 'https://api.github.com',
auth: () => ({
authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
accept: 'application/vnd.github+json',
}),
makeError: (a) => new GithubError(a),
fetchImpl: fetch as unknown as FetchLike,
})
// Each method returns Effect.Effect<…, GithubError>.
const repo = yield* github.getJson('/repos/acme/widgets')
const created = yield* github.postJson('/repos/acme/widgets/issues', {
body: { title: 'bug', body: 'it broke' },
})What the core does for you: retries 408/425/429/5xx with capped exponential
backoff (jittered, honouring Retry-After within the maxDelayMs ceiling),
fails a hanging request as transient after the timeout, pins every request to
the base URL's host (an off-host or malformed URL is rejected before any
fetch, fail-closed), maps a 401 to a non-transient session_expired
(re-auth, never retried), and wraps each request in an
integration-http.request span (method + host only — never a token). When the
token is resolved per request (per-subject credentials), give the client a
context type and pass ctx on each call:
const client = makeHttpClient<GithubError, { token: string }>({
baseUrl: 'https://api.github.com',
auth: (ctx) => ({ authorization: `Bearer ${ctx.token}` }),
makeError: (a) => new GithubError(a),
fetchImpl: fetch as unknown as FetchLike,
})
const me = yield* client.getJson('/user', { ctx: { token: perSubjectToken } })Server-side only. This module performs outbound network I/O — never import it (or a module that imports it) from a browser-loaded query / mutation / action / workflow descriptor.
Related
- Schema mixins — the OTHER plugin shape (schema-only, no runtime hooks).
- Auth strategies — a specialised plugin pattern for identity providers.