JustFlows

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.

Project structure
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.css

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

justflows.json (archive root)
{
  "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": []
  }
}
src/index.ts
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 1 and "plugin" today.
id
Dot-namespaced identifier. Becomes the /ext/&lbrace;id&rbrace;/** 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 author on the index.ts manifest.
version / license / engines
SemVer version, a GPL-compatible license (required), and the justflows host-version range this build supports.
permissions
The exact ctx APIs this plugin may use. The host highlights the sensitive ones during install and rejects any call outside this list — declaring contentTypes below is what required adding content:delete here.
entrypoints.server
The compiled file the host imports. Optional — defaults to dist/index.js, then index.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 under adminMenu. Requires admin:extend.
assets
Public JS/CSS bundled into /jf-plugins.<hash>.js / .css and 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: true marks 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, minus schemaVersion / type / publisher, with main in place of entrypoints.server. The loader validates this copy and builds ctx.permissions from 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 review content type the first time this activates. Idempotent — a no-op on every activation after that.
ctx.capabilities.register(…)
Adds a reviews:moderate user 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.js fills in.
ctx.http.post("/reviews", …)
The public submit endpoint the widget script posts to. Stores the row with ctx.data.put() and, when notifyEmail is set, sends a notification with ctx.mail.send().
deleteData(ctx)
Runs when the plugin is deleted, before deactivation. Honors the operator’s delete-data choice through pluginShouldDeleteData(), then clears everything under ctx.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/&lbrace;pluginId&rbrace; 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).

Mail

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:publish

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; the path must live below /admin/. setupPath opens a first-run wizard right after activation.

Front-end assets

assets.dir is served at /ext/&lbrace;pluginId&rbrace;/**, 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.

typescript
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-*.

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

Package from the plugin directory
pnpm build
COPYFILE_DISABLE=1 tar -czf ../../acme-reviews.jfpkg \
  justflows.json dist \
  $(test -d public && echo public) \
  $(test -d admin && echo admin)