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": "justflows.acme-reviews",
"apiNamespace": "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.4.0 <0.5.0" },
"permissions": ["content:create", "content:read", "content:delete", "admin:extend", "mail:send"],
"entrypoints": { "server": "dist/index.js" },
"setupPath": "setup",
"contentTypes": ["review"],
"adminMenu": [
{
"id": "reviews",
"label": "Reviews",
"labelKey": "nav.reviews",
"labels": { "nl": "Beoordelingen" },
"icon": "⭐",
"domain": "extensions"
}
],
"adminApp": {
"dir": "admin",
"routes": [
{ "entry": "index.html", "title": "Reviews" },
{ "path": "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: "justflows.acme-reviews",
apiNamespace: "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.4.0 <0.5.0" },
permissions: ["content:create", "content:read", "content:delete", "admin:extend", "mail:send"],
main: "index.js",
setupPath: "setup",
contentTypes: ["review"],
adminMenu: [
{
id: "reviews",
label: "Reviews",
labelKey: "nav.reviews",
labels: { nl: "Beoordelingen" },
icon: "⭐",
domain: "extensions",
},
],
adminApp: {
dir: "admin",
routes: [
{ entry: "index.html", title: "Reviews" },
{ path: "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: "justflows.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: /api/acme-reviews/reviews.
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
justflows.<name>, lowercase with hyphens. The installer rejects any other id. It becomes the/ext/{id}/**asset prefix, the/admin/plugins/{id}admin namespace, and the prefix of every hook and block the plugin owns.- apiNamespace
- Optional. Also serves the plugin’s routes at
/api/{apiNamespace}, such as/api/acme-reviews/reviews. - 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 page the host opens right after activation for first-run setup, relative to
/admin/plugins/{id}. Requiresadmin: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
- Up to 20 sidebar entries, gone the moment the plugin is uninstalled. Requires
admin:extend.pathis relative to/admin/plugins/{id}(omit it for the root).labelKeyis looked up in your ownadminApp.localescatalogs first, andlabelsgives per-locale text for a plugin without an admin app.rolesnames the roles that may open the page, such as one your plugin registers; without it, administrators and editors may.domainpicks the sidebar group, and an unknown group falls back toextensions. - adminSection
- Justflows 0.4.1 and later. Gives the plugin its own sidebar section, above Extensions, for menu items with
domain: "plugin": alabel, an optionallabelKeyorlabels, and aniconthat is an emoji or a.png,.svg, or.webpfile in the admin app folder. Requiresadmin:extend. - editorPanels
- Up to 5 content editor sections, next to Content, Discussion, and Advanced. A panel has up to 20
fields(text,textarea,url,image, orcheckbox, saved in the entry’sfields), or, from Justflows 0.4.1, anapp: an admin-app screen that receives the entry’scontentId.indexableOnlylimits a panel to content types included in search engines. Requiresadmin:extend. - workspacePanels
- Justflows 0.4.1 and later. Up to 5 cards on Platform → Workspace, each embedding an admin-app screen that receives the
workspaceId. Requiresadmin:extend. - adminApp
- The plugin’s own HTML admin screens, framed in an
<iframe>: up to 40routes, relative to/admin/plugins/{id}, and optionallocalescatalogs per admin language. 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, and theplugin.settings.schemafilter can drop fields or fillselectoptions per site. - requires
- Optional. Other plugin ids that must already be installed on the site, such as
["justflows.shop"]. Install is refused while one is missing, and activation is refused until each one is active. - registry
- Marketplace-only listing data — category, tags, pricing, screenshots. The runtime never reads it.
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, at
/api/acme-reviews/reviews(and/ext/justflows.acme-reviews/reviews). A path that starts with/claims a site-root path instead. Stores the row withctx.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 — ensureSchema() adds new columns and indexes on upgrade, and transaction(), insert(), update(), increment(), and find(…, { range, after }) cover race-free writes and paging.HTTP & jobs
ctx.http exposes /ext/{pluginId} routes, also at /api/{apiNamespace} when the manifest declares one (ctx.http.url(), pluginApiUrl()). binaryBody accepts uploads. ctx.jobs (needs jobs:register) runs cron jobs, per site with perSite: true, and stores enqueue() runs durably with retries.Private files
ctx.files (needs files:private) stores files that are never public, such as downloads. A route answers { file: { key } } and the host streams it with Range support, counted against the site’s file limits.Limits & workspaces
ctx.quotas registers and checks plugin meters; with platform:tenancy it sets workspace and site limits, and ctx.tenancy manages workspaces, only on the root site and never on behalf of an agent or API key. ctx.tenancy.siblingSites() lists the current workspace’s sites. ctx.users.removeRole() takes back a role the plugin gave. A flag meter registered with whenUnset: "off" stays off until a plan or an operator turns it on, and a website flag named <plugin id>.enabled gives the plugin a switch in the website’s Features list that hides its admin pages.Widgets & account
widgets.areas adds sidebars and blocks with category: "widgets" become widgets. account.sections adds request-scoped sections to the /account page.Network-wide behaviour
platform:network, a hook registered as { network: true } runs on every site while the plugin is active on the main site. ctx.network.mainSiteSettings.get() reads the main site’s settings from any site, and ctx.network.mainSiteSecrets reads its secrets, read-only, so a site can use a connection the main site saved. ctx.network.sites() lists every site from the main site, and ctx.network.site(siteId?) returns one live site with its public url from any site.Content & blocks
ctx.content idempotently creates types/pages — ensurePage can seed blocks and cacheControl on first insert only — and deletePages() removes a page in every locale. With content:read, ctx.content.listPublished(query?) reads published entries by type/locale/author/date. ctx.blocks registers namespaced server-rendered blocks; inspector chooses which builder settings they show, and the builder previews them with their own render. A media or images field gets a responsive prop; render it with the SDK’s renderResponsiveImage and applyResponsiveProp (plus escapeHtml and safeMediaSrc) for the same <picture> and srcset as core.image.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), with up to 5 attachments. ctx.mail.registerTemplate() ships versioned system-email templates.AI agents
mcp: { name, description, capability, inputSchema? } to a ctx.http route to offer it to agents as one MCP tool that runs the route’s own handler. Management API keys (Bearer jfk_…) authenticate plugin routes with role: "api-key". The mcp.tools filter no longer runs tools.Plugins, CAPTCHA & assistant
ctx.plugins.call(pluginId, request) runs another active plugin’s route in-process as a given session. ctx.captcha.verify() checks the site’s Discussion CAPTCHA without seeing its secret; without an action it expects the plugin’s own name (acme-reviews for justflows.acme-reviews). ctx.ai.complete() drafts one short text with the site’s assistant, with no tools; system sets your plugin’s role for the assistant, and the host always adds that the text is data, not instructions.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:publishplatform:tenancyplatform:networkfiles:privateWhat a route can answer
- status / type / body
- A normal JSON, HTML, or text response.
- file
- Stream one of the plugin’s private files (
ctx.files) withRangesupport. - redirect
- Send the browser to a same-site path, with a 303 unless
statusis already a redirect. Absolute and protocol-relative URLs are ignored. - renderPage
- Answer with a published page of this site, by slug, in the active theme at the URL the visitor asked for. Never cached.
- revalidate
truedrops this site’s page caches after a config write."network"drops every site’s, withplatform:networkon the main site.- cors
- Allow a vouched-for origin, such as a static export host, to read the response.
Admin 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 under /admin/plugins/{id}. domain: "plugin" with adminSection gives the plugin its own section. 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, your catalogs, and contentId or workspaceId when the screen is embedded in the content editor or on a workspace) and route changes. bridge.setLayout("fill") gives the frame the whole content area. 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)