type: reference status: accepted last-reviewed: 2026-07-17 relates-to:
- FND-001
- ADR-003
- crates/zeroclaw-plugins
Plugin Protocol
This document defines the protocol between ZeroClaw’s plugin host and WASM plugin components.
What a plugin is
A plugin is a self-contained WebAssembly component that ZeroClaw loads at
runtime to add a capability the core binary does not ship. It lives in its own
directory under ~/.zeroclaw/plugins/, alongside a manifest that names it and
declares what it provides. ZeroClaw discovers it on startup, verifies it, and
wires its exported functions into the running agent so they behave like
built-in capabilities: a tool plugin shows up to the model as just another
callable tool (WasmTool implements the same Tool trait a native tool does),
a channel plugin behaves as a messaging channel, a memory plugin as a storage
backend.
A plugin can provide one or more of the capabilities defined in
PluginCapability (crates/zeroclaw-plugins/src/lib.rs): a callable tool, a
messaging channel, a memory backend, an observability backend, or a bundle of
markdown skills. The skill case is special: it ships no WASM at all, just a
skills/ directory of markdown, which is why it is the one capability that
omits the compiled component.
Why build one
- Extend without forking. Add a tool or channel without modifying the ZeroClaw source tree or waiting on a release; the plugin is yours and loads from your install directory.
- Native behavior. A loaded plugin is not a second-class add-on. The bridge implements the same runtime traits the built-ins use, so a plugin tool is offered to the model, attributed, and invoked exactly like a first-party one.
- Language choice. The contract is WIT and the WASI Component Model, not a
Rust API. Any language that compiles to a
wasm32-wasip2component can implement a world. The worked guide below is Rust because that is the path with the most support today, but the boundary itself is language-agnostic. - Sandboxed by default. The host loads each plugin into a WASI context with
no filesystem preopens and no ambient network. A plugin cannot quietly reach
the host; it gets exactly the host functions wired into its world and nothing
more. Outbound HTTP is the one network surface that can be opened, and only
when the manifest grants
http_clientand that capability adapter explicitly enables its tested HTTP boundary. Tool and channel adapters do; memory does not yet. - Verifiable provenance. Manifests can be Ed25519-signed, and an operator can require signatures from trusted publishers before any plugin loads.
What a plugin cannot do (today)
These are real limits of the current host, not style preferences. Know them before you design around a capability that is not there.
logging, typed config, instance-scoped secrets,http_client, and host-fed inbound are wired. Of the permissions a manifest can declare,config_readexposes the plugin’s own schema-validated public config. A tool or channel schema can designate secrets withheld from public config and resolved in authorized service calls. Anhttp_clientgrant is necessary for outboundwasi:http, but the capability adapter must also opt into that host surface. Tool and channel adapters do; memory intentionally remains HTTP-free until its network boundary has component-level coverage. Filesystem and memory-access permissions are still accepted by the manifest schema but inert: their host functions are not yet registered in the linker. See Permissions and Host imports below.- No ambient host network or filesystem. The WASI context has no preopens and
no ambient network, so a plugin cannot open raw sockets or read host files
through ambient WASI. A tool or channel plugin with an
http_clientgrant gets outboundwasi:httpbecause those adapters opt in; it cannot listen. Channel plugins that must receive inbound traffic do not open a listener themselves: the host runs the listener and feeds messages through theinboundimport, which the plugin drains from itspoll-messageexport. - A 32-bit boundary. The target is
wasm32-wasip2. Guest memory is a 32-bit address space and the component ABI lowers offsets as 32-bit regardless of host word size. Large values (for example a channel attachment’s raw bytes) cross the boundary by value. See the 32-bit address space section for why this is an upstream-toolchain constraint, not a flag this repo can flip. - One tool per tool plugin. The
tool-pluginworld exports a singletoolinterface with one name and schema. A plugin that needs to expose several tools ships several components, or a different world. - Experimental, unfrozen contract.
wit/v0carries no.frozenmarker yet, so the interfaces can still change before the first stable release. Pin to a version and expect to recompile across a WIT bump.
Architecture
ZeroClaw plugins are WebAssembly components defined by WIT interfaces under
wit/v0/ and hosted through direct wasmtime (crates/zeroclaw-plugins). A
plugin is compiled to a WASI Preview 2 component (wasm32-wasip2) that exports
one of the plugin worlds (tool-plugin, channel-plugin, memory-plugin) and
imports the host interfaces declared by that world in wit/v0/.
The host lives in crates/zeroclaw-plugins/src/component.rs. It holds one
async-enabled wasmtime::Engine, generates the world bindings with
wasmtime::component::bindgen! from wit/v0, and wires a sandboxed WASI p2
surface into each world’s linker. Per-store host state (PluginState) carries a
WasiCtx built with no preopens and no network, plus the ResourceTable WASI
requires, its host-issued scope, and typed live service handles. Every world
imports logging; tool imports secrets, while channel imports config,
secrets, and inbound. A granted http_client permission additionally
attaches and links wasi:http.
The world declarations and the admitted scope remain the canonical contracts
for that surface (see Host imports).
The three world bridges map each WIT world onto the runtime’s native traits:
| World | Bridge module | Runtime surface |
|---|---|---|
tool-plugin | runtime.rs, wasm_tool.rs | zeroclaw_api::tool::Tool |
channel-plugin | wasm_channel.rs | channel trait |
memory-plugin | wasm_memory.rs | memory backend trait |
Tool plugins use a fresh store per call (stateless). Channel and memory plugins hold a warm store guarded by an async mutex for the lifetime of the plugin.
Tool plugins are discovered and registered end to end: the runtime walks
channel_plugin_details()’s tool counterpart and builds a WasmTool for each.
The channel host adapter (WasmChannel, its wasi:http gating, point-of-use
config services, and host-fed inbound queue) is complete and unit-covered, and
PluginHost::channel_plugin_details() exposes the wasm-backed channel plugins
to register. The runtime now resolves an explicitly declared
[channels.plugin.<alias>] binding, constructs its WasmChannel, and registers
it from the configured alias; that alias-aware construction and runtime config
resolution landed in
#10146. The remaining
follow-up is the per-vendor host listener that drains each transport into the
channel’s inbound queue. The memory bridge
(WasmMemory) is in the same position one step earlier: the adapter implements
the full Memory trait against the memory-plugin world, but the host does not
yet expose a memory counterpart to channel_plugin_details() and the runtime
does not yet construct a WasmMemory as a configurable backend.
Plugin structure
A plugin is a directory containing:
my-plugin/
manifest.toml # Plugin metadata and permissions
plugin.wasm # Compiled WASM module (optional for skill-only plugins)
Plugins are discovered from ~/.zeroclaw/plugins/ (configurable via
plugins.plugins_dir in config).
Registry search and install
The local plugin install path remains the source of truth for installed plugins. A registry is only a JSON index used at command time to discover and download a plugin archive:
zeroclaw plugin search calendar
zeroclaw plugin install team-calendar
zeroclaw plugin install team-calendar@0.2.0
zeroclaw plugin search calendar --registry https://example.invalid/registry.json
zeroclaw plugin install team-calendar --registry https://example.invalid/registry.json
zeroclaw plugin search fetches registry metadata and matches the query against
plugin names and descriptions. It does not install, enable, or execute plugin
code.
zeroclaw plugin install <name> resolves the name from the registry, downloads
the selected zip archive, verifies the optional SHA-256 digest, safely extracts
the archive, and then hands the extracted plugin directory to the existing
PluginHost::install path. Local path installs are unchanged:
When no version is pinned, ZeroClaw chooses the last matching entry in the registry index, so registry publishers should order repeated names intentionally.
zeroclaw plugin install ./my-plugin
zeroclaw plugin install ./my-plugin/manifest.toml
The default registry URL is:
https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw-plugins/main/registry.json
For private or staged registries, use --registry <url> per command or set
ZEROCLAW_PLUGIN_REGISTRY_URL.
Registry entries use this shape:
{
"plugins": [
{
"name": "team-calendar",
"version": "0.8.5",
"description": "Schedule meetings on a team calendar",
"author": "Example Team",
"capabilities": ["tool"],
"url": "https://example.invalid/team-calendar-0.2.0.zip",
"sha256": "sha256:<hex digest of the zip>"
}
]
}
The archive must contain either a root-level manifest.toml or one nested
plugin directory containing manifest.toml. Archives with traversal paths,
absolute paths, Windows drive-prefixed paths, or more than one manifest are
rejected before install. Downloads are capped while streaming, so a server
without Content-Length cannot force ZeroClaw to buffer an oversized archive.
Extraction is also capped, so a compressed archive cannot expand without bound
in the temporary install area.
Search is unauthenticated discovery. Install is the security boundary: registry
installs use the configured plugin signature policy and trusted publisher keys,
the same as local plugin installs through PluginHost::install.
Skill-only plugin layout (markdown bundle)
A plugin whose only capability is skill ships skills under a skills/
directory in agentskills.io format and omits
wasm_path:
my-toolkit/
manifest.toml # declares the skill capability, no wasm_path
README.md # optional bundle-level overview
skills/
design-review/
SKILL.md
scripts/
references/
code-review/
SKILL.md
data-analysis/
SKILL.md
references/
Each SKILL.md must include YAML frontmatter with name and description
fields; the runtime rejects bundles whose skills omit either at discovery time
rather than at first invocation. Skills register under plugin-namespaced IDs
of the form plugin:<plugin-name>/<skill-name> (e.g.
plugin:my-toolkit/design-review) to avoid collisions with user-authored
skills and between bundles.
Manifest format
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.
Capabilities
capabilities is a non-empty list of PluginCapability values, defined in
crates/zeroclaw-plugins/src/lib.rs (serialized snake_case). Each value
selects the WIT world the plugin exports (tool, channel, memory), names an
observability backend (observer), or marks a markdown-only skill bundle
(skill). Read the enum for the canonical set; it is the source of truth and
this page does not restate it.
A manifest must declare at least one capability. wasm_path is required for
every capability except a plugin whose only capability is skill, which carries
no WASM payload and is rejected at discovery if it omits a valid skills/
bundle (validate_manifest_shape in host.rs).
Permissions
permissions is a list of PluginPermission values, also defined in
crates/zeroclaw-plugins/src/lib.rs. Read the enum for the canonical set.
Be aware of the gap between declared and enforced: in the component host today
config_read and http_client have behavioral effect. Requesting
config_read requires a config_schema, and declaring that schema without the
permission is also rejected. Before a tool or channel component is used, the
host resolves its effective grant, materializes the plugin’s operator values to
typed JSON, and validates the complete object. runtime.rs strips any
caller-supplied __config before injecting validated non-secret values into a
tool call; direct top-level string properties marked x-secret: true are
omitted from public config and read through the host-scoped secrets import.
Tools receive that service during execute. Channels receive public config
through config.get and secrets through secrets.get during configure and
operational calls, while instantiation and static metadata discovery remain
unavailable. http_client is a necessary grant, not a complete authority
decision: the capability adapter must also construct the HTTP context and link
wasi:http. Tool and channel adapters opt in after grant validation. The
memory adapter deliberately does not, so granting http_client to a memory
scope alone adds no network surface. The remaining variants
(file_read, file_write, memory_read, memory_write) are accepted by the
manifest schema but are not yet wired to a host import: declaring them grants
nothing on its own. They reserve the names for the host functions that will
gate them (see Host imports below).
WIT interfaces
The plugin contract is the set of WIT files in wit/v0/, package
zeroclaw:plugin@0.1.0. Every item is gated behind
@unstable(feature = plugins-wit-v0) until the package stabilizes; see
wit/VERSIONING.md for the compatibility rules. The interfaces below are
summarized for orientation; the .wit files are authoritative for the exact
signatures.
Worlds
wit/v0/ defines three worlds, bound by bindgen! in component.rs. Each
imports logging (host) and exports plugin-info plus its primary interface:
tool-plugin exports tool, channel-plugin exports channel, and
memory-plugin exports memory. Tool also imports secrets; channel imports
config, secrets, and inbound. The required (no-default) exports for each
world are listed in the world’s doc comment in its .wit file.
tool interface
wit/v0/tool.wit defines the single-tool surface. The host calls name,
description, and parameters-schema once at load time, then dispatches
execute per invocation:
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>;
parameters-schema returns a JSON Schema string presented to the LLM for tool
calling. execute receives JSON-encoded arguments matching that schema and
returns a tool-result or an error string. json-string is a string type
alias from wit/v0/types.wit; callers produce valid JSON, receivers parse it.
channel and memory interfaces
wit/v0/channel.wit and wit/v0/memory.wit define capability-gated surfaces.
The host calls get-channel-capabilities / get-memory-capabilities once at
load time, and for each unset flag it uses the Rust trait default instead of
calling the plugin. A plugin must still export every function (a stub returning
the documented default value is sufficient); the host simply never calls the
ones whose flag is absent. The default each unset flag resolves to is documented
inline in the WIT next to the *-capabilities flags, which is the source of
truth for both the flag set and its defaults.
Capability flags
Optional methods are advertised through flags channel-capabilities and
flags memory-capabilities. Because flags are a bitmask, new optional methods
can be added to a vN/ package without a breaking change, paired with a new
@since function. Removing or renaming a flag, function, field, or variant case
is breaking and requires a new vN+1/ directory.
Host imports
Host functions are imported by the plugin and provided by the runtime. Every
world’s linker wires logging (via the host impl in component_logging.rs,
linked alongside add_wasi in component.rs). Tool and channel link the
instance-scoped secrets service. Channel also imports config for its typed
public object and inbound for the host-fed message queue it drains from
poll-message. Tool and channel adapters link outbound wasi:http only after
the admitted scope grants http_client (PluginStoreSpec::with_granted_http
and add_wasi_http in component.rs). Memory withholds both the context and
linker surface. The filesystem and memory-access permissions remain inert: the
host functions that would gate them are not yet wired into the linker. A
plugin’s ambient authority is the WASI context (no preopens, no ambient network)
plus exactly the host imports its grants and adapter opt-ins jointly enable.
ZeroClaw-owned imports share a fixed safety budget per host-dispatched service
frame. The canonical ceiling is MAX_HOST_CALLS_PER_FRAME in
crates/zeroclaw-plugins/src/component.rs. On exhaustion, logging becomes a
no-op, inbound polling reports empty, and public-config or secret reads return
unavailable. A new frame resets the budget. This ceiling is fixed host policy,
not duplicated operator configuration.
inbound
wit/v0/inbound.wit is imported by the channel-plugin world. A channel plugin
runs with no listener of its own, so the host runs the listener (a webhook
server, a vendor tunnel, a polling client) and enqueues each received message.
The plugin drains the queue from its poll-message export by calling
inbound-poll, with inbound-pending available to drain in batches:
inbound-poll: func() -> option<host-inbound-message>;
inbound-pending: func() -> u32;
The host side owns an InboundQueue per channel; WasmChannel::inbound hands a
clone to the listener task so enqueued traffic is visible to the plugin’s drain.
logging
wit/v0/logging.wit is imported by all three worlds. Plugins call log-record
to emit structured events back to the host:
log-record: func(level: log-level, event: plugin-event);
The call is fire-and-forget: it returns nothing and the host
(component_logging.rs) absorbs all errors, so a failed log write can never
crash plugin execution. Delivery is asynchronous: the import hands the record
to a bounded host-side queue drained by a dedicated thread and returns without
blocking, so a slow or wedged log consumer can never hold a guest export past
plugins.limits.call_timeout_ms. Deferral does not change what an event
means: each record captures the host span current at the guest call site and
is written inside that scope, so agent/channel/tool attribution and the
terminal label match inline emission. The bound is a real memory bound, because
the event fields are unbounded strings copied to host memory outside the
guest’s max_memory_mb ceiling: a record whose guest-controlled bytes exceed
64 KiB is dropped rather than truncated, and queued records draw on a fixed
8 MiB aggregate byte budget that is released only after a record is written.
A full queue, an over-cap record, or an exhausted budget all drop the newest
record; the drain thread reports the accumulated drop count after each write
and on an idle wake, so the loss stays observable even when no accepted
record ever follows the rejected ones. plugin-action and plugin-outcome mirror the closed
Action / EventOutcome taxonomies in zeroclaw-log; there is no escape-hatch
variant on purpose. Do not call wasi:logging directly, plugin events would be
formatted inconsistently and would not reach all of the destinations
zeroclaw_log writes to.
config
wit/v0/config.wit is imported by the channel world. It returns the current
schema-validated, non-secret object as JSON:
get: func() -> result<json-string, config-error>;
The object preserves the types declared by config_schema; properties marked
x-secret: true are omitted. The service is available during configure and
operational channel exports. It returns access-denied when the admitted
instance lacks the effective config_read grant. Calls during component
initialization or static metadata discovery, resolver or validation failure,
and host-call budget exhaustion return unavailable without exposing internal
detail.
config.get is point-of-use access, not a load-time snapshot. A compliant
channel plugin must call it in each operation that uses config and must not
retain its returned object in warm guest state. This is a plugin conformance
rule: after returning JSON to trusted guest code, the host cannot prevent a
malicious component from copying it.
secrets
wit/v0/secrets.wit is imported by the tool and channel worlds. The guest
supplies only a top-level property name:
get: func(name: string) -> result<string, secret-error>;
The host derives package, capability, binding, and effective grants from the
admitted PluginInstanceScope; none are guest inputs. Only direct top-level
string properties marked x-secret: true in the manifest schema are readable.
Tools can read them while the host dispatches execute. Channels can read them
during configure and operational calls such as send, poll, health, and
capability-gated actions. Component initialization and static metadata exports
return unavailable without resolving config. Within one channel service frame,
every config.get and secrets.get uses one resolved canonical config revision;
that frame is dropped on every exit path. A compliant plugin therefore observes
a same-binding public/secret rotation together on its next operation.
access-denied, not-found, and unavailable deliberately reveal no resolver
or schema detail. Successful reads return plaintext to the trusted guest. The
service prevents public injection and cross-instance selection; it is not an
egress proxy that keeps the value hidden from plugin code. A compliant channel
plugin must resolve secrets at each point of use and must not retain a second
copy in warm state. The host cannot enforce non-retention after returning the
plaintext.
Per-plugin config (__config and config.get)
Permission: config_read
A plugin does not read process environment variables. Its manifest must pair
config_read with a Draft 2020-12 config_schema; either one without the other
is an invalid manifest. The schema root must be an object with a properties
map and additionalProperties = false. Each top-level property must declare
one of string, boolean, integer, number, array, or object, directly
or through a package-local JSON Pointer. Tool and channel consumers may set
x-secret: true on a direct top-level string property; nested, false, or
non-boolean markers and secret non-string properties are rejected.
Unknown keys, malformed encodings, and constraint violations reject the
instance before values are delivered to guest code.
The operator’s canonical plugins.entries.<instance-key>.config values remain
a secret-marked string map in memory and are encrypted when persisted. The host
derives the versioned zpi1_… entry key from the full package, capability, and
binding identity; this lets different packages and capability worlds safely
reuse aliases such as main. The admitted package manifest selects the schema.
A string value is stored directly; boolean,
integer, and number values use JSON scalar text such as "true", "4",
or "0.5"; array and object values use JSON text such as
'["urgent","ops"]' or '{"region":"us-east"}'. The host materializes and
validates the complete data into typed JSON for each use, then partitions every
property exactly once. For a tool, non-secret values are injected under the
reserved __config key:
{
"prompt": "a sunset",
"__config": {
"retry_limit": 4,
"enabled": true,
"labels": ["urgent", "ops"]
}
}
The omitted api_key is read explicitly with secrets.get("api_key") if its
schema marks it secret. runtime.rs strips any caller-supplied __config
before injecting the public section, so the section cannot be spoofed. Tool
public injection and secret reads within one execute frame share one resolved
live-config revision; the frame is dropped on success, error, trap, panic, or
cancellation. A channel’s configure export has no config parameter. It calls
config.get for the public object and secrets.get for secret properties, as
does each later operational export that uses configuration. Both imports within
one call share one resolved revision. The host drops that materialized view on
success, error, trap, panic, or cancellation. Public config and credential
changes within the same logical binding are therefore available together on the
next operation when the compliant guest resolves both at point of use.
When the manifest requests config_read but the host does not effectively
grant it, resolution substitutes an empty object and validates that object
before guest code runs. A schema with required fields fails closed during
construction. If the empty object is valid, tools omit the empty __config,
while channel config.get and secrets.get return access-denied. A plugin
only ever sees its own section.
Channel static metadata exports cannot call either config service and are read
once at load. Changing a bot/account identity or any config-derived capability,
self-handle, mention, or multi-message delay therefore requires channel
lifecycle reconstruction; ordinary public config and credential rotation for
the same logical binding does not.
Tool and channel are the current config consumers. The memory world has no
config import yet, so memory plugins must not request config_read until that
ABI and runtime wiring land.
WASI Component Host
The host (crates/zeroclaw-plugins/src/component.rs) compiles and instantiates
components against a single async wasmtime::Engine. How a .wasm file is
loaded depends on the build’s execution backend:
plugins-wasm-cranelift: a JIT backend is present, soload_componentcompiles a.wasmcomponent on load viaComponent::from_file.- No JIT backend (
plugins-wasm-pulleyor runtime-only): there is no compiler in the binary, soload_componentdeserializes the file directly viaComponent::deserialize_file, treating it as a precompiled.cwasmproduced by a matching wasmtime. A mismatched artifact is rejected by deserialize’s version check.
Both backend features pull in plugins-wasmtime; the load path keys off whether
the cranelift compiler is in the build, not off pulley.
Per-call execution limits
Every guest export runs under per-call resource limits the host applies to the
store. The engine enables fuel metering, and each call is given a fresh fuel
budget so a runaway or malicious component traps instead of hanging the host.
The host also applies a wall-clock deadline around the complete export future,
including time awaiting async host imports such as wasi:http; periodic fuel
yields ensure uninterrupted guest computation cannot starve that timer, and
guest-reachable host imports never block the executor (log records are handed
to a bounded queue and written by a dedicated host thread), so the deadline
stays observable while host work runs. A
StoreLimits ceiling bounds linear memory, table elements, and instance count.
The tool world gets a fresh store per execute; the warm channel and memory
stores are refueled before each call so a long-lived plugin gets a fresh budget
rather than draining over its lifetime.
The five bounds are operator-tunable and every value is validated as non-zero:
plugins.limits.call_fuel (default 1,000,000,000 instruction units),
plugins.limits.call_timeout_ms (default 30,000 milliseconds),
plugins.limits.max_memory_mb (default 256), plugins.limits.max_table_elements
(default 100,000), and plugins.limits.max_instances (default 64). A store can
only be built with explicit limits, so no load path can construct an
unsandboxed plugin. Guest wasi:http request options may end a call sooner but
cannot extend the host deadline. An interrupted warm store is never resumed:
channels recreate it from host-owned inputs on the next call, while memory
instances remain unavailable until their owner rebuilds them. The canonical
fields and defaults live in the
Config reference.
32-bit address space (wasip2 is wasm32)
The plugin target is wasm32-wasip2, and the host engine is built with fuel
metering enabled (Config::consume_fuel(true)) without wasm_memory64. The
plugin boundary is a fixed 32-bit format, and that has consequences worth
stating plainly:
- The guest address space is 32-bit. A plugin runs in a wasm32 linear
memory. Large values cross the boundary by value: a channel plugin’s
media-attachmentcarries its full bytes as alist<u8>, andwit/v0/channel.witalready notes this can be several megabytes and leaves a resource-handle model to a future revision. Within that 32-bit space the host applies an explicit per-store memory ceiling fromplugins.limits.max_memory_mb(default 256), so a guest is bounded by the smaller of the wasm32 address space and that ZeroClaw-configured cap. - The component ABI lowers offsets as 32-bit regardless of host word size.
Even on a 64-bit host, list and string offsets in the canonical ABI are
i32.memory64widens a guest’s linear-memory addressing, not the component-model canonical ABI, so enabling it would not make WIT-level fields 64-bit. - There is no 64-bit wasip2 target to bind against.
wasm32-wasip2is the only WASI Preview 2 target in rustc and LLVM today; a plugin cannot be compiled to a 64-bit p2 component, so there is nothing for the host to load even if the engine enabledmemory64.
This is an upstream-toolchain constraint, not a host limitation that a flag in
this repo can lift. When a 64-bit p2 target and a wider component ABI land
upstream, the bindgen! seam regenerates against them and field widths are
revisited in the WIT under the wit/VERSIONING.md window. Until then, treat the
plugin boundary as 32-bit by construction.
Signatures
Plugin manifests may carry an Ed25519 signature
(crates/zeroclaw-plugins/src/signature.rs). The signature is base64url-encoded
over the canonical manifest bytes (the parsed TOML with only the exact root
signature and publisher_key entries removed); the publisher’s public key is
hex-encoded. Nested schema properties with those names remain signed. The host
enforces one of three modes from plugins.security.signature_mode:
| Mode | Unsigned plugin | Untrusted or invalid signature |
|---|---|---|
strict | rejected | rejected |
permissive | loaded with a warning | loaded with a warning |
disabled | loaded | not checked |
Verification runs at both discovery and install. Discovery skips a plugin that fails its policy rather than aborting the whole host; install returns the error.
Writing a plugin in Rust
A plugin is a cdylib crate that targets the component model. Generate the
guest bindings from the same wit/v0 package the host uses, implement the
exported world, and compile to wasm32-wasip2. For the full worked
walkthroughs from empty crate to installed plugin, see the
plugin guides; the notes below cover the
build and install mechanics.
Building
sh
# Install the WASI Preview 2 target (once)
rustup target add wasm32-wasip2
# Build the component
cargo build --target wasm32-wasip2 --release
The output component is at target/wasm32-wasip2/release/<crate_name>.wasm.
Copy it alongside your manifest.toml. For a runtime-only host build with no JIT
backend, precompile the component to a .cwasm with a matching wasmtime and ship
that instead, since such a host deserializes rather than compiles on load.
The host’s tool-plugin tests do not depend on a published artifact:
crates/zeroclaw-plugins/tests/fixtures/tool-fixture is an in-tree component
built from source at test time, and reference_plugin.rs and
reference_plugin_e2e.rs drive it through the same PluginHost,
config_schema, and config-resolution paths the daemon runs. If the fixture
cannot be built, those tests fail.
Installing
sh
# Copy to plugin directory
zeroclaw plugin install /path/to/my-plugin/
# Or manually
cp -r my-plugin/ ~/.zeroclaw/plugins/my-plugin/
Configuration
Operator values currently enter through generic string-map storage: edit
[[plugins.entries]] in TOML, or use zeroclaw config set after a tool install
has seeded its default-binding entry. zeroclaw plugin info <package> prints
the same tool key for migration and later edits. These automatic print and seed
surfaces are tool-only. A channel key depends on its configured alias, which
install and info do not own. The alias-aware construction that resolves a
channel’s typed config from that configured alias landed in
#10146; automatic
display and install-time seeding of the channel key remain manual until the
grant ceremony in
#9584, so a channel-only
package still cannot complete this migration through install and info alone.
Schema-driven forms and inline field help are not
implemented yet. The current surfaces are:
- The CLI handles plugin lifecycle with
list,search,install,remove,info, andmigrate.zeroclaw config setwrites individual raw plugin values; it does not interpret the plugin’s schema. - zerocode can edit ZeroClaw’s static plugin-host settings, but does not yet
generate per-plugin fields from
config_schema. - The web gateway is read-only for plugins:
GET /api/pluginsreports the loaded plugins and whether the system is enabled. - The host validates
config_schemawhen admitting the package and validates/materializes operator values again before guest use. - The manifest schema, for plugin authors, is the sole type and validation contract at the guest boundary. Define every supported key and constraint there; do not duplicate that contract in a host runtime config struct. Guest code should deserialize the host-validated JSON into its native typed struct.
The static config schema supplies the generic storage and secret-marking path,
not a dynamic per-plugin editor. The plugin config types in
crates/zeroclaw-config/src/schema.rs carry #[prefix = "plugins"],
#[prefix = "plugins.entries"], and #[prefix = "plugins.security"], and the
Configurable derive turns each prefixed field into a generic config path.
Secret fields (a plugin entry’s config map is marked #[secret]) encrypt at
rest under the adjacent .secret_key. The canonical fields,
defaults, and the signature_mode values for host configuration live in the
Config reference; that schema is the source of truth,
while each plugin manifest is the source of truth for its private config shape.
Build features
The plugin host is a compile-time opt-in. The binary-level features in the
workspace Cargo.toml select whether plugins are built in at all and which
execution backend ships:
plugins-wasmis the umbrella that pulls the plugin host and its runtime integration into the binary. Every backend feature below implies it, so enabling any execution backend (e.g.--features plugins-wasm-cranelift) always carries the plugin host and its CLI surface; a backend-only build cannot silently produce a binary without thepluginsubcommand. The umbrella alone is equivalent toplugins-wasm-runtime-only: no JIT, so only precompiled.cwasmcomponents load.plugins-wasm-runtime-onlyis the smallest and fastest to start: no JIT, so components are deserialized from a precompiled.cwasm.plugins-wasm-craneliftadds the Cranelift JIT, so a.wasmcomponent is compiled on load.plugins-wasm-pulleyis the most portable, supporting compilation on targets Cranelift does not cover.
These delegate to the zeroclaw-plugins crate features
(plugins-wasmtime, plugins-wasm-cranelift, plugins-wasm-pulley) that wire
up wasmtime. The load path keys off whether the Cranelift compiler is in the
build, as described under WASI Component Host. Read the feature comments in the
workspace Cargo.toml for the authoritative descriptions.