Creating Host Plugins for wasmCloud
Extend wasmCloud hosts with additional capabilities.
The wash-runtime crate includes built-in support for common WASI interfaces like HTTP, key-value, and configuration. When you need to provide capabilities that aren't covered by the built-ins—such as integrating with a proprietary database, exposing specialized hardware, or implementing a custom protocol—you can create a custom plugin.
A host plugin is primarily responsible for implementing a specific WIT world as a collection of imports and exports that will be directly linked to the workload's wasmtime::component::Linker.
The HostPlugin trait
#[async_trait]
pub trait HostPlugin: Any + Send + Sync + 'static {
/// Returns a unique identifier for the plugin.
/// Plugin IDs must be unique across all registered plugins.
fn id(&self) -> &'static str;
/// Returns the WIT world this plugin implements.
/// The binding process only occurs if workloads require these interfaces.
fn world(&self) -> WitWorld;
/// Called during host initialization before accepting workloads.
/// Use this for setup tasks like establishing connections.
async fn start(&self) -> anyhow::Result<()> {
Ok(())
}
/// Called when a workload begins binding to the plugin.
/// Enables pre-binding validation and setup.
async fn on_workload_bind(
&self,
workload: &UnresolvedWorkload,
interfaces: WitInterfaces<'_>,
) -> anyhow::Result<()> {
Ok(())
}
/// Called when configuring a specific component or service's linker.
/// `item` is an enum — match on `WorkloadItem::Component` or `WorkloadItem::Service`.
/// This is where you add your implementations to the linker.
async fn on_workload_item_bind<'a>(
&self,
item: &mut WorkloadItem<'a>,
interfaces: WitInterfaces<'_>,
) -> anyhow::Result<()> {
Ok(())
}
/// Called after successful workload binding and resolution.
async fn on_workload_resolved(
&self,
workload: &ResolvedWorkload,
component_id: &str,
) -> anyhow::Result<()> {
Ok(())
}
/// Called during unbinding or shutdown for cleanup.
async fn on_workload_unbind(
&self,
workload_id: &str,
interfaces: WitInterfaces<'_>,
) -> anyhow::Result<()> {
Ok(())
}
/// Called during host shutdown for final cleanup.
async fn stop(&self) -> anyhow::Result<()> {
Ok(())
}
// Since 2.9.0, four defaulted methods support operator-declared
// plugin bindings (see below): binding_schema(), validate_bindings(),
// narrows(), and defers_unnamed_instances(). Existing plugins
// compile unchanged.
}Plugin lifecycle
Plugins follow a defined lifecycle within the host:
- Registration: Plugins are registered via
HostBuilder::with_plugin(). Each plugin must have a unique ID. - Start: When the host starts, all plugins'
start()methods are called. If any plugin fails to start, the host startup fails. - Workload binding: When a workload starts, the host calls
on_workload_bind()once per plugin, then callson_workload_item_bind()for each component and service in that workload. - Resolution: After successful binding,
on_workload_resolved()is called. A plugin that pushes work into workloads (broker subscriptions, timers) resolves its dispatch targets here, before the workload starts serving. - Unbinding: When workloads stop,
on_workload_unbind()is called for cleanup. - Stop: During host shutdown, all plugins'
stop()methods are called, each capped atWASH_PLUGIN_STOP_TIMEOUT_SECS(default 5 seconds) plus a one-second grace.
Key types
The HostPlugin trait uses several types from the wash_runtime crate:
WitWorld- ContainsimportsandexportsasHashSet<WitInterface>, representing the WIT interfaces the plugin provides.WitInterface- Describes a specific interface withnamespace,package,interfaces(a set of interface names), an optionalversion, an optionalname(used for multi-backend binding), and aconfigmap. Since 2.9.0version,name, andconfigare serde-defaulted, so a manifest entry served by a declared binding can omitconfig.WitInterfaces<'a>- A borrowed wrapper around&HashSetpassed toon_workload_bind,on_workload_item_bind, andon_workload_unbind. Providesiter(),get(namespace, package, interfaces), andcontains(namespace, package, interfaces)lookup helpers, avoiding a clone of the interface set per callback.UnresolvedWorkload- A workload that has been initialized but not yet bound to plugins.WorkloadItem<'a>- An enum passed toon_workload_item_bind. Variants areWorkloadItem::Component(&mut WorkloadComponent)andWorkloadItem::Service(&mut WorkloadService). ImplementsDeref<Target = WorkloadMetadata>, soitem.linker()is accessible directly without pattern matching. Useitem.is_component()/item.is_service()when you need to handle the two variants differently.ResolvedWorkload- A fully bound workload ready for execution.
Operator-declared bindings
Since 2.9.0, operators can configure a plugin through a generic plugins declaration (host config or Helm values) instead of ad hoc per-plugin settings. Four defaulted HostPlugin methods let a plugin participate:
binding_schema(): classify the plugin's config keys by ownership (host-owned, ceiling, or workload-owned). A plugin that declares workload-owned keys gets a closed key set: unknown keys are refused at deploy. Since 2.10.0 a schema can also declare spelling aliases, resolved as one key with the stricter ownership, and required keys that the fully resolved binding must carry under any policy.validate_bindings(): validate the operator's declaration at host startup, so a bad declaration fails the host rather than the first workload.narrows(): define what it means for a workload to request a subset of a granted ceiling (for example, a narrower subject allowlist).defers_unnamed_instances(): whether a plain, unlabeled import should be handed to a sibling plugin that serves it plainly rather than to this one. Defaults tosupports_named_instances(), so a multiplexer yields plain imports to a single-backend plugin; a plugin serving both off one backend returnsfalse.configure_egress_policy()(since 2.10.0): receive the operator-declared egress ceiling (allowedHosts,allowedIpNameLookups,allowedHostLoopbackPorts) as aPluginEgressPolicythe plugin must check before its own client dials out, since native networking bypasses the WASI socket hooks. The default implementation rejects a declaration, so a ceiling on a plugin that cannot enforce it fails host startup instead of going silently unenforced.
By the time on_workload_item_bind runs, each interface's config map is already merged and policy-checked; plugins never see the workloadConfig policy itself.
Dispatching into workloads
Since 2.9.0, a native plugin can push calls into guest code through a DispatchTarget, resolved once per workload item in on_workload_resolved() via ResolvedWorkload::dispatch_target(item_id, plugin) (the plugin id, as a &'static str). Dispatched component calls run on the component's warm instance pool (honoring poolSize, maxConcurrency, and maxInvocations) or one-shot stores; dispatched service calls land on the service's pinned instance. A service reached this way must target WASI P3, and it stays up after wasi:cli/run returns so it can keep receiving dispatches. The host arms deadlines, tracks abandonment, and records guest metrics under the plugin's id for every dispatched call. The wasmcloud:nats plugin shares this dispatch machinery while driving the pool itself, because a JetStream delivery's message handle is tied to the store that must run it.
Dispatch is cancellable since 2.10.0. DispatchTarget::start returns a DispatchHandle with cancel() and outcome(), dropping the dispatch() future cancels the call, and a cloneable CancelHandle reports quiescence. A cancelled call that never entered the guest is dropped outright; one running on a dedicated store has the store dropped; a warm instance is retired and drained; a service call runs until the guest returns.
Looking up other plugins
Plugins that delegate to another plugin (for example, a custom backend that wraps wasi:keyvalue storage) can resolve siblings by ID from the runtime Ctx:
use wash_runtime::plugin::wasi_keyvalue::InMemoryKeyValue;
// Fallible: composes with `?` and returns a descriptive error if the
// plugin isn't registered or the type doesn't match.
let kv = ctx.try_get_plugin::<InMemoryKeyValue>("wasi-keyvalue")?;
// Infallible: panics on missing plugin or type mismatch. Use only
// when the plugin is guaranteed by your host's registration logic.
let kv = ctx.get_plugin::<InMemoryKeyValue>("wasi-keyvalue");The type parameter is the concrete backend your host registered for the looked-up plugin. The built-in wasi:keyvalue plugin ships four interchangeable backends — InMemoryKeyValue, FilesystemKeyValue, NatsKeyValue, and RedisKeyValue — all under the same plugin ID "wasi-keyvalue"; substitute whichever your HostBuilder::with_plugin() call registered. A type mismatch surfaces through the descriptive error from try_get_plugin.
Overriding WASI builtin interfaces
Since 2.11.0 a native plugin can replace functions in a WASI interface the host normally serves, which is what a plugin acting as a sandboxer needs. This is a wash-runtime library capability: there is no WIT export a component plugin can use to declare one, and no Helm value or CRD field that requests it.
Overridable interfaces are the host-linked WASI base — wasi:io, wasi:filesystem, wasi:clocks, wasi:random, wasi:cli, wasi:sockets, and wasi:http — at 0.2.0 or 0.3.0 (wasi:io at 0.2.0 only, since WASI 0.3 folds it into the component model). Interfaces the host does not link as builtins, such as wasi:keyvalue or wasi:logging, are served by a plugin the ordinary way rather than overridden.
Declaring one takes two steps. First return the interfaces from wasi_overrides:
fn wasi_overrides(&self) -> WitWorld {
WitWorld {
imports: HashSet::from([WitInterface::from("wasi:clocks/monotonic-clock@0.2.0")]),
..Default::default()
}
}Then, inside on_workload_item_bind, reopen the instance through the helper and redefine the functions you are replacing:
let mut instance = item.wasi_override_instance("wasi:clocks/monotonic-clock@0.2.0")?;
instance.func_wrap("now", |_, (): ()| Ok((4242u64,)))?;Use wasi_override_instance rather than linker().instance(..) directly. Linker::instance matches names exactly, so opening "...@0.2.0" by hand creates a separate, incomplete instance that neither overrides the host's registration nor inherits the rest of its functions. The helper resolves the request against the versions the linker actually registered and reopens that exact instance, so a partial override leaves every function you did not redefine as the host's.
Four rules govern the result:
- An explicit version is required, because it selects WASI 0.2 or WASI 0.3. A bare interface name is an error.
- Version matching is semver-compatible, not exact: a request for
@0.2.0resolves against the host's registered0.2.x, matching on equal major, or equal minor when major is0. A version compatible with nothing the linker registered is an error. (This is the same rule the host applies tohostInterfacesentries and(implements ..)labels, which is looser than the exact revision selectionwasmcloud:messagingdoes through itsversionkey.) - The workload opts in through a
hostInterfacesentry naming a non-WASI interface the plugin serves, exactly as for any plugin. A workload that never names the plugin is untouched, even if its components import the overridden interface. A service that imports only WASI still receives the override once its workload opts in. - Two plugins cannot override one interface. The host refuses to build rather than letting whichever binds last win, naming both plugin ids. One plugin may declare overlapping entries of its own, and
@0.2.0versus@0.3.0of the same interface do not conflict, because WASI 0.2 and WASI 0.3 are different interfaces.
Overriding plugins bind after non-overriding ones so nothing binds over a redefinition, and shadowing is enabled only for the duration of each overriding plugin's bind hook.
What a bind hook receives
A plugin's on_workload_bind receives exactly the hostInterfaces entries routed to that plugin, never the whole workload's interface config. A workload carrying an HTTP ingress entry, a wasi:config entry, and two entries for different keyvalue plugins delivers each plugin only its own, so one plugin's config — which on Kubernetes may come from a secretFrom Secret — is not visible to the others bound to the same workload. Inside a lifecycle hook, get-workload-id names the workload the hook concerns and get-component-id reports nothing, since a hook is delivered about a whole workload rather than on behalf of any one of its items.
Example: Custom logging plugin
Here's a simplified example of a custom plugin that implements logging:
use std::collections::HashSet;
use async_trait::async_trait;
use wash_runtime::{
engine::workload::WorkloadItem,
plugin::{HostPlugin, WitInterfaces},
wit::{WitInterface, WitWorld},
};
pub struct CustomLogger {
prefix: String,
}
impl CustomLogger {
pub fn new(prefix: impl Into<String>) -> Self {
Self { prefix: prefix.into() }
}
}
#[async_trait]
impl HostPlugin for CustomLogger {
fn id(&self) -> &'static str {
"custom-logger"
}
fn world(&self) -> WitWorld {
WitWorld {
imports: HashSet::new(),
exports: HashSet::from([WitInterface {
namespace: "wasi".to_string(),
package: "logging".to_string(),
interfaces: HashSet::from(["logging".to_string()]),
version: None,
name: None,
config: std::collections::HashMap::new(),
}]),
}
}
async fn start(&self) -> anyhow::Result<()> {
println!("[{}] Logger plugin started", self.prefix);
Ok(())
}
async fn on_workload_item_bind<'a>(
&self,
item: &mut WorkloadItem<'a>,
interfaces: WitInterfaces<'_>,
) -> anyhow::Result<()> {
// WorkloadItem implements Deref<Target = WorkloadMetadata>, so linker()
// is accessible directly without pattern matching:
// item.linker().func_wrap(...)?;
//
// Use item.is_component() / item.is_service() if you need to
// handle components and services differently.
//
// Use interfaces.contains("wasi", "logging", &["logging"]) to gate
// bind logic on which interfaces the workload actually requires.
Ok(())
}
async fn stop(&self) -> anyhow::Result<()> {
println!("[{}] Logger plugin stopped", self.prefix);
Ok(())
}
}Register the custom plugin with your host:
let logger = CustomLogger::new("my-app");
let host = HostBuilder::new()
.with_engine(engine)
.with_plugin(Arc::new(logger))?
.build()?;Keep reading
- Building Custom Hosts - Embed the wasmCloud runtime in your own application
- Host Plugins Overview - Built-in plugins and plugin architecture
- wash-runtime source code - Reference implementations of built-in plugins