Plugin-manager API reference
Everything a plugin registers goes through libs/aglyn/src/lib/plugin-manager,
re-exported from @aglyn/aglyn (client + isomorphic) and
@aglyn/aglyn/server (adds the server-only loaders). Hand-written by
design: the surface is small and curated, and each entry needs semantics
(ordering, caching, failure behavior) that generated signatures can't carry.
Console extensions — feature-plugins
| API | What it does |
|---|---|
registerConsoleExtension(extension) | Declares everything a plugin adds to the console shell. Idempotent by pluginId (re-registration replaces). |
listConsoleNavItems() / resolveConsolePluginPage(href) | How the shell renders nav + serves plugin pages under /[hostId]/[pluginSlug]. |
listConsoleWidgets(slot) | Widgets registered for a named zone — see Injection zones. |
listConsoleProviders() | App-level providers mounted around every console page. |
defineUiFeatureBundle(options, components) | Site/canvas component bundle; auto-depends on the base mui bundle. Component and bundle ids are persisted in screen docs — never rename. |
CONSOLE_WIDGET_SLOTS | The typed injection-zone catalog. |
ConsoleExtension fields: pluginId, displayName, featureFlag?
(plan-entitlement gate the shell applies — extensions cannot bypass plans),
navItems? (a nav item with a Component becomes a full page and receives
ConsolePluginPageProps { hostId, entitled, org?, permissions? }),
dashboardCards?, settingsSections?, widgets?, providers?.
Loading — plugin-loader
| API | Semantics |
|---|---|
createPluginLoader(manifest) | One loader per generated manifest; loads are cached per plugin, registrations once per plugin+surface. |
loader.ensure(ids, surfaces) | Loads + registers the given plugins' surfaces. Returns a stable promise per (ids, surfaces) so React use() can suspend on it during SSR — the canvas never renders against an empty registry. Unknown ids are ignored (marketplace realm plugins load separately); alwaysOn entries activate regardless. |
loader.ensureAll(surfaces) | Every manifest plugin — the API dispatchers' lazy-load-all. |
loader.pluginIdForApiPath(path) | Prefix-map fallback for the per-request org gate. |
Lifecycle: all register fns in an ensure batch run first, then each
module's optional bootstrap<Surface>() export runs (manifest order,
once per plugin+surface, failures logged not fatal) — the sanctioned place
for cross-plugin wiring. Plugins loaded by a later ensure bootstrap in that
batch, so read registries lazily rather than snapshotting.
Server APIs — api-plugins (/server only)
| API | Semantics |
|---|---|
registerPluginApiRoute(path, handler) | Registers an exact path under the [...pluginApi] dispatchers. Ownership is recorded at registration time for the per-request org gate — a disabled plugin's paths 404 for that workspace. |
PluginApiRequest | { method, query, body, headers, rawBody? } — rawBody carries the unparsed payload for Stripe/Svix signature verification. |
Site pipeline — site-runtime, site-page-hooks (/server for hooks)
| API | Semantics |
|---|---|
registerSiteRuntime({runtimeId, Component}) | Components rendered on every published page (overlay engines, experiment runners); they read back the props their server enricher wrote. |
registerSiteRedirectResolver(fn) | Runs before route resolution; first non-null redirect wins. |
registerSitePageResolver(fn) | Composes plugin-owned pages (commerce PDP/PLP). |
registerSitePageEnricher(fn) | Contributes page-prop slices; enricher errors are isolated — a broken plugin drops its slice, never the page. |
Stylesheets — plugin-styles
Raw CSS your plugin ships, routed so the besigner canvas resolves it exactly as a published page does.
| API | Semantics |
|---|---|
registerPluginStyles({pluginId, styleId?, css}) | Registers (or replaces) one stylesheet. Rendered as a plain <style> at the site-content root of every surface — published tenant, editor Preview, and inside the besigner canvas's shadow root. |
unregisterPluginStyles(pluginId, styleId?) | Drops one sheet, or all of a plugin's. |
listPluginStyles() / subscribeToPluginStyles(fn) | The registry, and a change subscription. |
capturePluginStyles(pluginId, load) | Used by the realm loader; see below. |
Why you cannot just write to document.head. You can, and on a published
page it works — but the besigner canvas renders site content inside a
closed shadow root, which a document-level rule never reaches. Measured:
the same rule that beats every MUI declaration on the published page has no
effect at all on the canvas. So a plugin that styles itself that way looks
one way in the editor and another way live.
loadRealmPlugins therefore wraps each bundle's module evaluation and
register() in capturePluginStyles, which picks up any <style> the bundle
appends to document.head — the shape import './styles.css' compiles to in
every bundler — and mirrors it into the canvas. The original element is left
where the bundle put it, so CSS you ship for a console surface keeps
working. Anything injected outside that window (lazily, or from a <link>)
is not mirrored: call registerPluginStyles for those.
Cascade position. Plugin CSS is deliberately unlayered, while all MUI
and author sx output sits in @layer mui. An unlayered normal declaration
beats every layered one regardless of specificity, so a one-tag selector of
yours outranks a MUI component default — by design, and identically on both
surfaces. Do not wrap your rules in @layer mui; that puts them inside the
layer, where they start losing.
Sanitising. Every registered and captured sheet goes through the same
sanitizeAuthorCss the Custom HTML element uses: url() targets with a
refused scheme (http: and anything unrecognised) are rewritten to
about:invalid. https:, data:, blob: and relative forms pass through,
and hosts are not restricted.
Billing — billing-webhook-hooks (/server)
registerBillingWebhookHandler(eventTypePrefix, handler) — receives the
platform Stripe events. Handler errors propagate to a 500; make handlers
idempotent. Handlers run sequentially with no error isolation, deliberately:
isolating them would trade a duplicated side effect for a dropped one, which
is the worse trade on a money path.
A handler may return { claimed: true } to tell the platform it recognised
the event as its own — it found the order, the purchase, the booking. Returning
nothing keeps the previous meaning ("not mine"), so existing handlers need no
change. Every registered handler still runs after one has claimed; the claim is
a report, never a dispatch rule.
The one event where this matters today is charge.dispute.*. A dispute carries
no metadata, so plugins self-select by joining on the payment intent — and a
plugin that finds nothing looks identical to a plugin that failed. The console
route raises a staff alert for any dispute nothing claimed, because that is
money moving with no record of it anywhere. If your plugin owns disputes, claim
the ones it handles, or every one of them will be reported as a platform fault.
A throw that reaches the route after any dispatch has begun makes the platform hold its Stripe-event idempotency claim instead of releasing it, so the redelivery short-circuits as a duplicate and your handler is not run again — the failure is recorded on the claim and escalated to staff to reconcile by hand. Releasing the claim would let a redelivery re-apply whatever the failed dispatch had already committed (an inventory decrement, a gift-card balance, a coupon redemption), and those cannot be un-applied.
A throw raised before any dispatch began still releases the claim and is redelivered normally.
So "make handlers idempotent" is not advice — it is the condition under which a failed event can be retried at all. A handler whose effects are idempotent per effect (guarded by a stamp on the row it mutates, not by the event claim) is one this route can safely re-run.
Enablement, flags, config, fields, permissions, jobs
| API | Semantics |
|---|---|
resolveEnabledPlugins(org) | The org switchboard: absent field → all first-party; always-on unioned in; unknown (marketplace) ids kept. |
filterPluginsByReleaseFlags(ids, isFlagOn, {staffBypass}) | Subtracts release-flagged-off first-party plugins (AGL-422). |
registerPluginConfigSchema(schema) / mergePluginConfig / validatePluginConfigValues | Per-plugin settings: declared once, generic form + typed defaults-merged reads everywhere (AGL-428). |
registerCustomFieldType(fieldType) / validateCustomFieldValue | Dataset field types riding existing storage types (AGL-434). |
registerPluginPermissions(list) | Role-resolved permission keys with per-tier defaults (AGL-435). |
registerPluginJob(job) / runPluginJobs(due?) | Scheduled jobs run by the guarded /api/plugins/run-jobs route (AGL-435). |
registerPluginInstallPresetMapper(fn) | Maps marketplace install docs to besigner drawer presets. |
Remote bundles — realm-plugins (isomorphic), realm-server (/server)
| API | Semantics |
|---|---|
PLUGIN_HOST_ABI_VERSION / setRealmPluginHost(host) | The __AGLYN_PLUGIN_HOST__ ABI slot; the app composes it from its own React/jsxRuntime/registry singletons. |
verifyRealmBundle(bytes, install, publicKey?) | sha256 pin always; Ed25519 signature mandatory when a key is configured (fails closed). |
loadRealmPlugins(installs, {artifactsBase, publicKeyBase64}) | Fetch → verify → blob-URL import → register(host). Cached per listing@version; ABI mismatches refused; per-bundle failures logged and skipped. |
loadRemoteServerBundles(source) | Env-gated server tier (default OFF); returns what loaded so callers can audit. |
isCompatibleHostAbi(hostAbi?) | The ABI gate: undeclared = legacy (allowed with a warning). |
Sandbox — plugin-bridge
The versioned postMessage protocol between the host PluginFrame and a
sandboxed bundle: parseGuestMessage (origin/source/schema-validated),
filterPluginProps (manifest allowlist), message types ready/init/
props/resize/event/fetch-request/fetch-response/error. The
bridge never evals or grafts anything from the frame — sized output and
named events only; network goes through the host-mediated fetch
(server-side allowlist re-check).