Skip to main content
Version: v0.23.1

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)
├─ cooldown.ts # per-chat cooldown gate (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.2.1",
"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.23.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, the network, and its own key-value store only through ctx.messages, ctx.engine, ctx.net, and ctx.storage. 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.
search:providectx.registerSearchProviderServe the gateway's GET /api/search queries. Required since v0.12.2 — a provider without it is refused with a sandbox_search_provider_denied warning.
storage:usectx.storage.get, set, delete, listPersisting state across restarts in the per-plugin key-value store. Required since v0.17.0 — before that the four verbs dispatched with no permission check at all.

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.

storage:use is required from v0.17.0

If your plugin calls ctx.storage, add one line to the manifest — "storage:use" inside permissions. A plugin that persists state and declares messages:send only becomes:

"permissions": ["messages:send", "storage:use"]

The permission is not checked at load. The refusal arrives at the next storage call, so an upgraded gateway looks fine until your plugin tries to store, and because every storage verb is async the denial arrives as a rejected promise rather than a synchronous throw. A plugin that stores during onEnable does not merely lose the write: the host records the error, sets the plugin to ERROR, unregisters its hooks, and leaves it disabled.

An unrecognised permission string is ignored by older gateways, so a manifest carrying storage:use still installs on a pre-0.17.0 host. For operators that means upgrading the plugins first and the gateway second — see the plugin catalog for the official version floors.

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, priority?). 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 handlers registered after yours. The optional third argument orders the handlers subscribed to one event: they run ascending, lower first, and an omitted priority defaults to 100. Pass one explicitly — without it the order is whatever order the plugins happened to register in, which differs between a restart and a manual enable. faq-bot passes 80: late enough that a plugin which only logs or translates the message has already seen it, early enough to answer before a bot that replies to everything. See Lifecycle hooks for the chain in full.

Here is faq-bot's plugin class. It lives in index.ts next to the FaqConfig type and the parseConfig helper, and imports the two host-independent modules beside it — ./rules.ts for matching, ./cooldown.ts for the fallback gate:

import type { IPlugin, PluginContext, HookContext, IncomingMessage } from '../types/openwa';
import { parseRules, matchRule, CompiledRule } from './rules.ts';
import { allowCooldown } from './cooldown.ts';

// Responder band: keyword rules are more specific than a bot that answers everything, less specific
// than a command prefix.
const HOOK_PRIORITY = 80;

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) => ({ continue: !(await this.onMessage(ctx, hook)) }),
HOOK_PRIORITY,
);
}

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(', ')}`);
}
}

// Returns true when this plugin answered, so the hook can claim the message and stop another bot
// from answering the same thing. Every early exit means "not mine".
private async onMessage(ctx: PluginContext, hook: HookContext): Promise<boolean> {
if (hook.source !== 'Engine' || !hook.sessionId) return false;
const m = (hook.data ?? {}) as Partial<IncomingMessage>;
if (m.fromMe || typeof m.body !== 'string' || !m.chatId || !m.id) return false;

// 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 false;
}

if (m.isGroup && !cfg.config.respondInGroups) return false;

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 true;
}
if (cfg.config.fallbackReply) {
const key = `${sessionId}:${m.chatId}`;
const cooldownMs = Math.max(0, cfg.config.fallbackCooldownSec) * 1000;
if (allowCooldown(this.fallbackAt, key, Date.now(), cooldownMs)) {
await ctx.messages.reply(sessionId, m.chatId, m.id, cfg.config.fallbackReply);
return true;
}
}
} catch (err) {
ctx.logger.error('faq-bot: reply failed', err);
}
return false; // nothing delivered — a later plugin may still have an answer
}
}

Two of the names this class relies on sit beside it in index.ts: the FaqConfig type and parseConfig (shown in Step 2). The third, allowCooldown, is imported from ./cooldown.ts — the 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. Like rules.ts it takes no PluginContext, which is exactly why it is its own module — a pure function you can test without the host:

// cooldown.ts — in-memory per-key cooldown with an LRU cap. Pure — no ctx.
const MAX_COOLDOWN_ENTRIES = 5000;

/**
* Decide whether an action may go to `key` now. On allow, records `nowMs` (re-inserting so the map
* evicts least-recently-used) and caps the map by dropping the LRU entry. A `cooldownMs` of 0 always allows.
*/
export function allowCooldown(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;
}

Five 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.
  • Claim only the messages you actually answered. onMessage resolves true only after a reply went out, and the handler inverts it — { continue: !(await this.onMessage(ctx, hook)) }. So a delivered answer returns { continue: false }, which tells the host this message is mine, don't let another bot answer it too; it stops the plugin handlers after yours and nothing else — the gateway still persists the message and still dispatches it to webhooks and the websocket. Every other path returns { continue: true }: a guard bail, an invalid config, no rule match, a fallback still inside its cooldown, and a send that threw. Claiming a message you did not answer silences the chat — the plugins behind you skip it and the contact gets nothing.
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 29 tests — 13 in index.test.ts (config coercion, message claiming, 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..29
# tests 29
# pass 29
# 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.2.1 → faq-bot.zip (16.7 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. On a plain text send, linkPreview: true asks the engine to attach a URL preview card — Baileys generates one only on request, so an adapter relaying a link delivers a bare URL without it, while whatsapp-web.js previews by default and takes false to suppress. It is ignored on media, location, and quoted sends. 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, the handover gate, and the payload fields that are easy to miss 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, storage:use) to the manifest.
missing the 'storage:use' permission on a ctx.storage callThe plugin persists state but its manifest predates v0.17.0, where storage:use became required.Add "storage:use" to the manifest permissions array and reload the plugin. If the store happens in onEnable, the plugin shows ERROR until you do.
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