Developing Plugins
Want to extend FediSuite with your own features? This page explains how a plugin is built, how to get started with the scaffold generator and what the plugin API (version 1) offers. You do not need deep prior knowledge to get started.
On this page
- Who is this page for?
- Prerequisites
- Quick start with the scaffold
- Plugin structure
- The plugin manifest (plugin.json)
- Capabilities and permissions
- The entry file: server/index.js
- Plugin types and examples
- Hooks
- Plugin settings
- Custom web pages (web/)
- Multilingual support (i18n)
- Testing a plugin locally
- Common errors & debugging
Who is this page for?
This page is for anyone who wants to build their own plugins for FediSuite or understand how the plugin system works internally. The scaffold generator takes care of the groundwork, and the examples on this page show what goes where. You write the actual logic of your plugin yourself.
This page is for you if …
- … you want to develop your own plugin.
- … you want to understand how plugins are built.
- … you want to adapt an existing plugin.
This page is not for you if …
- … you only want to install plugins Installing Plugins
- … you want to switch plugins on or off Plugin Management
Prerequisites
Basic JavaScript knowledge is enough for a simple plugin. The server side of a plugin is an ES module that exports a function register(context).
Plugins are written in JavaScript. For simple admin pages or widgets it is enough to know functions and objects.
To try things out you need a FediSuite instance where ./plugins is mounted to /app/plugins (the default in the self-hosting repository). Better not test new plugins on an instance with real accounts, because a plugin runs with the same permissions as the application itself.
The scaffold generator ships inside the app container, where Node.js is already available. Alternatively, run it from the source repository with a local Node.js installation. Check with: node --version
To clone the plugin repository and for versioning and updates.
Quick start with the scaffold
The recommended way to your own plugin is the scaffold generator. It is part of the FediSuite application (scripts/create-plugin-scaffold.mjs, run through npm run plugins:create) and creates a working plugin structure with one command: plugin.json, server/index.js, language files, web pages if needed and a README. You do not start from scratch.
Option 1: inside the running app container
Open a shell in the app container and run the command in the /app directory, which is where docker compose exec opens the shell by default. Without --target, the plugin is created under plugins/<id>, which inside the container is /app/plugins and therefore the mounted ./plugins folder on the host:
# Open a shell in the app container
docker compose exec app sh
# Run the scaffold in the application's main directory
npm run plugins:create -- \
--id my-plugin \
--name "My Plugin" \
--author "Your Name" \
--presets admin-page
Plugin scaffold created successfully.
Path: /app/plugins/my-plugin
Plugin ID: my-plugin
Presets: admin-page
root on the host. Take them over if needed with sudo chown -R "$USER": plugins/my-plugin. In the development Compose file of the source repository (docker-compose.build.yml), ./plugins is mounted read-only (:ro). Create the plugin there with option 2.
Option 2: from the source repository
With Node.js on your machine, clone the main repository FediSuite-Docker-Image (opens in new tab) and pass the target folder with --target. The target must not exist yet:
git clone https://forge.chrislo.de/FediSuite/FediSuite-Docker-Image.git
cd FediSuite-Docker-Image
npm run plugins:create -- \
--id my-plugin \
--name "My Plugin" \
--author "Your Name" \
--presets admin-page \
--target /path/to/fedisuite/plugins/my-plugin
The options
--id
Required. The technical ID of the plugin. The generator turns it into lowercase letters, digits and hyphens ("My Plugin" becomes my-plugin). The ID serves as the unique key in FediSuite, in the API path /api/plugins/<id>/ and in the language namespace. Once set, do not change it.
--name
Required. The display name shown in plugin management. It may contain spaces and special characters.
--author
Required. Your name or your organization's name. It is shown in plugin management.
--presets
Optional. Which building blocks to create. Separate several presets with commas. Without it, the plugin is created without any building blocks.
--description
Optional. The description for plugin.json. Default: "A FediSuite plugin scaffold for <name>."
--version
Optional. The initial version. Default: 1.0.0.
--target
Optional. The target folder. Default: plugins/<id> in the current directory. If it already exists, the generator aborts.
--help
Shows the help with all options and presets.
Available presets
Each preset creates the matching building block with example texts and enters the capability and permissions in plugin.json. Presets can be combined: --presets app-page,dashboard-widget
admin-page
Capability: admin.section
Permissions: admin.sections, web.runtime, api.routes
A page in the admin area with Markdown content and an example web page, plus a status route.
app-page
Capability: app.section
Permissions: app.sections, web.runtime, api.routes
Its own entry in the sidebar for all users, also with Markdown content, an example web page and a status route.
dashboard-widget
Capability: dashboard.widget
Permissions: dashboard.widgets
A statistics widget on a tab of the "Analytics" page.
composer-extension
Capability: composer.extension
Permissions: composer.extensions
Additional fields in the composer and a text transformation before publishing.
provider
Capability: provider
Permissions: providers
An additional platform whose accounts can be connected (example connection flow to adapt).
auth-provider
Capability: auth.provider
Permissions: auth.providers
An additional sign-in for FediSuite itself (example for start and return).
insights-provider
Capability: insights.provider
Permissions: insight.providers
A provider for additional tips about an account, initially with a fixed example tip.
settings
Capability: settings.section
Permissions: settings.global.read/write, settings.user.read/write
A settingsSchema in the manifest for plugin-specific settings (example fields for global and per-user values).
--presets admin-page. A simple admin page shows the complete basic structure without needing any provider logic. You can add more presets later. The FediSuite-Plugins-Repository (opens in new tab) contains such a scaffold result as my-plugin for you to look at.
Plugin structure
Every plugin is a folder directly inside the plugins/ directory. FediSuite recognizes a plugin by a plugin.json lying directly in that folder. Deeper nested folders are not searched. The folder name usually matches the plugin ID, but the id in plugin.json is what counts.
At least required
my-plugin/
├── plugin.json ← required
└── server/
└── index.js ← required
Created by the scaffold (admin-page)
my-plugin/
├── plugin.json
├── README.md
├── server/
│ └── index.js
├── i18n/
│ ├── de.json
│ ├── en.json
│ ├── es.json
│ ├── fr.json
│ └── it.json
└── web/ ← only for admin-page / app-page
├── manifest.json
├── admin-main.html
└── main.js
plugin.json
The manifest: describes the plugin, its permissions and entry points. FediSuite reads this file first.
server/index.js
The server-side entry file. This is where you register everything the plugin adds to FediSuite.
i18n/<language>.json
Language files (optional but recommended). The file name is the language code, and the scaffold creates de, en, it, fr and es. The files contain the plugin's texts as nested keys with strings.
web/manifest.json
Optional. Only needed if the plugin embeds its own HTML/JS pages in FediSuite.
README.md
Notes about the plugin generated by the scaffold. FediSuite does not read this file.
The plugin manifest (plugin.json)
The plugin.json is the most important file of a plugin. It sits in the root of the plugin folder and tells FediSuite who the plugin is, what it brings and which permissions it needs. If a required field is missing or the file is invalid, the plugin is not loaded and is shown with the status "Failed".
This is the manifest the scaffold generates with --presets admin-page:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"pluginApiVersion": 1,
"description": "A FediSuite plugin scaffold for My Plugin.",
"i18nDir": "./i18n",
"displayNameKey": "meta.name",
"descriptionKey": "meta.description",
"author": "Your Name",
"license": "GPL-3.0-or-later",
"capabilities": [
"admin.section"
],
"requiredPermissions": [
"admin.sections",
"web.runtime",
"api.routes"
],
"webEntry": "./web/manifest.json",
"serverEntry": "./server/index.js"
}
Required fields
id
string
The stable technical ID. Letters, digits, dots, hyphens and underscores are allowed. It must be at least 2 characters long and start with a letter or a digit. The ID is used in the API path (/api/plugins/<id>/...) and in the language namespace (plugin.<id>). Once assigned, do not change it.
name
string
The display name when no translation is available. With displayNameKey it is replaced by the translated text.
version
string
The plugin's version number, usually following the scheme major.minor.patch, for example "1.0.0".
pluginApiVersion
number
The plugin API version the plugin was written for. It must match exactly the version FediSuite expects. Currently that is 1 (environment variable PLUGIN_API_VERSION). With any other version, the plugin is rejected.
description
string
A short description shown when there is no translation. With descriptionKey it is replaced by the translated text.
author
string
Name of the author or organization.
license
string
The plugin's license, for example "GPL-3.0-or-later" or "MIT".
capabilities
array
Which kinds of extension the plugin provides, for example ["admin.section"]. The field must be present (an empty array is enough). FediSuite shows the values in plugin management. What the plugin may register is decided by requiredPermissions.
serverEntry
string
Relative path to the server-side JavaScript entry file, usually "./server/index.js". The file must exist and export a function register.
Permissions, texts and web pages
requiredPermissions
array
The permissions the plugin needs, for example ["admin.sections"]. Formally the field is optional. As soon as the plugin registers something without naming the matching permission, however, startup fails with a clear error message. Details in the next section.
i18nDir
string
Path to the directory with the language files, for example "./i18n". If the field is set, the directory must exist.
displayNameKey
string
Language key for the display name, for example "meta.name". Letters, digits, dots, hyphens and underscores are allowed.
descriptionKey
string
Language key for the description, for example "meta.description".
webEntry
string
Path to the web manifest, for example "./web/manifest.json". Only set it if the plugin embeds its own HTML/JS pages. The web.runtime permission is mandatory for this.
Other optional fields
settingsSchema
object
Describes plugin-specific settings. See the "Plugin settings" section.
homepage, repository
string
Links to the project page and the source code. Plugin management shows them in the details.
minAppVersion, maxAppVersion
string
The lowest and highest FediSuite version the plugin works with, for example "2.0.0". If the running version is outside this range, the plugin is rejected.
requiredEnv
array
Names of environment variables the plugin expects. Plugin management lists them, FediSuite does not check them.
serverEntry, webEntry and i18nDir are relative to the plugin folder. FediSuite rejects paths that lead out of the folder, including through symlinks.
Capabilities and permissions
The plugin.json has two fields that are easy to mix up: capabilities and requiredPermissions.
capabilities
Describe which kind of extension the plugin provides. Plugin management displays them. FediSuite does not check whether they match what the plugin actually registers.
requiredPermissions
Determine what the plugin may register and use. Every method of the context checks the matching permission. If it is missing, the plugin's startup aborts with an error message.
Which capability belongs to which permission?
admin.section
admin.sections
A page in the admin area. Custom web pages need web.runtime, custom API routes need api.routes.
Optional: web.runtime, api.routes
app.section
app.sections
An entry in the sidebar, visible to all signed-in users.
Optional: web.runtime, api.routes
dashboard.widget
dashboard.widgets
A widget on a tab of the "Analytics" page.
composer.extension
composer.extensions
Additional fields and transformations in the composer.
provider
providers
Connects an additional platform to FediSuite (like the Bluesky plugin).
Optional: web.runtime
auth.provider
auth.providers
An additional sign-in for FediSuite itself.
insights.provider
insight.providers
Delivers additional tips about an account. Note the singular "insight" in the permission.
settings.section
settings.<scope>.read/write
Reading and writing settings. The scope is global, user or account, and there is one permission each for reading and writing.
Further permissions that apply regardless of the plugin type:
api.routes
Register your own API routes under /api/plugins/<id>/ (auth: user or public).
api.routes.admin
Additionally required for routes with auth: admin.
web.runtime
Embed your own web pages. Mandatory as soon as the plugin.json contains a webEntry.
hooks
Register hooks. In addition, each event needs the permission of its group, see the "Hooks" section.
hooks.server, hooks.posts, hooks.accounts, hooks.insights, hooks.settings
The group permissions for hooks.
settings.global.read, settings.global.write
Read and write global plugin settings. Mandatory for the card in plugin management.
settings.user.read, settings.user.write
Read and write per-user settings. Mandatory for the section in the settings.
settings.account.read, settings.account.write
Read and write settings per connected account.
providers, otherwise startup fails with the message Plugin "…" must declare permission "providers" to register provider integrations. The scaffold enters the matching permissions for the chosen presets automatically.
The entry file: server/index.js
The server/index.js file is the heart of every plugin. FediSuite imports it when the plugin starts and calls the exported function register(context). It may be async. If it returns a function, FediSuite calls it when the plugin is disabled so the plugin can clean up. context.onDispose(callback) does the same and can be used several times.
The minimal skeleton:
export function register(context) {
// Register everything the plugin adds to FediSuite here.
// The context parameter is your interface to FediSuite.
}
What is context?
context is the plugin's official interface to FediSuite. You use it to register your plugin's features. Every registration checks the matching permission from the plugin.json. The methods prefixed with registerSimple… and registerMarkdown… are convenience helpers for the common case. The methods without these prefixes give you full control over the definition.
Information about the plugin
context.plugin
Contains the plugin's metadata, for example context.plugin.plugin_id for the plugin ID.
context.namespace
The plugin's language namespace, for example plugin.my-plugin.
context.settings
The global settings loaded at startup. For current values or other scopes, use getSettings().
context.hasPermission(name), context.requirePermission(name)
Checks whether the plugin declared a permission. requirePermission throws an error if the permission is missing.
Pages & UI
registerMarkdownAdminPage()
The simplest way to an admin page: the content comes as Markdown from the language file.
registerMarkdownAppPage()
Like the above, but as a sidebar entry for all users (additionally with navLabelKey for the menu entry's label).
registerAdminSection(), registerAppSection()
The base methods behind the Markdown helpers. A definition consists of id, title, description, badge and content ({ markdown } or { text }). App pages additionally have nav_label. For custom HTML/JS pages, the id belongs to an entry in web/manifest.json.
Widgets & extensions
registerSimpleDashboardWidget()
A statistics widget with a title, values and an optional list.
registerDashboardWidget()
A dashboard widget with the full definition (title, description, badge, content with stats, items, text or markdown).
registerSimpleComposerExtension()
Fields and transformation in the composer.
registerComposerExtension()
Composer extension with the full definition (fields, sections, instanceTypes).
Providers
registerSimpleProvider(), registerProvider()
Connects an additional platform. registerSimpleProvider is the entry point, registerProvider gives full control (see the Bluesky plugin).
registerSimpleAuthProvider(), registerAuthProvider()
Adds a login provider.
registerOidcAuthProvider()
A ready-made sign-in through OpenID Connect. It needs the auth.providers permission, and issuer and client details can come from the plugin settings.
registerSimpleInsightProvider(), registerInsightProvider()
Delivers additional tips about an account.
API, hooks & settings
registerApiRoute()
Registers an HTTP endpoint under /api/plugins/<pluginId>/... Plugin web pages call it through the SDK.
registerStatusRoute()
Registers a ready-made route at /status that returns the plugin ID, the status and the settings. The scaffold uses it for the example web page.
registerHook()
Reacts to an event, for example after a post is published. See the "Hooks" section.
getSettings(scope), getSetting(key, fallback, scope)
Reads the settings of a scope (global, user or account) or a single value. Without a scope, global applies. Details in the "Plugin settings" section.
getSettingsState(scope), saveSettings(values, scope)
Reads the settings together with schema and timestamp, or saves values. Both require the matching settings permission.
Languages & lifecycle
context.ref(key, fallback), context.createI18nRef(key, fallback)
Creates a reference to a language key of the plugin. ref is the short form of createI18nRef.
context.onDispose(callback)
Registers a cleanup function that runs when the plugin is disabled, for example for timers.
Plugin types and examples
The following examples show how the most important plugin types are registered in server/index.js. They match what the scaffold generates.
Texts whose name ends in Key (titleKey, descriptionKey and so on) accept a key from the plugin's language files, for example 'adminSections.main.title'. The same works with context.ref('adminSections.main.title', 'Fallback text'). More in the "Multilingual support" section.
The simplest plugin type. The page content comes as Markdown from the language file, so neither HTML nor JavaScript is needed. The page appears in the sidebar under Admin area, in the "Plugin pages" group. If there is an entry for the id in web/manifest.json, FediSuite shows that web page instead.
export function register(context) {
context.registerMarkdownAdminPage({
id: 'main',
titleKey: 'adminSections.main.title',
descriptionKey: 'adminSections.main.description',
badgeKey: 'adminSections.main.badge',
markdownKey: 'adminSections.main.markdown',
});
}
Permission: admin.sections
Like the admin page, but for all signed-in users. The entry appears in the sidebar in the "Plugin sections" group, labeled with navLabelKey and the plugin name underneath.
export function register(context) {
context.registerMarkdownAppPage({
id: 'main',
titleKey: 'appSections.main.title',
navLabelKey: 'appSections.main.navLabel',
descriptionKey: 'appSections.main.description',
markdownKey: 'appSections.main.markdown',
});
}
Permission: app.sections
Adds a card with key figures. With tab you choose the tab of the "Analytics" page (default: overview), with width the width (half or full, default: half). Instead of statusLabelKey and statusValueKey you can pass a list stats with entries made of label, value and tone (default, info, success, warning or danger). A list items adds lines of text below the key figures.
export function register(context) {
context.registerSimpleDashboardWidget({
id: 'main',
tab: 'interactions', // optional, default: 'overview'
titleKey: 'dashboardWidgets.main.title',
descriptionKey: 'dashboardWidgets.main.description',
badgeKey: 'dashboardWidgets.main.badge',
statusLabelKey: 'dashboardWidgets.main.stats.statusLabel',
statusValueKey: 'dashboardWidgets.main.stats.statusValue',
});
}
Permission: dashboard.widgets
Possible values for tab: overview (Overview), growth (Growth), interactions (Interactions), engagement (Engagement), hashtags (Hashtags), content (Content), optimize (Optimize) and tips (Tips). An unknown value makes the widget appear on the "Overview" tab.
Extends the composer with your own fields in the "Plugin extensions" card. Field types are text, textarea, boolean, number and select (password fields are not allowed in the composer). transformPost runs before the post is published and receives { account, post, data, extension, settings }. The function returns the post fields to change (for example content, title, spoiler_text, visibility, language), or an empty object if there is nothing to change. With instanceTypes you restrict the extension to certain platforms.
export function register(context) {
context.registerSimpleComposerExtension({
id: 'main',
displayNameKey: 'composer.main.displayName',
descriptionKey: 'composer.main.description',
fields: [
{
key: 'enabled',
type: 'boolean',
labelKey: 'composer.main.fields.enabled.label',
descriptionKey: 'composer.main.fields.enabled.description',
defaultValue: false,
},
{
key: 'suffix',
type: 'text',
labelKey: 'composer.main.fields.suffix.label',
descriptionKey: 'composer.main.fields.suffix.description',
defaultValue: ' - sent with my plugin',
},
],
transformPost: async ({ post, data }) => {
if (!data?.enabled || !String(data?.suffix || '').trim()) return {};
return {
content: `${String(post.content || '').trim()}${String(data.suffix)}`.trim(),
};
},
});
}
Permission: composer.extensions
Connects an additional platform to FediSuite. beginConnection starts the connection flow and returns a redirect_url. handleCallback processes the return and delivers the account data. The default return path is /api/providers/<providerId>/callback. The connected accounts appear under Settings → Connected accounts, in the "Plugin connectors" area. This example matches the scaffold and is a placeholder you replace with your real flow:
export function register(context) {
context.registerSimpleProvider({
id: 'my-plugin-provider',
displayNameKey: 'providers.main.displayName',
descriptionKey: 'providers.main.description',
beginConnection: async ({ req }) => ({
provider_id: 'my-plugin-provider',
user_id: req.user?.id || null,
redirect_url: `${req.protocol}://${req.get('host')}/api/providers/my-plugin-provider/callback?code=demo-code`,
state: 'demo-state',
}),
handleCallback: async ({ req }) => ({
provider_id: 'my-plugin-provider',
user_id: Number(req.query.user_id || 1) || 1,
account: {
instance_url: 'https://example.com',
username: 'demo',
display_name: 'Demo account',
stats_followers: 0,
stats_following: 0,
stats_statuses: 0,
max_characters: 500,
max_media_attachments: 4,
},
start_import: false,
}),
disconnect: async ({ account }) => ({
provider_id: 'my-plugin-provider',
disconnected: true,
account_id: account?.id || null,
}),
});
}
Permission: providers
Publishing, import and statistics
A complete provider like the Bluesky plugin uses registerProvider and declares what it can do. The fedisuite-plugin-bluesky plugin in the plugin repository is the best template for this. The handlers:
beginConnection, handleCallback
Connection flow. Bluesky uses its own plugin web page as a form (handle, app password, optional PDS host) and receives the input by POST in the callback.
disconnect
Called when an account is disconnected.
publishPost
Publishes a post to the platform.
importHistoricalData
Imports an account's earlier history.
refreshStats, refreshPosts
Refresh account statistics and posts.
Besides auth (the kind of sign-in, for example { type: 'credentials' }) and capabilities (connect, disconnect, publish, import_historical, refresh_stats, refresh_posts and composer_profile for the composer's appearance), these handlers belong in the definition. The handlers receive, among other things, req, pool (database), account and APP_URL.
Adds "Or sign in with a plugin" to the sign-in page. beginLogin returns the redirect_url to the external service, handleCallback returns the identity (external_subject, email, profile). Start and return go through /api/auth/providers/<id>/start and /api/auth/providers/<id>/callback by default.
export function register(context) {
context.registerSimpleAuthProvider({
id: 'my-plugin-login',
displayNameKey: 'authProviders.main.displayName',
descriptionKey: 'authProviders.main.description',
beginLogin: async ({ req }) => {
const baseUrl = `${req.protocol}://${req.get('host')}`;
return {
auth_provider_id: 'my-plugin-login',
redirect_url: `${baseUrl}/api/auth/providers/my-plugin-login/callback?demo_email=plugin-login@example.com&demo_subject=demo-user`,
};
},
handleCallback: async ({ req }) => ({
identity: {
external_subject: String(req.query.demo_subject || 'demo-user'),
email: String(req.query.demo_email || 'plugin-login@example.com'),
profile: { provider: 'my-plugin-login', display_name: 'Demo login' },
},
tab: 'dashboard',
}),
});
}
Permission: auth.providers
identity.email, therefore only return addresses the external service has actually confirmed. The scaffold's demo values are meant for tests only.
Delivers additional tips about an account, which FediSuite merges with its own tips and sorts. A tip needs an id. In addition there are title, text, optionally reason, priority, confidence (low, medium or high) and evidence. Instead of a fixed list you can supply a function generateInsights(context). It receives, among other things, accountId, userId, days, timezone, settings and stats and returns a list of tips.
export function register(context) {
context.registerSimpleInsightProvider({
id: 'my-plugin-insights',
displayNameKey: 'insights.main.displayName',
tips: [
{
id: 'first-tip',
title: 'Example tip',
text: 'This tip comes straight from the plugin.',
confidence: 'low',
priority: 1,
},
],
});
}
Permission: insight.providers
Makes your own HTTP endpoint available under /api/plugins/<pluginId>/..., typically for plugin web pages that call it through the SDK. Allowed methods are get, post, put, patch and delete. The path must start with / and is compared exactly. There are no placeholders such as :id (use query parameters). With auth you decide who may call the route: 'user' (default) requires a signed-in person, 'admin' requires admin rights and the additional api.routes.admin permission, 'public' requires no sign-in. The handler receives, among other things, req, res, pool and plugin_id.
export function register(context) {
context.registerApiRoute({
method: 'get',
path: '/status', // reachable at /api/plugins/<id>/status
auth: 'user', // 'user' (default), 'admin' or 'public'
summary: 'Returns the plugin status.',
handler: async ({ req, res }) => {
res.json({ plugin_id: context.plugin.plugin_id, user_id: req.user?.id || null, status: 'ok' });
},
});
}
Permission: api.routes
Hooks
With context.registerHook({ event, handler }) a plugin reacts to events in FediSuite. For this, the plugin needs the hooks permission and, in addition, the permission of the group the event belongs to. A hook may be async. If it throws an error, FediSuite writes a warning to the log and carries on, so a hook cannot abort an action. Hooks run in the process where the event occurs: posts are published, for example, by the worker running the scheduler.
export function register(context) {
context.registerHook({
event: 'post.after_publish',
handler: async ({ post, account, publishedId }) => {
console.log(`Post ${post.id} was published as ${publishedId}.`);
},
});
}
server.start
Permission: hooks.server
After the server has started listening.
Context: port, appUrl
post.before_saved
Permission: hooks.posts
Before new posts are saved.
Context: postPayloads, userId, accountId, scheduledAt
post.after_saved
Permission: hooks.posts
After posts have been saved.
Context: posts, userId, accountId
post.before_publish
Permission: hooks.posts
Before a post is sent to the platform.
Context: post, account, mediaIds
post.after_publish
Permission: hooks.posts
After a post has been published.
Context: post, account, publishedId, parentFediverseId
post.publish_failed
Permission: hooks.posts
When publishing a scheduled post has failed.
Context: post, account, errorMessage
account.connected
Permission: hooks.accounts
After an account was connected through a plugin provider.
Context: accountId, userId, providerId, instanceType
account.before_disconnect
Permission: hooks.accounts
Before an account is disconnected.
Context: accountId, userId, account
account.after_disconnect
Permission: hooks.accounts
After an account was disconnected.
Context: accountId, userId, account
insights.generated
Permission: hooks.insights
After the tips for an account were generated.
Context: accountId, userId, days, timezone, account, tips, meta
settings.updated
Permission: hooks.settings
After plugin settings were saved.
Context: pluginId, scope, scopeRefId, userId, accountId, settings
Plugin settings
A plugin describes its settings in the settingsSchema field of the plugin.json. FediSuite generates the input forms from it: the "Plugin settings" card in plugin management for the global values (admins only) and the "Plugin settings" section under Settings → Preferences for the per-user values. The settings preset creates an example.
"requiredPermissions": [
"settings.global.read",
"settings.global.write",
"settings.user.read",
"settings.user.write"
],
"settingsSchema": {
"title": { "key": "settings.title", "fallback": "Plugin settings" },
"sections": [
{
"id": "general",
"title": { "key": "settings.sections.general.title", "fallback": "General" },
"fields": [
{
"key": "enabled",
"type": "boolean",
"label": { "key": "settings.fields.enabled.label", "fallback": "Enabled" },
"defaultValue": true
},
{
"key": "welcomeMessage",
"type": "text",
"label": { "key": "settings.fields.welcomeMessage.label", "fallback": "Welcome message" },
"defaultValue": "Hello from plugin settings"
}
]
}
]
}
A settingsSchema contains either a list fields or sections (sections), each with its own list fields. Every key may only appear once.
text, textarea, password, boolean, number and select. Password fields count as secret: FediSuite does not return stored values in plain text. Text may be up to 4096 characters long, textarea up to 65536.
label, description, placeholder, required, defaultValue, additionally min, max and step for number, and a list options with value and label for select. Texts may be strings or language references of the form { "key": "…", "fallback": "…" }.
global (for the whole instance, only changeable by admins), user (per user) and account (per connected account). The effective value is built from the default value, the global values and, on top of those, the values of the user and the account.
In code you read the values with getSettings() and getSetting(). Without a scope, global applies. For user and account you have to pass the ID, for example inside a route with req.user.id:
export function register(context) {
context.registerApiRoute({
method: 'get',
path: '/greeting',
auth: 'user',
handler: async ({ req, res }) => {
const global = await context.getSettings(); // global values
const message = await context.getSetting(
'welcomeMessage',
'Hello',
{ scope: 'user', userId: req.user.id }, // this person's value
);
res.json({ enabled: global.enabled, message });
},
});
}
With saveSettings(values, scope) you save values from within the plugin, which requires the matching write permission. When admins or users save values in the interface, that triggers the settings.updated hook.
Custom web pages (web/)
If your plugin needs an interactive page with its own HTML and JavaScript, you provide web files. FediSuite loads the HTML file and embeds it directly into the app, without an iframe. You do not need a separate server. Stylesheets are limited to the plugin area (selectors such as body or :root only apply there), relative paths such as ./main.js or ./style.css are resolved relative to the page, and scripts (including type="module") are executed. The CSS limiting is not a security boundary.
The web/ directory is optional. For simple text or Markdown content you do not need it. A plugin with webEntry needs the web.runtime permission. FediSuite only serves these file types: html, js, mjs, css, json, svg, txt, png, jpg, jpeg, gif, webp, ico, woff, woff2 and ttf.
web/manifest.json
This file describes which HTML file belongs to which page. frontendApiVersion must be 1. The sectionId must match exactly the id you assigned in server/index.js. entry is a path relative to web/, without a leading slash and without ...
For admin pages
{
"frontendApiVersion": 1,
"adminPages": [
{
"sectionId": "main",
"entry": "admin-main.html"
}
]
}
For app pages
{
"frontendApiVersion": 1,
"appPages": [
{
"sectionId": "main",
"entry": "app-main.html"
}
]
}
sectionId in web/manifest.json must be identical to the id of the registered page (registerMarkdownAdminPage, registerAdminSection and the app variants). Even a difference in upper or lower case means FediSuite does not find the web page and shows the Markdown content instead. An admin page belongs in adminPages, an app page in appPages.
The plugin web SDK
A ready-made SDK handles communication between the HTML page and FediSuite. It takes care of authentication and API calls, so you do not have to manage tokens or HTTP headers yourself. All calls go to the routes your plugin registered with registerApiRoute.
Include the SDK in your HTML file:
<script src="/plugin-sdk/fedisuite-plugin-web.js"></script>
After that, window.FediSuitePluginWeb is available. The basic skeleton, which the scaffold also generates (web/main.js):
const output = document.getElementById('output');
async function boot() {
const plugin = window.FediSuitePluginWeb;
// Wait for FediSuite's initialization, before any other call
const context = await plugin.ready();
// context contains: pluginId, sectionId, sectionKind ('admin' or 'app'), language
try {
// Call the plugin API (route from registerApiRoute or registerStatusRoute)
const status = await plugin.get('/status');
output.textContent = JSON.stringify({ context, status }, null, 2);
} catch (error) {
output.textContent = error instanceof Error ? error.message : String(error);
}
}
boot();
Available SDK methods
ready()
Returns the context data (pluginId, sectionId, sectionKind, language). Call it first, before any API calls.
getContext()
Returns the same context immediately, without waiting.
get(path)
Sends a GET request to /api/plugins/<pluginId>/<path>. The path starts with /.
post(path, body)
Sends a POST request. body is a JavaScript object and is transmitted as JSON.
put(path, body), patch(path, body), delete(path, body)
Corresponding requests with a JSON body.
request(path, { method, body })
The general form behind the methods above. An error status from the route leads to a rejected promise carrying the error message.
resize(), autoResize()
Only present for compatibility. Because the page is embedded directly in the app, you need neither.
Multilingual support (i18n)
FediSuite supports several languages. To fit in, you move titles, descriptions and content into language files in the i18n/ directory (entered in plugin.json through i18nDir). The file name is the language code, for example de.json. Each file is a JSON object whose values are strictly strings (or lists of strings). Numbers and booleans are not allowed.
This is what the scaffold's language file for an admin page looks like:
{
"meta": {
"name": "My Plugin",
"description": "A short description of my plugin."
},
"adminSections": {
"main": {
"title": "My Plugin Admin",
"description": "What this plugin does.",
"badge": "Plugin",
"markdown": "## Welcome\n\nThe content goes here."
}
}
}
In code you pass the key, not the text. When you need a reference with a fallback, create it with context.ref:
// Recommended: a key from the language file
titleKey: 'adminSections.main.title'
// Equivalent, with a fallback text in case the key is missing
titleKey: context.ref('adminSections.main.title', 'My Plugin')
// Possible, but not translated: a fixed text
title: 'My Plugin'
- FediSuite picks the language file in this order: the interface language, its base language (de instead of de-AT), then English. An en.json is therefore a useful fallback.
- If the key is missing in the chosen language file, FediSuite shows the fallback text of context.ref. If there is none, the key itself appears.
- The display name and description in plugin management come from the displayNameKey and descriptionKey keys in plugin.json.
- A text that looks like a key (segments of letters, digits, hyphens and underscores, separated by dots, without spaces) is treated as a key. Any other text stays a fixed text.
Testing a plugin locally
Once your plugin sits in plugins/<id>/ and the folder is mounted, testing always works the same way:
Create or change the plugin, for example with the scaffold.
Restart the containers, because FediSuite only reads plugins and their manifests at startup: docker compose restart app worker1 worker2
Check in plugin management whether the plugin appears. For an entry with the status "Failed", the expanded details name the cause.
Enable the plugin and then restart the workers (docker compose restart worker1 worker2) if the plugin should run there as well.
Check the page, the widget or the composer (see "What an active plugin adds" in plugin management) and watch the logs while doing so: docker compose logs -f app
Common errors & debugging
Whether a plugin has started is visible in its status in plugin management. For "Failed", the cause is in the expanded details. The container logs do not name it. These messages appear there most often:
Plugin manifest is missing required field "…"
A required field is missing in plugin.json.
Plugin API version X is not compatible with supported version 1.
pluginApiVersion does not match the version FediSuite expects.
Plugin requires at least app version …
minAppVersion or maxAppVersion excludes the running FediSuite version.
Plugin entry file not found: …
serverEntry, webEntry or i18nDir points to a file that does not exist.
Plugin "…" does not export a register(context) function.
server/index.js does not export a function register.
Plugin "…" must declare permission "…" to …
The plugin registers something without naming the permission in requiredPermissions.
Plugin web manifest … / frontendApiVersion …
The web/manifest.json is invalid, has a frontendApiVersion other than 1, an unsafe path or duplicate sectionId values.
Plugin is not detected
- Is plugin.json directly in the plugin folder (not one level deeper)?
- Is the plugins/ folder mounted as a volume at /app/plugins?
- Were the containers restarted after the change?
- Are PLUGINS_ENABLED and PLUGIN_SCAN_ON_START not set to false?
ls -la plugins/my-plugin/
docker compose config | grep plugins
docker compose exec app ls /app/plugins
docker compose restart app worker1 worker2
Plugin does not start (status "Failed")
- Is pluginApiVersion in plugin.json correct? Currently the value must be 1.
- Does the file serverEntry points to exist, and does it export a function register?
- Are all permissions your register function needs set in requiredPermissions?
- Is the JSON in plugin.json and in the language files valid, and do the language files contain only strings?
docker compose exec app node --check /app/plugins/my-plugin/server/index.js
python3 -m json.tool plugins/my-plugin/plugin.json
Page or widget does not appear in the app
- Was the page registered with the matching method (admin page or app page)?
- Is the matching permission listed in requiredPermissions?
- Is the plugin enabled in plugin management and does it have the status "Booted"?
- Did you reload the page after enabling?
docker compose logs app
Plugin web page stays empty or does not load
- Is webEntry set in plugin.json and is the web.runtime permission present?
- Is web/manifest.json valid JSON with frontendApiVersion 1?
- Does the sectionId in web/manifest.json match the id of the registered page exactly?
- Is the page in the right list (adminPages or appPages)?
- Is the SDK included: <script src="/plugin-sdk/fedisuite-plugin-web.js">?
- Is await window.FediSuitePluginWeb.ready() called before the API calls?
- Are asset paths relative to the web/ directory (./main.js, ./style.css)?
- Does the route the page calls exist, and does its auth value allow access?
docker compose logs app
server/plugins (opens in new tab) directory.