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 29-function surface of which only 6 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 logs it, backs off, and reports the channel unhealthy in the gateway’s /health snapshot, even if you export no health-check of your own. A trap leaves the instance unable to run any export, so the host discards it and the next call builds a new one, running configure again. The message the trapping poll had taken off the queue is lost; later messages still arrive. Any export that traps or misses the call deadline is replaced the same way, while an error string your export returns leaves the instance in place. A rebuild reconnects a gateway-style plugin, so the host budgets them: it rebuilds at once while failures average no more than one every five minutes, allowing a burst of three, and beyond that waits for the budget to refill, failing calls in the meantime without running your plugin. A missed deadline in request-approval or request-choice, which wait for a person to answer, does not count against the budget. An export that routinely needs longer than the deadline, such as a large upload, calls for a higher plugins.limits.call_timeout_ms. Keep it simple: drain the queue, translate, return.

Capability flags: the 23 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 whether the channel works. The host asks about every 30 seconds while listening and shows the answer, combined with poll health, in /health (see Health checks).
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.
Webhook ingresswebhook-ingressServe inbound webhooks: the gateway mounts your webhook-path segment under /plugin/<segment> and passes each request to parse-webhook, which verifies platform authenticity and returns messages or a challenge reply. Implement both exports together.

Start with the required 6 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.

Health checks

While the channel listens, the host calls health-check on the running instance between polls: right after the first poll, then about every 30 seconds. The answer becomes the channel’s component in the gateway’s /health snapshot, channel:plugin.<alias>:

  • starting until the first answer. A first answer of false also reads starting until the next check, so a plugin still completing its handshake is not reported as failed.
  • ok while the latest answer is true.
  • error when the latest answer is false, when the check traps or misses the call deadline, when poll-message traps, or when no answer has arrived for two intervals plus two call deadlines (two minutes with the default 30-second deadline).

zeroclaw channel doctor also calls health-check, once, on a freshly configured instance.

Because the check repeats, answer from state the plugin already holds: whether its connection is up, or how its last exchange with the platform went. A network request in health-check runs on every check and delays the next poll; if you verify credentials over the network, keep the last result and refresh it on your own schedule. A check that traps or misses the call deadline discards the instance, and the host rebuilds it with configure, so a gateway-style plugin loses its connection; after each such failure the host waits twice as long before the next check, up to ten minutes. The host also puts off a check while one more failure would make a rebuild wait, so a failed check never by itself makes one wait.

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.
wasm_sha256in strict mode for WASM capabilitiesHexadecimal SHA-256 of the exact component bytes. When present it is always enforced; strict signature policy requires it so the signed manifest binds the admitted executable.
capabilitiesyes, non-emptyWhat the plugin is: any of tool, channel, memory, observer, skill (PluginCapability, serialized snake_case).
providesnoFor a channel plugin that is a drop-in for a compiled-in channel: the snake_case config id it mirrors (telegram, gmail_push). The host admits one logical instance per configured and enabled [channels.<id>.<alias>] that an enabled agent routes to, so canonical channel config stays the single home for those settings. Requires config_read. The plugin family is reserved and refused, an id this build does not define is refused, and two installed packages claiming one id are both refused. Absent (the default) means a novel channel configured from [channels.plugin.<alias>]. Mirrors are admitted but not yet constructed; see the activation plan in crates/zeroclaw-runtime/src/plugin_runtime.rs.
permissionsnoHost services the code may reach: http_client, config_read, state_read, state_write, file_read, file_write, memory_read, memory_write (PluginPermission). HTTP, config, and exact-instance state permissions are enforced for tool/channel adapters; filesystem and memory permissions remain inert. Declaring config_read requires config_schema.
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. A secret property name is a 1–128 byte portable ASCII name containing only letters, digits, _, -, or .. 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.
egressnoOptional [egress] table carrying a hosts list: the destinations this plugin declares it needs, as exact hosts (api.example.com) or explicit suffix patterns (*.example.com, which matches subdomains but not the apex). It is part of the canonical manifest bytes, so it is covered when the manifest is signed and changing it requires re-signing. Invalid grammar rejects the whole manifest at discovery and at install. Absent, empty, and “declares nothing” are the same state.

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.

[egress] is a declaration, not a grant. Nothing in a manifest confers network reach: the allowlist the host enforces is the operator’s plugins.entries[].egress_hosts on the instance’s own zpi1_… row, so an unsigned component that writes its own [egress] table still reaches nothing. What the declaration buys is the install ceremony: zeroclaw plugin install seeds it into a row it creates and prints what it granted, and it never widens a row that already exists. See Declaring and granting egress.

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, and the grant alone still reaches nothing: the host owns the egress policy, which is deny-by-default. The operator must list the platform’s API hosts under plugins.entries.<key>.egress_hosts for that channel instance before send has a network path; any destination outside that list is refused before a packet leaves, and a granted host that resolves to a loopback, private, or link-local address is refused too unless it is also listed under egress_allow_private. Declare the platform’s API hosts in your manifest’s [egress] table. When an operator binds an instance with zeroclaw plugin bind <package> --channel-alias <alias> --egress declared, that declaration is seeded into the new instance’s egress_hosts and printed; --egress none binds it with no network reach instead. For a channel instance the declaration counts with any governed transport, http_client, websocket_client, or socket_client, so a channel that speaks only over a socket is seeded the same way. A host that is deployment configuration, such as a self-hosted server, is not yours to declare: say in your README that the operator grants it.

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 key exists once an operator binds an alias: zeroclaw plugin bind creates that instance’s entry, and both it and zeroclaw plugin info list each of this schema’s required keys as set or missing, with the zeroclaw config set command for a missing one. A secret key’s command prompts for the value without echo, and no value is ever printed. The command is printed only for a property name in the portable key grammar (1 to 128 ASCII letters, digits, _, -, or .); any other name is reported with the config file row to edit instead, so keep required property names inside it. When the entry does not exist yet, as for a binding written by hand, plugin info prints the plugin bind command that creates it in place of the keys, once per egress decision when your manifest declares destinations, since config set resolves only an entry that exists.

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. The operator path is zeroclaw plugin bind, which writes the binding, seeds the instance’s config entry, and reports what the instance still needs before it can start:

zeroclaw plugin bind acme.chat --channel-alias operations --egress declared

--egress declared grants the destinations the manifest declares, and --egress none binds with no network reach. The flag matters only when the command creates the instance’s config entry, and it is required then if the manifest declares destinations and the package holds a transport that can reach them (http_client, websocket_client, or socket_client). zeroclaw plugin install <source> --channel-alias operations runs the same ceremony during installation; there the flag governs only the channel instance’s entry, and a tool entry of the same package is seeded as install always seeds it. Binding a channel instance describes it in full.

In config.toml, an instance that is bound and routed to an agent looks like this:

[plugins]
enabled = true

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

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

plugin bind writes only the [channels.plugin.operations] table and the instance’s [[plugins.entries]] row. A new binding is written with package and the default enabled = true. Two steps stay explicit operator decisions that the command never makes: turning the plugin system on (plugins.enabled), and ownership, adding plugin.operations to an enabled agent’s channels list (agents.<agent>.channels). The readiness report the command prints, which zeroclaw plugin info repeats, names the first precondition still unmet and, where one exists, the command that meets it.

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.

zeroclaw plugin bind and zeroclaw plugin info ask the activation plan for this verdict rather than restating its rules, so the first unmet gate they report, the ceiling included, is the one the daemon applies to the same config and installed packages. A ready verdict means the instance is admitted, not that its component loads; plugin info reports that separately with its load check.

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 release archives for the 64-bit desktop and server targets (x86_64 and aarch64 Linux with glibc or musl, macOS on Apple silicon and Intel, Windows x86_64) carry it, and every release executes a plugin from each of those archives before it publishes. The two 32-bit ARM archives and the experimental Android archive do not carry it, and neither does a source build with Cargo defaults (cargo install zeroclaw, install.sh --source, or cargo build without features): there zeroclaw plugin ... is an unrecognized subcommand and installed plugins are never discovered. For those, build from source with a plugin execution backend, e.g. cargo build --release --features plugins-wasm-cranelift. The protocol page lists plugin support for every artifact.

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