Plugin development
Build a third-party plugin
A plugin is an installable ESM module that activates inside Justflows through a permission-scoped context. It can react to lifecycle events, add UI and blocks, store data, expose routes, and integrate external services.
Start from the supported example
Copy plugins/hello-world in the CE repository. The runtime loads dist/index.js or index.js; it does not execute TypeScript from src, and it does not run npm install or a build step for you — compile before you package.
acme-plugin/
├── justflows.json
├── package.json
├── src/
│ ├── index.ts
│ └── styles/plugin.css
├── public/ # manifest.assets — bundled into /jf-plugins.<hash>.js
│ └── widget.js
├── admin/ # manifest.adminApp — framed admin screens
│ └── index.html
└── dist/
├── index.js
└── styles/plugin.cssThe two files below are one worked example, Acme Reviews — a plugin that adds a review content type, an admin inbox, a public star-rating widget, and an email notification setting. It uses every manifest field you are likely to need; copy it whole and delete what you don’t.
{
"schemaVersion": 1,
"type": "plugin",
"id": "acme.reviews",
"name": "Acme Reviews",
"version": "1.2.0",
"publisher": "Acme",
"description": "Star ratings and moderated reviews for any content type, with a public widget and an admin inbox.",
"homepage": "https://acme.example/reviews",
"license": "GPL-2.0-or-later",
"engines": { "justflows": ">=0.2.0 <0.3.0" },
"permissions": ["content:create", "content:read", "content:delete", "admin:extend", "mail:send"],
"entrypoints": { "server": "dist/index.js" },
"setupPath": "/admin/acme-reviews/setup",
"contentTypes": ["review"],
"adminMenu": [
{
"id": "reviews",
"label": "Reviews",
"labelKey": "nav.reviews",
"path": "/admin/acme-reviews",
"icon": "⭐",
"domain": "extensions"
}
],
"adminApp": {
"dir": "admin",
"routes": [
{ "path": "/admin/acme-reviews", "entry": "index.html", "title": "Reviews" },
{ "path": "/admin/acme-reviews/setup", "entry": "setup.html", "title": "Reviews setup" }
]
},
"assets": {
"dir": "public",
"scripts": ["jf-reviews.js"],
"styles": ["jf-reviews.css"]
},
"settingsSchema": {
"minRatingToAutoPublish": {
"type": "number",
"label": "Minimum star rating to auto-publish",
"default": 3
},
"notifyEmail": {
"type": "string",
"label": "Notify on new review (optional)",
"description": "Sent through the configured outbound mail transport.",
"default": ""
},
"showReviewerName": {
"type": "boolean",
"label": "Show reviewer name publicly",
"default": true
}
},
"registry": {
"commercialMarketplace": false,
"listed": true,
"free": true,
"comingSoon": false,
"category": "Commerce",
"tags": ["reviews", "ratings", "widget"],
"screenshots": []
}
}import type { PluginModule } from "@justflows/sdk";
import { pluginShouldDeleteData } from "@justflows/sdk";
const plugin: PluginModule = {
// Every field here except "main" also lives in justflows.json and must say
// the same thing — the installer reads that file before any code runs; the
// loader reads this object only after your compiled module is imported.
manifest: {
id: "acme.reviews",
name: "Acme Reviews",
version: "1.2.0",
description: "Star ratings and moderated reviews, with a public widget and an admin inbox.",
author: "Acme Team",
license: "GPL-2.0-or-later",
engines: { justflows: ">=0.2.0 <0.3.0" },
permissions: ["content:create", "content:read", "content:delete", "admin:extend", "mail:send"],
main: "index.js",
setupPath: "/admin/acme-reviews/setup",
contentTypes: ["review"],
adminMenu: [
{
id: "reviews",
label: "Reviews",
labelKey: "nav.reviews",
path: "/admin/acme-reviews",
icon: "⭐",
domain: "extensions",
},
],
adminApp: {
dir: "admin",
routes: [
{ path: "/admin/acme-reviews", entry: "index.html", title: "Reviews" },
{ path: "/admin/acme-reviews/setup", entry: "setup.html", title: "Reviews setup" },
],
},
assets: {
dir: "public",
scripts: ["jf-reviews.js"],
styles: ["jf-reviews.css"],
},
settingsSchema: {
minRatingToAutoPublish: {
type: "number",
label: "Minimum star rating to auto-publish",
default: 3,
},
notifyEmail: {
type: "string",
label: "Notify on new review (optional)",
description: "Sent through the configured outbound mail transport.",
default: "",
},
showReviewerName: { type: "boolean", label: "Show reviewer name publicly", default: true },
},
},
async activate(ctx) {
// Idempotent: safe to run on every activation, does nothing once the type exists.
await ctx.content.ensureType({
slug: "review",
label: "Review",
fields: [
{ key: "rating", label: "Rating", type: "number", required: true },
{ key: "body", label: "Review", type: "textarea", required: true },
],
});
ctx.capabilities.register({ id: "reviews:moderate", label: "Moderate reviews", group: "Reviews" });
// Renders the hydration marker the "jf-reviews.js" asset below fills in.
ctx.blocks.register({
type: "acme.reviews.widget",
version: 1,
title: "Reviews",
description: "Star rating and review list for the current page.",
category: "content",
schema: { targetId: { type: "text" } },
validateProps: (raw) => ({ targetId: String((raw as { targetId?: string })?.targetId ?? "") }),
render: (props) =>
`<div class="jf-reviews" data-target="${String((props as { targetId: string }).targetId)}"></div>`,
});
// Public submit route the widget posts to.
ctx.http.post("/reviews", async (req) => {
const body = req.body as { targetId?: string; rating?: number; body?: string };
await ctx.data.put("reviews", `${Date.now()}`, body);
const to = await ctx.settings.get<string>("notifyEmail");
if (to) await ctx.mail.send({ to, subject: "New review", text: body.body ?? "" });
return { status: 201, type: "application/json", body: { ok: true } };
});
ctx.logger.info("Acme Reviews activated");
},
async deleteData(ctx) {
if (await pluginShouldDeleteData(ctx)) {
await ctx.data.clear();
}
},
};
export default plugin;entrypoints.server is optional — omit it and the installer tries dist/index.js, then index.js. The embedded manifest.main field is kept for compatibility only; nothing reads it.
justflows.json, field by field
- schemaVersion / type
- Fixed values that mark this as a plugin package manifest — always
1and"plugin"today. - id
- Dot-namespaced identifier. Becomes the
/ext/{id}/**asset prefix and the plugin’s slug everywhere else. - name / description / homepage
- Display metadata for the Marketplace and Admin → Plugins. The runtime never reads these.
- publisher
- The registry account this listing belongs to — distinct from the optional
authoron theindex.tsmanifest. - version / license / engines
- SemVer version, a GPL-compatible license (required), and the
justflowshost-version range this build supports. - permissions
- The exact
ctxAPIs this plugin may use. The host highlights the sensitive ones during install and rejects any call outside this list — declaringcontentTypesbelow is what required addingcontent:deletehere. - entrypoints.server
- The compiled file the host imports. Optional — defaults to
dist/index.js, thenindex.js. - setupPath
- Admin route the host opens right after activation for first-run setup. Requires
admin:extend. - contentTypes
- CMS type slugs this plugin owns. The host offers to delete them and every entry when the plugin is uninstalled. Requires
content:delete. - adminMenu
- Sidebar entries this plugin contributes — gone from the sidebar the moment it is uninstalled. Requires
admin:extend. - adminApp
- The plugin’s own HTML admin screens, framed in an
<iframe>for each route declared underadminMenu. Requiresadmin:extend. - assets
- Public JS/CSS bundled into
/jf-plugins.<hash>.js/.cssand added to every public page. - settingsSchema
- Small config fields rendered on Admin → Plugins → Settings.
ctx.settings.get()/set()read and write them. - registry
- Marketplace-only listing data — category, tags, pricing, screenshots. The runtime never reads it.
- hostCooperative
- Optional. The runtime leaves the first-party ids it renders itself (
justflows.seo, …) inactive;hostCooperative: truemarks an installed module that only augments the host — extra routes, feed autodiscovery — as safe to activate.
src/index.ts, section by section
- manifest
- The same data as
justflows.json, minusschemaVersion/type/publisher, withmainin place ofentrypoints.server. The loader validates this copy and buildsctx.permissionsfrom it. - activate(ctx)
- Runs once, when the plugin turns on. Everything registered inside — hooks, blocks, capabilities, HTTP routes — is torn down automatically on deactivate; you never write matching cleanup code for these.
- ctx.content.ensureType(…)
- Creates the
reviewcontent type the first time this activates. Idempotent — a no-op on every activation after that. - ctx.capabilities.register(…)
- Adds a
reviews:moderateuser capability while the plugin is active, so an operator can grant it per role. - ctx.blocks.register(…)
- Registers the editor/public block that renders the hydration marker
jf-reviews.jsfills in. - ctx.http.post("/reviews", …)
- The public submit endpoint the widget script posts to. Stores the row with
ctx.data.put()and, whennotifyEmailis set, sends a notification withctx.mail.send(). - deleteData(ctx)
- Runs when the plugin is deleted, before deactivation. Honors the operator’s delete-data choice through
pluginShouldDeleteData(), then clears everything underctx.data.
The plugin context
Hooks & capabilities
ctx.hooks registers actions, gates, and filters. ctx.capabilities adds plugin-owned user capabilities while active.Storage & secrets
ctx.data stores plugin-scoped JSON; ctx.secrets stores encrypted credentials; ctx.databases manages prefixed tables.HTTP, jobs & mail
ctx.http exposes /ext/{pluginId} routes. Jobs and mail transports require their manifest permissions.Content & blocks
ctx.content idempotently creates types/pages and, with content:read, ctx.content.listPublished(query?) reads published entries by type/locale/author/date. ctx.blocks registers namespaced server-rendered blocks.Locales
ctx.i18n.defaultLocale() and ctx.i18n.locales() return the site’s configured locales (read-only), so a plugin can emit locale-aware output — the html.head filter context also carries the page locale.Settings & cache
ctx.settings stores small plugin settings. ctx.cache is namespaced and supports read-through caching.Cookies
ctx.cookies.declare() registers non-essential cookies so consent UI can disclose and enforce them.Patterns & diagnostics
ctx.patterns.register() contributes editor patterns; ctx.diagnostics.register() publishes a read-only health check (needs diagnostics:publish).ctx.mail.send() sends through the host-configured transport without seeing credentials (needs mail:send). ctx.mail.registerTemplate() ships versioned system-email templates.Permissions are least-privilege
Declare only what you use. The host uses the manifest to decide which context APIs are available and highlights sensitive access during installation.
content:readcontent:createcontent:updatecontent:deletecontent:publishcontent:revisions:readcontent:revisions:restorecontent:revisions:discardmedia:readmedia:uploadmedia:deleteusers:readusers:managesettings:readsettings:manageadmin:extendjobs:registerauth:hooknetwork:outboundmail:transportmail:templatesmail:hookmail:senddiagnostics:publishAdmin pages, asset bundles, and admin apps
Acme Reviews above already declares all three, so there is no separate manifest to copy — only the rule for each:
Admin menu
admin:extend plus adminMenu adds a sidebar entry; the path must live below /admin/. setupPath opens a first-run wizard right after activation.Front-end assets
assets.dir is served at /ext/{pluginId}/**, and every active plugin’s scripts / styles are concatenated into one content-hashed /jf-plugins.<hash>.js / .css added to every public page — no ctx.http route or html.head filter needed. The static-site exporter downloads the bundle automatically. Write scripts as progressive enhancement (no import/export, wrapped in an IIFE); at most 20 of each, .js/.mjs/.css only. PLUGIN_ASSETS_BUNDLE=0 emits one tag per file for debugging.Admin apps
adminApp.dir ships an HTML build the host mounts in a same-origin <iframe> for each declared route — your own screen, own design, no React page compiled into core. The frame talks to the host only over postMessage through @justflows/admin-bridge: it sends ready / resize / navigate, the host replies with context (locale, adminBase, routePath, theme) and route changes. Same-origin, so the frame reads the CSRF cookie itself and calls the plugin’s own ctx.http routes for data.Publish a health check
Declare diagnostics:publish, then register a read-only check during activation. Check IDs are namespaced by plugin, execution is time-bounded, and returned details are recursively redacted before display or export.
const unregister = ctx.diagnostics.register({
id: "provider",
label: "Provider connection",
async run() {
return (await ctx.secrets.has("apiKey"))
? { status: "ok", summary: "Provider credentials are configured" }
: { status: "warning", summary: "Provider credentials are not configured" };
},
});Styles that follow the active theme
Read and cache your built stylesheet during activation, then append it through the async theme.css filter. Namespace classes and use public theme variables such as --color-*, --space-*, and --radius-*.
ctx.hooks.filter("theme.css", (current) =>
current.includes(MARKER)
? current
: `${current}\n${MARKER}\n${stylesheet}`
);Cleanup and packaging
Every plugin implements deleteData. Remove plugin data, owned tables, and optionally declared content types according to operator settings. Build before packaging, because the host will not install or compile anything.
Include every top-level file or folder your manifest points to — justflows.json and dist/ always; public/ when you declared assets; admin/ when you declared adminApp. Never include node_modules, src, or tsconfig.json — the host does not need them, and shipping src would let anyone read your unminified source.
pnpm build
COPYFILE_DISABLE=1 tar -czf ../../acme-reviews.jfpkg \
justflows.json dist \
$(test -d public && echo public) \
$(test -d admin && echo admin)