Skip to main content
Version: v0.10.2

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.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:

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. Static — config edits can't widen it.
sessionScopednoDefault true: the plugin only runs for sessions an operator activated it for. false makes it global (always runs, no per-number notion).
hooksnoThe events you register handlers for.
configSchemanoA declarative form the dashboard renders for operator config. See Step 2.
configUinoA self-contained HTML config editor (inline JS/CSS) served in a sandboxed iframe. The dashboard prefers it over configSchema when present.
net.allownoOutbound-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.
statusno"stable", "beta", or "development". Shown as a badge.
minOpenWAVersionnoCompatibility convention. Not yet enforced by OpenWA.
i18nnoDashboard 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.
providesnoFeature tags rendered on the dashboard plugin card.
sdkVersionnoIntegration SDK major the plugin targets (e.g. "1"). Integration Fabric only — see Author an Integration Fabric adapter.
ingressnoInbound webhook routes to claim (requires webhook:ingress). Integration Fabric only.
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.

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.

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, getChatHistory, canonicalChatId, …Reading contacts, groups, chats, chat history, or resolving a @lid to a canonical id (read-only).
net:fetchctx.net.fetchOutbound HTTP. Also requires a net.allow host allowlist in the manifest.
webhook:ingressctx.registerWebhookIntegration Fabric — receive inbound webhooks from an external system.
conversation:sendctx.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.

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 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.config inside the handler, per event. The host resolves ctx.config to 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/onConfigChange still parse once, but only to fail fast on invalid input.
  • 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.
Don't cache ctx.config at enable

A 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.

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.7 → 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. Besides the bundle, dist/ carries a package.json that pins CommonJS (so Node loads the CJS bundle even when the repo root is ESM), and a plugin that declares configUi.entry gets that static editor directory zipped in as well.
  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.

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

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, webhook:ingress, conversation:send) 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