Contents
- Domma CMS User Manual
- 1. Using the CMS
- 2. Tutorials
- 3. Components
- 4. API Reference
- 5. Tools
- Tools
- Analytics Pro
- Backup Pro
- Blog
- Blog Pro
- Booking
- Booking Pro
- Calendar
- Campaigns Pro
- Cards
- Card credits
- Contacts Pro
- Dashboard Pro
- Data Transfer
- Docs
- Docs Pro
- Email Pro
- Feedback
- Garage
- Invoices
- Job Board
- Mail Reader
- Messages
- MongoDB Adapter
- Onboard Pro
- Security
- Security Pro
- SEO Pro
- Sheets
- Shopping Cart
- Shopping Cart Pro
- Social Media Pro
- Support
- Surveys
- Theme Roller
- Waypoint Pro
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"
}
]
folderis where the Tool starts:overview,content,data,tools(the default),systemordocumentation.groupputs 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.hotkeyis 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
- Create the
plugins/my-plugin/directory with all three required files (or use New plugin, which does this and switches it on). - 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
onEnableruns now. - 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
restartOnPluginToggleis notfalseinconfig/server.json. Otherwise restart it yourself. The log then shows[plugins] Loaded N plugins: ..., my-plugin. - 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
On this page
- Directory structure
- 1. plugin.json - the manifest
- 2. plugin.js - the Fastify plugin
- 3. config.js - settings defaults
- 4. Admin views (optional)
- 5. Making it feel like a Tool
- Its place in the sidebar
- A badge with a popover
- The banner, help and the tool kit
- Needing other Tools
- Data and blocks
- 6. Injection snippets (optional)
- 7. User guide
- 8. Switching it on and testing
- 9. Sharing it