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"}Fields
Section titled “Fields”| 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.
Permissions you can declare
Section titled “Permissions you can declare”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.permissionDescriptionsDescriptions are cut at 200 characters.
Permissions for panel users
Section titled “Permissions for panel users”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' }] }Config field types
Section titled “Config field types”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 block
Section titled “Events block”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
Section titled “Dependencies”"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.
Related
Section titled “Related”- Plugin Data & Permissions — how
permissionsgate host APIs - Plugin Frontend — using
userPermissionsids inrequiredPermissions - API reference — the panel REST API the manifest enables
