Skip to main content
Version: v2

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

rust
#[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:

  1. Registration: Plugins are registered via HostBuilder::with_plugin(). Each plugin must have a unique ID.
  2. Start: When the host starts, all plugins' start() methods are called. If any plugin fails to start, the host startup fails.
  3. Workload binding: When a workload starts, the host calls on_workload_bind() once per plugin, then calls on_workload_item_bind() for each component and service in that workload.
  4. 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.
  5. Unbinding: When workloads stop, on_workload_unbind() is called for cleanup.
  6. Stop: During host shutdown, all plugins' stop() methods are called, each capped at WASH_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 - Contains imports and exports as HashSet<WitInterface>, representing the WIT interfaces the plugin provides.
  • WitInterface - Describes a specific interface with namespace, package, interfaces (a set of interface names), an optional version, an optional name (used for multi-backend binding), and a config map. Since 2.9.0 version, name, and config are serde-defaulted, so a manifest entry served by a declared binding can omit config.
  • WitInterfaces<'a> - A borrowed wrapper around &HashSet passed to on_workload_bind, on_workload_item_bind, and on_workload_unbind. Provides iter(), get(namespace, package, interfaces), and contains(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 to on_workload_item_bind. Variants are WorkloadItem::Component(&mut WorkloadComponent) and WorkloadItem::Service(&mut WorkloadService). Implements Deref<Target = WorkloadMetadata>, so item.linker() is accessible directly without pattern matching. Use item.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 to supports_named_instances(), so a multiplexer yields plain imports to a single-backend plugin; a plugin serving both off one backend returns false.

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:

rust
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:

rust
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:

rust
let logger = CustomLogger::new("my-app");

let host = HostBuilder::new()
    .with_engine(engine)
    .with_plugin(Arc::new(logger))?
    .build()?;

Keep reading