Plugin Frontend
frontend.entry in plugin.json points at an ES module. The panel loads it
two ways:
- Compiled in. A checkout of
catalyst-pluginsis bundled with the panel frontend. The module shares the panel’s React, so hooks work. This is how official plugins render. - Runtime bundle. A marketplace install with no compiled copy is loaded
from
/plugins-assets/<name>/frontend.mjs. That file is one self-contained ESM bundle. It has its own React copy, and hooks throw. Use it for static UI, or ship the plugin in the repository so it is compiled in.
The loader only reads the module when the plugin is enabled and the manifest
has a frontend block.
Definition
Section titled “Definition”Default-export the object returned by createFrontendPlugin:
import { createFrontendPlugin, createPluginApi } from '@catalyst/plugin-sdk/frontend';import { AdminTab, ServerTab } from './components';
const api = createPluginApi('my-plugin');
export default createFrontendPlugin({ manifest: { name: 'my-plugin', version: '1.0.0', displayName: 'My Plugin', description: 'What this UI does', author: 'Your Name', }, tabs: [ { id: 'my-plugin-admin', label: 'My Plugin', icon: 'Puzzle', component: AdminTab, location: 'admin', order: 100, requiredPermissions: ['admin.read'], }, { id: 'my-plugin-server', label: 'My Plugin', icon: 'Zap', component: ServerTab, location: 'server', order: 100, requiredPermissions: ['server.read'], }, ], routes: [ { path: '/my-plugin', component: AdminTab, requiredPermissions: ['server.read'] }, ], components: [ { slot: 'dashboard-widgets', component: MyWidget, order: 50, requiredPermissions: ['server.read'], }, ], onMount: () => {}, onUnmount: () => {},});onMount runs when the module is first imported. onUnmount runs when a
hot reload replaces it. Neither runs per page navigation.
If the default export has no manifest object, the loader falls back to
legacy named exports: AdminTab, ServerTab, UserPage, slots, and
registerSlots(). New plugins should use the definition above. Official
plugins all do.
| Property | Required | Meaning |
|---|---|---|
id |
yes | Unique among your tabs. |
label |
yes | Sidebar label. |
component |
yes | React component. |
location |
yes | 'admin' or 'server'. |
icon |
no | Accepted for compatibility, but the panel does not render it: admin and server tabs always draw a fixed plug icon. |
order |
no | Lower comes first. Default 50. |
requiredPermissions |
no | Hidden unless the signed-in user holds them. Default is admin.read for admin tabs and server.read for server tabs. |
templateFilter |
no | Server tabs only. Hides the tab unless the server matches. |
A server-tab component receives { serverId: string }.
templateFilter limits a server tab to matching servers. While the server
record is still loading, a filtered tab stays hidden.
templateFilter: { namePattern: 'counter-strike', // case-insensitive regex against the template name env: { SRCDS_APPID: '10' }, // every entry must match the server environment, case-insensitive}cs16-admin uses this so its tab only appears on Counter-Strike 1.6 servers.
routes[].path is an app path (/my-plugin). The panel renders component
when the location matches. There is no router nesting; keep paths to a single
segment unless you have confirmed the panel route that mounts them.
components[].slot injects a component where the panel already mounts a slot.
Only these names render today:
| Slot | Rendered in |
|---|---|
dashboard-widgets |
Dashboard, below the activity list |
profile-connections |
Profile page, below the linked-accounts card |
server-header-stats |
Server detail header, between DISK and NETWORK |
| Property | Required | Meaning |
|---|---|---|
slot |
yes | Slot name from the table above. |
component |
yes | React component. |
order |
no | Lower comes first. Default 50. |
requiredPermissions |
no | Hidden unless the signed-in user holds one of them. Omit to show it to everyone who can reach the surface. |
A server-header-stats component receives
{ serverId, serverStatus, backupAllocationMb }. serverStatus is the
server’s stored status, and the component renders even when the metrics stream
is down — it is not gated on a live reading. The slot mounts only once the
server record has loaded.
discord-oauth
uses profile-connections for its link/unlink card. Any other slot name is
accepted, stored, and never mounted — sidebar-bottom is one of those.
Who sees your UI
Section titled “Who sees your UI”Every signed-in user loads the plugin store, so visibility is yours to decide:
- No
requiredPermissions— the component, tab or page renders for anyone who can reach the surface it is mounted on. - Built-in permissions — the host evaluates them for the viewer. On a
server surface that includes server-scoped grants, so an owner or a subuser
holding
backup.readon that server sees the UI even though they hold no panel-wide admin permission.*,admin.readandadmin.writekeep their usual widening behaviour. - Your own permissions — declare them in
plugin.json(see Manifest) and an admin can grant them to a role. The role editor lists them under Plugins. Tokens you do not declare still work inrequiredPermissions, but nobody can be granted them, so only*holders would see that UI.
Admins keep the full plugin inventory (consent state, licensing, capability
counts, config). Everyone else gets a reduced list — name, version, display
name, description and author of the enabled plugins that ship a frontend —
purely so the panel can mount the UI each plugin targets. That does tell a
signed-in user which plugins are installed; a plugin’s bundle
(/plugins-assets/...) is reachable for the same reason. Disabled plugins,
backend-only plugins and sourcemaps stay admin-only, and the per-plugin detail
and frontend-manifest endpoints are unchanged.
Calling your backend
Section titled “Calling your backend”createPluginApi prefixes /api/plugins/<name>/ and sends cookies.
const api = createPluginApi('my-plugin');
const hello = await api.get<{ message: string }>('hello');const created = await api.post('items', { name: 'map' });await api.put('items/1', { name: 'map2' });await api.patch('items/1', { name: 'map3' });await api.del('items/1'); // api.delete is the same callA response is { success, data?, error? }. HTTP failures come back as
{ success: false, error } rather than throwing. Paths are relative to the
plugin prefix — pass 'hello', not '/api/plugins/my-plugin/hello'.
Wrap tab trees in PluginErrorBoundary from the same module so a render
error in your component does not blank the page around it:
import { PluginErrorBoundary } from '@catalyst/plugin-sdk/frontend';
export function AdminTab() { return ( <PluginErrorBoundary pluginName="my-plugin"> {/* tab body */} </PluginErrorBoundary> );}The runtime bundle
Section titled “The runtime bundle”Marketplace UI that is not compiled into the panel needs frontend/frontend.mjs
inside the package. frontend/frontend.mjs is generated, not committed: any
plugin with a vite.config.mts (ai-assistant, cs16-admin, discord-oauth,
fastdl-sync, licensed-example, oidc-sso) gets one from
scripts/build-bundles.mjs
before packing. egg-explorer, example-plugin, and ticketing-plugin have no Vite
config, so their UI only renders when the panel compiled it in.
Build with Vite in library mode:
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';import path from 'path';
const repoRoot = path.resolve(import.meta.dirname, '..');
export default defineConfig({ plugins: [react()], define: { 'process.env.NODE_ENV': '"production"' }, resolve: { alias: [ // @catalyst/plugin-sdk is not on npm: the SDK's frontend entry is // vendored in catalyst-plugins/sdk-frontend and aliased here. { find: '@catalyst/plugin-sdk/frontend', replacement: path.resolve(repoRoot, 'sdk-frontend/index.ts'), }, ], }, build: { lib: { entry: 'frontend/index.ts', formats: ['es'], fileName: () => 'frontend.mjs', }, outDir: 'dist', emptyOutDir: true, rollupOptions: { output: { inlineDynamicImports: true } }, },});Then move dist/frontend.mjs to frontend/frontend.mjs before packing. The
bundle must inline every dependency, including React, and must not contain a
bare import. scripts/build-bundles.mjs does both steps for every plugin that
has a vite.config.mts.
Backend-only plugins omit frontend entirely.
Related
Section titled “Related”- Plugin Manifest —
userPermissionsand the fields the frontend reads - Plugin Backend — the routes the UI calls
- API reference — the panel REST API
createPluginApisits beside
