Writing a Channel Plugin
A channel plugin is a messaging-platform integration: it delivers the agent’s responses to a platform and surfaces the platform’s messages to the agent. It is the most involved plugin kind, because a channel is long-lived, stateful, and interacts with the runtime through a 27-function surface of which only 5 are mandatory.
This guide assumes you have built the tool plugin
and understand crate setup, the __config rule, logging, and install. It is
checked against wit/v0/channel.wit and the host adapter in
crates/zeroclaw-plugins/src/wasm_channel.rs.
Wiring status. The host side of channel plugins is complete and unit-covered:
WasmChannelimplements the runtime’sChanneltrait, andPluginHost::channel_plugin_details()exposes discovered channel plugins. The remaining seam is orchestrator registration plus the per-vendor host listener; until that lands, a channel plugin loads and passes its contract tests but is not yet constructed by a running daemon. Build against the contract now; the contract is what freezes.
The lifecycle
A channel plugin’s runtime shape differs from a tool’s in three fundamental ways, and each drives a design decision in your code:
- One warm store for the plugin’s lifetime. The host instantiates your
component once (
WasmChannel::from_wasm) and holds the store behind an async mutex. Your component keeps state between calls: connection handles, caches, sequence counters. The store is refueled before every call (call_plugin!incomponent.rs), so a long-lived channel gets a fresh fuel budget per call rather than draining over its lifetime. - Configuration arrives before anything else. The host calls your
configureexport exactly once, at load, before any other call. The argument is a JSON object of your channel’s resolved settings, secrets already decrypted, supplied only when the manifest grantsconfig_read(otherwise you receive{}, perresolve_configure_jsoninwasm_channel.rs). Parse it, validate it, store it in your component’s state; return an error string to fail the load if the config is unusable. - You do not listen; the host feeds you. The WASI context has no network
listener capability. Inbound traffic reaches you through the imported
inboundinterface: the host runs the actual listener (webhook server, vendor tunnel, polling client), enqueues each received message onto anInboundQueue, and yourpoll-messageexport drains it by callinginbound-poll. Batch-drain withinbound-pendingif useful.
Required exports
Five functions have no Rust trait default and must genuinely work
(world channel-plugin doc, channel.wit):
| Export | Contract |
|---|---|
name | Human-readable channel name. |
configure | Receive the resolved config JSON once at load; error string fails the load. |
send | Deliver a send-message (content, recipient, optional subject/thread/attachments) to the platform. |
poll-message | Non-blocking: return the next inbound message or none immediately. Never block; the host’s poll bridge handles pacing. |
get-channel-capabilities | Return the bitmask of optional methods you actually implement. Called once at load. |
The poll bridge deserves a note: the host runs a poll-to-push loop
(listen in wasm_channel.rs) that calls poll-message with exponential
backoff from 50ms to 500ms while the queue is empty, resetting on traffic. If
your poll-message traps, the host marks the channel poll-unhealthy, logs,
and backs off; a plugin whose poll keeps trapping reports unhealthy through
health_check even if it exports no health-check of its own. Trapping in
poll-message is therefore visible, not fatal, but it makes your channel
useless. Keep it simple: drain the queue, translate, return.
Capability flags: the 22 optional methods
Everything else in the interface is gated by channel-capabilities flags.
The pattern (identical to the memory world):
- The host reads your flags once at load.
- For every unset flag, the host uses the Rust trait default and never calls your export.
- You must still export every function; a stub returning the documented default value compiles and is never called.
The flag-by-flag defaults are documented inline in channel.wit next to the
flags declaration, which is the source of truth. In summary, the groups:
| Group | Flags | What implementing buys you |
|---|---|---|
| Health | health-check | Report platform reachability; combined with poll health by the host adapter. |
| Identity | self-handle, self-addressed-mention, drop-self-message | Self-loop protection (the runtime drops the bot’s own messages) and correct @-mention forms in the per-channel system prompt. The host caches self-handle and self-addressed-mention at load; they are read once. |
| Typing | start-typing, stop-typing | Composing indicators while the agent thinks. |
| Drafts | supports-draft-updates, send-draft, update-draft, update-draft-progress, finalize-draft, cancel-draft | Progressive message editing: the runtime streams the response into an editable platform message instead of waiting for completion. Implement all six together or none. |
| Multi-message streaming | supports-multi-message-streaming, multi-message-delay-ms | Paragraph-by-paragraph delivery with a minimum inter-message delay (default 800ms, cached at load). |
| Moderation | add-reaction, remove-reaction, pin-message, unpin-message, redact-message | Emoji reactions, pinning, message deletion. |
| Interaction | request-approval, request-choice, supports-free-form-ask | Tool-call approval prompts and multiple-choice questions presented natively on the platform. |
Start with the required 5 plus health-check, and add groups as the
platform supports them. Advertising a flag you have not implemented is worse
than omitting it: the host will call your export and trust the answer.
The approval surface
request-approval is the deepest integration point. The runtime presents a
compact approval-request (tool name, arguments summary, optional raw JSON
arguments) and your channel renders it however the platform allows (buttons,
reactions, a reply convention). The approval-response variant you return
drives the security machinery:
approve: execute this one calldeny: refuse italways-approve: execute and add the tool to the session-scoped allowlistdeny-with-edit(string): refuse, but supply edited replacement arguments
Return none when the prompt cannot be presented; the caller falls back to
auto-deny. Fail closed.
Inbound message shape
Translate platform events into inbound-message records faithfully. The
runtime’s threading logic keys off the platform payload fields, while routing
identity comes only from the host-issued endpoint
(channel.wit, from_wit_inbound in wasm_channel.rs):
id,sender,content: the basics.reply-targetis where a response should go (channel ID, chat ID, email address).channelandchannel-aliasare legacy hints retained in the v0 record. The host ignores both for routing and stamps the admitted channel type and configured binding, so a plugin cannot select another owner or session.thread-tscarries the platform’s thread identifier for threaded replies;subjectexists for email threading.interruption-scope-idgroups messages for interruption/cancellation. Leave itnonefor top-level messages.attachmentscarry full raw bytes across the boundary (media-attachment: file name, bytes, optional MIME type). A voice note is several megabytes crossing by value; this is the documented cost of the 32-bit boundary, and a resource-handle model is explicitly deferred to a future WIT revision.
On the outbound side, send-message mirrors the same fields; the Rust
SendMessage’s cancellation token is deliberately omitted from the WIT
record because it is a host-side concept with no meaning inside the plugin.
Skeleton
The structure, omitting the per-platform translation that is your actual work:
#![allow(unused)]
fn main() {
#[cfg(target_family = "wasm")]
mod component {
wit_bindgen::generate!({
path: "wit/v0",
world: "channel-plugin",
features: ["plugins-wit-v0"],
});
use std::cell::RefCell;
use exports::zeroclaw::plugin::channel::{
ApprovalRequest, ApprovalResponse, ChannelCapabilities,
Guest as Channel, InboundMessage, SendMessage,
};
use exports::zeroclaw::plugin::plugin_info::Guest as PluginInfo;
use zeroclaw::plugin::inbound::inbound_poll;
struct State {
api_token: String,
default_recipient: Option<String>,
}
// One warm instance per plugin: interior mutability holds parsed config.
thread_local! {
static STATE: RefCell<Option<State>> = const { RefCell::new(None) };
}
struct MyChannel;
impl Channel for MyChannel {
fn name() -> String {
"my-platform".to_string()
}
fn configure(config: String) -> Result<(), String> {
let parsed: serde_json::Value = serde_json::from_str(&config)
.map_err(|e| format!("invalid config JSON: {e}"))?;
let token = parsed["api_token"]
.as_str()
.ok_or("api_token is required")?
.to_string();
STATE.with(|s| {
*s.borrow_mut() = Some(State {
api_token: token,
default_recipient: parsed["default_recipient"]
.as_str()
.map(str::to_string),
});
});
Ok(())
}
fn send(message: SendMessage) -> Result<(), String> {
// Outbound platform delivery via wasi:http
// (requires the http_client permission in the manifest).
// ...
Ok(())
}
fn poll_message() -> Option<InboundMessage> {
// Drain the host-fed queue and translate.
inbound_poll().map(translate_inbound)
}
fn get_channel_capabilities() -> ChannelCapabilities {
ChannelCapabilities::HEALTH_CHECK
}
fn health_check() -> bool {
STATE.with(|s| s.borrow().is_some())
}
// Every other method: a stub returning the WIT-documented default.
// The host never calls them while their flag is unset.
// ...
}
export!(MyChannel);
}
}
The thread_local + RefCell pattern is how a component holds state without
static mut: wasm components are single-threaded, so this is safe and idiom
for wit-bindgen guests.
Manifest and permissions
The manifest is the file named manifest.toml in the plugin directory. Its
fields are the serde surface of PluginManifest in
crates/zeroclaw-plugins/src/lib.rs, which is the source of truth:
| Field | Required | Meaning |
|---|---|---|
name | yes | Unique canonical package slug and operator config key. Use 1–128 lowercase ASCII characters; start and end with [a-z0-9], with only [a-z0-9._-] between. Discovery rejects invalid or duplicate names. |
version | yes | Version string, e.g. 0.1.0. |
description | no | Human-readable description shown by zeroclaw plugin list. |
author | no | Author name or organization. |
wasm_path | for WASM capabilities | Component file name, relative to the plugin directory. Required unless the only capability is skill. Discovery skips the plugin if the named file does not exist. |
capabilities | yes, non-empty | What the plugin is: any of tool, channel, memory, observer, skill (PluginCapability, serialized snake_case). |
permissions | no | Host services the code may reach: http_client, config_read, file_read, file_write, memory_read, memory_write (PluginPermission). Only the first two are enforced today; the rest are accepted but inert. |
signature | no | Base64url Ed25519 signature over the canonical manifest bytes. Set when signing for distribution. |
publisher_key | no | Hex-encoded Ed25519 public key of the signer. |
Declare only the permissions the code actually uses. An undeclared permission is a host surface the component cannot reach; an unnecessary declared one is attack surface you asked for and audit burden for whoever reviews your plugin.
For a channel: capabilities containing channel, and almost certainly both
config_read (no platform works without credentials) and http_client. The
channel adapter implements outbound wasi:http, but links it only after that
grant is validated; without both pieces, send has no network path to the
platform.
Build and install
Install the WASI Preview 2 target once, then build the component:
rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2
The component lands at target/wasm32-wasip2/release/<crate_name>.wasm
(hyphens in the crate name become underscores). Rename it to whatever your
manifest’s wasm_path declares when you assemble the plugin directory.
Important
Compiled
.wasmand.cwasmfiles are binary artifacts, often megabytes each. Do not check them into a git source tree without Git LFS: every rebuild committed as a plain blob bloats the repository history permanently, andgit diff/review tooling chokes on them. Treat them like any other build output: addtarget/and*.wasm/*.cwasmto.gitignore, and distribute through a release artifact or plugin registry archive instead. If an artifact truly must live in the tree, track the pattern with LFS (git lfs track "*.wasm") before the first commit.
If the target host is a runtime-only build (no JIT backend compiled in), it
cannot compile .wasm on load; it deserializes a precompiled .cwasm instead.
Precompile with a wasmtime CLI whose version matches the host’s and ship the
.cwasm as the wasm_path artifact. A version-mismatched artifact is rejected
by wasmtime’s deserialization check, not silently misloaded.
Each plugin lives in its own subdirectory of the plugins directory (default
~/.zeroclaw/plugins/, resolved through plugins.plugins_dir), holding the
manifest and the component named to match the manifest’s wasm_path:
~/.zeroclaw/plugins/
└── my-plugin/
├── manifest.toml
└── my-plugin.wasm
Install from a local directory (this validates the manifest shape and runs the signature policy before copying anything):
zeroclaw plugin install ./my-plugin/
Enable the plugin system and confirm discovery:
zeroclaw config set plugins.enabled true
zeroclaw plugin list
zeroclaw plugin info my-plugin
A plugin missing from zeroclaw plugin list was skipped at discovery: check
the startup log for the skip warning (malformed manifest, missing wasm_path
file, or signature policy rejection).
Testing against the host contract
The host adapter’s unit tests in wasm_channel.rs are the executable
specification: they cover the configure jail (a plugin without config_read
receives {}, never another channel’s secrets), the inbound queue handoff,
capability-gated dispatch, and poll-health accounting.
To run your own component under those exact semantics, write an integration
test that instantiates it through the real host adapter. zeroclaw-plugins
is not published to crates.io, so pull it as a git dev-dependency pinned to
the tag matching your target host:
cargo add --dev zeroclaw-plugins \
--git https://github.com/zeroclaw-labs/zeroclaw --tag <host-version> \
--no-default-features --features plugins-wasm-cranelift
The test then loads your built component through WasmChannel::from_wasm
with a test config, enqueues onto the InboundQueue handle it exposes, and
asserts your poll-message drains and translates the message. That is the
same code path a production daemon will run; passing it is the strongest
pre-distribution signal you can get without a live host.
Next
- Writing a memory plugin: the other warm-store world, with agent attribution semantics.
- Distributing plugins for signing and registry publication.