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 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 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.
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.

For a memory backend: capabilities containing memory, and config_read if the backend needs connection settings. Do not rely on http_client yet: 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.

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

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