Skip to main content
Version: v0.7.6

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.

Prerequisites
  • 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 ADMIN role.

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.4",
"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.6.1"
}

The fields that decide whether the install succeeds and what your code is allowed to do:

FieldRequiredPurpose
idyesUnique identifier, ^[a-z0-9][a-z0-9._-]*$. Cannot be a reserved id (see below).
nameyesShown in the dashboard plugin card.
versionyesSemVer. Must equal the top released heading in CHANGELOG.md — the packager rejects a mismatch.
typeyesOnly "extension" is user-installable. Any other value fails the build.
mainyesThe require()-able entry inside the package. Always dist/index.js.
permissionsnoThe capabilities your code may call. Enforced at runtime — see Step 3.
sessionsnoThe session scope the plugin may act on. ["*"] = all sessions.
hooksnoThe events you register handlers for.
configSchemanoA declarative form the dashboard renders for operator config. See Step 2.
statusno"stable", "beta", or "development". Shown as a badge.
minOpenWAVersionnoCompatibility convention. Not yet enforced by OpenWA.
Reserved ids

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.

The schema drives the form, not validation

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.

PermissionUnlocksWhen you need it
messages:sendctx.messages.sendText, ctx.messages.replySending or replying to messages.
engine:readctx.engine.getContacts, getGroupInfo, getChats, …Reading contacts, groups, or chats (read-only).
net:fetchctx.net.fetchOutbound HTTP. Also requires a net.allow host allowlist in the manifest.

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.

Least privilege

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 read config.
  • onConfigChange(ctx) — runs when config is saved. Re-read and re-apply.

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 above, 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 rules: CompiledRule[] = [];
private config: FaqConfig = { fallbackReply: '', fallbackCooldownSec: 600, respondInGroups: false };
private ctx: PluginContext | null = null;
private readonly fallbackAt = new Map<string, number>();

async onEnable(ctx: PluginContext): Promise<void> {
this.apply(ctx);
ctx.registerHook('message:received', async (hook: HookContext) => {
await this.onMessage(hook);
return { continue: true };
});
}

async onConfigChange(ctx: PluginContext): Promise<void> {
this.apply(ctx);
}

private apply(ctx: PluginContext): void {
this.ctx = ctx;
const { config, rules, skipped } = parseConfig(ctx.config);
this.rules = rules;
this.config = config;
if (skipped.length) {
ctx.logger.warn(`faq-bot: skipped ${skipped.length} rule(s) with an invalid regex: ${skipped.join(', ')}`);
}
}

private async onMessage(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;
if (m.isGroup && !this.config.respondInGroups) return;

const sessionId = hook.sessionId;
const rule = matchRule(this.rules, m.body);
try {
if (rule) {
await this.ctx?.messages.reply(sessionId, m.chatId, m.id, rule.reply);
return;
}
if (this.config.fallbackReply) {
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, this.config.fallbackCooldownSec) * 1000;
if (allowFallback(this.fallbackAt, key, Date.now(), cooldownMs)) {
await this.ctx?.messages.reply(sessionId, m.chatId, m.id, this.config.fallbackReply);
}
}
} catch (err) {
this.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.
  • Read ctx.config through your parser, in apply. Re-running apply from both onEnable and onConfigChange means a config edit takes effect without a restart.
  • return after the rule match. A matched rule replies and stops; only an unmatched message reaches the fallback branch. The two config fields from Step 2fallbackReply and fallbackCooldownSec — 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.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 18 tests — 8 in index.test.ts (config coercion and the cooldown gate) and 10 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..18
# tests 18
# pass 18
# 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.

Push host-independent logic out of the class

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.4 → faq-bot.zip (12.3 KB)
sha256: 3f8a…c1d9

Under the hood the packager:

  1. Validates the manifest — required fields present, type is extension, and version equals the top CHANGELOG.md heading. Any mismatch aborts the build.
  2. Bundles index.ts to a single CommonJS dist/index.js with esbuild (platform: node, target: node22).
  3. Zips manifest.json + dist/ into faq-bot.zip at the repo root.
  4. Reports size and sha256, and fails if the package exceeds 5 MB.
Package limits

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.

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

SymptomCauseFix
version drift: manifest is X but CHANGELOG top is YThe 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 installablemanifest.type is not extension.Set "type": "extension". Only extensions are user-installable.
Install rejected — id reservedThe id is one of whatsapp-web.js, baileys, auto-reply, translation.Rename to a unique id.
Install rejected — over limitPackage 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 dashboardYour 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 runtimeYou called a capability without declaring its permission.Add the matching permission (messages:send, engine:read, net:fetch) to the manifest.
Hook handlers are time-boxed

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