Skip to content

Plugin Manifest

plugin.json sits at the plugin root. The panel parses it with the manifest schema in catalyst-backend/src/plugins/validator.ts before loading any code. A plugin that fails validation does not load.

{
"name": "my-plugin",
"version": "1.0.0",
"displayName": "My Plugin",
"description": "What an admin sees in the plugin list.",
"author": "Your Name",
"catalystVersion": ">=1.62.0",
"permissions": ["server.read", "my-plugin.sample"],
"permissionDescriptions": {
"my-plugin.sample": "Shown verbatim in the safety dialog."
},
"userPermissions": [
{
"id": "my-plugin.view",
"label": "View the sample widget",
"description": "Shows the widget on the dashboard."
}
],
"backend": { "entry": "backend/index.js" },
"frontend": { "entry": "frontend/index.ts", "hasTabs": true },
"config": {
"greeting": { "type": "string", "default": "Hello", "description": "Shown by /hello" }
},
"events": {
"my-plugin:ready": {
"payload": { "id": "string" },
"description": "Emitted after the first successful run."
}
},
"dependencies": {},
"storageEngine": "dedicated"
}
Field Required Rule
name yes 1–50 chars, ^[a-z0-9-]+$. Must equal the directory name in the official repository.
version yes MAJOR.MINOR.PATCH only (1.0.0). No pre-release tags.
displayName yes 1–100 chars. Shown in the admin UI.
description yes 1–500 chars.
author yes 1–100 chars.
catalystVersion yes Comparison against the running panel: >=1.62.0, >1.0.0, =1.62.2, <2.0.0, <=1.62.2. A bare 1.62.0 means equal. Anything that does not match that pattern is incompatible.
permissions no Array of strings. Defaults to []. Each token is trimmed; tokens that are not ^[a-zA-Z0-9_.*-]{1,64}$ are dropped, as are duplicates.
permissionDescriptions no Map of permission token → 1–200 char sentence. Every key must also appear in permissions, or discovery fails.
userPermissions no Up to 32 { "id", "label", "description"? } entries. Permissions you expose to the panel’s role editor (the ids your frontend uses in requiredPermissions). Ids match ^[a-zA-Z0-9_.-]{1,64}$, must be unique, and * is rejected. label is 1–64 chars, description 1–200.
backend no { "entry": "backend/index.js" }. Path is resolved inside the plugin directory.
frontend no { "entry": "frontend/index.ts", "hasTabs"?: boolean, "hasComponents"?: boolean }. The two booleans are accepted and unused; the panel only checks that frontend exists.
dependencies no Map of other plugin name → exact MAJOR.MINOR.PATCH. A missing or mismatched dependency is logged as an error at discovery; the plugin still loads.
config no Map of field name → schema object. Keys match ^[a-zA-Z][a-zA-Z0-9_-]{0,50}$.
events no Map of event name → { "payload": object, "description"?: string }.
authProviders no Up to 8 sign-in buttons. See Sign-in plugins.
storageEngine no "legacy" (default) or "dedicated". See Data.
runtime no "legacy" (default) or "isolated". Isolation is not implemented: a plugin that declares "isolated" is refused at load time (isolated plugin execution is unavailable), so leave the field unset.
licensing no Vendor-owned licensing declaration. Presence of the block declares the phone-home and is disclosed before enable. licenseServer is required inside it. See Plugin licensing.

Unknown top-level keys are stripped by the schema. Do not hide required settings there.

Built-in tokens already have reviewer copy. Add permissionDescriptions for custom tokens, or to replace the built-in sentence.

Token What granting it allows
server.read Read server rows through context.db.servers. Field list is fixed; see Data.
server.write context.db.servers.update may change status only. Reaching the update method still requires table access, so declare server.read too.
user.read Read user rows through context.db.users (no credentials).
user.write Declared for route intent. context.db.users.update whitelists no fields, so it cannot change a user. Role changes go through roles.assign.
files.read Read files through context.fileTunnel (list, download, archive-contents).
files.write Write files through context.fileTunnel. Also satisfies the read ops.
console.write Send frames to a node agent through context.wsGateway.sendToAgent.
admin.read Reviewer signal that the plugin registers admin-information routes. It opens no database tables. It does satisfy server.read, so it also satisfies the file-read gate.
admin.write Reviewer signal that the plugin registers admin-mutation routes. It opens no database tables. The hasGrant gates (file tunnel, sendToAgent) accept it like *; the literal routes.public and plugin.rpc checks do not.
plugin.rpc context.callPluginApi. Checked on every call.
routes.public Lets registerRoute({ config: { auth: 'public' | 'optional' } }) skip session auth. Without it those routes are forced back to required.
auth.sessions context.auth.createSession.
auth.users context.auth.findUser and createUser.
roles.assign context.auth.listRoles, listUserRoles, assignRoles, removeRoles.
* Covers every declared token and every host capability. Treated as critical in the consent dialog.

The file-tunnel and agent-console gates use hasGrant semantics: * and admin.write satisfy any token, and admin.read satisfies server.read (which the file-read gate also accepts). Declare the narrow token anyway — the consent dialog renders what you declared, and an admin who revokes admin.write or * takes the whole capability away.

Custom tokens (for example my-plugin.sample) are stored and shown to admins. They do not unlock host APIs. Gate your own routes with context.requirePermission('my-plugin.sample').

The SDK helper that produces the two manifest fields:

import { definePermissions } from '@catalyst/plugin-sdk';
const declared = definePermissions(
'server.read',
{ token: 'my-plugin.sample', description: 'Read the sample records this plugin stores.' },
);
// declared.permissions, declared.permissionDescriptions

Descriptions are cut at 200 characters.

permissions is the plugin’s own capability list: the admin grants those tokens to the plugin, and the host enforces them on host APIs. userPermissions is the other direction — permissions the plugin invents for panel users, so an admin can grant them to a role and the plugin can gate its own UI:

{
"userPermissions": [
{
"id": "my-plugin.view",
"label": "View the sample widget",
"description": "Shows the widget on the dashboard."
}
]
}

The panel lists them under Plugins in the role editor (they appear only while the plugin is enabled). Use the same ids in the frontend’s requiredPermissions to gate a tab, page or slot component — see Plugin Frontend. An id that is also a built-in permission is ignored in the role editor: use the built-in token directly.

import { defineUserPermissions } from '@catalyst/plugin-sdk';
export const exposedPermissions = defineUserPermissions(
{ id: 'my-plugin.view', label: 'View the sample widget' },
);
// → { userPermissions: [{ id: 'my-plugin.view', label: 'View the sample widget' }] }

The admin plugin page renders a control from each entry in config.

type Control Useful keys
string Single-line text default, description
password Masked text default, description
text Multiline text default, description
number Number default, description
boolean Toggle default, description
select Dropdown default, options, description

config itself is not restricted by the manifest schema, and the SDK’s ConfigFieldDef also accepts object and array. The admin page has no control for those: any other type falls back to a single-line text field.

enum, min, max, and required are accepted by the SDK’s createConfigSchema, which builds a zod schema for your own tests. The panel ignores them: the settings form saves whatever it is given, so the plugin must validate the values it reads.

options may be a list of strings (the ticketing plugin does this) or [{ "label": "High", "value": "high" }]. getConfig returns the stored value, falling back to default. It does not return this schema object.

import { defineConfig, configField } from '@catalyst/plugin-sdk/config';
const config = defineConfig({
greeting: configField({ type: 'string', default: 'Hello', description: 'Greeting' }),
enabled: configField({ type: 'boolean', default: true }),
priority: configField({
type: 'select',
default: 'medium',
options: [
{ label: 'High', value: 'high' },
{ label: 'Medium', value: 'medium' },
],
}),
});

defineConfig returns the object you put in plugin.json. It does not write the file for you.

events documents payloads your plugin emits. emitTyped warns when a payload is missing a field declared in payload, and when the event is not declared at all; it does not type-check values and does not reject the emit. Listeners use context.on — see Backend.

"dependencies": { "ticketing-plugin": "3.0.0" }

The value must be an exact MAJOR.MINOR.PATCH version — unlike catalystVersion, operators such as >= are rejected by the schema. The panel loads dependencies first. A missing or mismatched dependency, and a dependency cycle, are logged as errors at discovery; the plugin still loads, so do not rely on either to gate it.