Plugins
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.
- Writing a tool plugin: a callable tool the model can invoke. Start here; it is the complete worked path from empty crate to installed tool.
- Writing a channel plugin: a messaging platform integration with the full capability-flag surface.
- Writing a memory plugin: a storage backend implementing agent-attributed recall.
- Distributing plugins: signing, registries, and install security.
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.
Why WebAssembly
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:
- Capability-based sandboxing. A WebAssembly component has no ambient
authority. It cannot open files, sockets, or environment variables unless
the host explicitly wires that capability into its linker. ZeroClaw’s host
builds every plugin store with a WASI context that has no filesystem
preopens and no network (
PluginStateincrates/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. - Metered execution. The engine is built with fuel metering enabled, and
every call gets a fresh fuel budget. A plugin that loops forever traps; it
cannot hang the agent. Memory, table, and instance ceilings are enforced by
a store limiter. All four bounds come from operator config
(
plugins.limits.*) and are validated non-zero, and a store cannot be constructed without them, so no load path can produce an unsandboxed plugin. - A typed, language-agnostic ABI. The contract between host and plugin is
a set of WIT interface files (
wit/v0/in the ZeroClaw repository), not a Rust API. The host generates its bindings from those files with wasmtime’sbindgen!; a plugin generates the mirror-image guest bindings withwit-bindgenin Rust or the equivalent tooling in any language that compiles to awasm32-wasip2component. Records, variants, results, and option types cross the boundary with their types intact. - Behavior identical to built-ins. Each plugin kind is adapted onto the
same Rust trait the first-party implementations use: a tool plugin becomes
a
Tool(wasm_tool.rs), a channel plugin aChannel(wasm_channel.rs), a memory plugin aMemory(wasm_memory.rs). The agent loop, attribution, receipts, and security policy see no difference.
The pieces
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:
- Capabilities: what the plugin is. One or more of
tool,channel,memory,observer,skill(thePluginCapabilityenum incrates/zeroclaw-plugins/src/lib.rs). Each WASM capability selects the WIT world the component must export. Theskillcapability is the odd one out: it marks a markdown skill bundle riding the install machinery, not code, and needs no component. - Permissions: what host services the plugin’s code may reach. The
PluginPermissionenum in the same file. Todayconfig_readis enforced, andhttp_clientis the necessary grant for adapters that implement outboundwasi:http. Tool and channel adapters enable that surface; memory intentionally does not yet. The filesystem and memory-access permissions are accepted by the schema but not yet backed by host functions, so declaring them grants nothing.
The worlds
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 22 capability-gated methods | Warm store behind an async mutex, refueled per call; also imports inbound |
memory-plugin | memory: store, recall, get, forget, plus 11 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.
Execution model
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:
- a
Storecarrying the sandboxed WASI context, the resource table, the optional HTTP context, and the fuel budget; - a
Linkerwith exactly the imports its world, grants, and adapter support call for:loggingalways,inboundfor channels, andwasi:httpfor tool and channel adapters only when the manifest grantshttp_client. Memory creates neither an HTTP context nor an HTTP linker. Each adapter cross-checks its context and linker at instantiation (ensure_http_coherent).
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.
Current wiring status
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 |
Configuration
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.
Where the trust boundary actually is
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.enabledfalse (the default): no plugin code runs, ever.- Signature
strict: only components whose manifest carries a valid Ed25519 signature from a key in your trusted set load. - Loaded plugin: bounded by fuel, memory ceilings, no-preopen WASI, and the permission-gated import set.
What the sandbox does not bound is the semantic behavior of a tool the model
chooses to call: a tool with the http_client grant and the tool adapter’s HTTP
surface 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.