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. Channel plugins are constructed by a running daemon. An installed package bound through [channels.plugin.<alias>] is admitted at startup and supervised exactly like a native channel. See Activating a channel plugin below.

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. The component may keep guest-owned protocol state between calls, but operator config remains host-owned. A compliant plugin must call config.get and secrets.get in every operation that needs them and must not copy their results into warm guest state. The host drops its materialized view after each call, but it cannot stop malicious guest code from retaining returned JSON or plaintext. The store is refueled before every call (call_channel! in component.rs), so a long-lived channel gets a fresh fuel budget per call rather than draining over its lifetime.
  2. Configuration is requested at point of use. The host calls your no-argument configure export exactly once, at load, before any other export. Call config.get for the typed public JSON object validated against your manifest’s config_schema; properties marked x-secret = true are omitted and must be read through secrets.get. Public and secret reads in configure, or in any later operational export, share one resolved config revision. A same-binding public config plus credential rotation is therefore visible together on the next operation. Calls during instantiation and static discovery return unavailable without resolving config. Static discovery includes name, plugin-info, get-channel-capabilities, self-handle, self-addressed-mention, and multi-message-delay-ms; changing bot/account identity or other static metadata requires channel lifecycle reconstruction.
  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.
configureComplete load-time initialization. It takes no arguments; call config.get and secrets.get for one current revision. An 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 exports::zeroclaw::plugin::channel::{
        ApprovalRequest, ApprovalResponse, ChannelCapabilities,
        Guest as Channel, InboundMessage, SendMessage,
    };
    use exports::zeroclaw::plugin::plugin_info::Guest as PluginInfo;
    use zeroclaw::plugin::config::get as config_get;
    use zeroclaw::plugin::inbound::inbound_poll;
    use zeroclaw::plugin::secrets::get as secret_get;

    #[derive(serde::Deserialize)]
    #[serde(deny_unknown_fields)]
    struct ChannelConfig {
        api_base: String,
    }

    fn current_config() -> Result<ChannelConfig, String> {
        let json = config_get().map_err(|_| "public config is unavailable".to_string())?;
        serde_json::from_str(&json).map_err(|e| format!("invalid config JSON: {e}"))
    }

    fn current_api_token() -> Result<String, String> {
        secret_get("api_token").map_err(|_| "api_token is unavailable".to_string())
    }

    fn current_inputs() -> Result<(ChannelConfig, String), String> {
        // Both imports in this export share one resolved canonical revision.
        Ok((current_config()?, current_api_token()?))
    }

    struct MyChannel;

    impl Channel for MyChannel {
        fn name() -> String {
            "my-platform".to_string()
        }

        fn configure() -> Result<(), String> {
            let (config, api_token) = current_inputs()?;
            validate_configuration(&config.api_base, &api_token)
        }

        fn send(message: SendMessage) -> Result<(), String> {
            let (config, api_token) = current_inputs()?;
            // Outbound platform delivery via wasi:http
            // (requires the http_client permission in the manifest). Build the
            // request from this call's values; never retain a second copy.
            send_to_platform(&config.api_base, &api_token, message)
        }

        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 {
            current_inputs().is_ok()
        }

        // Every other method: a stub returning the WIT-documented default.
        // The host never calls them while their flag is unset.
        // ...
    }

    export!(MyChannel);
}
}

current_inputs is deliberately called at point of use. The host binds both imports to this admitted package, channel capability, and alias; reads in one export share one resolved config revision, while the next export can observe a same-binding public config plus credential rotation. ChannelConfig is a per-call typed view and is dropped with the token. Do not add a thread_local config or credential cache.

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 the package component of each derived instance config key. It is not itself an 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. Declaring config_read requires config_schema, and only tool/channel adapters currently deliver it.
config_schemaexactly with config_readDraft 2020-12 JSON Schema for this plugin’s private config; it is included in the canonical manifest bytes and therefore covered when the manifest is signed. The root must be an object with a properties map and additionalProperties = false. Every top-level property must have one explicit supported type, directly or through a local JSON Pointer: string, boolean, integer, number, array, or object. Tool and channel consumers may set x-secret = true directly on a top-level string property to remove it from public config and expose it through the scoped secrets.get host import. Tools receive public config under __config and may read secrets during execute. Channels read the current public object through config.get and secrets through secrets.get during configure and operational calls; both imports are unavailable during instantiation and static metadata discovery. Nested, false, or non-boolean secret markers and secret non-string properties are rejected. A schema without config_read, or config_read without a schema, is rejected.
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.

Operator values remain strings in plugins.entries and are encrypted when persisted, keyed by a versioned zpi1_… string derived from the host-owned package, capability, and binding identity (installation prints and seeds the default tool binding’s full-instance key): strings are stored as-is, booleans and numbers use JSON scalar text, and arrays and objects use JSON text. Before any guest code runs, the host materializes those strings to the package schema’s types and validates the complete object for tool and channel adapters. Non-secret tool properties form __config; a channel obtains the non-secret object through config.get. A property marked x-secret = true is omitted from both public surfaces and is available only through secrets.get("property") in an authorized service frame. A channel’s public and secret reads within one call share one canonical revision, and the host drops that materialized view when the call ends. A compliant channel plugin must resolve both at each point of use and must not retain config or credential values in warm guest state; returning plaintext to the guest means the host cannot enforce non-retention against malicious code. If config_read was requested but not effectively granted, the host validates an empty object; therefore a schema with required properties fails closed instead of starting without required configuration. If the empty object is valid, a tool omits empty __config and channel config/secret imports return access-denied; calls outside an authorized frame, resolution failure, and host-call budget exhaustion return unavailable.

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.

Pair config_read with the schema consumed by ChannelConfig:

name = "my-platform"
version = "0.1.0"
wasm_path = "my_platform.wasm"
capabilities = ["channel"]
permissions = ["config_read", "http_client"]

[config_schema]
"$schema" = "https://json-schema.org/draft/2020-12/schema"
type = "object"
additionalProperties = false
required = ["api_base", "api_token"]

[config_schema.properties.api_base]
type = "string"
minLength = 1

[config_schema.properties.api_token]
type = "string"
minLength = 1
x-secret = true

The host validates both properties as one object. config.get returns typed JSON containing api_base and omits api_token, which is available only through secrets.get. Because both are required, withholding config_read fails closed before guest code runs instead of starting a channel without required config. Each channel instance selects the plugins.entries key derived from its full package, channel capability, and binding identity while reusing this one package-owned schema. Identical aliases in different packages therefore remain isolated. The install and info commands cannot create this key because they do not own the configured channel alias; their automatic print and seed behavior is tool-only, so a channel instance’s entry is written by hand.

Call config.get and secrets.get inside each operation that uses them. The host resolves at most one canonical revision for that call and drops its view afterward. A public config plus credential rotation within the same logical binding is visible together on the next operation without daemon reload or channel reconstruction. Changing the bot/account identity, advertised capabilities, self-handle, mention, or other load-time metadata requires channel lifecycle reconstruction because those exports are read once during static discovery.

For an optional schema whose empty object is valid, an instance denied the effective config_read grant can load, but config.get and secrets.get return access-denied. Either import returns unavailable during instantiation or static discovery, after resolver/validation failure, or when the shared host-call budget is exhausted. secrets.get additionally returns not-found for a name that is absent or not marked x-secret = true.

Activating a channel plugin

An installed package does nothing until an operator binds it to a logical channel instance. The binding names the package and nothing else; the alias is the instance’s identity:

[plugins]
enabled = true

[channels.plugin.operations]
package = "acme.chat"
enabled = true

[agents.support]
channels = ["plugin.operations"]

The alias becomes an ordinary channel reference, so plugin.operations is routed, supervised, restarted, and addressed exactly like telegram.main. Two aliases may name one package; each gets its own instance, its own store, and its own plugins.entries key, so they share no state.

An instance is admitted only when all of the following hold. Each is a deliberate fail-closed gate, and a declaration that misses one is inert rather than half-started:

  • plugins.enabled is true.
  • The declaration’s enabled is true.
  • The named package is installed and its manifest declares the channel capability.
  • Some enabled agent lists plugin.<alias> in its channels. An unreferenced binding would run a listener with nowhere to deliver.

Admission happens before any guest code runs: it is decided from manifests the package host already verified, so a package whose component is corrupt is planned and rejected identically to one that is sound. A package that passes admission but then fails to construct is logged and skipped, so one broken plugin cannot stop the daemon from starting your other channels.

plugins.max_active_instances caps how many logical instances are admitted across all capabilities. Explicit channel bindings rank ahead of auto-discovered tools and skills, so a full plugin directory cannot displace a channel the operator configured by hand.

The same admitted set drives all three loaders: the channel loader, the tool registry, and the plugin-skill loader. The ceiling is therefore one shared budget rather than a per-capability one. A package that provides both a channel and a tool really does spend two slots, and a tool or skill over the ceiling is not constructed at all. Admission is a pure function of your current config and installed packages: it holds no counter, so the tool registries rebuilt per agent, per CLI run, per delegate, and per SOP execution each re-derive the same set instead of exhausting the ceiling over a long-running daemon’s lifetime.

Tool and skill instances are auto-discovered, so they are admitted only when plugins.auto_discover is true. Explicit [channels.plugin.<alias>] declarations do not need it. With plugins.enabled = true and auto_discover = false, you get exactly the channel bindings you declared and nothing else.

Migrating from plugins.max_plugins. The old key was never enforced and has been replaced by plugins.max_active_instances. The two count different things: the old key counted installed packages, the new one counts admitted logical instances, so a package providing both a channel and a tool consumes two. Because the units differ, an existing max_plugins value is not carried over: it is ignored, and the new key takes its default. Set max_active_instances explicitly if you relied on a non-default ceiling.

What is not wired yet

Plugin channels are constructed asynchronously, after the synchronous channel-map surfaces have already been built. Channel-addressed tools therefore cannot target a plugin channel yet. Inbound polling and outbound delivery through the supervised listener are unaffected; only tool-side addressing is missing.

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.

These commands need a binary with the plugin host compiled in. The prebuilt release binaries the installer ships are built without the plugins-wasm feature, so zeroclaw plugin ... is an unrecognized subcommand there and installed plugins are never discovered. Build from source with a plugin execution backend, e.g. cargo build --release --features plugins-wasm-cranelift.

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

zeroclaw plugin list and zeroclaw plugin info confirm a package is installed and discoverable, but discovery is not activation. plugins.enabled = true turns the plugin host on; auto-discovered tool and skill capabilities load at runtime only when plugins.auto_discover = true as well, and that flag is false by default (fail-closed):

zeroclaw config set plugins.auto_discover true

So plugins.enabled = true on its own gives you the channels you declare under [channels.plugin.<alias>] and no plugin tools or skills: a tool or skill package can appear in zeroclaw plugin list yet contribute nothing at runtime. Explicit channel bindings are operator-named rather than auto-discovered, so they do not need auto_discover; the flag gates only auto-discovered tools and skills.

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 and config resolver tests are the executable specification: they cover typed materialization and schema validation, point-of-use public and secret scope, coherent same-revision rotation, denied grants, static-discovery denial, 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 wraps a PluginConfigResolver::new backed by the manifest and test operator values in PluginHostServices, loads your component through WasmChannel::from_wasm, 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