Skip to main content
Version: v0.23.1

Architecture & Sandbox

This page explains how the OpenWA plugin runtime works: how a plugin reaches WhatsApp through a small set of permission-gated capabilities, how lifecycle hooks fit into message and session processing, how the ADMIN-only management API drives a plugin's state, and what the worker-thread sandbox does and does not protect against. It is the conceptual companion to Building a Plugin — read this to understand why the contract looks the way it does before you write code.

The runtime is shipped and wired in OpenWA v0.9.0.

The core idea: capabilities, not access

A plugin never gets a reference to OpenWA's internals. It cannot call MessageService, touch the database, or reach the WhatsApp engine directly. Instead it receives a PluginContext exposing a small set of narrow capability namespaces, and every method on them is checked against the plugin's declared permissions before it runs.

This inverts the usual extension model. Rather than handing a plugin the keys and trusting it, OpenWA hands it a vending machine: it can request exactly the actions it declared up front, and nothing else. A call the plugin did not earn fails fast with a PluginCapabilityError.

Three things follow from this design, and the rest of the page expands each:

  1. A plugin declares capability permissions in its manifest. There are exactly seven.
  2. A plugin runs its logic inside lifecycle hooks, returning a result that the host threads into the next handler.
  3. An ADMIN-only API (and the dashboard on top of it) installs, enables, configures, and scopes the plugin — the plugin never controls its own lifecycle.

The capability permission model

A plugin reaches the outside world only through capability namespaces on the context: ctx.messages, ctx.engine, and ctx.net form the base outbound surface, and the Integration SDK v1 adds ctx.registerWebhook, ctx.conversations, ctx.handover, and ctx.mappings. Since v0.17.0 the per-plugin key-value store ctx.storage is gated the same way. Each namespace maps to a permission string, which the plugin lists in its manifest permissions array:

PermissionNamespaceWhat it grants
messages:sendctx.messagesSend and reply to messages on a session. Routes through the host MessageService, so persistence and history are preserved.
engine:readctx.engineRead-only engine queries: group info, contacts, chats, and number-existence checks. No writes.
net:fetchctx.netOutbound HTTP via ctx.net.fetch, passed through the host's SSRF guard and scoped to a host allowlist.
webhook:ingressctx.registerWebhookIntegration SDK v1 — claim a verified inbound ingress route. The host authenticates every delivery before the plugin code runs.
conversation:sendctx.conversations, ctx.handover, ctx.mappingsIntegration SDK v1 — send normalized replies into a chat, flip a conversation's bot/human handover state, and manage the WhatsApp-chat↔external-conversation map.
search:providectx.registerSearchProviderServe the gateway's GET /api/search queries. Since v0.12.2 this permission is required: under the default SEARCH_PROVIDER=auto a registered provider is also made active (superseding builtin-fts), so a plugin that declared no permission would otherwise see every query the gateway serves. A plugin without it is refused before the provider reaches the registry, and the host logs one warning per enable (sandbox_search_provider_denied).
storage:usectx.storageRead and write the plugin's own key-value store — get, set, delete, and list, each gated separately. Since v0.17.0 this permission is required: before it, the four storage verbs dispatched with no permission check at all, so a plugin whose manifest declared nothing still wrote to the host disk. A plugin without it is denied at its next storage call, not at load; one that stores during onEnable is left in ERROR.

The first three are the base surface any plugin can declare; the next two are the Integration Fabric pair for bidirectional external integrations (helpdesk/CRM/chatbot handover) — see Integration Fabric; search:provide gates the search-provider capability (added in v0.12.2), so a search plugin must declare it explicitly; and storage:use gates the per-plugin key-value store (required since v0.17.0). A manifest that declares none gets no capability access at all — it can still register hooks and observe events, but it cannot act on them.

Enforcement does not live in a separate policy engine; it lives at the call boundary. Each capability method runs assertPermission(manifest, permission) before doing any work:

// Conceptual shape of the check the loader runs on every capability call.
private assertPermission(manifest, permission) {
if (!(manifest.permissions ?? []).includes(permission)) {
throw new PluginCapabilityError(
`Plugin ${manifest.id} is missing the '${permission}' permission required for this capability. ` +
`Add "${permission}" to the "permissions" array in the plugin's manifest.json, then reload the plugin.`,
);
}
}

Rendered, that reads: Plugin gsheets-logger is missing the 'storage:use' permission required for this capability. Add "storage:use" to the "permissions" array in the plugin's manifest.json, then reload the plugin.

storage:use is required from v0.17.0

A plugin that calls ctx.storage needs one manifest line — "storage:use" inside its permissions array. There is no load-time check for it, so the refusal arrives at the next storage call, not at load: an upgraded gateway looks fine until a plugin tries to store. Every storage verb is async, so the denial arrives as a rejected promise. A plugin that stores during onEnable does not merely lose the write — the loader records the error, sets the plugin to ERROR, unregisters its hooks, and leaves it disabled.

Operators: upgrade the seven official plugins that declare storage:use before upgrading the gateway. The new plugin versions are safe on an older gateway (an unrecognised permission string is ignored); the old versions are not safe on v0.17.0. The version floors are in the plugin catalog.

Two checks stack on top of the permission

Holding messages:send is necessary but not sufficient. Two further gates apply per call:

  • Session scope. Every messages and engine call carries a sessionId, supplied by the plugin. The host runs assertSessionAllowed(manifest, sessionId) against the manifest sessions list (['*'] means all). Because the sessionId originates from the plugin, this is the real security boundary — and it is static: editing config can never widen which sessions a plugin may touch.
  • Network allowlist. ctx.net.fetch additionally requires the target host to appear in manifest.net.allow, and the request always traverses the SSRF guard, which blocks internal IP ranges even for an allowlisted host.
Why the session scope is static

A capability call is only as trustworthy as the sessionId the plugin passes in. If an operator could widen sessions by editing config at runtime, a compromised or buggy plugin could escalate from one session to all of them. Pinning the scope in the manifest keeps the boundary fixed for the life of the install.

The manifest and the IPlugin contract

Two artifacts define a plugin: a declarative manifest.json and a code entry point that default-exports a class implementing IPlugin.

The manifest is the source of truth for what a plugin may do. Its required fields are id, name, version, type, and main; everything else is optional. The capability-relevant fields are:

FieldPurpose
permissionsThe capability permissions above. Absent or empty means no capability access.
sessionsSession ids the plugin may act on, or ['*']. Absent defaults to ['*']. Static — config edits can't widen it.
sessionScopedDefault true: the plugin only sees events for sessions an operator activated it for. false means it always runs.
net.allowOutbound-HTTP host allowlist for ctx.net.fetch. Absent means deny all.
configSchemaA declarative schema the dashboard renders into a config form.
hooksThe hook events the plugin listens to (informational).

The loader validates the required fields and resolves main strictly inside the plugin directory — a main path that escapes the directory is rejected. It does not gate on a host version; minOpenWAVersion is a catalog convention, not an enforced check.

The entry point is the behavior. Its module default-exports a class whose lifecycle methods are all optional and each receive only the PluginContext — config is read from ctx.config, never passed as a second argument:

// plugins/my-plugin/index.ts — shape only; implement IPlugin directly.
export default class MyPlugin {
async onLoad(ctx) {
ctx.registerHook('message:received', async hookCtx => {
const cfg = ctx.config; // per-session-merged config for this event
const { sessionId, data } = hookCtx;
const message = data;
if (message.body?.toLowerCase() === 'help' && sessionId) {
// Requires 'messages:send' + an in-scope, live session.
await ctx.messages.sendText(sessionId, message.from, cfg.greeting ?? 'How can I help you?');
return { continue: false }; // stop the chain
}
return { continue: true };
});
}
}

The context deliberately omits things a plugin might expect from other ecosystems. There is no ctx.api, ctx.router, or ctx.events: plugins do not mount HTTP routes and do not get an event emitter. They observe and act only through hooks and the permission-gated capability namespaces above. Storage is one of those namespaces: ctx.storage is a per-plugin key-value store whose values must survive a structured clone, bounded per plugin by PLUGIN_STORAGE_MAX_BYTES (default 50 MiB), and since v0.17.0 each of its four verbs requires storage:use.

The SDK package is planned, not published

Plugins implement the IPlugin interface directly today. An @openwa/plugin-sdk npm package is planned; until it ships, the live runtime interfaces are the contract. Importing the package name in examples is for type shape only.

Payload fields that are easy to miss

Plugins in the marketplace repository compile against a vendored types/openwa.d.ts rather than a published package. Six fields the host contract carries were absent from that file until recently — none of them a deliberate narrowing, so nothing marked them as missing. A field the vendored type does not name is a field an author has no way to discover, which is the whole cost of the omission:

TypeFieldWhat it carries
IncomingMessageephemeralDuration?: numberThe chat's disappearing-messages timer in seconds, carried on every message. 0 or absent means no timer; WhatsApp's values are 86400 (24 h), 604800 (7 d) and 7776000 (90 d).
IncomingMessagecall?: { video: boolean; missed: boolean }Set on call (call-log) messages: video versus voice, and whether an incoming call went unanswered.
IncomingMessagebackgroundColor?: stringText status/story styling — the background as #RRGGBB. Only set by engines that expose it.
IncomingMessagefont?: numberText status/story styling — the WhatsApp font index. Only set by engines that expose it.
ChatSummarykind: ChatKindThe full chat discriminator: individual, group, channel, status, broadcast, or unknown. Required, as in core. isGroup is retained for back-compat but collapses six kinds into two.
ConversationSendEnvelopelinkPreview?: booleanAsk the engine for a link preview on a plain text send. Baileys generates one only when this is true, so a plugin relaying a URL otherwise delivers a bare link; whatsapp-web.js previews by default and takes false to suppress. Ignored on media, location, and quoted sends, which route through engine paths that take no preview option.
The vendored contract is a hand check, not a guarantee

The marketplace package ships separately from the gateway and cannot import from core, so "verified field-by-field against OpenWA core" in that file's header means someone diffed it by hand. Nothing enforces it, and it has been wrong before — four of the six fields above had been on the host for several minor versions by the time the alignment that missed them was written. When you re-align the file for a newer host, diff member by member and annotate anything you leave out, so the next reader can tell an omission from a decision.

Lifecycle hooks

A plugin's real work happens in hook handlers it registers during onLoad via ctx.registerHook(event, handler, priority?). The host fires hooks at well-defined points across three lifecycles:

  • Session: session:created, session:starting, session:ready, session:qr, session:disconnected, session:error, session:deleted.
  • Message: message:received, message:sending, message:sent, message:failed, message:ack, message:persisted, message:deleted.
  • Webhook: webhook:before, webhook:queued, webhook:delivered, webhook:after, webhook:error.

message:persisted re-fires on every persisted transition as an upsert keyed by the row id — SENT with the engine id, FAILED on send failure, and the surviving row after an echo-won merge — and message:deleted fires for the row that merge drops. Payloads are shallow snapshots taken at emission time, so fire-and-forget delivery sees the state as emitted. A search provider indexing these hooks therefore always sees the finalized state rather than a ghost PENDING row it cannot correlate. Search-provider responses are validated at the host boundary: malformed hits or totals now fail with 502 (previously a bogus 200/500) — a breaking change for plugins that returned loose shapes.

Handlers for a given event run in priority order (lower priority first; default 100). The host walks the chain, threading each handler's result into the next:

A handler controls the chain through its return value:

ReturnEffect
{ continue: true }Let the next handler (and the host) proceed unchanged.
{ continue: true, data: x }Replace the event payload — the next handler and the host see x.
{ continue: false }Stop the chain immediately.
{ error: e }Propagate an error; the handler's data mutation is discarded.

The hook manager is built to keep one plugin from breaking the system:

  • A handler that throws is logged, and the chain continues with the previous data. One bad plugin cannot break the chain. Inside the sandbox, the first handler error per hook also surfaces as a rate-limited structured host log and in the plugin's health check — sandboxed handler errors are no longer swallowed wholesale.
  • Same-event re-entrancy is blocked. A handler that re-fires the event it is handling is short-circuited (this guards synchronous re-entry).
  • Plugins never call the hook manager directly. ctx.registerHook wraps the handler with the per-session activation gate, so a session-scoped plugin only runs for the sessions an operator activated it for.

The ADMIN-only management API

A plugin does not enable or configure itself. Its entire lifecycle is driven by PluginsController, mounted at plugins, where every route requires the ADMIN role — not a bare API-key guard. The dashboard is a client of this API; an operator (or your automation) is always the one in control.

Method & pathPurpose
GET /pluginsList all plugins.
GET /plugins/catalogList the remote catalog, annotated with install state.
GET /plugins/:idGet a single plugin.
POST /plugins/installInstall from an uploaded .zip (multipart/form-data, field file, ≤ 5 MB).
POST /plugins/install-urlInstall by downloading a .zip from a URL (SSRF-guarded; https://, or http:// with a required #sha256= integrity pin; in production the pin is required on every URL install, v0.20.0+).
POST /plugins/:id/updateUpdate an installed plugin in place from a URL (preserves config + enabled state).
POST /plugins/:id/enableEnable a plugin.
POST /plugins/:id/disableDisable a plugin.
PUT /plugins/:id/configUpdate the plugin's base config.
PUT /plugins/:id/config/:sessionIdSet (or clear) a per-session config override.
PUT /plugins/:id/sessionsSet which sessions a session-scoped plugin is activated for.
GET /plugins/:id/healthRun the plugin's healthCheck.
DELETE /plugins/:idUninstall a plugin and remove its files (built-ins are protected).

Installation is always a .zip package — by upload, by URL, or from the catalog — never an npm or GitHub source descriptor. Two state rules matter conceptually:

  • Loading is not enabling. On startup the loader discovers every plugin on disk, reads and validates its manifest, and records it as INSTALLED without running any plugin code. A previously-enabled plugin comes back as INSTALLED after a restart and must be re-enabled. A plugin whose load fails at boot has its registry entry reconciled to ERROR instead of reporting installed/enabled; the operator config is preserved, and a later successful load restores the status.
  • Enabling is always explicit. Running a plugin's onLoad/onEnable and wiring its capabilities is an ADMIN action, never automatic.
Config UIs are served as untrusted HTML

GET /plugins/:id/config-ui returns the plugin's config editor with Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff. The dashboard fetches it with the API key but injects the body as an iframe srcdoc on an opaque origin, exchanging config over a postMessage bridge — so the API key never reaches the plugin's UI code.

The worker-thread sandbox

OpenWA splits plugins into two trust tiers, and the loader routes each automatically:

TierWhat runs hereWhere it runsHow it reaches capabilities
Built-in (trusted)The two engine adapters (whatsapp-web.js, baileys), registered programmaticallyIn-processDirect, full speed
UntrustedAnything loaded from the plugins/ directoryIn a Node worker_threadOnly through the host-validated bridge

There is no vm2. An untrusted plugin runs in its own V8 context inside a worker thread, and every capability call and hook dispatch round-trips to the host over a MessagePort. The host applies the same permission and session-scope checks it would for an in-process plugin — the sandbox cannot be used to bypass them.

What the sandbox guarantees

  • No host-object access. The worker holds no reference to the loader, the engine, the database, or any host singleton. Its only channel out is the MessagePort, and the host's verbs are allowlisted — a worker cannot invoke an arbitrary host method.
  • Mediated capabilities. WhatsApp, the engine, and storage are reachable only through ctx.messages / ctx.engine / ctx.storage, each re-checked against permissions and session scope on the host side. A sandboxed plugin can never exceed its declared manifest permissions.
  • Hook safety. A sandboxed hook handler has a 5-second budget (SANDBOX_HOOK_TIMEOUT_MS). On timeout the host resolves { continue: true } (fail-open), so a wedged handler never stalls the chain; the same value drains in-flight hooks if the worker crashes.
  • Fault and resource containment. Each worker has a heap cap (maxOldGenerationSizeMb, default 256 MB) — an OOM kills the worker, not the host. A runaway, even an infinite synchronous loop, can be force-terminated, and a crash rejects only its in-flight calls. Lifecycle methods are bounded by a 30-second timeout and healthCheck by 5 seconds, so a wedged plugin can't hang an ADMIN enable/disable. Capability calls time out after PLUGIN_CAP_TIMEOUT_MS (default 30 seconds), freeing the in-flight slot — a hung plugin could previously pin all 32 capability slots forever — and a call that settles late is logged as a warning; work already running in the worker is not cancelled, by design. The log relay truncates lines, caps throughput per plugin, and flushes its dropped-line count on worker exit, so a plugin that goes quiet still reports what it dropped. The worker also gets a minimal allowlisted environment (NODE_ENV, NODE_EXTRA_CA_CERTS, TZ); host secrets such as the master key and database credentials are withheld.

What the sandbox does NOT guarantee

A worker thread is not an OS-level sandbox

A worker_thread is a separate V8 context in the same OS process, under the same user. The worker still has access to Node built-ins — require('fs'), process, network sockets — and runs as the same uid as OpenWA.

The sandbox protects the integrity of the host: no host-object compromise, contained faults, mediated capabilities. It does not, on its own, protect the confidentiality of the host filesystem against a plugin that deliberately abuses Node built-ins. Such a plugin could read files the OpenWA process can read or open outbound sockets directly, outside the capability model.

For genuinely untrusted, third-party plugins, combine the sandbox with OS-level containment: the shipped Docker image already runs a read-only rootfs, as non-root, with cap_drop: ALL, which bounds what any plugin's fs and network access can reach. Until an in-dashboard marketplace exists, the standing guidance is to install only plugins you trust.

What this means for plugin authors

The trust tier is invisible in your code — a sandboxed plugin keeps the same IPlugin shape. Three rules follow from the boundary being real:

  1. Capability calls genuinely cross a thread. They were always async; nothing changes in how you call them.
  2. Only serializable data crosses. Hook payloads, capability arguments, and results must be structured-clone-safe: plain objects, arrays, primitives, Date, typed arrays. No functions, no class instances with methods, no live references.
  3. Declare every permission you use. A capability call is denied unless the manifest declares the matching permission. Treat Node built-ins inside the worker as off-limits — anything you legitimately need should be a declared capability, and OS containment may block direct access anyway.

Next steps