Cluster Hosts (Washlet)
Run a wasmCloud host as a cluster node connected to the operator mesh.
A washlet is a cluster-connected host: a ClusterHost from the wash_runtime::washlet module. Unlike a standalone Host (covered in Building Custom Hosts), a washlet connects to the wasmCloud operator mesh via NATS and receives workload commands from the scheduler. This is how the Kubernetes Operator deploys and manages workloads across a fleet of hosts.
Standalone host vs. cluster host
Standalone Host | ClusterHost (washlet) | |
|---|---|---|
| Managed by | Your application code | wasmCloud operator via NATS |
| Workload commands | Programmatic API (HostApi) | NATS subjects |
| Plugins | In-memory (dev) or custom | NATS-backed for production |
| Entry point | HostBuilder | ClusterHostBuilder |
| Use case | Embedding, custom integration | Operator-managed fleet |
Running with wash host
The simplest way to run a washlet is the wash host CLI command. It connects to NATS and joins a host group, ready to receive workload commands from the operator.
wash host --host-group my-cluster --scheduler-nats-url nats://nats:4222wash host flags
| Flag | Default | Description |
|---|---|---|
--host-group | default | Host group label used for workload scheduling |
--host-name | (auto-generated) | Human-readable name for this host |
--scheduler-nats-url | nats://localhost:4222 | NATS URL for the control plane (workload commands) |
--data-nats-url | nats://localhost:4222 | NATS URL for the data plane (plugin backends) |
--http-addr | (disabled) | If set, enables the HTTP handler on this address |
--postgres-url | (disabled) | If set, enables the wasmcloud:postgres plugin (direct queries and prepared statements) |
--wasi-webgpu | false | Enable the WebGPU interface |
--allow-insecure-registries | false | Allow pulling from insecure OCI registries |
--registry-pull-timeout | 30s | Timeout for OCI pulls |
--scheduler-tls-ca | — | TLS CA for scheduler NATS |
--data-tls-ca | — | TLS CA for data plane NATS |
This table covers the core cluster flags; for the full and current flag surface, including the probe, drain, and memory flags and the socket and filesystem policy flags, see the wash host command reference.
Environment variables
WASH_POSTGRES_URL can be used in place of --postgres-url.
Embedding programmatically
Use ClusterHostBuilder to embed a washlet in your own application. The builder mirrors HostBuilder but adds NATS-specific configuration:
use std::sync::Arc;
use wash_runtime::{
host::http::{Ingress, DynamicRouter},
plugin::{
wasi_blobstore::NatsBlobstore,
wasi_config::DynamicConfig,
wasi_keyvalue::NatsKeyValue,
wasi_logging::TracingLogger,
wasmcloud_messaging::NatsMessaging,
},
washlet::{ClusterHostBuilder, run_cluster_host},
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
// Connect to NATS (scheduler and data plane can be separate connections)
let scheduler_nats = async_nats::connect("nats://localhost:4222").await?;
let data_nats = async_nats::connect("nats://localhost:4222").await?;
// Build the cluster host with NATS-backed plugins
let mut builder = ClusterHostBuilder::default()
.with_host_group("my-cluster")
.with_nats_client(Arc::new(scheduler_nats))
.with_plugin(Arc::new(DynamicConfig::new(true)))? // true = NATS-backed
.with_plugin(Arc::new(TracingLogger::default()))?
.with_plugin(Arc::new(NatsBlobstore::new(&data_nats)))?
.with_plugin(Arc::new(NatsMessaging::new(data_nats.clone())))?
.with_plugin(Arc::new(NatsKeyValue::new(&data_nats)))?;
// Optionally enable HTTP
let http_router = DynamicRouter::default();
let http_server = Ingress::new(http_router, "0.0.0.0:8080".parse()?).await?;
builder = builder.with_http_handler(Arc::new(http_server));
let cluster_host = builder.build()?;
// Start the host and run until shutdown
let shutdown = run_cluster_host(cluster_host).await?;
println!("Cluster host running. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;
// Graceful shutdown
shutdown.await?;
println!("Host stopped.");
Ok(())
}ClusterHostBuilder methods
| Method | Description |
|---|---|
with_host_group(str) | Host group label for workload scheduling |
with_host_name(str) | Human-readable name (auto-generated if not set) |
with_nats_client(Arc<Client>) | NATS client for the control plane |
with_plugin(Arc<dyn HostPlugin>) | Register a plugin (IDs must be unique) |
with_http_handler(Arc<dyn HostHandler>) | Register the HTTP handler |
with_host_builder(HostBuilder) | Provide a pre-configured HostBuilder for advanced use |
with_artifact_cleaner(frequency, max_age) | Clean up stale cached artifacts on a schedule |
build() | Construct the ClusterHost |
NATS-backed plugins
In cluster mode, use NATS-backed plugin implementations so that workloads can access shared state across hosts:
| Plugin | Type | Description |
|---|---|---|
| Config | DynamicConfig::new(true) | NATS-backed runtime config (pass false for dev/in-memory) |
| Logging | TracingLogger::default() | Structured logging via tracing |
| Key-value | NatsKeyValue::new(&nats_client) | NATS-backed key-value store |
| Blobstore | NatsBlobstore::new(&nats_client) | NATS-backed blob storage |
| Messaging | NatsMessaging::new(nats_client) | NATS pub/sub messaging |
Attaching control to a host you own
ClusterHostBuilder owns host construction: you hand it a HostBuilder, plugins, and an HTTP handler, and it builds, starts, and owns the Host. Since 2.11.0 AttachedHostControl inverts that, serving the same NATS control API for a host you built, started, and keep owning.
use wash_runtime::washlet::{AttachedHostControl, HostCommandHandler, HostControlDefaults};
let control = AttachedHostControl::builder(host.clone(), nats_client)
.with_host_group("my-cluster")
.with_handler(Arc::new(MyPolicy))
.attach()
.await?;
// ... later, stop serving control while the host keeps running
control.shutdown().await?;The builder also takes with_heartbeat_interval (zero means 15s), with_max_concurrent_starts, with_artifact_cleaner(frequency, max_age), and with_liveness. Dropping the AttachedHostControl aborts the loop.
What this makes possible is one host serving both locally-originated and operator-originated workloads, with your code arbitrating which workloads the control plane may see or touch. A HostCommandHandler intercepts every remote start, stop, and status, and can refuse it, rewrite it, or delegate to HostControlDefaults for stock behavior:
#[async_trait]
impl HostCommandHandler for MyPolicy {
async fn start(&self, defaults: &HostControlDefaults, req: WorkloadStartRequest)
-> anyhow::Result<WorkloadStartResponse> {
if !req.workload_id.starts_with("owned-") {
anyhow::bail!("admission denied");
}
defaults.start(req).await
}
}Nothing in the runtime distinguishes a locally-started workload from an operator-started one, so protecting one is the handler's job: HostControlDefaults::stop stops whatever workload_id it is given. Refuse a stop or status whose id your own code owns, as the example above refuses a start. What the runtime does guarantee is that only one attachment may be active at a time — a second is refused rather than silently layered over the first, so a handler's policy cannot be bypassed by attaching again.
Note that with_host_group sets the host group label in heartbeats only; it does not add a hostgroup label to the host itself. ClusterHostBuilder has no with_handler, so command interception means moving onto this path.
Since 2.11.0 a host proves its control subscription is live before reporting started: it subscribes, publishes a marker to runtime.host.<host_id>.__control.ready, and waits to receive its own message back. The connection must therefore be allowed to publish to that subject and must not use no_echo. Failing either, startup fails after 10 seconds with a message naming the subjects, instead of a host that starts cleanly and silently serves no commands. The chart's bundled NATS applies no subject-level permissions, so a stock install is unaffected; this matters only where you have hand-rolled restrictive NATS authorization.
How the operator communicates with washlets
The operator sends commands to washlets by publishing to NATS subjects:
runtime.host.<host_id>.workload.start
runtime.host.<host_id>.workload.stop
runtime.host.<host_id>.workload.status
runtime.host.<host_id>.heartbeat
Washlets publish heartbeats to:
runtime.operator.heartbeat.<host_id>
Messages are JSON-encoded. You typically don't interact with these subjects directly—the operator handles scheduling and the washlet handles command processing.
Keep reading
- Building Custom Hosts - Embed a standalone host without NATS
- Creating Host Plugins - Build custom plugins for your host
- Kubernetes Operator - Deploy washlets managed by the operator
- wash-runtime source code -
ClusterHostimplementation