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:
- 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 callconfig.getandsecrets.getin 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!incomponent.rs), so a long-lived channel gets a fresh fuel budget per call rather than draining over its lifetime. - Configuration is requested at point of use. The host calls your
no-argument
configureexport exactly once, at load, before any other export. Callconfig.getfor the typed public JSON object validated against your manifest’sconfig_schema; properties markedx-secret = trueare omitted and must be read throughsecrets.get. Public and secret reads inconfigure, 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 returnunavailablewithout resolving config. Static discovery includesname,plugin-info,get-channel-capabilities,self-handle,self-addressed-mention, andmulti-message-delay-ms; changing bot/account identity or other static metadata requires channel lifecycle reconstruction. - 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 | Complete load-time initialization. It takes no arguments; call config.get and secrets.get for one current revision. An 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 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:
| Group | Flags | What implementing buys you |
|---|---|---|
| Health | health-check | Report 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). |
| 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. |
| Webhook ingress | webhook-ingress | Serve 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>:
startinguntil the first answer. A first answer offalsealso readsstartinguntil the next check, so a plugin still completing its handshake is not reported as failed.okwhile the latest answer istrue.errorwhen the latest answer isfalse, when the check traps or misses the call deadline, whenpoll-messagetraps, 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 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 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:
| Field | Required | Meaning |
|---|---|---|
name | yes | Unique 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. |
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. |
wasm_sha256 | in strict mode for WASM capabilities | Hexadecimal 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. |
capabilities | yes, non-empty | What the plugin is: any of tool, channel, memory, observer, skill (PluginCapability, serialized snake_case). |
provides | no | For 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. |
permissions | no | Host 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_schema | exactly with config_read | Draft 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. |
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. |
egress | no | Optional [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.enabledis true.- The declaration’s
enabledis true. - The named package is installed and its manifest declares the
channelcapability. - Some enabled agent lists
plugin.<alias>in itschannels. 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 byplugins.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 existingmax_pluginsvalue is not carried over: it is ignored, and the new key takes its default. Setmax_active_instancesexplicitly 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
.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.
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, orcargo buildwithout features): therezeroclaw 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
- Writing a memory plugin: the other warm-store world, with agent attribution semantics.
- Distributing plugins for signing and registry publication.