Writing a Tool Plugin
This is the entry-level guide of the series: a complete worked path from empty
crate to a tool the model calls in conversation. The tool built here is
redact, which masks emails, known credential prefixes, and operator-supplied
patterns in text. It is deliberately config-driven, because reading your own
jailed config section is the thing every non-trivial plugin needs and the
thing easiest to get wrong.
Everything on this page is checked against the contract source: the
tool-plugin world in wit/v0/tool.wit, the host-side call path in
crates/zeroclaw-plugins/src/runtime.rs and wasm_tool.rs, and manifest
validation in host.rs. Source paths are citations into the ZeroClaw
repository for verification; the plugin itself is your own crate in your own
repository. You never need a ZeroClaw checkout to build one, only the wit/
contract files (fetched in step 1) and an installed zeroclaw binary with
the plugin host compiled in to run it.
The release binary is not that binary. The prebuilt binaries the installer ships do not include the plugin host (
zeroclaw plugin …is an unrecognized subcommand), andplugins-wasmis not in the crate’s default feature set. Build the host side from source with an execution backend; every backend feature carries theplugins-wasmumbrella itself, so one flag is enough:cargo build --release --features plugins-wasm-craneliftThe protocol page documents the backend choices.
How a tool call flows
Understand the runtime shape before writing code:
- At startup, discovery finds your plugin directory, validates the manifest
shape, runs signature policy, and then validates
config_schema. Before registration, the host materializes the plugin’s operator values to typed JSON and validates them. Survivors becomeWasmToolinstances. - At registration, the host instantiates the component once to read
name,description, andparameters-schema. These are cached; they are never re-asked. If that probe fails, registration fails; the host never substitutes synthetic metadata for a broken component. - Per call,
WasmTool::executeresolves and validates config from canonical state, creates a fresh store (new WASI context, new fuel budget, no state from the previous call), and instantiates the component. That one resolved object serves the whole frame: the host injects only its non-secret values under__config, serves the schema-marked secrets through the scopedsecretsimport, and invokesexecute.
The fresh-store-per-call model is the design constraint that matters most: a tool plugin is stateless by construction. Anything you want to persist between calls has to live outside the plugin (in the text you return, or in operator config).
1. Crate setup
Create the crate and add the guest-side dependencies:
cargo new --lib my-plugin
cd my-plugin
cargo add wit-bindgen@0.46
cargo add serde --features derive
cargo add serde_json
Then make two manual edits to the package manifest:
- Set the library
crate-typeto["cdylib", "rlib"].cdylibis what the component build produces;rliblets the same crate’s pure-logic modules compile and unit-test natively on the host. - In the release profile, set
opt-level = "s",lto = true, andstrip = true. Component size is download and load time; there is no reason to ship debug symbols across the plugin boundary.
Copy the wit/v0/ directory from the ZeroClaw repository into the crate root
as wit/. You do not need a full checkout; fetch just that directory from the
tag matching your target host version:
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/zeroclaw-labs/zeroclaw /tmp/zeroclaw-wit
git -C /tmp/zeroclaw-wit sparse-checkout set wit
cp -r /tmp/zeroclaw-wit/wit .
The WIT files are the ABI: the host generated its bindings from these exact files, so your guest bindings must come from the same ones. Pin the version: WIT worlds evolve with the host, and a component built against newer worlds than the host binds will fail to instantiate.
2. Split logic from glue
Put the actual behavior in a plain Rust module with no wit-bindgen imports,
and keep the component glue thin. The reason is testability: the component
target cannot run cargo test natively, so logic trapped in the glue is logic
you can only verify end to end through a wasm host. The glue should be too
thin to be wrong.
src/redact.rs holds a config struct and a pure function:
#![allow(unused)]
fn main() {
pub const DEFAULT_REPLACEMENT: &str = "[REDACTED]";
/// Redaction policy resolved from the plugin's own config section.
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RedactConfig {
pub replacement: String,
pub redact_emails: bool,
pub patterns: Vec<String>,
}
impl Default for RedactConfig {
fn default() -> Self {
Self {
replacement: DEFAULT_REPLACEMENT.to_string(),
redact_emails: true,
patterns: Vec::new(),
}
}
}
/// Redact the input. Returns the output and the number of masked spans.
pub fn redact(input: &str, cfg: &RedactConfig) -> (String, usize) {
// Mask emails when cfg.redact_emails, credential prefixes
// (sk-, ghp_, AKIA, xoxb-), and each literal in cfg.patterns,
// replacing every hit with cfg.replacement.
// ...
}
}
The guest receives the schema-materialized public JSON object, so deserialize it
once instead of repeating string parsing. This example’s schema makes every
field optional, and Default owns their behavior when the host supplies {}.
An empty object is normal when the operator has not configured the plugin or
when the host denies the requested config_read grant. If a plugin cannot
operate without a value, mark it required in config_schema; the host will then
reject an empty object before guest code starts.
3. Implement the world
wit/v0/tool.wit defines the surface you must export. The world is:
world tool-plugin {
import logging;
import secrets;
export plugin-info;
export tool;
}
and the tool interface is four functions:
record tool-result {
success: bool,
output: string,
error: option<string>,
}
name: func() -> string;
description: func() -> string;
parameters-schema: func() -> json-string;
execute: func(args: json-string) -> result<tool-result, string>;
src/lib.rs generates the guest bindings and implements both exports:
#![allow(unused)]
fn main() {
pub mod redact;
#[cfg(target_family = "wasm")]
mod component {
wit_bindgen::generate!({
path: "wit/v0",
world: "tool-plugin",
features: ["plugins-wit-v0"],
});
use crate::redact::{redact, RedactConfig};
use exports::zeroclaw::plugin::plugin_info::Guest as PluginInfo;
use exports::zeroclaw::plugin::tool::{Guest as Tool, ToolResult};
use zeroclaw::plugin::logging::{
log_record, LogLevel, PluginAction, PluginEvent, PluginOutcome,
};
struct RedactPlugin;
#[derive(serde::Deserialize)]
struct ExecuteArgs {
text: String,
#[serde(rename = "__config", default)]
config: RedactConfig,
}
impl PluginInfo for RedactPlugin {
fn plugin_name() -> String {
"my-redact-plugin".to_string()
}
fn plugin_version() -> String {
"0.1.0".to_string()
}
}
impl Tool for RedactPlugin {
fn name() -> String {
"redact".to_string()
}
fn description() -> String {
"Redact secrets and PII from text before it reaches a log, \
channel, or model. Masks emails, credential prefixes, and \
operator-configured literal patterns."
.to_string()
}
fn parameters_schema() -> String {
serde_json::json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to redact."
}
},
"required": ["text"]
})
.to_string()
}
fn execute(args: String) -> Result<ToolResult, String> {
let parsed: ExecuteArgs = match serde_json::from_str(&args) {
Ok(a) => a,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("invalid arguments: {e}")),
});
}
};
let (output, count) = redact(&parsed.text, &parsed.config);
log_record(
LogLevel::Info,
&PluginEvent {
function_name: "my_redact_plugin::tool::execute".into(),
action: PluginAction::Complete,
outcome: Some(PluginOutcome::Success),
duration_ms: None,
attrs: Some(format!("{{\"redactions\":{count}}}")),
message: "redacted input".into(),
},
);
Ok(ToolResult { success: true, output, error: None })
}
}
export!(RedactPlugin);
}
}
Contract points, each anchored in the host source:
plugin-infois a required export of every world. It reports the component’s own name and version. Keep both in sync with the manifest.- Metadata is read once.
call_tool_metadatainruntime.rsreadsname,description, andparameters-schemaat registration and caches them. Do not compute them from anything dynamic; they will never be re-observed. - The schema is the model’s entire view of your tool. The host parses it
as JSON at load (
tool parameters-schema is not valid JSONis a hard registration failure) and forwards it to the LLM verbatim. Describe every property. Never declare__configin it: that key is host-reserved, and the host strips any caller-supplied value before injection precisely so the model cannot pose as your operator. success: falseversusErr. AToolResultwithsuccess: falseflows back to the model as a normal tool response it can react to (retry with fixed arguments, apologize, pick another tool). AnErr(String)crosses the boundary as a plugin fault: the host wraps it asplugin execute returned errorand the call fails. ReserveErrfor genuinely broken states, and report bad input viasuccess: false.- Log through the imported
logginginterface, neverwasi:logging.log-recordis fire-and-forget; the host absorbs all errors so a failed log write can never crash your call, and events land in every destinationzeroclaw_logwrites to, carrying thezeroclaw.*attribution (agent_alias,session_key, provider, channel) of the host span your call runs under. Note theattrsfield onplugin-eventis not attribution: it is the free-formattributespayload of the log row. Attribution is alias-bound, inherited from the ambient tracing span on the host side, and nothing a plugin sends can set or clobber it.PluginActionandPluginOutcomeare closed enums mirroring the host taxonomies; there is no free-form variant on purpose. Pick the closest.
4. The __config jail
A plugin never reads process environment variables and never sees global
config. A manifest that requests config_read must also declare
config_schema; a schema without that permission is equally invalid. The
schema is Draft 2020-12, its root must be an object with a properties map and
additionalProperties = false, and every top-level property must explicitly
resolve to string, boolean, integer, number, array, or object.
The host resolves the section stored under the versioned config-entry key
derived from this instance’s package, tool capability, and binding,
materializes it according to the package schema, validates the complete typed
object, and only then partitions it. Only the non-secret properties are merged
into execute under the reserved __config key:
- Any
__configalready present in the model-supplied arguments is deleted first. Spoofing is structurally impossible. - Operator storage remains an encrypted string map. Store strings directly;
encode booleans and numbers as JSON scalars (
"true","4","0.5") and arrays and objects as JSON ('["secret-a","secret-b"]'). The guest receives real JSON booleans, numbers, arrays, and objects, not those storage strings. - A direct top-level string property marked
x-secret = trueis excluded from__config. Read it explicitly with the generatedzeroclaw::plugin::secrets::getfunction. Nested markers, false/non-boolean markers, and secret non-string properties fail manifest admission. - The host enables secret reads only while dispatching
execute. Calls from component initialization or metadata exports returnunavailablewithout resolving config. Public__configand secret reads during one execution use the same resolved config view. - If
config_readwas requested but not effectively granted, the host resolves{}and validates it. This example’s optional schema therefore causes the tool to omit__configand#[serde(default)]selectsRedactConfig::default. A required schema fails closed instead of running without credentials. - Unknown keys, invalid JSON encodings, wrong types, and schema constraint
failures reject the plugin before its code runs. Operators currently set
values under the installation-printed instance key through TOML or the
generic
zeroclaw config setpath; those values encrypt at rest under the config’s secret key. Schema-driven zerocode and gateway editors are future SDK/config-surface work.
For this tool the typed section has three optional keys: replacement is a
string, redact_emails is a boolean, and patterns is an array of strings.
5. The manifest
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. |
capabilities | yes, non-empty | What the plugin is: any of tool, channel, memory, observer, skill (PluginCapability, serialized snake_case). |
permissions | no | Host services the code may reach: http_client, config_read, file_read, file_write, memory_read, memory_write (PluginPermission). Only the first two are enforced today; the rest are accepted but inert. Declaring config_read requires config_schema, and only tool/channel adapters currently deliver it. |
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. 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. |
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.
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 this plugin: name and version matching what plugin-info reports,
wasm_path naming the component file you will ship next to it,
capabilities containing exactly tool, and permissions containing exactly
config_read. Add http_client only if your tool makes outbound HTTP calls.
The tool adapter implements wasi:http, but links it only after that grant is
validated; without both adapter support and the grant there is no HTTP surface.
The matching manifest contract for the typed RedactConfig is:
name = "my-redact-plugin"
version = "0.1.0"
wasm_path = "my_redact_plugin.wasm"
capabilities = ["tool"]
permissions = ["config_read"]
[config_schema]
"$schema" = "https://json-schema.org/draft/2020-12/schema"
type = "object"
additionalProperties = false
[config_schema.properties.replacement]
type = "string"
minLength = 1
[config_schema.properties.redact_emails]
type = "boolean"
[config_schema.properties.patterns]
type = "array"
items = { type = "string" }
These properties are optional, matching the guest’s defaults. For a credential
that must exist, add its name to required in [config_schema]; a denied grant
or missing value will then prevent the component from starting.
Tools that call the network
Arguably the most common real-world tool shape is not a pure transform like
redact but a bridge to an external API: declare http_client in the
manifest, read credentials through the scoped secret service, and make an
outbound request. Mark the credential in the signed schema:
[config_schema]
required = ["api_key"]
[config_schema.properties.api_key]
type = "string"
minLength = 1
x-secret = true
The missing piece relative to this guide is an HTTP client that works inside a
component: reqwest and friends do not, because there is no socket surface,
only wasi:http. A client known to work against this host is
waki, which is blocking and therefore fits
execute’s synchronous signature directly. Add it gated to the component
target so your pure-logic modules stay natively testable:
cargo add waki --target 'cfg(target_family = "wasm")'
The shape of a call, inside execute after parsing public __config:
#![allow(unused)]
fn main() {
let api_key = zeroclaw::plugin::secrets::get("api_key")
.map_err(|_| "api_key is unavailable".to_string())?;
let resp = waki::Client::new()
.get("https://api.example.com/search")
.query([("q", term.as_str())])
.header("Authorization", format!("Bearer {api_key}"))
.connect_timeout(std::time::Duration::from_secs(5))
.send()
.map_err(|e| format!("request failed: {e}"))?;
}
Two version facts that look like breakage but are not: waki vendors its own
wit-bindgen (0.34) alongside the 0.46 your world bindings use; the two
coexist, each generating its own bindings. And waki emits wasi:http@0.2.4
imports while the current toolchain baseline is @0.2.6; the host links
both without issue. Neither requires action.
Remember the trust framing from the overview: http_client is
all-or-nothing. The sandbox does not bound where a granted plugin sends
data, so operators running strict signature policy are trusting your code,
not a URL allowlist.
6. Test the logic natively
Because redact.rs has no wasm dependency, plain cargo test covers it on
the host:
#![allow(unused)]
fn main() {
#[test]
fn empty_config_falls_back_to_defaults() {
let cfg: RedactConfig = serde_json::from_str("{}").unwrap();
let (out, n) = redact("mail me at a@b.example", &cfg);
assert_eq!(n, 1);
assert!(out.contains("[REDACTED]"));
}
}
Cover at minimum: the jail case (empty section), the configured case, and clean pass-through of text with nothing to mask. Every behavior the glue forwards should be provable here without a wasm toolchain in sight.
7. Build
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.
8. Install and verify
These commands need a binary with the plugin host compiled in. The prebuilt release binaries the installer ships are built without the
plugins-wasmfeature, sozeroclaw plugin ...is an unrecognized subcommand there and installed plugins are never discovered. Build from source with a plugin execution backend, e.g.cargo build --release --features plugins-wasm-cranelift.
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).
9. Run it
Ask the agent to use the tool:
> redact this before you log it: key sk-live-abc123, mail ops@example.com
The model sees redact in its catalog with your schema, calls it, and the
host runs the component in a fresh store under the configured fuel and memory
limits. Plugin tools are not in the builtin read-only auto-approve set, so at
non-full autonomy the call surfaces the operator approval prompt like any
other privileged tool; anticipate that in your tool description rather than
being surprised by it. Your log-record events appear in the structured log
with the
span attribution of the host
call site.
Two operational constraints worth repeating from the plugins overview:
- Tool names must not collide with built-ins. Built-in tools register
first and dispatch resolves first-match (
find_toolin the runtime), so a plugin tool named like a built-in is never selected. There is no error; there is just silence. Pick a unique name. - One tool per component. The
tool-pluginworld exports a singletoolinterface. A toolbox is several plugin directories, one component each.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Plugin missing from zeroclaw plugin list | Plugin system disabled; malformed manifest; wasm_path file missing; signature policy rejected it. The startup log carries the specific skip warning. |
Present in zeroclaw plugin list but the tool never loads | plugins.auto_discover is false (the default). Auto-discovered tool and skill capabilities load only when plugins.auto_discover = true; plugins.enabled = true alone activates only explicitly-declared channels. Run zeroclaw config set plugins.auto_discover true. |
| Tool rejected during registration | Config validation or the metadata probe failed. Check the log for the specific error; a probe failure usually means the component was built against mismatched WIT. |
| Tool never selected by the model | Name collides with a built-in, or the description/schema do not tell the model when the tool applies. |
__config absent despite configured section | The effective scope denied config_read, the entry does not use the installation-printed full-instance key, the validated object is empty, or every validated property is marked secret. A config_schema/permission mismatch rejects the plugin instead. |
secrets.get returns not-found | The property is missing or is not a direct top-level string marked x-secret = true in the admitted schema. |
secrets.get returns unavailable | The call ran outside execute, config resolution failed, or the execution exhausted its fixed host-call budget. |
| Call fails or traps | Fuel, wall-clock, or memory ceiling hit. Raise plugins.limits.call_fuel, plugins.limits.call_timeout_ms, or plugins.limits.max_memory_mb as appropriate, or do less per call. |
| Load fails on a runtime-only host | You shipped .wasm to a host with no JIT; ship a version-matched .cwasm instead. |
Next
- Writing a channel plugin for the warm-store lifecycle, capability flags, and host-fed inbound.
- Distributing plugins when this tool should leave your machine.