Skip to content

Plugin extension points

Everything a plugin can add to core, in one place. You register all of it from onEnable(ctx), and it’s removed again when the plugin is disabled.

export default definePlugin({
async onEnable(ctx) {
ctx.registerStatSource({ id: "forum", collect: () => … });
},
});

Two rules apply to every registry on this page. They’re why a plugin can be disabled safely.

Registering isn’t storing. Registries live in memory and are rebuilt on boot. Core re-enables every enabled plugin before the server starts listening, so the registries are complete before the first request comes in. Nothing a plugin registers survives a disable, and nothing should need to.

Disabling removes what the plugin added, never the admin’s data. Say an admin set a threshold on a metric your plugin provides, and your plugin gets disabled. We skip that threshold rather than delete it, and it counts again as soon as your plugin is back. It’s the same rule we use for unknown blocks in a layout: skip it, keep it.


An Express router, mounted at /api/p/<your-plugin-id>/….

const r = Router();
r.get("/threads", ctx.host.middleware.optionalAuth, handler);
ctx.registerRoutes(r);

Core puts an enabled check in front of your router. While your plugin is disabled, every one of its routes answers 404 XEC-PLUGIN-2020 without running your code. So if anything in core’s UI calls a plugin route, it has to cope with that route not being there.

Use the host’s middleware (requireAuth, optionalAuth, requirePermission) rather than parsing tokens yourself. req.userId is what the rest of the platform means by “the caller”.

await ctx.registerPermissions([{
key: "forum.thread.create", // domain.action, namespaced to your plugin
label: "Create threads",
domain: "Forum", // how the ACP's permission matrix groups it
defaults: ["member", "moderator"], // GROUP KEYS, not ids
}]);

We upsert by key, so adding a permission in an update is just declaring it. The label is refreshed and existing grants stay as they are.

Heads up: defaults are applied again on every boot, not only on first install. onEnable runs for every enabled plugin at startup, and the grant is an upsert. So read defaults as “this group always has this”, and leave out anything an admin should be able to take away. (That’s a known bug; it’s meant to happen on first install only.)


A tab in site search.

ctx.registerSearchSource({
id: "forum",
label: "Threads",
permission: null, // null = public; otherwise a permission key
async search(query, { isLoggedIn, page, limit }) {
return { data: [...], total: 0 };
},
});

Core checks permission before it calls search, but row-level access is still your job. Core can’t know which of your rows this viewer is allowed to see.

Items for the home feed and the guest feed.

ctx.registerHomeFeedSource({
id: "forum",
label: "Forum threads",
async recent(limit) { return [...]; },
async hot(limit) { return [...]; }, // optional; feeds the "Hot threads" rail
});

Counters for the stats row in the footer.

ctx.registerStatSource({
id: "forum",
async collect() {
return [{ id: "forum.threads", label: "Threads", value: 128 }];
},
});

id is a machine key that the browser translates. label is only the English fallback, not what’s displayed. See Translating a plugin.


Something a badge can be earned for. The forum registers forum.posts, which is why an admin can only require forum posts while the forum is installed.

ctx.registerBadgeCriteria({
key: "forum.posts", // namespace it with your plugin's prefix
label: "Forum posts", // a translation key AND the fallback text
async valueFor(userId) { return 42; },
});

You report a number, and core does the comparing. The threshold lives on the badge and an admin sets it, so your plugin never stores or interprets one.

This is different from registerProgressionCriteria, which answers yes/no against a ladder group’s own thresholds. A badge keeps its thresholds on the badge.

If a badge’s criteria only use metrics from plugins that aren’t there, nobody gets it; it never goes to everybody. Please don’t return 0 for a user you don’t know about to be helpful. Throw, or return a real count.

Per-group thresholds for the progression ladder. Your plugin owns both the thresholds and the users’ metrics; core never sees your schema.

evaluate(userId, groupId) returns one boolean for each threshold you’ve set on that group (an empty list if there are none), and core combines them with its own results according to progression.threshold_logic.

Its ACP page is a groupConfigPanel tab, and it autosaves. See Plugin pages in the ACP.


registerAcpPage(page) / registerNavItem(item)

Section titled “registerAcpPage(page) / registerNavItem(item)”

An admin page, and a link in the site’s nav.

Plugin nav links always go below core’s. The position you declare is a hint, not an index; admins reorder links in the ACP.

These are covered in Plugin-authored layout blocks: blockRegistry for real blocks, ctx.registerLayoutTemplate to open one of your own pages to the Layout Editor, and the slotRegistry contributions (header widget, layout widget, profile tab) that core turns into placeable blocks for you.

Import FontAwesomeIcon from the externalized module. Every plugin already does, through __XENTIUM_HOST__.FaReact. Core swaps that one export for its own wrapper, so your icons follow the site’s icon style without any change or rebuild on your side.

If your plugin bundles its own copy of @fortawesome/react-fontawesome, it quietly opts out: its icons stay solid while the rest of the site changes. Keep it in your Vite externals. See Icons.


Your error codes, so the ACP’s error reference can explain them.

ctx.registerNecCodes([{
code: "XEC-FORUM-6001", msg: "Thread locked", sev: "Warning",
domain: "Forum", desc: "", cause: "", fix: "",
}]);

Use your plugin’s own domain. XEC-API-* and the other core domains are ours.

await ctx.notify({
userId: "", type: "forum.reply", title: "", body: "",
link: "/forum/thread/1", channels: ["in_app"],
});

Make type dotted and start it with your plugin’s namespace. We match per-user notification preferences by prefix (forum.*), so a type without a namespace can’t be turned off by the member and might collide with one of ours.

Say so when a notification has no human sender. Unless the type matches a system prefix, the bell draws a UserAvatar from the notification’s title, and you end up with initials cut out of a sentence. Declare your prefixes with systemNotificationPrefixes in your plugin meta.

A notification should never block the thing it’s about. Send it after your write and wrap it in a try/catch: a mail outage shouldn’t cost a member an action that already worked.

Right now this only logs. We’ve reserved it for a future preferences UI. Registering is harmless and future-proof, but it doesn’t make a type controllable by users today; the prefix above does that.


Refuse a login or a registration by throwing.

ctx.registerAuthGuard({
id: "beta-invite",
async beforeRegister(req) { … }, // throw to refuse
});

If beforeRegister returns "allow", you’ve authorized the registration, and core’s own auth.registration_mode check no longer applies. That’s how a private beta works on a site where registration is otherwise closed. Returning nothing means “no opinion”, and core’s policy still runs.

Staff go through beforeLogin too. Your guard decides whether to let them through; core doesn’t assume any policy exempts anyone.

registerSecurityScan(fn) / claimSecurityPatterns(patterns)

Section titled “registerSecurityScan(fn) / claimSecurityPatterns(patterns)”

registerSecurityScan runs your own abuse check on core’s security-analysis schedule (every 30 minutes), and your scan opens review cases with ctx.host.security.createCase. With claimSecurityPatterns your plugin replaces one of core’s built-in patterns with its own version, so the two don’t both fire. Core’s version comes back when your plugin is disabled.


ctx.host carries prisma, queues, env, logger, errors (XentiumError, XEC), middleware (requireAuth, optionalAuth, requirePermission), utils (resolveAvatar, sanitizeRichText, cron), progression.getEffectiveGroupId, notificationService and security.

Use host.prisma, never a client of your own. Plugin tables live outside the Prisma schema and the plugin migration runner migrates them (with your <pluginid>_ prefix, tracked in xcf_plugin_migrations). A second client just means a second connection pool.