Writing a Skill Bundle
A skill bundle is the one plugin kind that ships no WebAssembly at all. It is
a directory of markdown skills, packaged and distributed through the plugin
machinery: same manifest, same discovery, same signature policy, same
zeroclaw plugin install. Use it when the capability you are adding is
instructions, prompts, and workflows rather than code, and you want plugin
distribution semantics (signing, registry install, versioning) instead of
loose files in a skills directory.
Check your binary first. Skill bundles ride the plugin machinery, and the prebuilt release binaries the installer ships are built without the
plugins-wasmfeature: on a stock binaryzeroclaw plugin ...is an unrecognized subcommand and plugin-shipped skills do not load. To use the bundles on this page, build from source with a plugin execution backend, e.g.cargo build --release --features plugins-wasm-cranelift. If you just want a shared directory of skills on a stock binary, use the native bundles described in Skills instead:zeroclaw skills bundle add <alias>creates one andzeroclaw skills install <source> --bundle <alias>installs into it, giving you the same skills without plugin distribution semantics.
This guide is checked against the validation path in
crates/zeroclaw-plugins/src/host.rs (validate_skill_bundle,
validate_skill_md_frontmatter) and the loader in
crates/zeroclaw-runtime/src/skills/mod.rs.
For what a skill itself is and how agents use them, read Skills first. This page covers only the bundle packaging.
Layout
A skill-only plugin omits wasm_path and carries a skills/ directory in
agentskills.io format:
my-toolkit/
manifest.toml # capabilities = skill only, no wasm_path
README.md # optional bundle-level overview
skills/
design-review/
SKILL.md
scripts/ # optional
references/ # optional
code-review/
SKILL.md
data-analysis/
SKILL.md
references/
Validation: what discovery enforces
The host validates the bundle shape at discovery and install, and rejects the
whole plugin on the first failure (validate_skill_bundle in host.rs).
The exact rules:
skills/must exist and be a directory.- It must contain at least one subdirectory. An empty
skills/is an invalid manifest, not an empty bundle. - Every subdirectory must contain a
SKILL.md. - Every
SKILL.mdmust open with YAML frontmatter (a---fence on line one, terminated by a closing---), and that frontmatter must declare non-emptynameanddescriptionkeys.
The frontmatter check runs at discovery time on purpose: a bundle whose
skills omit name or description fails when the plugin loads, not when an
agent first invokes the skill mid-conversation.
A valid skill header:
---
name: design-review
description: Structured design review workflow for architecture proposals.
---
# Design Review
...instructions...
Namespacing
Loaded bundle skills register under plugin-qualified IDs:
plugin:<plugin-name>/<skill-name>, e.g. plugin:my-toolkit/design-review
(namespace_plugin_skill in skills/mod.rs). Each skill also receives a
plugin:<plugin-name> tag. This prevents collisions with user-authored
skills and between bundles: two bundles can both ship a code-review skill
and coexist.
The namespacing interacts with skill precedence: in the agent’s effective-skill resolution, same-name skills from different sources are deduplicated by precedence and the losers are recorded as shadowed. The plugin qualifier keeps your bundle out of that fight entirely unless another copy of the same bundle name is in play.
Scripts
A skill may carry a scripts/ directory. Whether script-bearing skills load
is governed by the operator’s skills.allow_scripts setting, which the
plugin-skill loader passes through unchanged (discover_plugin_skills in
skills/mod.rs): a bundle skill with scripts is subject to exactly the same
audit-and-drop rules as a workspace skill. Do not assume your scripts run
just because the bundle installed.
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 a skill bundle: capabilities containing exactly skill, no
wasm_path, and typically no permissions at all; the bundle is data, and
the permission set gates host functions that markdown never calls.
A mixed-capability plugin (say tool + skill) is legal: it must then carry
a valid wasm_path for the tool world and a valid skills/ bundle, and
both validations run.
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).
After discovery, the skills appear namespaced in the skills surfaces (the
skills list, the dashboard) as plugin:<your-bundle>/<skill>. Ask the agent
to use one to confirm end to end.
Next
- Distributing plugins: a skill bundle is the simplest thing to publish, and the signing story is identical to WASM plugins.