Create a Plugin
Prerequisites
Section titled “Prerequisites”- Node.js 20 or newer.
- A running Catalyst panel you can sign in to as an administrator.
- A checkout of the Catalyst repository.
The SDK and its CLI live in that repo (
packages/plugin-sdk). They are not published to the npm registry, sonpx @catalyst/plugin-sdk-cliandnpm install @catalyst/plugin-sdkdo not work against the public registry.
1. Scaffold
Section titled “1. Scaffold”The SDK is a workspace project; the CLI is not, so build it by path. From the Catalyst checkout root:
pnpm --filter @catalyst/plugin-sdk run buildpnpm --dir packages/plugin-sdk/cli run buildnode packages/plugin-sdk/cli/dist/index.js create my-plugin --template fullstackcd my-pluginThere is no catalyst-plugin on PATH: nothing in the workspace depends on
@catalyst/plugin-sdk-cli, so pnpm never links its binary, and
pnpm --filter @catalyst/plugin-sdk-cli … silently matches no project. Run the
CLI through node <checkout>/packages/plugin-sdk/cli/dist/index.js, or alias
that. npx catalyst-plugin does not work either, because the package is not on
npm.
create options are --template/-t, --path/-p (parent directory,
default .), and --typescript/-ts. The default template is
backend-only; --template is one of:
| Template | What you get |
|---|---|
fullstack |
plugin.json, a TypeScript backend, and a React frontend with an admin tab and a server tab. |
backend-only |
plugin.json and a TypeScript backend that registers one route. No UI. |
minimal |
plugin.json and a JavaScript entry with two lifecycle hooks. No backend or frontend key, no package.json, no tsconfig.json. |
The plugin name must match ^[a-z0-9-]+$. The schema additionally caps it at
50 characters; the CLI itself only checks the pattern. The scaffold writes the
name into plugin.json and into the route and tab ids, and runs git init in
the new directory when git is available.
Other commands, run from the plugin directory. Substitute the absolute path to
your checkout, or alias catalyst-plugin='node "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js"':
CATALYST_REPO=/path/to/catalystnode "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" build # parse plugin.json, then npx tsc if tsconfig.json existsnode "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" test # npx vitest runnode "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" pack # write my-plugin-1.0.0.catpkg.zip + .sha256node "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" pack --out distbuild accepts --watch but ignores it; use npm run dev (tsc --watch) in a
TypeScript scaffold instead. It only JSON-parses plugin.json and echoes
name/version — it does not run schema validation, so a manifest that would
be rejected at install time still “builds”. create always copies the
TypeScript templates; --typescript does not switch them off. pack needs
archiver resolvable (it is a devDependency of packages/plugin-sdk, hoisted
to the workspace root).
The scaffold is a starting point, not the layout the official repository
ships. Its backend.entry points into dist/, and a marketplace zip is not
allowed to contain dist/ — see Packaging. Every
plugin in
catalyst-plugins instead
authors backend/index.js directly and points the manifest at that file, so
there is no compile step to forget. If you did compile TypeScript, copy the
compiled entry to backend/index.js and repoint backend.entry before you
publish.
2. What the scaffold looks like
Section titled “2. What the scaffold looks like”fullstack matches the layout of
catalyst-plugins:
my-plugin/ plugin.json package.json "type": "module", build = tsc tsconfig.json src/index.ts backend; compiled under dist/ frontend/index.ts UI entry frontend/components.tsxThe backend default-exports the four lifecycle hooks and registers routes in
onLoad:
import { defineRoutes } from '@catalyst/plugin-sdk';
export default { async onLoad(context) { const routes = defineRoutes((router) => { router.get('/hello', async () => ({ success: true, message: 'Hello' })); }); for (const route of routes) context.registerRoute(route); }, async onEnable(context) { context.logger.info('enabled'); }, async onDisable() {}, async onUnload() {},};GET /hello is served at GET /api/plugins/my-plugin/hello.
The frontend default-exports a plugin definition. Official plugins import
createFrontendPlugin from @catalyst/plugin-sdk/frontend:
import { createFrontendPlugin } from '@catalyst/plugin-sdk/frontend';import { AdminTab, ServerTab } from './components';
export default createFrontendPlugin({ manifest: { name: 'my-plugin', version: '1.0.0', displayName: 'My Plugin', description: 'A Catalyst plugin: My Plugin', author: 'Catalyst Developer', }, tabs: [ { id: 'my-plugin-admin', label: 'My Plugin', component: AdminTab, location: 'admin', order: 100 }, { id: 'my-plugin-server', label: 'My Plugin', component: ServerTab, location: 'server', order: 100 }, ],});A server tab component receives { serverId: string }.
3. Install it on a panel
Section titled “3. Install it on a panel”Installing is an operator action in the panel, not a CLI step on the server. On the target panel:
- Open Admin → Extensions → Marketplace.
- Find your plugin in the list and select Install.
- Return to Admin → Extensions → Plugins. The plugin now shows in the list, disabled.
That is the whole install. The panel downloads the .catpkg.zip, verifies its
sha256 against the index entry, extracts it, and validates plugin.json. An
index entry without a sha256 cannot be installed. The code is on disk and
not running until you enable it.
For the panel to see your plugin, publish the package first:
node "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" packThat writes my-plugin-1.0.0.catpkg.zip and a .sha256 sidecar in the plugin
directory. Put both behind HTTPS and add an index entry with the digest from the
sidecar — see Packaging. Add your index in the
marketplace’s Marketplaces rail (or with PLUGIN_MARKETPLACE_URLS) if it is
not the official one.
As a local checkout (the path the official repository uses): drop the
directory into a checkout of catalyst-plugins and rebuild the panel image.
The panel compiles frontend/index.ts into the frontend bundle. You still
need frontend/frontend.mjs if the plugin should render on a panel that did
not compile it in.
4. Enable it
Section titled “4. Enable it”- On Admin → Extensions → Plugins, select Enable on the plugin row.
- The safety-consent dialog opens. Tick the acknowledgement and select
Accept & Enable. It lists every permission the manifest declares. Until
you do, the backend module is never imported and
onLoadnever runs. - Confirm the route:
curl -H "Authorization: Bearer $CATALYST_API_KEY" \ https://panel.example.com/api/plugins/my-plugin/helloA disabled plugin keeps its routes registered. They answer 503 until an
admin enables it. Disabling does not unregister routes; it closes the gate
and clears tasks and WebSocket handlers.
5. Develop against the SDK
Section titled “5. Develop against the SDK”The SDK and its CLI are in the Catalyst
repository under packages/plugin-sdk and are not on the public npm
registry, so npm install @catalyst/plugin-sdk fails. A scaffold’s
package.json declares "@catalyst/plugin-sdk": "workspace:*", which only
resolves when the plugin directory is itself a workspace package of that
checkout. In practice:
- The frontend import works without installing anything. The panel’s
catalyst-frontend/vite.config.tsaliases@catalyst/plugin-sdk/frontendtopackages/plugin-sdk/src/frontend/index.ts, and the catalyst-plugins repo aliases it to its vendoredsdk-frontend/copy so bundles build without the panel checkout. - The backend entry cannot import the SDK package at runtime. Official
plugins do not try:
backend/index.jscallsctx.registerRoutedirectly and imports onlynode:*builtins and its own sibling files. The panel stages the plugin directory and imports the entry as plain ESM — there is no bare-import resolution for the SDK. If you want the SDK helpers in a backend, bundle them into your entry at build time (the same rule asfrontend.mjs).
| Import | Use it for |
|---|---|
@catalyst/plugin-sdk |
defineRoutes, defineConfig, definePermissions, defineUserPermissions, createTypedCollection, createTypedContext |
@catalyst/plugin-sdk/frontend |
createFrontendPlugin, createPluginApi, PluginErrorBoundary |
@catalyst/plugin-sdk/testing |
createMockContext, createTestPlugin, createMockLicense |
@catalyst/plugin-sdk/config |
defineConfig, configField, createConfigSchema |
@catalyst/plugin-sdk/routes |
defineRoutes, PluginRouteBuilder |
@catalyst/plugin-sdk/storage |
createTypedCollection |
@catalyst/plugin-sdk/context |
createTypedContext, defineTypedContext |
@catalyst/plugin-sdk/licensing |
Backend-only vendor licensing helpers (uses node:crypto) |
fastify and react are optional peer dependencies. The backend entry the
panel loads must be plain ESM JavaScript — TypeScript sources are not executed.
Worked example
Section titled “Worked example”example-plugin
in the official repository is the short tour: routes, a scoped server read, a
whitelisted status write, a scheduled task, server:started / server:stopped,
a WebSocket ping, and key-value storage. Copy that shape, then read
Backend for the contracts those calls rely on.
Selling a commercial plugin? See Plugin licensing for vendor-owned license validation and encrypted payloads.
Related
Section titled “Related”- Plugin Manifest — every
plugin.jsonfield - Plugin Backend — the contracts your routes rely on
- Packaging & marketplace — ship the
.catpkg.zip - API reference — the host REST API your plugin calls beside
