docs/book/src/plugins/index.md
ZeroClaw's plugin system lets you add capabilities to the agent without touching the core binary. This page explains the technology decision: what a plugin is made of, why it is WebAssembly, and how the host keeps an untrusted component contained. The guides below it walk through building each kind of plugin, getting more technical as you go down.
Markdown-only skill bundles are not plugins, but they travel through the same manifest, signing, and install machinery; that page lives with the Skills documentation.
For the operator's view of discovery, signature policy, and configuration, see How plugins work. For the normative contract reference, see Plugin protocol.
A plugin runs arbitrary third-party code inside a process that holds your API
keys, your conversation history, and shell access. The isolation boundary has
to be real, not advisory. ZeroClaw uses the WASI Component Model on wasmtime
because it gives four properties no dynamic-library or subprocess scheme
matches at once:
PluginState in
crates/zeroclaw-plugins/src/component.rs). What a plugin can reach is
exactly the set of host imports its world declares plus whatever its
manifest permissions add, and nothing else.plugins.limits.*) and are validated non-zero, and a store cannot be
constructed without them, so no load path can produce an unsandboxed
plugin.wit/v0/ in the ZeroClaw repository), not a
Rust API. The host generates its bindings from those files with wasmtime's
bindgen!; a plugin generates the mirror-image guest bindings with
wit-bindgen in Rust or the equivalent tooling in any language that
compiles to a wasm32-wasip2 component. Records, variants, results, and
option types cross the boundary with their types intact.Tool (wasm_tool.rs), a channel plugin a Channel
(wasm_channel.rs), a memory plugin a Memory (wasm_memory.rs). The
agent loop, attribution, receipts, and security policy see no difference.A plugin on disk is a directory holding a manifest and a compiled component:
~/.zeroclaw/plugins/
└── my-plugin/
├── manifest.toml # identity, capabilities, permissions, signature
└── my-plugin.wasm # wasm32-wasip2 component
The manifest declares two orthogonal things:
tool, channel,
memory, observer, skill (the PluginCapability enum in
crates/zeroclaw-plugins/src/lib.rs). Each WASM capability selects the WIT
world the component must export. The skill capability is the odd one out:
it marks a markdown skill bundle riding the
install machinery, not code, and needs no component.PluginPermission enum in the same file. Today config_read (the plugin
receives its own resolved config section) and http_client (outbound
wasi:http is wired into its store and linker) are enforced; the
filesystem and memory-access permissions are accepted by the schema but not
yet backed by host functions, so declaring them grants nothing.wit/v0/ defines one world per WASM capability. Every world imports the host
logging interface, whose log-record events land in the structured log
carrying the span attribution
of the host call site, and exports plugin-info (self-reported name and
version) plus its primary interface:
| World | Exports | Store lifecycle |
|---|---|---|
tool-plugin | tool: name, description, parameters-schema, execute | Fresh store per execute; nothing persists between calls |
channel-plugin | channel: configure, send, poll-message, plus {{#include ../_snippets/plugin-channel-flag-count.md}} capability-gated methods | Warm store behind an async mutex, refueled per call; also imports inbound |
memory-plugin | memory: store, recall, get, forget, plus {{#include ../_snippets/plugin-memory-flag-count.md}} capability-gated methods | Warm store behind an async mutex, refueled per call |
The channel and memory worlds use capability flags: a bitmask the host
reads once at load time (get-channel-capabilities /
get-memory-capabilities). For every unset flag the host uses the Rust trait
default and never calls the plugin's export. This is how the WIT contract
stays additive: a new optional method is a new flag plus a new function, never
a break.
The host (crates/zeroclaw-plugins/src/component.rs) owns one async
wasmtime::Engine for the process. Loading is backend-dependent: a build with
the Cranelift JIT compiles .wasm on load; a runtime-only build deserializes
a precompiled .cwasm. Each plugin instantiation gets:
Store carrying the sandboxed WASI context, the resource table, the
optional HTTP context, and the fuel budget;Linker with exactly the imports its world and permissions call for:
logging always, inbound for channels, wasi:http only when the manifest
grants http_client. The HTTP context and the linked interface are derived
from the same permission set and cross-checked at instantiation
(ensure_http_coherent), so they cannot disagree.Tool calls are stateless by construction: WasmTool::execute builds a fresh
store, runs the call, and drops it. Channels and memory backends are stateful
by nature, so they hold one warm store for the plugin's lifetime; the host
refuels it before every call so a long-lived plugin gets a full budget per
call rather than draining over time.
The boundary is 32-bit: wasm32-wasip2 is the only WASI Preview 2 target the
Rust toolchain ships, and the component ABI lowers offsets as 32-bit
regardless of host word size. Large values (a channel attachment's bytes)
cross by value. See the
protocol page
for why this is an upstream constraint.
Be aware of what is registered end to end versus what is host-complete but not yet reachable from a running daemon:
| Capability | Host adapter | Runtime wiring |
|---|---|---|
tool | WasmTool | Registered end to end; discovered tool plugins appear in the agent's tool set |
skill | markdown loader | Registered end to end; skills load namespaced as plugin:<plugin>/<skill> |
channel | WasmChannel, complete and unit-covered | Orchestrator registration and the per-vendor host listener are the remaining seam |
memory | WasmMemory, implements the full Memory trait | The runtime does not yet construct it as a configurable backend |
observer | none | PluginCapability::Observer is reserved; no WIT world or adapter exists yet |
The plugin system is configured through the same schema mirror as everything
else, via zerocode, the gateway, or the CLI. Prefer these surfaces over
hand-editing: a syntax slip in a hand-edited section (for example
[plugins.entries] where [[plugins.entries]] is meant) currently makes the
whole [plugins] section fail deserialization and silently fall back to
defaults, which reads back as plugins.enabled = false with no warning
(tracked in issue #8636). The common operations:
# turn the system on
zeroclaw config set plugins.enabled true
# where plugins are discovered (default: ~/.zeroclaw/plugins)
zeroclaw config set plugins.plugins_dir /srv/zeroclaw/plugins
# signature policy: disabled | permissive | strict
zeroclaw config set plugins.security.signature_mode strict
# per-call sandbox limits
zeroclaw config set plugins.limits.call_fuel 1000000000
zeroclaw config set plugins.limits.max_memory_mb 256
Per-plugin settings live under plugins.entries, keyed by plugin name; each
entry carries a secret-marked key-value map that is what a config_read
plugin receives at call time. One known seam: config set routes list paths
by natural keys already present in live config, and plugin install does not
yet seed an entry, so the first write to a fresh plugin's entry fails
with Unknown property and currently requires adding the entry to the config
file by hand (tracked in issue #8636); once the entry exists, every surface
reads and writes it normally. Values written through the CLI are stored
encrypted (enc2:…) under the secret marking; hand-written plaintext values
are also accepted at load. The canonical field list and defaults are in the
Config reference; zeroclaw config list shows the
live values.
The sandbox bounds what a loaded plugin can do; the signature policy bounds what loads at all. Both are operator decisions, and they compose:
plugins.enabled false (the default): no plugin code runs, ever.strict: only components whose manifest carries a valid Ed25519
signature from a key in your trusted set load.What the sandbox does not bound is the semantic behavior of a tool the
model chooses to call: a granted http_client plugin can send whatever the
model passes it to wherever its code decides. Signature policy exists because
"which code do I load" is the decision that matters most; make it
deliberately.