Skip to content

Plugin Frontend

frontend.entry in plugin.json points at an ES module. The panel loads it two ways:

  1. Compiled in. A checkout of catalyst-plugins is bundled with the panel frontend. The module shares the panel’s React, so hooks work. This is how official plugins render.
  2. 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.

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.

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.read on that server sees the UI even though they hold no panel-wide admin permission. *, admin.read and admin.write keep 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 in requiredPermissions, 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.

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 call

A 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>
);
}

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.