Writing a plugin
Plugins are how you extend Xentium without touching core. A plugin can register routes, permissions, ACP pages, nav items, notification types, XEC error codes and content sources for search and the home feed. It runs inside the host process, but only through the SDK. You don’t get free access to core internals.
Check Framework packages first for what’s on npm, and where
@xentium/plugin-sdk stands.
What’s in a plugin
Section titled “What’s in a plugin”my-plugin/├─ manifest.json identity, version, permissions, migrations, entry points├─ server/ server entry, exports a PluginDefinition│ └─ index.ts├─ web/ (optional) web entry, registers UI│ └─ index.ts└─ migrations/ raw SQL, in up/down pairs ├─ 001_create_things.up.sql └─ 001_create_things.down.sqlWe validate manifest.json on install:
{ "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "vendor": "Acme", "minXentiumVersion": "1.0", "licenseRequired": false, "tablePrefix": "myplugin_", "permissions": ["myplugin.thing.create"], "migrations": ["001_create_things"], "entrypoints": { "server": "server/dist/index.js", "web": "web/dist/index.umd.js" }}Migrations and the database
Section titled “Migrations and the database”Your plugin owns its schema through raw-SQL migrations. We track them separately from
core’s Prisma migrations, in the xcf_plugin_migrations table. The host checks every rule
below on install, enable and update, and breaking one aborts the operation with the
XEC-PLUGIN-* code shown. Design for them from the start.
- DDL only. A migration may only
CREATE/ALTER/DROPaTABLE,INDEX,SEQUENCE,TYPE,FUNCTIONorTRIGGER(andCREATE EXTENSION). Any DML (INSERT/UPDATE/DELETE/SELECT/TRUNCATE/COPY/…) is rejected withXEC-PLUGIN-2010. Seed data goes inonEnable, not in a migration. - Up and down come in pairs. Every
NNN_name.up.sqlneeds a matchingNNN_name.down.sql(XEC-PLUGIN-2011). UseIF EXISTS/IF NOT EXISTSso a down migration can safely run twice. - Table names. Core tables start with
xcf_, and your schema stays in its own namespace. Declare atablePrefix(short, snake_case, ending in_, e.g.forum_orsb_), and we check that every table, index, sequence, type, function and trigger your migrations create, alter, drop or index starts with it. Whether you declare one or not, a plugin migration can never touch a reserved core object (names starting withxcf_,xentium_,_prisma,pg_orinformation_schema). Both mistakes raiseXEC-PLUGIN-2013.CREATE EXTENSIONandCOMMENTare exempt. The prefix is optional, but we strongly recommend it; all our official plugins use one. - A migration that has run is frozen. We record its checksum, and editing the file
afterwards is rejected (
XEC-PLUGIN-2012). Put schema changes in a new migration in your next version. - Updates only go forward, matched by name. On update we run only the migrations whose name we haven’t seen before, in any version. So keep the full list in the manifest and only append to it. Never renumber or rename one you’ve shipped.
- No foreign keys across the boundary. Prisma doesn’t know about plugin tables, so
your table can’t
REFERENCESa core table (and core can’t reference yours). Store the id (e.g.user_id TEXT), enforce integrity in your service, and clean up when an account is deactivated with the SDK’sonUserDeactivatedhook.
The server entry
Section titled “The server entry”Your server entry exports a PluginDefinition. You wire everything up in
onEnable(ctx). onDisable is optional, because the engine removes your routes,
registrations, ACP pages and XEC codes for you.
import type { PluginDefinition } from "@xentium/plugin-sdk";import { createRouter } from "./router.js";import { searchThings } from "./service.js";
const plugin: PluginDefinition = { id: "my-plugin",
async onEnable(ctx) { // Routes are mounted at /api/p/my-plugin/* (host services come in via ctx.host) ctx.registerRoutes(createRouter(ctx.host));
// Permission keys (written to the permissions table; defaults assigned on first install) await ctx.registerPermissions([ { key: "myplugin.thing.create", label: "Create things", domain: "My Plugin", defaults: ["member", "admin"] }, ]);
// A nav item on the site (hidden automatically when the plugin is disabled) await ctx.registerNavItem({ label: "Things", url: "/things", position: 5 });
// An ACP sidebar page (listed by GET /api/admin/registered-pages). // Settings pages in the ACP AUTOSAVE (useAutosave + SaveChip from the // host globals), with no Save button, like core's. Secrets (API keys) are the // exception: give them an explicit Set/Remove. ctx.registerAcpPage({ section: "My Plugin", label: "Things", path: "/admin/things" });
// Notification types (for the preferences UI) + XEC error codes (for the Error Reference) ctx.registerNotificationTypes(["myplugin.thing_created"]); ctx.registerNecCodes([ { code: "XEC-MYPLUGIN-6001", msg: "Thing not found", sev: "Error", domain: "My Plugin", desc: "…", cause: "…", fix: "…" }, ]);
// A SEARCH source: core merges your results and never queries your tables. ctx.registerSearchSource({ id: "things", label: "Things", permission: null, // null = public, or a permission key to gate it search: (query, opts) => searchThings(query, opts), // → { data: SearchResultItem[], total } });
// A HOME FEED source: the same idea, for the home and guest feeds. ctx.registerHomeFeedSource({ id: "things", label: "Things", recent: (limit) => /* … */, // → HomeFeedItem[] });
// A SECURITY SCAN: runs with core's security analysis (every 30 min); // open review cases with ctx.host.security.createCase(...). ctx.registerSecurityScan(async () => { /* … */ });
// Optionally CLAIM core baseline detection patterns that your scan replaces. // Core skips its own version while you're enabled and resumes it when you're not. ctx.claimSecurityPatterns(["multi_account_suspicion"]); },};
export default plugin;Host services (ctx.host)
Section titled “Host services (ctx.host)”We inject everything your plugin needs, so you never import core internals:
ctx.host.* |
What it is |
|---|---|
prisma |
the host’s Prisma client (use raw SQL for your own tables) |
queues |
BullMQ queues: you enqueue, the core worker runs the jobs |
middleware |
requireAuth, optionalAuth, enforcePostLength |
errors |
XentiumError, XEC |
utils |
shared helpers (e.g. resolveAvatar) |
notificationService |
send notifications (or use ctx.notify(...)) |
env, logger |
the validated config and a Pino logger |
Core never knows your schema
Section titled “Core never knows your schema”The host knows nothing about your tables, ids or routes. Want your content in search or
the home feed? Register a source that returns normalized items (SearchResultItem /
HomeFeedItem). Core asks your source to run the query and merges what comes back. That
way your plugin can be installed, disabled or removed, and core just works with whatever
is registered at the moment.
ACP pages autosave
Section titled “ACP pages autosave”Settings pages in our ACP don’t have a Save button. They save as you change things and
show a status indicator. Your plugin’s admin pages should do the same, or they’ll feel
out of place. Externalize the two host bridges in your vite.config.ts:
"@web/hooks/useAutosave": `${H}.webUseAutosave`,"@web/components/SaveChip": `${H}.webSaveChip`,const saveMut = useMutation({ mutationFn: (v: Settings) => api.put(`${BASE}/admin/settings`, v) });useAutosave({ value: form, savedValue: data, onSave: (v) => { if (v) saveMut.mutate(v); } });// …and render <SaveChip saving={saveMut.isPending} /> in the header.That goes for settings pages and per-group panels registered through the
groupConfigPanel slot. Per-group config (permissions, progression) belongs there,
because it then shows up as a tab in core’s own group settings dialog instead of a page
of its own. Keep an explicit button for create/edit dialogs, secrets (never
autosave a half-typed credential; use Set/Remove) and destructive actions.
📖 The full recipe, the ways it goes wrong, and the groupConfigPanel + progression
path: docs/plugin-acp-autosave.md.
Local development
Section titled “Local development”- Put your plugin in
plugins/community-examples/<name>/(orplugins/official/<name>/). Both are pnpm workspace members. - For hot reload of the web side during
vite dev, add your web entry toapps/web/src/plugins/dev-plugins.ts(keep that edit local). - Start the stack with
./xentium.sh, then install and enable your plugin under ACP → Extensions → Upload (or setBOOTSTRAP_OFFICIAL_PLUGINS=trueto load the first-party plugins on startup).
Packaging
Section titled “Packaging”Plugins ship as a .xtplugin archive: a zip of the manifest, the compiled
server/web bundles and the migrations.
node scripts/package-plugin.mjs [pluginDir] [outDir]# default: plugins/official/xentium-forum → dist/marketplace/The plugin engine extracts the archive to a temp directory (with zip-slip protection), validates the manifest and the migrations, runs the migrations in a transaction, then registers and enables the plugin. Installing and enabling are always separate. Enabling turns routes and registrations on; disabling turns them off and keeps the data.
Plugin lifecycle
Section titled “Plugin lifecycle”upload → validate manifest → compatibility/trust check → install (stored, migrations run) → enable (onEnable: routes + registries active) → disable (behaviour stops, data kept) → uninstall (migrations reversed, package removed)Registrations live in memory. Everything
onEnableregisters (routes, ACP pages, content sources, security scans and claims, XEC codes) sits in the host process’s registries, and we rebuild it on startup from thexcf_installed_pluginstable. The lifecycle endpoints keep memory and the database in sync. But if you change plugin state behind a running process (deleting a row by hand, resetting the DB in dev), the process keeps serving what it had, e.g. an ACP menu entry for a plugin that’s gone, until you restart it. Restarting the API and the worker always fixes it. The worker loads plugins too, so restart it after replacing a plugin package.