Railbase
GPTClaude

Building plugins

How plugins are structured, run, and distributed (advanced).

Updated

Video guide —watch on YouTube ↗

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_json inside the bundle. The core reconciles them into the shared Vault on install.
  • UI as bundle/manifest.json with manifest.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. Legacy widgets.json files 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 — stamp tenant_id on write, filter on read.
  • Identitye.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().publish is 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:

  1. The bundle is published to railbase.app (the vendor's distribution server) with its version, content hash, and Ed25519 signature.
  2. A customer buys a subscription; railbase.app issues a lic1 license.
  3. 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.

Was this page helpful?Thanks for your feedback!