Write your first plugin
The walkthrough from an empty directory to a plugin running inside a board and listed on the marketplace: scaffold, change a hook, test it, install it, publish it.
This is the walkthrough: from an empty directory to a plugin running inside a board and submitted to the marketplace, with a working extension at every step. The policy — what a plugin may and may not do, and what the guarantees cover — lives in Plugins; every hook and payload is in the generated Plugin hooks reference. This page assumes both exist and shows the path through them.
Scaffold it
npx create-meith --plugin first-light
cd first-light
npm install
npm testThat is already a complete, passing extension. The scaffold's source is
generated from the meith repository's examples/hello-plugin — reviewed,
CI-covered code, renamed for you — so what you start from is the worked
example, not a boilerplate that drifted from it. It contains:
src/plugin.tsx— the plugin: onedefinePlugincall declaring a setting, a migration, a task, an admin page, a region contribution and two hooks.src/plugin.test.ts— a passing test driving theview.footerhook the way the board will.src/index.ts— the entry point, exporting the plugin under the two fixed names (plugin,messages) the board's install path expects.listing.json— a pre-filled marketplace listing for later.README.md— a shorter copy of this walkthrough, kept with the code.
definePlugin validates the whole manifest at import time — a bad key, a
migration touching a table outside the plugin's namespace, a secret setting
with a shipped default all throw before anything registers. The first test
in src/plugin.test.ts exists to catch exactly that: if the module
imports, the manifest is valid.
Change what it does
Open src/plugin.tsx. The plugin already handles two hooks:
view.footeris a filter: what the handler returns replaces the value, so the scaffold appends a footer link by returning a copy of the model with one more entry.post.createdis an event: the return value is discarded, and a throw is isolated and logged rather than taking the page down.
Make it react to new threads instead. Replace the post.created entry
with:
'thread.created': (thread, context) => {
console.log(`thread ${thread.threadId} by user ${context.userId ?? 'guest'}`)
},Both names come from the generated reference — if a hook is not listed
there, it does not fire. The value and context types are enforced: your
editor autocompletes thread.threadId because definePlugin knows the
payload for every hook name.
Test it
The scaffold's test file shows the pattern: call the handler directly with a model shaped like the reference says, and assert on what comes back. Handlers are plain functions — no board, no database, no mocking layer:
it('appends its link without disturbing the board’s own', () => {
const filter = firstLightPlugin.hooks?.['view.footer'] as FilterHandler<'view.footer'>
const footer = {
boardTitle: 'A board',
links: [{ label: 'Contact', href: '/contact' }],
timezoneLabel: 'Europe/Dublin',
}
const filtered = filter(footer, { userId: null, isGuest: true, requestId: null })
expect(filtered.links).toHaveLength(2)
expect(footer.links).toHaveLength(1)
})That last assertion is the one worth copying. A filter must return a new value rather than mutating the one it was handed: the same model is passed to every plugin in the chain, and one that edits in place changes what the others see.
npm test runs vitest; npm run typecheck runs tsc over the same
source, and catches a payload field that does not exist before the board
would.
Run it inside a board
Scaffold a board next to the plugin if you do not have one, then install the plugin into it by path:
cd ..
npx create-meith my-board
cd my-board
npm install ../first-lightRegister it in the board's community.plugins.ts — the comment at the top
of that file shows the shape:
import { messages as firstLightMessages, plugin as firstLightPlugin } from 'first-light'
export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [
{ key: 'first-light', enabled: true, plugin: firstLightPlugin, messages: firstLightMessages },
]and mirror it in board.plugins.json:
{ "plugins": [{ "key": "first-light", "package": "first-light", "enabled": true }] }Then build, migrate and start:
npm run build
npx community migrate
npm run startThe scaffold ships one migration, which is why community migrate is in
the list — it creates the plugin's own plugin_first_light_wave table,
inside the namespace the host enforces. The plugin now appears under
Admin → Plugins: its setting is editable there, its admin page renders
under it, and its footer line is on the board index. Registration is
static on purpose — nothing scans a directory at runtime, so what the
bundler saw at build time is exactly what runs
(Plugins explains why).
While iterating, npm installs a local directory as a symlink: edit the plugin, rebuild the board, and the change is there — no reinstalling.
Publish it
npm publishThe scaffold publishes src/ as TypeScript source, the way every
@meith/* package ships. A board that installs your published package gets
the same files a path install gets. Version it honestly: the version in
package.json is what the admin panel shows and what your migration
history is recorded against.
Submit it to the marketplace
The marketplace is a curated feed, not an open index — a listing is a pull
request against the meith repository, reviewed against the bar described in
The marketplace. The scaffold pre-filled
listing.json with your plugin's key, package name and a compatibility
range; before submitting:
- Set
repositoryto your real repository URL (it starts as a placeholder). - Take the screenshot the listing names and add it to
marketplace/screenshots/. - Copy
listing.jsonintomarketplace/listings/<key>.jsonin your pull request and runpnpm marketplace:gen.
The marketplace documents the review checklist — what reviewers read, what gets a listing declined, and how removal works.
Themes take the same path
npx create-meith --theme <name> scaffolds the equivalent starting point
for a theme — the default board recoloured plus one slot override,
generated from examples/iris-theme the same way. From there,
Themes is the policy and Theme slots
the reference.