Plugin system
A plugin adds features to CMDX without changing the code in core/ or modules/. It is built from the same parts as a module: routes, tables, settings, capabilities and hook handlers. It is packaged so that an administrator can install, enable, upgrade and remove it on a running deployment. This page explains where plugins come from, the states a plugin moves through, what it can add, and how it is kept apart from the rest of CMDX.
Plugins and modules
| Module | Plugin | |
|---|---|---|
| Lives in | backend/modules/ | backend/plugins/, an external folder, or a Python package |
| Installed | Always, as part of CMDX | By an administrator, one plugin at a time |
| Can be switched off | No | Yes, for the whole installation or per tenant |
| Imports from | Anything in core/ | core.plugin_api only |
The built-in learning item types, such as quiz and SCORM, are modules, not plugins. The repository includes two plugins, announcements and gamification, and example code under backend/plugins/examples/.
Where plugins come from
When the backend starts, it looks for plugins in three places:
- The
backend/plugins/folder in the repository. - Each absolute path in the
EXTERNAL_PLUGINS_DIRSenvironment variable, separated by colons. - Installed Python packages that declare an entry point in the
cmdx.pluginsgroup.
The code has to be there before the process starts: baked into the image, mounted as a volume, or installed with pip. Every plugin found is recorded in the plugin registry, a table in the database.
The manifest
Each plugin has a manifest.py that says what it is:
- name: matches the plugin's folder name
- type: a category such as
auth,report,integrationorlocal - version and db_version: the code version, and the schema version its upgrade steps reach
- dependencies: other parts it needs, written as
core.<name>,module:<name>orplugin:<name>, each with a version range - personal_data: each of its tables that holds rows about a user, with the column that names the user (default
user_id). A user's data export includes these rows and erasure deletes them. An entry can keep rows through erasure, giving the reason inkeep, or leave them out of the export withexport=False, for example for a per-user token. - capabilities: the permissions it adds, named
<plugin-name>.<action>. While the plugin is installed, they join the capability catalog. - settings namespace: where its settings live
- scope:
global(on or off for the whole installation) orper_tenant(on or off for each tenant). The default isper_tenant.
Lifecycle
A plugin moves through four states, which the registry records:
| State | What it means | How it gets there |
|---|---|---|
| Discovered | The code is present and the manifest is valid. | Found at start-up. |
| Installed | The plugin's tables exist. | An administrator installs it, which runs its install steps. Later upgrades run its upgrade steps up to db_version. |
| Enabled | The plugin is active. | An administrator enables it: once for a global plugin, or for each tenant for a per_tenant plugin. Only an installed plugin can be enabled; enabling never installs it. |
| Disabled | The plugin is inactive. | An administrator disables it. A plugin that another enabled plugin depends on cannot be disabled. |
Enabling changes the state in the database. The running backend loads the plugin the next time it starts, or straight away when an administrator calls the plugin's reload endpoint.
Managing plugins needs these capabilities:
| Capability | Allows |
|---|---|
plugin:read | Listing plugins and reading their settings |
plugin:tenant_toggle | Enabling and disabling a per_tenant plugin for the caller's own tenant |
plugin:manage | Installing, upgrading, uninstalling and reloading plugins, changing their settings, enabling or disabling a global plugin, and enabling a per_tenant plugin for another tenant |
On a development machine, the python -m scripts.plugin command does the same from the backend folder: list, validate, install, enable, disable, uninstall and reload.
What a plugin adds
When the backend loads an enabled plugin, it:
- Registers the plugin's tables. A plugin describes them once as portable table definitions, named
plg_<plugin-name>_*, and reads and writes them through theDBfacade. - Registers its settings schema, so administrators can configure it.
- Registers its hook handlers, so it can react to events in CMDX.
- Mounts its API routes under
/api/v1/plugins/<plugin-name>/.
Plugins load in dependency order, so a plugin loads after the plugins it depends on.
Per-tenant plugins
A per_tenant plugin runs only in tenants where it is enabled. Its routes refuse requests from any other tenant, and its hook handlers skip events from any other tenant.
An event's tenant is the tenant_id or organization_id in its payload, or else the tenant of the request that raised it. An event with no tenant, such as user.registered from self-registration, never reaches a per_tenant plugin. If your plugin must see those events, give it the global scope.
If CMDX can't check whether the plugin is enabled, a handler with failure_mode="continue" is skipped. A handler with failure_mode="abort" raises an error instead, as the handler itself would have.
How plugins are kept apart
- One public API. Plugin code imports only from
core.plugin_api. That package is kept compatible across minor CMDX releases; anything else incore/ormodules/can change without notice. If a plugin needs somethingcore.plugin_apidoesn't offer, that is a request for the public API to grow. - Own tables. A plugin's tables carry its name as a prefix and are created and upgraded by its own steps, so they never collide with CMDX tables or another plugin's.
- Own capabilities. A plugin's capabilities are named after the plugin, so they never collide with another plugin's.
- Failures stay contained. A plugin that fails to load is logged and skipped, and the backend and the other plugins keep running.
- Checked before install.
python -m scripts.plugin validatechecks that the folder name matches the manifest, thatdb_versionmatches the highest upgrade step, that capabilities and settings use the plugin's name, that dependencies are well formed, and that every table with a user column is declared inpersonal_dataand exists with that column.
This keeps plugins apart by contract, not by sandbox. A plugin runs inside the backend process with the same database connection as CMDX, so install only plugins you trust.
Next
- Build a plugin: write and install a plugin step by step
- Hooks and events: what a plugin can react to
- Packaging and testing: ship a plugin outside the repository