background-shape
Rust article cover illustration on a gradient background
January 23, 2026 · 9 min read · by Muhammad Amal programming
Advertisement

TL;DR — An autonomous agent network on WebAssembly turns each agent into a composable component with a typed message interface / the Component Model and WASI 0.2 give you capability-scoped isolation and language-neutral wiring / I cover the WIT contract, a host-mediated message bus, supervision, and the deadlock you’ll hit.

A few months back I inherited a “multi-agent” system that was really just five long-running processes sharing a Redis instance and a lot of optimism. Any agent could read any key. A bad deploy of one agent corrupted state the other four depended on. There was no contract between them — just convention and tribal knowledge.

An autonomous agent network on WebAssembly fixes the structural problem, not just the symptoms. Each agent becomes a Wasm component with an explicit, typed interface. It can only touch what the host hands it. Agents talk through a message bus the host mediates, so there’s no shared mutable state to corrupt and no implicit coupling to discover the hard way. The Component Model makes those interfaces real artifacts you can version and validate, and WASI 0.2 gives the agents a sane standard library without granting them the whole OS.

Advertisement

This post designs that network. We define the agent contract in WIT, build a host that owns the message bus, implement two agents in Rust, and add supervision so a crashed agent restarts without taking the network with it. If you haven’t built a single-component runtime yet, my earlier post on a Wasm inference runtime for edge agents covers the host fundamentals this one builds on.

Why the Component Model changes the design

WASI 0.1 (the wasm32-wasi “preview 1” era) gave you a module with a flat list of function imports and exports, all speaking integers and memory offsets. Composing two of those modules meant agreeing on memory layout by hand. It did not scale past two parties.

The Component Model replaces that with components: units that import and export interfaces described in WIT. Strings, lists, records, variants, and resources cross the boundary with defined semantics. Two components written in different languages compose if their interfaces match — the toolchain checks it.

For an agent network this is the whole game. An agent’s contract is a WIT world. The host validates every agent against it at load time. If an agent declares it imports bus/publisher and exports agent/lifecycle, the host knows exactly what to wire and what to deny. There’s no “this agent secretly also opens a file” — the capability simply isn’t in the world, so the code can’t name it.

The agent contract in WIT

Start with the interfaces. This file is the network’s API and deserves the same review rigor as a public HTTP schema.

// wit/agent-net.wit
package agent-net:core@0.1.0;

interface bus {
  // A message is an opaque typed envelope; topic routes it.
  record envelope {
    topic: string,
    sender: string,
    payload: list<u8>,
    correlation-id: string,
  }
  variant send-error {
    no-subscriber(string),
    payload-too-large(u32),
    backpressure,
  }
  // Host-provided: an agent publishes onto the bus.
  publish: func(msg: envelope) -> result<_, send-error>;
}

interface lifecycle {
  use bus.{envelope};
  // Agent-provided: host calls these.
  init: func(agent-id: string, config: list<u8>) -> result<_, string>;
  handle: func(msg: envelope) -> result<list<envelope>, string>;
  shutdown: func();
}

world agent {
  import bus;
  export lifecycle;
}

Two decisions worth defending. First, handle returns a list of envelopes rather than publishing as a side effect. That makes an agent’s reaction to a message a pure function of the message — trivially testable, and the host stays the only thing that touches the bus. Second, send-error includes backpressure as a first-class variant. An agent network without backpressure is a network that falls over under load; making it a typed result forces every caller to decide what to do.

Building the host

The host owns three things: the component registry, the message bus, and the supervisor. Pin the toolchain.

# Cargo.toml
[package]
name = "agent-net-host"
version = "0.1.0"
edition = "2021"
rust-version = "1.84"

[dependencies]
wasmtime = { version = "28.0", features = ["component-model", "async"] }
wasmtime-wasi = "28.0"
anyhow = "1.0"
tokio = { version = "1.43", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

The bus is an in-process topic router. Each agent gets an inbound mpsc channel; a routing table maps topics to subscriber agent IDs.

// src/bus.rs
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};

#[derive(Clone)]
pub struct Envelope {
    pub topic: String,
    pub sender: String,
    pub payload: Vec<u8>,
    pub correlation_id: String,
}

const MAX_PAYLOAD: usize = 256 * 1024;
const INBOX_CAPACITY: usize = 64;

#[derive(Clone)]
pub struct Bus {
    inboxes: Arc<RwLock<HashMap<String, mpsc::Sender<Envelope>>>>,
    routes: Arc<RwLock<HashMap<String, Vec<String>>>>,
}

#[derive(Debug)]
pub enum SendError {
    NoSubscriber(String),
    PayloadTooLarge(u32),
    Backpressure,
}

impl Bus {
    pub fn new() -> Self {
        Self {
            inboxes: Arc::new(RwLock::new(HashMap::new())),
            routes: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Register an agent and return its inbound receiver.
    pub async fn register(&self, agent_id: &str) -> mpsc::Receiver<Envelope> {
        let (tx, rx) = mpsc::channel(INBOX_CAPACITY);
        self.inboxes.write().await.insert(agent_id.to_string(), tx);
        rx
    }

    pub async fn subscribe(&self, topic: &str, agent_id: &str) {
        self.routes
            .write()
            .await
            .entry(topic.to_string())
            .or_default()
            .push(agent_id.to_string());
    }

    /// Fan a message out to every subscriber. try_send gives us
    /// non-blocking backpressure instead of an unbounded queue.
    pub async fn publish(&self, msg: Envelope) -> Result<(), SendError> {
        if msg.payload.len() > MAX_PAYLOAD {
            return Err(SendError::PayloadTooLarge(msg.payload.len() as u32));
        }
        let routes = self.routes.read().await;
        let targets = routes
            .get(&msg.topic)
            .ok_or_else(|| SendError::NoSubscriber(msg.topic.clone()))?;
        let inboxes = self.inboxes.read().await;
        for agent_id in targets {
            if let Some(tx) = inboxes.get(agent_id) {
                tx.try_send(msg.clone())
                    .map_err(|e| match e {
                        mpsc::error::TrySendError::Full(_) => SendError::Backpressure,
                        mpsc::error::TrySendError::Closed(_) => {
                            SendError::NoSubscriber(agent_id.clone())
                        }
                    })?;
            }
        }
        Ok(())
    }
}

Bounded channels with try_send are deliberate. An unbounded queue trades a crash for an OOM, which is worse because it’s silent until it isn’t. Backpressure propagates back to the publishing agent as a typed error it must handle.

The agent worker

Each agent runs in its own Tokio task with its own Wasmtime Store. The task pulls from the inbox, calls the component’s handle, and republishes whatever comes back.

// src/worker.rs
use anyhow::{Context, Result};
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};
use crate::bus::{Bus, Envelope, SendError};

pub struct AgentState {
    wasi: WasiCtx,
    table: ResourceTable,
    bus: Bus,
    agent_id: String,
}

impl WasiView for AgentState {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

pub async fn run_agent(
    engine: Engine,
    component: Component,
    linker: Linker<AgentState>,
    bus: Bus,
    agent_id: String,
    config: Vec<u8>,
) -> Result<()> {
    let mut inbox = bus.register(&agent_id).await;

    let state = AgentState {
        wasi: WasiCtxBuilder::new().inherit_stderr().build(),
        table: ResourceTable::new(),
        bus: bus.clone(),
        agent_id: agent_id.clone(),
    };
    let mut store = Store::new(&engine, state);
    store.set_fuel(2_000_000_000)?;

    let instance = linker
        .instantiate_async(&mut store, &component)
        .await
        .with_context(|| format!("instantiating agent {agent_id}"))?;

    let lifecycle = instance.get_export(&mut store, None, "agent-net:core/lifecycle")
        .context("agent missing lifecycle export")?;
    let init = instance.get_typed_func::<(String, Vec<u8>), (Result<(), String>,)>(
        &mut store, &instance.get_export(&mut store, Some(&lifecycle), "init").unwrap(),
    )?;
    let handle = instance.get_typed_func::<(Envelope,), (Result<Vec<Envelope>, String>,)>(
        &mut store, &instance.get_export(&mut store, Some(&lifecycle), "handle").unwrap(),
    )?;

    init.call_async(&mut store, (agent_id.clone(), config)).await?
        .0
        .map_err(|e| anyhow::anyhow!("agent {agent_id} init failed: {e}"))?;

    while let Some(msg) = inbox.recv().await {
        // Refill fuel per message so one expensive message can't starve later ones.
        store.set_fuel(2_000_000_000)?;
        let cid = msg.correlation_id.clone();
        match handle.call_async(&mut store, (msg,)).await {
            Ok((Ok(outgoing),)) => {
                for out in outgoing {
                    if let Err(SendError::Backpressure) = bus.publish(out).await {
                        tracing::warn!(agent = %agent_id, %cid, "dropped reply: backpressure");
                    }
                }
            }
            Ok((Err(e),)) => tracing::error!(agent = %agent_id, %cid, "handle error: {e}"),
            Err(trap) => return Err(trap).context("guest trapped"),
        }
    }
    Ok(())
}

Refilling fuel per message is the per-agent fairness mechanism. Without it, the first message that burns the grant traps every subsequent one.

Supervision

A network is autonomous only if it heals. The supervisor watches each agent task and restarts a crashed one with exponential backoff, capped so a permanently broken agent stops thrashing the host.

// src/supervisor.rs
use std::time::Duration;
use tokio::time::sleep;

pub async fn supervise<F, Fut>(agent_id: String, spawn: F)
where
    F: Fn() -> Fut,
    Fut: std::future::Future<Output = anyhow::Result<()>>,
{
    let mut backoff = Duration::from_millis(200);
    let max_backoff = Duration::from_secs(30);
    let mut restarts = 0u32;

    loop {
        match spawn().await {
            Ok(()) => {
                tracing::info!(agent = %agent_id, "agent exited cleanly");
                return;
            }
            Err(e) => {
                restarts += 1;
                tracing::error!(agent = %agent_id, restarts, "agent crashed: {e:#}");
                if restarts > 10 {
                    tracing::error!(agent = %agent_id, "restart limit reached, giving up");
                    return;
                }
                sleep(backoff).await;
                backoff = (backoff * 2).min(max_backoff);
            }
        }
    }
}

Because each agent has its own Store, a trap inside one guest cannot corrupt another. The supervisor rebuilds the store on restart, so the agent comes back with clean linear memory and a fresh resource table. That clean-restart guarantee is the practical payoff of the per-agent isolation.

Common Pitfalls

  • Topic typos route nowhere. Topics are strings. A subscriber registered on "sensor.temp" won’t see "sensor.temperature". Define topics as constants in a shared crate both sides depend on.
  • Cyclic message flows livelock the bus. Agent A replies to B, B replies to A, forever. Carry a hop count or TTL field in the envelope and drop messages that exceed it.
  • Sharing one Store across agents. It defeats isolation and serializes all guest execution. One store per agent task, always.
  • Unbounded handle output. An agent that returns 10,000 envelopes per message will saturate the bus. Cap the returned list length in the host and log when you clamp it.
  • Forgetting init ordering. If A publishes on startup before B has subscribed, the message is lost. Either have agents subscribe in init synchronously, or buffer early messages until all agents report ready.

Troubleshooting

Symptom: publish always returns no-subscriber even though the agent is running. Cause: the agent registered an inbox but never called subscribe for that topic, or subscribed after the publisher started. Fix: subscribe inside init, before init returns. Log the routing table once all agents have initialized and confirm every topic has at least one route.

Symptom: the whole network freezes under sustained load. Cause: a cyclic flow filled bounded channels and every agent is blocked in try_send. Fix: add a TTL to envelopes; drop on expiry. Check tracing for repeated backpressure warnings on the same correlation ID, which is the signature of a cycle.

Symptom: an agent restarts in a tight loop. Cause: a deterministic trap — out-of-bounds access or unwrap on bad config — so every restart fails identically. Fix: the backoff cap and restart limit contain the damage; the real fix is in the guest. Reproduce by feeding the same config to the component in isolation and read the trap backtrace.

Symptom: instantiate_async fails with “component imports do not match”. Cause: the agent component was built against a different agent-net.wit version than the host links. Fix: keep the WIT package version in agent-net:[email protected] and bump it on every breaking change; rebuild all agents when it changes.

What’s Next

You have an autonomous agent network where every agent is an isolated, typed component, the host owns all messaging, and a supervisor heals crashes without cascading failure. The natural extensions are persisting the bus to disk for crash-recoverable delivery, and distributing it across machines by swapping the in-process router for a network transport while keeping the exact same WIT contract. The Component Model documentation is the reference to keep open as you grow the interface set.

Advertisement