Skip to content

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.

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.sql

We 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"
}
}

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 / DROP a TABLE, INDEX, SEQUENCE, TYPE, FUNCTION or TRIGGER (and CREATE EXTENSION). Any DML (INSERT/UPDATE/DELETE/SELECT/TRUNCATE/COPY/…) is rejected with XEC-PLUGIN-2010. Seed data goes in onEnable, not in a migration.
  • Up and down come in pairs. Every NNN_name.up.sql needs a matching NNN_name.down.sql (XEC-PLUGIN-2011). Use IF EXISTS / IF NOT EXISTS so a down migration can safely run twice.
  • Table names. Core tables start with xcf_, and your schema stays in its own namespace. Declare a tablePrefix (short, snake_case, ending in _, e.g. forum_ or sb_), 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 with xcf_, xentium_, _prisma, pg_ or information_schema). Both mistakes raise XEC-PLUGIN-2013. CREATE EXTENSION and COMMENT are 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 REFERENCES a 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’s onUserDeactivated hook.

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;

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

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.

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.

  1. Put your plugin in plugins/community-examples/<name>/ (or plugins/official/<name>/). Both are pnpm workspace members.
  2. For hot reload of the web side during vite dev, add your web entry to apps/web/src/plugins/dev-plugins.ts (keep that edit local).
  3. Start the stack with ./xentium.sh, then install and enable your plugin under ACP → Extensions → Upload (or set BOOTSTRAP_OFFICIAL_PLUGINS=true to load the first-party plugins on startup).

Plugins ship as a .xtplugin archive: a zip of the manifest, the compiled server/web bundles and the migrations.

Terminal window
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.

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 onEnable registers (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 the xcf_installed_plugins table. 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.