Skip to main content

Overview

whatsapp-rust supports native plugins: build-time, type-safe extensions that get scoped access to core events, tasks, messaging, and IQ execution through a capability model, without touching the client’s internals directly. This is a low-level extension seam. If you’re writing an application that just sends and receives messages, use Bot directly — you don’t need this page. Reach for native plugins when you’re building a reusable component (metrics, a search index, a moderation layer) that should install once, observe events non-blockingly, own its own background tasks across reconnects, and expose a typed API to the rest of your application.
The plugin host is intentionally native-only today: it is trusted in-process Rust code, not a sandboxed or dynamically-loaded extension model. There is no dynamic (.so/.wasm) loading, no ingress interception of inbound stanzas, and no pre-ack decision hook.

Enabling the feature

Native plugins live behind the opt-in plugins feature, which also enables the lower-level client-lifecycle feature.
The plugins feature landed on main with PR #1061 and will be included in the next published release. Until then, depend on the git source:
Cargo.toml
Once a release that includes plugins is published, you can switch to the version form:
Cargo.toml
A default build has neither plugin fields nor their runtime branches. If you’re implementing a plugin directly in your application, enable plugins there. If you’re publishing a reusable plugin crate, enable plugins on its own whatsapp-rust dependency instead — Cargo’s feature unification then activates the host for any application that depends on your crate, so its Cargo.toml doesn’t need to enable plugins itself. See Installation — feature flags for where plugins and client-lifecycle sit relative to the rest of the feature matrix.

Defining a plugin

A plugin implements ClientPlugin, chooses an associated Api type, and returns it from an async install hook:
PluginManifest carries a stable string ID and a semver version. Duplicate IDs, duplicate marker types, malformed versions, and dependency cycles between plugins all fail before the client is assembled — not at first use.

Registering plugins with ClientBuilder

ClientBuilder is the canonical low-level construction path — BotBuilder delegates to the same path and remains the typestate-preserving facade for everyday bot code. Register plugins with with_plugin before calling build:
Client::plugin::<P>() returns Option<Arc<P::Api>> — the registry is keyed by the TypeId of the plugin marker P, not by the API type, so two different plugins can expose the same Api type without colliding. The Option reflects that the installed plugin set is decided at runtime by whichever builder call assembled this particular client. ClientBuilder follows one publication boundary. It validates dependencies and plugin manifests, then resolves plugin dependencies topologically. It assembles an inert client, installs plugins while staging their APIs, and starts client services. Only then does it atomically activate lifecycle and plugin resources and publish the completed client. Tasks a plugin requests during installation stay parked until that final activation — nothing a plugin schedules can run against a client that hasn’t finished construction. Installation is transactional: if a plugin’s install fails, is cancelled, panics, or races a terminal shutdown, every already-staged plugin resource closes synchronously and any asynchronous shutdown hooks run in reverse installation order. A plugin is install-once for the lifetime of one Client — reconnecting never replaces the plugin instance or its exposed API.

Runtime-defined plugins

The typed with_plugin / Client::plugin::<P>() path assumes the plugin’s Rust type is known at compile time. For an adapter that hosts a dynamic or runtime-defined set of extensions (for example, a future foreign-language bridge), implement UntypedClientPlugin instead and register instances with with_untyped_plugin (or with_untyped_plugin_arc to register a trait object). Untyped instances are keyed only by their manifest ID, may share a single concrete adapter type, and don’t appear in Client::plugin::<P>().

Capabilities

A plugin’s manifest requests capabilities, and PluginContext only exposes the small handle for each one it was granted:
This is API shaping, not a sandbox: a native plugin is trusted in-process Rust code and can use any dependency available to your build. It exists so a plugin’s install signature documents what it actually touches, and so PluginContext never hands out the raw backend or Signal stores.
Request a capability by chaining with_capability on the manifest, then retrieve its handle from the PluginContext passed into install. Extending SearchPlugin above to request CoreEvents means giving SearchApi a field for the handle and updating manifest/install to match:
The other capabilities follow the same pattern: context.tasks(), context.messaging(), context.iq(), and context.plugin_events() each return Option<&Plugin*>, populated only when the matching PluginCapability was requested in manifest(). Capability handles hold a weak reference to the client internally and reject calls once the client has shut down, so a plugin API that outlives the client (for example, one your application keeps an Arc to) fails safely instead of resurrecting it. PluginCoreEvents::subscribe returns an RAII token for the registration. Dropping it, or explicitly unsubscribing, removes the handler immediately.

Lifecycle and task scopes

Plugin work is either install-scoped (runs once, for the life of the client) or connection-scoped (tied to one connection_generation — cancelled on every reconnect and every terminal shutdown):
  • install runs once while the client is still inert.
  • on_ready runs, in dependency order, after authentication succeeds for that connection generation.
  • A reconnect or terminal teardown cancels the current generation’s scope synchronously; the host then waits (up to a configurable deadline) for tasks to drain before calling on_closed, in reverse dependency order.
PluginTasks::spawn (install-scoped) and PluginConnectionTasks::spawn (generation-scoped) drop the spawned future outright when cancellation wins a race. Use spawn_cooperative instead when a task needs to wind down cleanly rather than being dropped mid-flight. A cooperative task must poll shutdown_signal() (install-scoped) or cancellation_signal() (generation-scoped) and return once it observes cancellation. The host still only waits up to its configured drain deadline for that task to finish. Past that deadline, the host proceeds anyway and marks the plugin degraded if the task hasn’t returned. PluginHostConfig lets you tune the installation deadline (default 30s), and the per-callback and per-task-drain deadlines (default 5s each; none accept zero). Callbacks are serialized, bounded by their timeout, and isolated from panics — one misbehaving plugin’s on_ready/on_closed panic doesn’t suppress another plugin’s callback.

Custom events

Plugins can publish and subscribe to their own events, without going through the sealed core Event enum. PluginEventRouter routes each event by an exact (plugin_id, topic) selector. Every subscriber gets its own independently bounded queue. PluginEventEndpointConfig sets that queue’s overflow policy: either DropNewest or DropOldest. Each delivered envelope carries six fields: the plugin ID, the topic, a schema version, the payload bytes, the connection generation at publish time, and a route-local monotonic sequence number. A subscriber uses that sequence number to detect gaps left by dropped events. PluginEventSubscription is an RAII endpoint — dropping it removes all of that subscriber’s selectors from the router at once.

Diagnostics

Client::plugin_stats() returns a PluginStats snapshot per installed plugin: lifecycle state (PluginState), sticky health (PluginHealth), callback failures, spawned-task panics, drain timeouts, active task scopes, and subscription/publisher counters. PluginEventRouter::stats() and PluginEvents::stats() report queue depth and backpressure totals for custom events. Client::memory_report() folds in plugin resource accounting. These snapshots are on-demand and approximate under concurrency, and — matching the rest of the client’s observability surface — never include JIDs, phone numbers, or message bodies.

Reference example

The workspace ships plugins/metrics as a public-API conformance example: a metrics plugin built only against the same public API documented on this page, with no private access to the main crate. It’s a good starting point for structuring your own plugin crate — manifest, capability requests, an Api type, and lifecycle hooks.

What’s not supported yet

The current host deliberately does not support dynamic plugin loading, ingress interception or pre-ack decisions on inbound stanzas, a foreign (non-Rust) wire protocol, or process isolation/sandboxing. These are real design commitments, not just missing features, and would only be added behind a concrete consumer need.

See also

  • Observability — the tracing instrumentation that plugin diagnostics complement.
  • Custom backends — the other ClientBuilder seam, for swapping storage/transport/runtime instead of adding behavior.