Back to Zeroclaw

Plugins

docs/book/src/plugins/index.md

0.8.310.7 KB
Original Source

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.

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:

  1. 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 (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.
  2. 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.
  3. 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'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.
  4. 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 a Channel (wasm_channel.rs), a memory plugin a Memory (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:

text
~/.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 (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.
  • Permissions: what host services the plugin's code may reach. The 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.

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:

WorldExportsStore lifecycle
tool-plugintool: name, description, parameters-schema, executeFresh store per execute; nothing persists between calls
channel-pluginchannel: configure, send, poll-message, plus {{#include ../_snippets/plugin-channel-flag-count.md}} capability-gated methodsWarm store behind an async mutex, refueled per call; also imports inbound
memory-pluginmemory: store, recall, get, forget, plus {{#include ../_snippets/plugin-memory-flag-count.md}} capability-gated methodsWarm 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 Store carrying the sandboxed WASI context, the resource table, the optional HTTP context, and the fuel budget;
  • a 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.

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:

CapabilityHost adapterRuntime wiring
toolWasmToolRegistered end to end; discovered tool plugins appear in the agent's tool set
skillmarkdown loaderRegistered end to end; skills load namespaced as plugin:<plugin>/<skill>
channelWasmChannel, complete and unit-coveredOrchestrator registration and the per-vendor host listener are the remaining seam
memoryWasmMemory, implements the full Memory traitThe runtime does not yet construct it as a configurable backend
observernonePluginCapability::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:

bash
# 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.enabled false (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 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.