Skip to content
Meith
Documents
Plugins

The plugin API

@meith/plugin-kit is the contract between the board and a plugin.

This document is the policy — what a plugin is, what it may and may not do, and what the guarantees actually cover. The reference (every hook, every payload) is generated into Plugin hooks.

Writing a plugin

A plugin is a module that calls definePlugin and is registered in community.plugins.ts — the installed list lives in its own file, beside community.config.ts, so the operator CLI can read it without importing the themes' component trees.

export const greeter = definePlugin({
key: "greeter",
name: "Greeter",
version: "0.1.0",
hooks: {
// A filter: what it returns replaces the value.
"view.footer": (footer) => ({
...footer,
links: [...footer.links, { label: "Rules", href: "/rules" }],
}),
// An event: its return value is discarded.
"post.created": { handler: (post) => report(post.postId), priority: 200 },
},
})

Installing it is pnpm add, a line in community.plugins.ts, and a redeploy.

What a plugin can declare

Field What it is
hooks Handlers for named hooks. Filters change a value; events observe.
settings Settings the admin panel renders, stored under plugin.<key>.<name>.
migrations Forward-only SQL, applied in ascending id order and recorded per plugin.
tasks Scheduled work, registered as plugin.<key>.<id> and run by the same tick as core's.
adminPages Pages mounted under /admin/plugins/<key>/.
contributions Markup in named UI regions.
onInstall / onEnable / onDisable / onUninstall Lifecycle callbacks — declared and typed, not yet dispatched by the host. See the inventory below.

What a plugin cannot do

These are not discouraged. There is no API for them.

It cannot Why
Decide authorization No hook filters authorization.can(), and none ever will. A plugin able to change that answer is a plugin able to grant itself anything
Reach inside the visibility filter No hook sits in the query path. A plugin that could rewrite a where clause could publish a private forum, and no amount of isolation makes that recoverable
See an Actor Payloads carry { userId, isGuest }. An Actor carries resolved group membership, which invites a plugin to make its own permission decision from group ids
Open a database connection Migrations are SQL text the host runs. A plugin does not import @meith/db
Patch core There is no monkey-patching seam and no way to replace a domain command
Fill a theme slot A theme owns its slots. Plugins contribute to regions — see below

Filters and events

What it gets What happens to the return value
Filter A value It is used. Filters chain: each plugin receives what the previous one returned
Event A notification Discarded

Ordering

Handlers run in (priority, plugin key) order. Lower priority runs first; the default is 100, so a plugin can insert on either side of an unopinionated one without negative numbers.

Both halves are declared and total, so two plugins compose the same way on every request, on every instance, in every deployment. Nothing depends on registration order or on how community.config.ts happens to list its plugins.

Failure isolation

Every handler runs inside the host's try/catch.

What happens Result
A filter throws The value is left as it was, and the chain continues with the next plugin
A filter returns undefined Treated the same way — that is the shape of a handler that forgot to return
An event throws Recorded and forgotten

Nothing a plugin does propagates to the page. That makes plugin failures survivable, not invisible: every failure is counted, logged with the plugin key and the hook, and reported by host.health().

Three limits worth stating

A guarantee with an unstated edge is worse than a smaller honest one.

Timing is measured, never enforced. Each call is timed, and slow ones are logged and counted. There is no timeout, because JavaScript cannot abort a handler: a Promise.race that "times out" returns control while the handler keeps running, keeps its connection, and resolves later. That is not a timeout.

UI contributions are isolated when they are built, not while they render. The host calls your render function inside a try/catch, so a throw there drops your contribution and the region renders without it. A node that throws during React's own render cannot be contained from the server — catching that needs an error boundary, and error boundaries are client components.

So: build your markup in the function. Do not return a component that does work.

UI regions

Regions are not theme slots, and the distinction is deliberate. If a plugin could fill a slot, an installed plugin would decide what a post looks like — and two plugins filling the same slot would have to be resolved somehow.

A region is the other arrangement: an explicit "plugins may add something here" point that a theme renders.

  • The theme keeps control of where plugin output appears.
  • The plugin keeps control of what it is.
  • Several plugins compose by concatenation, in the usual deterministic order.

There are six, listed in Plugin hooks. The list is short on purpose — every region is a commitment every theme has to render or silently drop.

Namespacing

A plugin's key namespaces everything it registers, and the host builds the names, so a plugin cannot collide with another or reach a core one.

Thing Name it gets
Setting plugin.<key>.<setting>
Task plugin.<key>.<task>
Admin page /admin/plugins/<key>/<path>

One name in that namespace is the host's: plugin.<key>._enabled is the operator's kill switch. A plugin cannot declare it — setting names cannot start with an underscore — which is what makes the collision impossible rather than unlikely.

definePlugin refuses a key, setting name, task id or page path that would not namespace cleanly — a dot in a plugin key produces an ambiguous setting key, and a slash in a page path escapes the admin prefix.

Migrations

Forward-only, like core's, and for the same reason: a down migration that drops a column is a data-loss button on a live board.

Ids look like 0001_add_table and are applied in sort order.

Versioning

definePlugin requires semver. The version is the plugin's own — it is what the admin panel shows and what its migration history is recorded against.

apiVersion declares which plugin-kit major the plugin was written against. The same policy as the theme API applies: a minor adds hooks, payload fields and regions; a major may remove or rename one, and only after a deprecation cycle.

What is wired, and what is not

An honest inventory, because the alternative is a document describing a system that does not run. It is derived rather than remembered: scripts/hook-callsites.mjs computes it by scanning the tree, so the generated reference's column cannot drift from the code.

25 of the 95 hooks are wired — the shell filters, the view models of every reading surface, and the three posting events. The generated reference's wired column is the authoritative list.

A hook that is declared but not wired is not broken; it is a call site that has not been written. Registering a handler for one is legal, does nothing, and the reference marks it so you find out before you ship.

plugins/reference must handle every wired hook, enforced by its own test. That is the ratchet: wiring a new call site into the board fails the reference plugin's test until a handler is added there, so a hook cannot join the running product without something proving it fires.

The lifecycle callbacks do not run yet. onInstall, onEnable, onDisable and onUninstall are part of the declared shape and validated like everything else, but no host code dispatches them today. Write them if the shape of your plugin wants them — just do not put anything there that must run for the plugin to be correct.

The four descriptors execute

Migrations are applied by community upgrade in dependency order, one transaction each. Settings are stored at plugin.<key>.<name> and edited in the control panel. Tasks are registered as plugin.<key>.<id> and run by the same tick as everything else. Admin pages are mounted at /admin/plugins/<key>/<path>.

What that leaves, stated plainly:

  • A page cannot reach anything a task cannot. Both are handed a PluginRuntimeContext — resolved settings and a logger — and neither gets the Actor, the request, or a database handle. A page renders under an already-authenticated panel route; there is no per-page permission to declare, because a plugin does not get to make that decision.
  • A task's failure is not swallowed. Hooks are isolated because the alternative is a plugin taking down a page render. A task has no page to take down, and the scheduler already records failures and notifies administrators — catching there would turn every failure into a successful run of nothing.
  • There is no plugin-run button for migrations, and there will not be. A schema change belongs to the deploy that shipped the code expecting it. The panel reports which migrations have and have not been applied, which is the part an operator cannot otherwise find out.
  • Disabling is durable and immediate; uninstalling is not offered. The panel's switch writes a row that every instance reconciles against on its next request, so it survives a redeploy — the plugin somebody switched off at 2am is exactly the one that must stay off. Removing a plugin is pnpm remove, a line out of community.plugins.ts, and a redeploy; a button that dropped the rows and left the code running would produce a state neither installing nor removing does.

The generated reference is a gate

Plugin hooks is written by scripts/plugin-hook-docs.mjs from the registry. pnpm verify and CI run pnpm plugin:docs:check, which fails when the file and the code disagree.

Hook documentation goes stale faster than most, because a hook is added in the feature that needs it and documented, if at all, afterwards.

If the check fails, run pnpm plugin:docs and commit the result.