Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Writing a Memory Plugin

A memory plugin is a storage backend: it persists what the agent remembers and answers recall queries. It is the most data-model-heavy plugin kind. Where a tool has one function and a channel has a message shape, a memory backend has multi-agent attribution, namespaces, sessions, categories, and importance weighting, and the runtime’s memory semantics (scoped recall, GDPR export, supersession) lean on your implementation getting the row model right.

This guide assumes the tool plugin basics and the warm-store lifecycle from the channel guide. It is checked against wit/v0/memory.wit and the host adapter in crates/zeroclaw-plugins/src/wasm_memory.rs.

Wiring status. WasmMemory implements the runtime’s full Memory trait against the memory-plugin world, capability-gated and unit-covered. The runtime does not yet construct it as a configurable backend; the host lacks a memory counterpart to channel_plugin_details(). As with channels, build against the contract: the WIT world and the adapter semantics are the part that freezes. The memory world also has no config export yet, so do not request config_read; add that only after a typed config ABI and resolver are wired into WasmMemory.

The data model

One record type crosses the boundary in both directions, memory-entry (memory.wit). Internalize its fields before designing storage, because the optional methods are all views over them:

FieldMeaning
idRow identity.
keyLookup key. Not unique: multiple rows may share a key, one per agent. Your storage must key on (key, agent-id), not key.
contentThe remembered text.
categorycore (long-term facts), daily (session logs), conversation (context), or custom(string).
timestampRFC 3339 creation time. Time-range recall bounds are inclusive.
session-idOptional conversation scope.
namespaceIsolation boundary between agents or contexts.
scoreRetrieval relevance 0.0-1.0; none for non-vector recall.
importanceOptional prioritization weight 0.0-1.0.
superseded-byID of the entry that replaced this one, if any.
agent-alias / agent-idDisplay name versus raw storage identifier. Use agent-id for scope equality checks, agent-alias for display.

The (key, agent-id) composite is the single most common mistake. The base get contract explicitly says: when multiple rows share a key, an arbitrary matching row is returned, and agent-scoped lookup goes through get-for-agent. Likewise forget deletes all rows for a key regardless of attribution, while forget-for-agent deletes exactly the (key, agent-id) row and leaves siblings alone.

Required exports

Twelve functions have no default and must work (memory.wit, required-methods section):

ExportContract notes
nameBackend name.
get-memory-capabilitiesBitmask of optional methods; read once at load.
store-entryStore (key, content, category, session-id). Named store-entry because store is reserved in wit-bindgen.
recallQuery + limit + optional session and RFC 3339 time bounds (inclusive). An empty or bare-* query means time-only recall: return the most recent entries.
getBy key; arbitrary row on multi-agent key collision.
list-entriesOptional category and session filters. Named for the wit-reserved list.
forgetDelete all rows for key; true if anything was deleted.
forget-for-agentDelete the (key, agent-id) row only.
countTotal entries.
health-checkReachability.
store-with-agent / recall-for-agentsThe attribution-aware pair; see below.

recall-for-agents takes an agent-filter variant: all (no agent filter) or some(list<string>) (restrict to the listed agent IDs). The runtime maps its Rust &[&str] slice as empty-slice-means-all, so treat some([]) as matching nothing, not everything.

Capability flags: the 11 optional methods

Same mechanism as channels: the host reads get-memory-capabilities once, and for each unset flag it uses the Rust trait default instead of calling you. The defaults are documented inline in memory.wit next to the flags, and the host-side fallbacks are visible in wasm_memory.rs (each gated method checks the flag and takes the fallback path when absent):

FlagHost fallback when unset
get-for-agentHost composes get + agent-id equality filter
purge-namespace, purge-session, purge-session-for-agent, purge-agentHost returns “not supported”
reindexHost returns 0
store-proceduralHost no-ops
ensure-agent-uuidHost echoes the alias unchanged
recall-namespacedHost calls recall and post-filters by namespace
export-entriesHost calls list-entries and post-filters
store-with-metadataHost delegates to store-entry, dropping namespace and importance

Read that last row twice: if you do not implement store-with-metadata, the namespace and importance the runtime asked for are silently dropped by the fallback. A backend that stores namespaced data should implement store-with-metadata, recall-namespaced, and purge-namespace as a set, or namespace isolation quietly degrades to post-filtering and lossy writes.

The purge family is your data-deletion surface. purge-agent takes an agent-alias (not an ID); export-entries exists for GDPR Art. 20 data portability and must return entries ordered by creation time ascending with embeddings excluded. If your backend serves real user data, implement the purge and export flags; “not supported” is an acceptable answer only for throwaway backends.

Sketch: the storage shape

The component pattern is the channel one (warm instance, thread_local state), so only the data layer differs. A minimal honest backend is an in-memory map, keyed correctly:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

struct Row {
    id: String,
    content: String,
    category: Category,
    timestamp: String,
    session_id: Option<String>,
    namespace: String,
    importance: Option<f64>,
    superseded_by: Option<String>,
    agent_alias: Option<String>,
}

/// (key, agent_id) -> Row. agent_id None models unattributed rows.
type Table = HashMap<(String, Option<String>), Row>;
}

Every required method is then a straightforward walk:

  • store-entry inserts at (key, None) with namespace "default".
  • store-with-agent inserts at (key, agent-id) with the caller’s namespace and importance.
  • recall filters by substring/rank against content, applies the session filter, applies inclusive RFC 3339 bounds against timestamp, sorts, and truncates to limit. Handle the empty/* query as most-recent-first.
  • recall-for-agents adds the agent-filter walk over the key’s second component.

A real backend can swap the map for an embedded store without changing the contract shape. The memory adapter intentionally does not link wasi:http yet, even when its scope carries http_client; remote backends require the separate, component-tested memory-network boundary.

What the host does around you

Knowing the adapter behavior in wasm_memory.rs explains several contract edges:

  • Warm store, refueled per call. Same as channels: one instance for the plugin’s lifetime, fresh fuel each call, calls serialized behind a mutex. Your backend never sees concurrent calls.
  • Capabilities cached at load. The host reads your flags once during from_wasm and never again. There is no dynamic capability discovery; restarting the daemon is what re-reads them.
  • Trap wrapping. Every call site wraps traps with a named context (memory.recall-namespaced trapped, etc.). A trap in one call does not tear down the plugin, but repeated traps make the backend useless; return err(string) for expected failures instead of panicking.
  • Post-filter fallbacks are host-side. When your recall-namespaced flag is unset, the namespace filter runs on the host after your recall returned. Your limit handling interacts with that: the host passes the caller’s limit to recall, so post-filtering can under-fill the result. Another reason to implement the namespaced variants natively.

Manifest, build, install

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:

FieldRequiredMeaning
nameyesUnique 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.
versionyesVersion string, e.g. 0.1.0.
descriptionnoHuman-readable description shown by zeroclaw plugin list.
authornoAuthor name or organization.
wasm_pathfor WASM capabilitiesComponent 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.
capabilitiesyes, non-emptyWhat the plugin is: any of tool, channel, memory, observer, skill (PluginCapability, serialized snake_case).
permissionsnoHost 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_schemaexactly with config_readDraft 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.
signaturenoBase64url Ed25519 signature over the canonical manifest bytes. Set when signing for distribution.
publisher_keynoHex-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 memory backend: capabilities containing memory. Do not request config_read yet: admission requires a schema, but the current memory world has no export through which the host can deliver the resulting object. Do not rely on http_client either: a grant alone cannot widen the memory adapter, which currently exposes no network surface.

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 .wasm and .cwasm files 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, and git diff/review tooling chokes on them. Treat them like any other build output: add target/ and *.wasm/*.cwasm to .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.

These commands need a binary with the plugin host compiled in. The prebuilt release binaries the installer ships are built without the plugins-wasm feature, so zeroclaw 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).

Next