Building plugins
How plugins are structured, run, and distributed (advanced).
Updated
Plugins are how functionality is packaged and sold on top of Railbase. This page is an overview of the plugin model for developers; it's an advanced topic — most apps are built directly on the core (see the rest of this section).
A plugin is a data-resident bundle the core runs
The unit a customer receives and the platform runs is a data-resident
bundle: an encrypted JavaScript + schema bundle stored in the core's own Vault
(the _plugins row), decrypted only at the license gate, and executed
in-process in the core's goja runtime. A plugin has no main(), no port, no
subprocess, no database of its own, and no run path outside the core — it is
data the platform runs, not a program.
The bundle ships:
- Verbs registered with
$app.routerAdd("POST", "/api/<slug>/…", fn)— the core serves them in-process at/api/<slug>/*. There is no reverse proxy and no child process; on install the core reloads its hooks runtime and the plugin's verbs mount immediately — no rebuild, no restart, no extra port. - Collections declared as
schema_jsoninside the bundle. The core reconciles them into the shared Vault on install. - UI as
bundle/manifest.jsonwithmanifest.ui.pages(pages + widgets bound to the plugin's verbs). The core's site shell mounts it at/<slug>on install — the whole plugin (backend + schema + UI) lands in one marketplace action. Legacywidgets.jsonfiles may exist for migration compatibility, but the shipped source of truth is the bundle manifest.
Internal dev/embedder workflows can compile a controlled host build for engineering use, but that is not the unit a customer receives and never a distribution channel. Everything below describes the shipped, data-resident model.
How a data-resident plugin uses the platform
The platform provides everything; the plugin only consumes it through mediated
host bindings — never *App internals:
- Data —
$app.dao(), the tenant-scoped binding over the platform's shared Vault (single-file CBOR document store, not SQL). There is no per-plugin store and no embedded vault; the DB is the platform's, shared but mediated. Tenant scoping is explicit — stamptenant_idon write, filter on read. - Identity —
e.auth, the trusted authenticated user/tenant on each request. Never read identity from a client header. - Events — publish a free-form domain topic with
$app.emit(topic, payload)(optionally a third originating-tenant argument) and consume with$app.onEvent(topic, fn). ($app.realtime().publishis a separate, narrower binding for record-shaped CRUD events{collection, verb, record}— not arbitrary topics.) - Jobs — background work via
$jobs. - Licensing — plan/quota via
$app.license.
The core enforces min_core from the catalog (it refuses a plugin that needs a
newer core).
Inter-plugin communication
Plugins never import one another — every cross-plugin interaction goes through the platform's event bus. A plugin publishes and subscribes through the bus; there are no Go imports and no direct plugin→plugin calls.
A consumer's handler receives a plain JS event object { topic, payload, tenant_id } and reads e.payload directly — validate the fields you need, since
a producer on another schedule may send a shape you don't expect. Two contracts
are enforced at install/update time by the plugindock docking gate (not a
CI step): every topic your plugin consumes must resolve to a real subscription
in its code, and every declared publish/consume must line up with a live
counterpart (or a documented allowlist entry) — aspirational declarations are
rejected when the bundle docks.
The canonical example is the ledger contract: ap/ar/fa (and
htr/inventory) emit gl.journal.post.request, and the gl ledger plugin
posts the journal idempotently and replies gl.journal.posted.
For ecosystem value, a plugin can also declare named capabilities a peer may
consume (capabilities.provides in its manifest) and run an on_uninstall hook
to clean up when it is removed — always over declared, discoverable contracts,
never ad-hoc Go calls.
Distribution
Plugins reach customers only through the marketplace, as signed bundles. railbase.app is the sole orchestrator of purchase, licensing, install, update, and removal — there are no side channels and no manual binary handoff:
- The bundle is published to railbase.app (the vendor's distribution server) with its version, content hash, and Ed25519 signature.
- A customer buys a subscription; railbase.app issues a
lic1license. - The customer's core pulls the bundle, verifies sha256 + signature against the pinned vendor key, lands the encrypted, license-gated row in its Vault, and runs the decrypted JS in goja. A paid plugin without a valid license installs dormant — its code is never decrypted — and recovers to active automatically once a valid license is bound.
There is no sideloading — see How plugins work and Licensing & seats for the full trust and billing model.
Pricing is a value-metric union. The common kinds are flat_monthly (one price
per company), tenant_monthly (per tenant/company of a multi-tenant instance —
in practice the most-used kind), seat_monthly (per billable user/role seat, the
legacy model), host_seat_monthly (per host/organizer), flat_usage_monthly
(flat price with a usage quota), or custom.
Note
Authoring and publishing plugins to the marketplace is a vendor/partner process. If you want to distribute a plugin, get in touch via Support.