Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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: WasmChannel implements the runtime’s Channel trait, and PluginHost::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:

  1. 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! in component.rs), so a long-lived channel gets a fresh fuel budget per call rather than draining over its lifetime.
  2. Configuration arrives before anything else. The host calls your configure export 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 grants config_read (otherwise you receive {}, per resolve_configure_json in wasm_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.
  3. You do not listen; the host feeds you. The WASI context has no network listener capability. Inbound traffic reaches you through the imported inbound interface: the host runs the actual listener (webhook server, vendor tunnel, polling client), enqueues each received message onto an InboundQueue, and your poll-message export drains it by calling inbound-poll. Batch-drain with inbound-pending if useful.

Required exports

Five functions have no Rust trait default and must genuinely work (world channel-plugin doc, channel.wit):

ExportContract
nameHuman-readable channel name.
configureReceive the resolved config JSON once at load; error string fails the load.
sendDeliver a send-message (content, recipient, optional subject/thread/attachments) to the platform.
poll-messageNon-blocking: return the next inbound message or none immediately. Never block; the host’s poll bridge handles pacing.
get-channel-capabilitiesReturn 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:

GroupFlagsWhat implementing buys you
Healthhealth-checkReport platform reachability; combined with poll health by the host adapter.
Identityself-handle, self-addressed-mention, drop-self-messageSelf-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.
Typingstart-typing, stop-typingComposing indicators while the agent thinks.
Draftssupports-draft-updates, send-draft, update-draft, update-draft-progress, finalize-draft, cancel-draftProgressive 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 streamingsupports-multi-message-streaming, multi-message-delay-msParagraph-by-paragraph delivery with a minimum inter-message delay (default 800ms, cached at load).
Moderationadd-reaction, remove-reaction, pin-message, unpin-message, redact-messageEmoji reactions, pinning, message deletion.
Interactionrequest-approval, request-choice, supports-free-form-askTool-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 call
  • deny: refuse it
  • always-approve: execute and add the tool to the session-scoped allowlist
  • deny-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-target is where a response should go (channel ID, chat ID, email address).
  • channel and channel-alias are 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-ts carries the platform’s thread identifier for threaded replies; subject exists for email threading.
  • interruption-scope-id groups messages for interruption/cancellation. Leave it none for top-level messages.
  • attachments carry 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:

FieldRequiredMeaning
nameyesUnique 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.
versionyesVersion string, e.g. 0.1.0.
descriptionnoHuman-readable description shown by zeroclaw plugin list.
authornoAuthor name or organization.
wasm_pathfor WASM capabilitiesComponent 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.
capabilitiesyes, non-emptyWhat the plugin is: any of tool, channel, memory, observer, skill (PluginCapability, serialized snake_case).
permissionsnoHost 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.
signaturenoBase64url Ed25519 signature over the canonical manifest bytes. Set when signing for distribution.
publisher_keynoHex-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 .wasm and .cwasm files 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, and git diff/review tooling chokes on them. Treat them like any other build output: add target/ and *.wasm/*.cwasm to .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