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 with a schema gets a closed key set: unknown keys are refused at deploy.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.
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.
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.
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