How to Create and Load Custom Plugins with PluginLoaderService in OpenWA
To create and load custom plugins in OpenWA, implement the IPlugin interface in a TypeScript file, define a manifest.json describing the plugin metadata, place both files in a subdirectory of the configured plugins folder, and activate the plugin via the REST API or by calling PluginLoaderService.enablePlugin().
OpenWA provides a robust plugin subsystem that enables runtime extension of WhatsApp bot functionality without modifying core framework code. The PluginLoaderService (src/core/plugins/plugin-loader.service.ts) handles automatic discovery, validation, lifecycle management, and sandboxed execution of custom plugins. This guide covers the exact implementation details required to build, register, and enable plugins using the OpenWA plugin architecture.
Understanding the PluginLoaderService Architecture
When the NestJS application bootstraps, PluginLoaderService.onModuleInit() executes a two-phase loading process:
- Built-in plugin registration – Engine plugins bundled with the core are registered first (lines 47-53 in
plugin-loader.service.ts). - Directory scanning – The service scans the configured plugins directory (
plugins.dir, defaulting to./plugins). For each subdirectory containing amanifest.json, it validates the manifest and stores aPluginInstancein an internal Map vialoadPluginsFromDirectory()(lines 55-81).
When you enable a plugin through enablePlugin(), the loader dynamically requires the file declared in manifest.main, instantiates the default exported class (which must implement IPlugin), and invokes lifecycle hooks with a PluginContext providing access to the HookManager, sandboxed PluginStorage, namespaced logger, and configuration.
All plugin state changes persist through PluginStorageService in data/plugins/registry.json, ensuring activated plugins remain enabled across restarts.
Creating a Custom Plugin
Directory Structure
Place your plugin in a subdirectory of the plugins folder:
<project_root>
└── plugins/
└── my-greeting/
├── manifest.json
└── index.ts
Defining the Manifest
Create a manifest.json file that conforms to the PluginManifest interface defined in src/core/plugins/plugin.interfaces.ts:
{
"id": "my-greeting",
"name": "My Greeting Plugin",
"version": "1.0.0",
"type": "extension",
"description": "Adds a custom greeting command to the bot.",
"author": "Your Name",
"main": "index.ts",
"hooks": ["message.received"],
"configSchema": {
"type": "object",
"properties": {
"greeting": {
"type": "string",
"title": "Greeting Text",
"default": "Hello, world!"
}
}
}
}
Required fields include id, name, version, type, and main. The hooks array optionally declares events your plugin will listen to, while configSchema defines configurable parameters exposed through the API.
Implementing the IPlugin Interface
Create index.ts with a default export implementing IPlugin (defined in src/core/plugins/plugin.interfaces.ts):
import { IPlugin, PluginContext } from '../../core/plugins/plugin.interfaces';
export default class MyGreetingPlugin implements IPlugin {
async onLoad(context: PluginContext) {
context.logger.info('MyGreetingPlugin loaded');
}
async onEnable(context: PluginContext) {
context.registerHook('message.received', this.handleMessage.bind(this));
context.logger.info('MyGreetingPlugin enabled');
}
async onDisable(context: PluginContext) {
context.logger.info('MyGreetingPlugin disabled');
}
async onConfigChange(context: PluginContext, newConfig: Record<string, unknown>) {
context.logger.debug('Config updated', { newConfig });
}
private async handleMessage(payload: any) {
const { from, body, config } = payload;
console.log(`Greeting ${from}: ${config?.greeting ?? 'Hi'} – ${body}`);
}
}
The onEnable hook receives a PluginContext created by createPluginContext() (lines 61-93 in plugin-loader.service.ts), which provides the registerHook method for subscribing to events like message.received.
Loading and Enabling Plugins
Via REST API
The PluginsController (src/modules/plugins/plugins.controller.ts) exposes endpoints for plugin management. After placing your plugin files in the directory and restarting OpenWA, enable the plugin:
curl -X POST http://localhost:3000/plugins/my-greeting/enable
Response:
{ "success": true, "message": "Plugin my-greeting enabled successfully" }
You can also disable plugins via POST /plugins/:id/disable or update configuration via the plugin config endpoints.
Programmatic Activation
Inject PluginLoaderService into your service or controller:
await this.pluginLoader.enablePlugin('my-greeting');
Disable using:
await this.pluginLoader.disablePlugin('my-greeting');
Working with PluginContext and Storage
The PluginContext object provides sandboxed resources to your plugin. Access per-plugin persistent storage through context.storage, implemented in PluginStorageService.createPluginStorage() (src/core/plugins/plugin-storage.service.ts):
// Inside any lifecycle hook or event handler
await context.storage.set('counter', 1);
const count = await context.storage.get<number>('counter');
The storage service creates isolated directories for each plugin under data/plugins/, preventing cross-plugin data access. Use context.logger for namespaced logging and context.config to access validated configuration values defined in your configSchema.
Summary
- Place custom plugins in subdirectories of
./plugins(or the path specified byplugins.dirconfiguration). - Define a valid
manifest.jsonwith required fields:id,name,version,type, andmain. - Export a default class implementing
IPluginwith lifecycle methods (onLoad,onEnable,onDisable). - Access runtime resources through
PluginContext, includingregisterHook,storage, andlogger. - Enable plugins via
POST /plugins/:id/enableorPluginLoaderService.enablePlugin(). - Plugin state persists automatically in
data/plugins/registry.jsonviaPluginStorageService.
Frequently Asked Questions
What file format should the plugin main entry point use?
The main field in manifest.json should reference a TypeScript or JavaScript file (e.g., index.ts or index.js) that exports a default class implementing the IPlugin interface. OpenWA's runtime compilation or build process handles the TypeScript files during the require operation performed by PluginLoaderService.enablePlugin().
How does OpenWA validate plugin manifests before loading?
The PluginLoaderService.loadPluginsFromDirectory() method validates each manifest.json against the PluginManifest interface. It checks for required fields including id, name, version, type, and main. If validation fails, the plugin is not registered in the internal Map and will not appear in the available plugins list.
Can plugins be enabled or disabled without restarting the server?
Yes. While onModuleInit() discovers plugins at startup, you can enable or disable plugins at runtime using the REST API (/plugins/:id/enable and /plugins/:id/disable) or programmatically via PluginLoaderService. These calls trigger the onEnable and onDisable lifecycle hooks without requiring a server restart.
Where does OpenWA store plugin configuration and data?
PluginStorageService handles persistence, storing registry state in data/plugins/registry.json and providing sandboxed file-based storage through PluginContext.storage. Each plugin receives its own isolated storage space, accessible only through the context provided to that specific plugin instance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →