Domma CMS User Manual

Writing a Tool

Updated by Darryl Waterhouse on 14 October 2026 9 min read

A Tool is a screen in the admin with its own place in the sidebar: Notes, Invoices, Booking and Email are all Tools. The ones you add yourself are plugins. That is the older name, and it is still used for the folder they live in, the API under /api/plugins/ and the files you share. Once a plugin is switched on it is a Tool like any other: it files under Tools in the sidebar, gets the same banner, and an admin can move it, hide it or give it a hotkey.

Each Tool is a self-contained folder in plugins/, with three required files and optional admin/, public/ and docs/ subdirectories.

The quickest start is New plugin in the banner of System > Plugins (the Marketplace screen). It copies the built-in template into plugins/<slug>/ with a server entry, config defaults, a wired admin view, a starter user guide and a CLAUDE.md, switched on. Restart the server to activate it. The rest of this page explains what those files do.

Directory structure

plugins/
  my-plugin/
    plugin.json          - manifest (required)
    plugin.js            - Fastify plugin (required)
    config.js            - settings defaults (required)
    admin/
      views/
        index.js         - admin view (optional)
      templates/
        index.html       - view template (optional)
      css/
        index.css        - admin styles (optional)
    public/
      inject-head.html   - injected into <head> on every page (optional)
      inject-body.html   - injected before </body> on every page (optional)
    docs/
      guide.md           - user guide under Documentation > Plugins (optional)
    data/                - plugin data store (optional, never served)

Only admin/ and public/ are served over HTTP; plugin.js, config.js and data/ never are.


1. plugin.json - the manifest

All fields below are required. Missing any will cause the plugin to be skipped on startup with a warning in the server log. name must match the folder name.

{
    "name": "my-plugin",
    "displayName": "My Plugin",
    "version": "1.0.0",
    "description": "A short description shown in the Marketplace.",
    "author": "Your Name",
    "date": "2026-03-01",
    "icon": "star"
}

Common optional fields:

Field Type Description
inject.head string Path (relative to plugin root) to an HTML snippet injected into <head>.
inject.headLate string A snippet injected at the end of <head>, after the site stylesheets - for CSS that must win.
inject.bodyEnd string Path to an HTML snippet injected before </body>.
admin.sidebar array The Tool's sidebar entries. Each may name its default folder (tools unless you say otherwise), a group inside it, a hotkey and a countUrl for its badge - see section 5.
admin.routes array SPA routes to register in the admin router.
admin.views object View modules to dynamically import into the admin SPA.
admin.css array Admin stylesheets loaded with the plugin's views.
permissions array Permission resources the plugin guards its routes with. They appear in the role editor; grant gives existing roles them once.
requires array Built-in Tools (contacts, notes, todo, analytics, seo) or plugins this one cannot work without. It is not loaded while one is off.
uses object {"contacts": "Invite a contact group"} - Tools or plugins it works without but uses when they are on. The text is what the admin is told they lose.
supersedes array Plugins (or built-in Tools) this one replaces outright, such as a Pro edition of a free plugin.
settingsSchema array Labels, types and help for the Marketplace Configure form.
minCmsVersion string The oldest Domma CMS the plugin works on. Install and update are refused on an older CMS.

2. plugin.js - the Fastify plugin

This is the server-side entry point. It must export a default async function that Fastify will call with (fastify, options).

The CMS hands you auth middleware in options.auth (authenticate, requireAdmin, requirePermission, requireRole, requireVisibility), extension points in options.hooks (shortcodes, transforms, sidebar items, notifications, registerComponent and more) and the merged settings in options.settings. Always take them from there rather than importing the middleware directly.

import { getPluginSettings, savePluginState } from '../../server/services/plugins.js';
export default async function myPlugin(fastify, options) {
    const { authenticate, requireAdmin } = options.auth;
    // Public endpoint - no auth needed
    fastify.get('/hello', async () => {
        return { message: 'Hello from my plugin!' };
    });
    // Admin-only endpoint (role levels 0 and 1)
    fastify.get('/settings', { preHandler: [authenticate, requireAdmin] }, async () => {
        return getPluginSettings('my-plugin');
    });
    fastify.put('/settings', { preHandler: [authenticate, requireAdmin] }, async (request) => {
        savePluginState('my-plugin', { settings: request.body });
        return { ok: true };
    });
}
// Optional: runs once each time the plugin is switched on, not at install.
export async function onEnable({ fastify, services, hooks }) {
    // Create collections, pages or forms here if needed.
}
// Optional: runs when it is switched off, and before an uninstall (uninstall: true).
export async function onDisable({ fastify, services, hooks, uninstall }) {
}

Routes are registered under the prefix /api/plugins/{name} automatically - write '/hello', not the full path. The prefix is always locked to your plugin's directory name.

For your own screens, prefer requirePermission('my-plugin', 'read') with a permission declared in plugin.json over requireAdmin: the site owner can then give it to any role in System > Roles.


3. config.js - settings defaults

Export a plain object of default settings. These are merged with any overrides stored in config/plugins.json when getPluginSettings() is called. The site owner changes them with Configure on the plugin in the Marketplace.

export default {
    greeting: 'Hello, world!',
    enableFeature: true,
    maxItems: 10
};

config.js is only loaded for enabled plugins. Side-effect code here will not run for disabled plugins.


4. Admin views (optional)

To add a page to the admin panel, declare the route and view in plugin.json:

"admin": {
    "sidebar": [
        {
            "id": "my-plugin",
            "text": "My Plugin",
            "icon": "star",
            "url": "#/plugins/my-plugin",
            "section": "#/plugins/my-plugin"
        }
    ],
    "routes": [
        {
            "path": "/plugins/my-plugin",
            "view": "plugin-my-plugin",
            "title": "My Plugin - Domma CMS"
        }
    ],
    "views": {
        "plugin-my-plugin": {
            "entry": "my-plugin/admin/views/index.js",
            "exportName": "myPluginView"
        }
    }
}

The view file follows the standard Domma view pattern - a templateUrl and an onMount($container) function. Call your API with H.get / H.post, which add the sign-in header and refresh the token for you - do not hand-build a Bearer header with fetch():

// admin/views/index.js
export const myPluginView = {
    templateUrl: '/plugins/my-plugin/admin/templates/index.html',
    async onMount($container) {
        const res = await H.get('/api/plugins/my-plugin/settings');
        const settings = res.data ?? res;
        $container.find('#greeting').text(settings.greeting);
        I.scan();
    }
};

The template is a plain HTML fragment (no <html> wrapper) using Domma classes (card, btn, form-input). The admin already wraps every plugin view in a banner with the plugin's name, version and licence, so do not draw your own page heading:

<!-- admin/templates/index.html -->
<div class="card">
    <div class="card-body">
        <p id="greeting">Loading...</p>
    </div>
</div>

Files under admin/ are fetched by the browser, so a hard refresh of the admin picks up a change without a restart.


5. Making it feel like a Tool

These are what make a screen look and behave like the built-in Tools. None is required, and each one is small.

Its place in the sidebar

"sidebar": [
    {
        "text": "Invoices",
        "url": "#/plugins/invoices",
        "icon": "file-text",
        "folder": "tools",
        "group": "Business",
        "hotkey": "i",
        "permission": "invoices",
        "countUrl": "/api/plugins/invoices/_count"
    }
]
  • folder is where the Tool starts: overview, content, data, tools (the default), system or documentation. group puts it in a sub-folder by name, made if it is not there. An admin can move the Tool anywhere in Content > Site menus > Admin sidebar, and their choice wins.
  • hotkey is one letter: G then that letter opens the Tool. The built-in Tools claim their letters first, and the first Tool to ask for a free one gets it.
  • A Tool is known by its url, so give each entry its own.

A badge with a popover

A countUrl on the entry draws a count badge beside it. The route answers {count, label, tone, details}. tone is danger (red, something is late), live (a pulsing dot, happening now) or info. details fills the popover shown on hover: {title, rows: [{label, value}], items: [{text, meta}], empty}, all plain text. Counts refresh every minute. When your screen changes the number, tell the sidebar straight away with sidebarCount(url) from the tool kit (below). With Dashboard Pro, the same answer is offered as a Dashboard widget.

fastify.get('/_count', { preHandler: [authenticate] }, async () => {
    const overdue = await countOverdue();
    return {
        count: overdue,
        label: `${overdue} overdue`,
        tone: overdue ? 'danger' : 'info',
        details: { title: 'Invoices', rows: [{ label: 'Overdue', value: String(overdue) }], empty: 'Nothing overdue.' }
    };
});

The banner, help and the tool kit

Every Tool screen sits under a banner showing its name, version, date and licence, drawn by the admin. Put screen-level buttons (New, Import, a settings cog) in the banner with addBannerAction. Add a (?) beside anything that needs explaining with a data-help attribute (plain text) and helpPopovers. tool-kit.js has the rest: openMenuAt for a ⋮ button that opens a row's right-click menu, viewLifetime to tidy up listeners when the user leaves, onceClosed to remove a slideover once it closes, and sidebarCount.

Load these at run time with import(), never a static import. The admin is replaced on every CMS update, so your Tool must keep working, with less, on a CMS that lacks one of them:

async onMount($container) {
    try {
        const chrome = await import('/admin/js/lib/plugin-chrome.js');
        chrome.addBannerAction?.($container, { icon: 'plus', title: 'New invoice', onClick: () => openNew() });
        const help = await import('/admin/js/lib/help-popover.js');
        help.helpPopovers($container, { position: 'bottom' });
    } catch {
        // an older admin: put the button in your own toolbar instead
    }
}

Right-click menus come from E.contextMenu on your rows, and screens stay readable in every theme if your CSS uses only the theme's --dm-* variables.

Needing other Tools

requires lists Tools yours cannot work without: it is not loaded while one of them is off. uses lists the ones it can do without, with what is lost ({"contacts": "Invite a contact group"}). When an admin switches one of those off, the Marketplace shows them that text first.

Data and blocks

A Tool that keeps records can ship collections (collections/<slug>/schema.json, created the first time it is switched on), forms and pages. To give your public pages a design site owners can change, use a block. Create it in onEnable, which runs every time the Tool is switched on, so only when it is not there yet - otherwise you would undo the site owner's changes in Data > Blocks:

export async function onEnable({ services }) {
    const exists = await services.blocks.getBlock('invoice-card').then(() => true, () => false);
    if (!exists) await services.blocks.saveBlock('invoice-card', INVOICE_CARD_HTML, { css: INVOICE_CARD_CSS });
}

6. Injection snippets (optional)

HTML snippets declared in inject.head, inject.headLate and inject.bodyEnd are read from the plugin's folder and inserted into every public page. Use this for analytics scripts, stylesheets, or widgets. They are read once at start-up.

<!-- public/inject-body.html -->
<script>
(function () {
    // This runs on every public page
    fetch('/api/plugins/my-plugin/hello')
        .then(r => r.json())
        .then(d => console.log(d.message));
})();
</script>

Snippet paths are validated - they must stay within the plugin's own directory. Paths containing .. are blocked.


7. User guide

Markdown files in the plugin's docs/ folder become pages under Documentation > Plugins while the Tool is on, shown only to users who can use it. guide.md is the first page; further files become tabs. Start with ## sections, not a top-level heading, and write for site owners: what it does, getting started, one section per screen, settings, permissions. Update it in the same change as the feature.


8. Switching it on and testing

  1. Create the plugins/my-plugin/ directory with all three required files (or use New plugin, which does this and switches it on).
  2. Open System > Plugins. A plugin you made by hand is listed, switched off. Right-click it (or use its menu) and choose Switch on. Its onEnable runs now.
  3. The server restarts by itself to load the plugin's routes and screens, where a supervisor (the fleet manager or pm2) will bring it back and restartOnPluginToggle is not false in config/server.json. Otherwise restart it yourself. The log then shows [plugins] Loaded N plugins: ..., my-plugin.
  4. Verify your endpoint: GET /api/plugins/my-plugin/hello

9. Sharing it

On the Marketplace screen, right-click your Tool and choose Export .dcmsplugin. On another site, Install from file in the Marketplace banner takes that file. Installing a newer version of a Tool that is already there updates it in place, keeping its data/, and if anything goes wrong the old version is put back. An unsigned file, or an older version than the one installed, asks for your confirmation first.

Tip: use npm run dev during development - the server restarts automatically on file changes. Server-side files (plugin.js, config.js, plugin.json, snippets) only apply after a restart; the in-admin code editor (View source on the plugin) has a Restart server button. For the full reference see docs/plugin-development.md in the Domma CMS package.


Next: Form Follow-Up