Building a Plugin
Build a plugin that reacts to incoming WhatsApp messages, declare exactly the permissions it needs, test it locally, and package it into the .zip an operator installs. This guide walks the full path using the official faq-bot plugin as the worked example — an auto-reply bot that answers inbound messages from keyword and regex rules.
- Node.js 22 (the package builder targets
node22). - A working OpenWA install you can upload to. See Installation.
- A grasp of the plugin runtime — what a hook is, what the sandbox guarantees. Read Plugin architecture first.
- An admin API key. Plugin install, config, and enable routes require the
ADMINrole.
By the end you will have a built faq-bot.zip that an operator can install, configure, and enable.
How a plugin is structured
A plugin is a folder with a manifest and a default-exported class. OpenWA reads the manifest, loads the compiled entry, and drives your class through a lifecycle (load → enable → run hooks → disable). At minimum you ship two things: manifest.json (what the platform reads) and a built dist/index.js (what it runs).
The faq-bot source folder looks like this:
faq-bot/
├─ manifest.json # metadata, permissions, hooks, config schema
├─ index.ts # default-exports the IPlugin class
├─ rules.ts # rule parsing + matching (plain TS, no host API)
├─ index.test.ts # unit tests (node --test + tsx)
├─ rules.test.ts
├─ CHANGELOG.md # Keep a Changelog; top version must equal the manifest
├─ README.md # human-readable docs
└─ dist/index.js # built artifact — produced by the packager, gitignored
You write TypeScript; the packager bundles it to a single CommonJS dist/index.js. The platform require()s that file — never your .ts sources.
Step 1 — Write the manifest
manifest.json is the single source of truth. OpenWA reads the fields it knows and ignores unknown ones. Here is faq-bot's manifest, trimmed to the load-bearing fields:
{
"id": "faq-bot",
"name": "FAQ / Auto-Reply Bot",
"version": "0.1.7",
"type": "extension",
"main": "dist/index.js",
"description": "Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules.",
"author": "Yudhi Armyndharis <yudhi@rmyndharis.com>",
"license": "MIT",
"permissions": ["messages:send"],
"sessions": ["*"],
"hooks": ["message:received"],
"status": "stable",
"minOpenWAVersion": "0.6.1",
"testedOpenWAVersion": "0.8.1"
}
The fields that decide whether the install succeeds and what your code is allowed to do:
| Field | Required | Purpose |
|---|---|---|
id | yes | Unique identifier, ^[a-z0-9][a-z0-9._-]*$. Cannot be a reserved id (see below). |
name | yes | Shown in the dashboard plugin card. |
version | yes | SemVer. Must equal the top released heading in CHANGELOG.md — the packager rejects a mismatch. |
type | yes | Only "extension" is user-installable. Any other value fails the build. |
main | yes | The require()-able entry inside the package. Always dist/index.js. |
permissions | no | The capabilities your code may call. Enforced at runtime — see Step 3. |
sessions | no | The session scope the plugin may act on. ["*"] = all sessions. Static — config edits can't widen it. |
sessionScoped | no | Default true: the plugin only runs for sessions an operator activated it for. false makes it global (always runs, no per-number notion). |
hooks | no | The events you register handlers for. |
configSchema | no | A declarative form the dashboard renders for operator config. See Step 2. |
configUi | no | A self-contained HTML config editor (inline JS/CSS) served in a sandboxed iframe. The dashboard prefers it over configSchema when present. |
net.allow | no | Outbound-HTTP host allowlist for net:fetch (host:port entries; a bare host also works). Absent means deny all. Pair with net.allowConfigHosts for operator-opened hosts. |
status | no | "stable", "beta", or "development". Shown as a badge. |
minOpenWAVersion | no | Compatibility convention. Not yet enforced by OpenWA. |
i18n | no | Dashboard localization, keyed by BCP-47 locale tag (en, es, fr, it, ar, he, te, zh-CN, zh-HK). The packager warns on an unsupported code. |
provides | no | Feature tags rendered on the dashboard plugin card. |
sdkVersion | no | Integration SDK major the plugin targets (e.g. "1"). Integration Fabric only — see Author an Integration Fabric adapter. |
ingress | no | Inbound webhook routes to claim (requires webhook:ingress). Integration Fabric only. |
These ids are taken by built-ins and will be rejected at install: whatsapp-web.js, baileys, auto-reply, translation. Pick a unique id.
Step 2 — Define the config schema
Most plugins need operator-supplied settings — keywords, replies, an API key. Declare them in configSchema and the dashboard renders an authenticated form; the operator's input is saved through PUT /api/plugins/{id}/config and handed to your code as ctx.config.
faq-bot declares four fields:
"configSchema": {
"type": "object",
"properties": {
"rules": {
"type": "textarea",
"required": true,
"title": "Rules (JSON)",
"description": "JSON array of { mode: 'contains'|'exact'|'regex', pattern, reply }."
},
"fallbackReply": {
"type": "string",
"default": "",
"title": "Fallback reply",
"description": "Sent when no rule matches. Leave empty to stay silent."
},
"fallbackCooldownSec": {
"type": "number",
"default": 600,
"title": "Fallback cooldown (seconds per chat)",
"description": "Minimum seconds between fallback replies to the same chat. 0 = reply every time."
},
"respondInGroups": {
"type": "boolean",
"default": false,
"title": "Also respond in group chats"
}
}
}
Each property is a field. The type drives the widget (string, number, boolean, textarea for multi-line, plus object/array for nesting). Useful extras: default seeds the form, title/description label it, enum renders a <select>, and secret: true masks a value such as an API key.
On OpenWA ≥ 0.10.0 a declared default does more than seed the form: the host merges schema defaults into the stored config at load time, so a field with a declared default arrives in ctx.config even if the operator never opened or saved the form. Explicit values — including null — are never overwritten, and a required field without a default still needs real operator input. Keep your code's fallbacks equal to the schema defaults so the plugin behaves identically on older hosts.
configSchema only tells the dashboard how to draw the form. The platform does not validate operator input against it. Your code reads ctx.config as Record<string, unknown> and must validate defensively. faq-bot does exactly this — parseConfig coerces every field and throws on bad input:
export function parseConfig(raw: Record<string, unknown>): {
config: FaqConfig;
rules: CompiledRule[];
skipped: string[];
} {
const rulesJson = String(raw.rules ?? '').trim();
if (!rulesJson) throw new Error('faq-bot: rules is required (a JSON array)');
const parsed = parseRules(rulesJson); // throws on structurally invalid rules
const cooldown = Number(raw.fallbackCooldownSec ?? 600);
return {
rules: parsed.rules,
skipped: parsed.skipped,
config: {
fallbackReply: String(raw.fallbackReply ?? ''),
fallbackCooldownSec: Number.isFinite(cooldown) ? cooldown : 600,
respondInGroups: raw.respondInGroups === true,
},
};
}
A throw here surfaces the plugin as ERROR in the dashboard instead of misbehaving silently.
Step 3 — Declare permissions and sessions
A plugin reaches WhatsApp, the engine, and the network only through ctx.messages, ctx.engine, and ctx.net. Each call is gated by a declared permission; calling a capability you did not declare throws a PluginCapabilityError. Declare the minimum and nothing more.
| Permission | Unlocks | When you need it |
|---|---|---|
messages:send | ctx.messages.sendText, ctx.messages.reply | Sending or replying to messages. |
engine:read | ctx.engine.getContacts, getGroupInfo, getChats, getChatHistory, canonicalChatId, … | Reading contacts, groups, chats, chat history, or resolving a @lid to a canonical id (read-only). |
net:fetch | ctx.net.fetch | Outbound HTTP. Also requires a net.allow host allowlist in the manifest. |
webhook:ingress | ctx.registerWebhook | Integration Fabric — receive inbound webhooks from an external system. |
conversation:send | ctx.conversations.send, ctx.handover.set, ctx.mappings.* | Integration Fabric — send normalized replies, flip handover, manage the chat↔external-conversation map. |
faq-bot only ever replies, so it declares one permission and one scope:
"permissions": ["messages:send"],
"sessions": ["*"]
sessions: ["*"] grants the capability scope across all sessions. The scope is static — editing config at runtime cannot widen it. To run a plugin only for the sessions an operator chooses, leave sessionScoped at its default (true); the operator then picks the active sessions via PUT /api/plugins/{id}/sessions, and ctx.config resolves to that session's slice inside the hook.
Declare only what you call. A plugin that asks for net:fetch it never uses is a red flag in review and a wider blast radius if compromised. faq-bot reads inbound messages and replies — nothing else — so messages:send is all it declares.
Step 4 — Implement the hook
Your plugin is a default-exported class implementing IPlugin. The lifecycle methods you care about:
onEnable(ctx)— runs when the operator enables the plugin. Register your hooks here and fail fast on invalid config.onConfigChange(ctx)— runs when config is saved. Re-validate so a bad edit surfaces immediately.
Register a handler with ctx.registerHook(event, handler). The handler receives a HookContext and returns a HookResult — { continue: true } lets the event flow to the next plugin and the host; { continue: false } stops the chain.
Here is faq-bot's plugin class. It lives in the same index.ts as the parseConfig and allowFallback helpers, so it imports only from ./rules.ts — there is no self-import:
import type { IPlugin, PluginContext, HookContext, IncomingMessage } from '../types/openwa';
import { parseRules, matchRule, CompiledRule } from './rules.ts';
export default class FaqBot implements IPlugin {
private readonly fallbackAt = new Map<string, number>();
async onEnable(ctx: PluginContext): Promise<void> {
this.warnSkipped(ctx); // fail-fast + surface any invalid regex rules at enable
ctx.registerHook('message:received', async (hook: HookContext) => {
await this.onMessage(ctx, hook);
return { continue: true };
});
}
async onConfigChange(ctx: PluginContext): Promise<void> {
this.warnSkipped(ctx); // re-validate on change (fail-fast feedback + fresh skipped warning)
}
private warnSkipped(ctx: PluginContext): void {
const { skipped } = parseConfig(ctx.config);
if (skipped.length) {
ctx.logger.warn(`faq-bot: skipped ${skipped.length} rule(s) with an invalid regex: ${skipped.join(', ')}`);
}
}
private async onMessage(ctx: PluginContext, hook: HookContext): Promise<void> {
if (hook.source !== 'Engine' || !hook.sessionId) return;
const m = (hook.data ?? {}) as Partial<IncomingMessage>;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return;
// Re-parse per event: ctx.config is the resolved per-session slice for this hook fire,
// so a per-session override is honored. A snapshot cached at enable would ignore it.
let cfg: { config: FaqConfig; rules: CompiledRule[] };
try {
cfg = parseConfig(ctx.config);
} catch (e) {
ctx.logger.warn(`faq-bot: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`);
return;
}
if (m.isGroup && !cfg.config.respondInGroups) return;
const sessionId = hook.sessionId;
const rule = matchRule(cfg.rules, m.body);
try {
if (rule) {
await ctx.messages.reply(sessionId, m.chatId, m.id, rule.reply);
return;
}
if (cfg.config.fallbackReply) {
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, cfg.config.fallbackCooldownSec) * 1000;
if (allowFallback(this.fallbackAt, key, Date.now(), cooldownMs)) {
await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.fallbackReply);
}
}
} catch (err) {
ctx.logger.error('faq-bot: reply failed', err);
}
}
}
This class shares index.ts with three names it relies on: the FaqConfig type, parseConfig (shown in Step 2), and allowFallback. That last one is the cooldown gate behind fallbackReply — it records the last fallback time per sessionId:chatId key, refuses a fallback inside the configured window, and caps its own map (MAX_COOLDOWN_ENTRIES = 5000, LRU eviction) so it can't grow unbounded:
const MAX_COOLDOWN_ENTRIES = 5000;
/** True if a fallback may go to `key` now; on allow, records `nowMs` (LRU touch) and caps the map. */
export function allowFallback(map: Map<string, number>, key: string, nowMs: number, cooldownMs: number): boolean {
const last = map.get(key);
if (last !== undefined && nowMs - last < cooldownMs) return false;
map.delete(key); // re-insert so iteration order tracks recency (LRU by touch)
map.set(key, nowMs);
if (map.size > MAX_COOLDOWN_ENTRIES) {
const oldest = map.keys().next().value as string | undefined;
if (oldest !== undefined) map.delete(oldest);
}
return true;
}
Four patterns worth copying:
- Guard the event first. Ignore your own outbound messages (
m.fromMe), non-text payloads, and group chats you were not configured for. A hook fires for every matching event — filter early. - Re-parse
ctx.configinside the handler, per event. The host resolvesctx.configto the firing session's slice — the base config with that session's override merged over it — so per-event parsing means a config edit or a per-session override takes effect on the next message, with no restart.onEnable/onConfigChangestill parse once, but only to fail fast on invalid input. returnafter the rule match. A matched rule replies and stops; only an unmatched message reaches the fallback branch. The two config fields from Step 2 —fallbackReplyandfallbackCooldownSec— drive that branch.- Catch your own errors. A handler that throws is logged by the host and the chain continues with the previous data — but you lose the chance to log context. Wrap the capability call and log through
ctx.logger.
ctx.config at enableA config snapshot taken in onEnable silently ignores per-session overrides (PUT /api/plugins/{id}/config/{sessionId}) set after enable — the plugin keeps answering with the base config for every session. Re-parse ctx.config per event as faq-bot does, or — for a stateful coordinator that must hold parsed state — cache keyed by a config signature and rebuild when the signature changes. Which official plugins support overrides is cataloged in the plugin catalog.
ctx.messages.reply(sessionId, chatId, quotedMessageId, text) sends a quoted reply through the host's message service, with the messages:send permission checked on the call. The quotedMessageId is the inbound message's id, so the answer threads under the customer's question.
The message flow
Step 5 — Test locally
Keep your matching and parsing logic in plain TypeScript modules with no dependency on the host context, then unit-test them directly. faq-bot puts rule parsing and matching in rules.ts and config coercion in parseConfig, so the tests need no running gateway:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseConfig } from './index.ts';
const rules = JSON.stringify([{ mode: 'contains', pattern: 'hi', reply: 'hello' }]);
test('parseConfig requires rules', () => {
assert.throws(() => parseConfig({}), /rules is required/);
});
test('parseConfig parses rules and applies option defaults', () => {
const { config, rules: parsed } = parseConfig({ rules });
assert.equal(parsed.length, 1);
assert.equal(config.fallbackCooldownSec, 600);
assert.equal(config.respondInGroups, false);
});
The two excerpts above are part of faq-bot's suite of 25 tests — 9 in index.test.ts (config coercion and the cooldown gate) and 16 in rules.test.ts (mode matching, the ReDoS guard). Run just this plugin's tests with Node's built-in runner over tsx, then type-check the whole repo:
npx tsx --test "faq-bot/*.test.ts" # run faq-bot's tests only
npm run typecheck # tsc --noEmit, repo-wide
Expected output:
1..25
# tests 25
# pass 25
# fail 0
npm test runs the same runner across every plugin in the repo, so its total is higher; scope to your plugin's glob while iterating.
Everything in rules.ts — mode matching, regex compilation, the ReDoS guard — is a pure function. That is deliberate: pure functions are trivial to test without mocking PluginContext. Reserve the class for wiring the lifecycle and calling capabilities.
Step 6 — Package the .zip
The packager validates the manifest, bundles index.ts to dist/index.js, zips the result, and prints the size and sha256. Run it from the plugins repo root with your plugin's folder name:
node package.mjs faq-bot
Expected output:
✓ Packaged faq-bot v0.1.7 → faq-bot.zip (12.3 KB)
sha256: 3f8a…c1d9
Under the hood the packager:
- Validates the manifest — required fields present,
typeisextension, andversionequals the topCHANGELOG.mdheading. Any mismatch aborts the build. - Bundles
index.tsto a single CommonJSdist/index.jswith esbuild (platform: node,target: node22). - Zips
manifest.json+dist/intofaq-bot.zipat the repo root. Besides the bundle,dist/carries apackage.jsonthat pins CommonJS (so Node loads the CJS bundle even when the repo root is ESM), and a plugin that declaresconfigUi.entrygets that static editor directory zipped in as well. - Reports size and sha256, and fails if the package exceeds 5 MB.
OpenWA rejects a package at install if it exceeds any of: 5 MB compressed, 200 files, or 20 MB uncompressed. Bundle to a single dist/index.js (the packager does this) rather than shipping node_modules.
Author an Integration Fabric adapter
The faq-bot plugin above is outbound-only: it reacts to message:received and replies. An Integration Fabric adapter goes further — it receives inbound webhooks from an external system (a helpdesk, CRM, or chatbot) and relays them to WhatsApp, all inside the same sandboxed plugin with no separate server. The full guide is at Integration Fabric; this section covers the manifest and code shape.
Declare the SDK version and an ingress route in the manifest, and request the two Integration Fabric permissions:
{
"sdkVersion": "1",
"permissions": ["conversation:send", "webhook:ingress"],
"ingress": [
{
"route": "webhook",
"mode": "async",
"signature": { "scheme": "hmac-sha256", "header": "X-Signature", "encoding": "hex", "timestampHeader": "X-Timestamp", "toleranceSec": 300 },
"verify": "core",
"maxBodyBytes": 1048576
}
]
}
The host validates this at load time: the SDK major must match, webhook:ingress must be declared, routes must be unique, and a toleranceSec replay window must be greater than zero. The host verifies the HMAC over the raw request bytes (a re-serialized body would not match), so the route is not bound to a DTO — unknown provider fields pass through.
In onEnable, claim the route with ctx.registerWebhook. The handler receives the verified request and returns an HTTP response:
async onEnable(ctx: PluginContext): Promise<void> {
ctx.registerWebhook('webhook', async (req) => {
// req.verified is true — the host already checked the HMAC and dedup.
const providerMsg = JSON.parse(req.body ?? '{}');
// Resolve the WhatsApp chat for this external conversation.
const mapping = await ctx.mappings.getByProvider(req.instanceId, providerMsg.conversation_id);
if (!mapping) return { status: 404 };
// Send the agent's reply as a normalized message.
await ctx.conversations.send({
sessionId: mapping.sessionId,
instanceId: req.instanceId,
chatId: mapping.chatId,
type: 'text',
text: providerMsg.body,
source: { provider: 'helpdesk', externalConversationId: providerMsg.conversation_id },
});
return { status: 200 };
});
}
The envelope type selects the send path: text, image/video/audio/file (with mediaUrl and caption from text), voice (a PTT voice note), or location. Use ctx.mappings.upsert to record a new chat↔conversation link, and ctx.handover.set to flip a conversation to human so other bots stop replying while an agent handles it. The capability model and handover gate are documented in Plugin architecture.
Install, configure, and enable
With faq-bot.zip built, an operator installs it in three admin-authenticated calls. Plugin routes sit under the /api prefix and require the ADMIN role.
Upload the package:
curl -X POST "http://localhost:2785/api/plugins/install" \
-H "X-API-Key: YOUR_API_KEY" \
-F "file=@faq-bot.zip"
Set its config (the rules value is a JSON string, so its quotes are escaped):
curl -X PUT "http://localhost:2785/api/plugins/faq-bot/config" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "config": { "rules": "[{\"mode\":\"contains\",\"pattern\":\"price\",\"reply\":\"Plans start at $10/mo.\"}]" } }'
Enable it:
curl -X POST "http://localhost:2785/api/plugins/faq-bot/enable" \
-H "X-API-Key: YOUR_API_KEY"
Send "price" from another phone to a connected session and the bot replies "Plans start at $10/mo." as a quoted reply. The operator can also do all three steps from the dashboard's Plugins tab.
YOUR_API_KEY is an admin key from your OpenWA configuration. See Authentication for how to obtain one.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
version drift: manifest is X but CHANGELOG top is Y | The manifest version and the top CHANGELOG.md heading disagree. | Make them match. The top heading must read ## [X.Y.Z] — YYYY-MM-DD. |
type must be "extension" to be installable | manifest.type is not extension. | Set "type": "extension". Only extensions are user-installable. |
| Install rejected — id reserved | The id is one of whatsapp-web.js, baileys, auto-reply, translation. | Rename to a unique id. |
| Install rejected — over limit | Package exceeds 5 MB / 200 files / 20 MB. | Bundle to a single dist/index.js; drop node_modules from the zip. |
Plugin shows ERROR in the dashboard | Your config parser threw (for example, invalid rules JSON). | Fix the operator config; check ctx.logger output. This is the parser doing its job. |
PluginCapabilityError at runtime | You called a capability without declaring its permission. | Add the matching permission (messages:send, engine:read, net:fetch, webhook:ingress, conversation:send) to the manifest. |
A sandboxed plugin's hook runs under a host timeout (5 s). Keep handlers fast — match, decide, send. Do not block on long external work inside a message:received handler.
Next steps
- Publish your plugin — tag a release and ship the
.zipto users. - Plugin architecture — the sandbox model, lifecycle, and capability facade in depth.
- Plugin catalog — the ten official plugins, including faq-bot, as further worked examples.