Skip to main content
Version: v0.7.6

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

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 three 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 three.
  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 ctx.messages, ctx.engine, and ctx.net. Each namespace maps to one 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.

There is no fourth permission. 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`,
);
}
}

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 three capabilities. Storage is the one extra surface — ctx.storage is a per-plugin key-value store whose values must survive a structured clone.

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.

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.
  • Webhook: webhook:before, webhook:queued, webhook:delivered, webhook:after, webhook:error.

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.
  • 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).
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.
  • 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. 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