Tutorial: write a plugin (echo)
When to read this: your first WASM plugin: three tools, built, installed, and called by an agent.
Full source:
modules/echo/
(~80 lines). Also see
modules/echo/README.md
for build commands and the integration-test checklist.
Prerequisites: the wasm32-wasip2 target — make setup, or
rustup target add wasm32-wasip2 — before the “Build and install” step
below; without it cargo build --target wasm32-wasip2 fails with “the
wasm32-wasip2 target may not be installed”.
What a plugin is
A plugin contributes tools to a chatty agent. It never runs a loop of
its own and is never an agent itself: an agent spec lists it under
[[plugins]], the agent’s model sees its tools as <plugin>__<tool>, and
chatty calls the plugin when the model asks for one. The contract is the WIT
world chatty:plugin@0.3.0 (WIT reference).
| Export | echo’s answer |
|---|---|
metadata | name echo, version 0.2.0, no requested capabilities |
list-tools | echo, reverse, count_words, each taking {"input": string} |
invoke-tool | runs one of them, or a typed error |
Core: the Plugin trait
Every plugin implements three functions and wires them with export!:
#![allow(unused)]
fn main() {
use chatty_module_sdk::{
export, Plugin, PluginMetadata, ToolCallRequest, ToolDefinition, ToolError, ToolResult,
};
pub struct Echo;
impl Plugin for Echo {
fn metadata() -> PluginMetadata { /* … */ }
fn list_tools() -> Vec<ToolDefinition> { /* … */ }
fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> { /* … */ }
}
export!(Echo);
}
Plugin and export! are generated by wit-bindgen from the WIT file, so the
component’s export names always match what chatty loads.
Step 1 — Metadata
metadata says who the plugin is and which host capabilities it needs.
name and version match [module] in its module.toml. echo only logs,
and logging is always granted, so it requests nothing:
#![allow(unused)]
fn main() {
fn metadata() -> PluginMetadata {
PluginMetadata {
name: "echo".to_string(),
version: "0.2.0".to_string(),
description: "Reference plugin: echo, reverse and count_words tools".to_string(),
requested_capabilities: vec![],
config_keys: vec![],
}
}
}
A plugin that calls the model through llm::complete would request
Capability::Llm; one that reads [config] values, Capability::Config.
Step 2 — Define tools
list_tools advertises each tool with a JSON Schema for its arguments:
#![allow(unused)]
fn main() {
fn list_tools() -> Vec<ToolDefinition> {
vec![
tool("echo", "Returns the input string unchanged.", "String to echo"),
tool("reverse", "Returns the input string with characters in reverse order.", "String to reverse"),
tool("count_words", "Returns the number of whitespace-separated words in the input string.", "String to count words in"),
]
}
}
(tool is a small helper in src/lib.rs that builds the
{"input": string} schema.)
Step 3 — Run a tool call
invoke_tool gets a ToolCallRequest: the tool’s name and the model’s
arguments as one JSON object in arguments_json, shaped by the schema the
tool declared. It returns the text the model sees, or a typed error the model
reads as <kind>: <message>:
#![allow(unused)]
fn main() {
fn invoke_tool(call: ToolCallRequest) -> Result<ToolResult, ToolError> {
chatty_module_sdk::log::info(&format!("echo: invoke_tool name={}", call.name));
let run: fn(&str) -> String = match call.name.as_str() {
"echo" => |input| input.to_string(),
"reverse" => |input| input.chars().rev().collect(),
"count_words" => |input| input.split_whitespace().count().to_string(),
other => return Err(ToolError::unknown_tool(other)),
};
Ok(ToolResult::text(run(&input_argument(&call.arguments_json)?)))
}
}
input_argument parses arguments_json and answers
ToolError::invalid_arguments when input is missing.
Full implementation → src/lib.rs
Build and install
cd modules/echo
cargo build --target wasm32-wasip2 --release
cp target/wasm32-wasip2/release/echo.wasm .
# Linux: install into chatty's module directory (Settings → Modules shows it)
cp -r . ~/.local/share/chatty/modules/echo/
module.toml beside the .wasm names it:
[module]
name = "echo"
version = "0.2.0"
wasm = "echo.wasm"
[capabilities]
tools = ["echo", "reverse", "count_words"]
[protocols]
mcp = true
Call it from an agent
A plugin does nothing until an agent loads it. Write a spec that lists it,
e.g. .chatty/agents/echo-user.toml in your workspace:
[agent]
name = "echo-user"
preamble = "Use the echo tools when asked to transform text."
[[plugins]]
module = "echo"
Then:
chatty-tui --agent echo-user --headless -m "Reverse the word hello."
The model sees echo__echo, echo__reverse and echo__count_words beside
its other tools; a call runs the plugin in-process, under its sandbox limits.
The next tutorial gives a real agent a plugin:
give an agent the plugin (benford).
Expose it to external MCP clients
With [protocols] mcp = true, the desktop’s protocol gateway (Settings →
Plugins, module runtime on) also serves the tools to any MCP client that holds
its launch token, over a Unix socket rather than a port
(Use a plugin from another MCP client):
dir="$XDG_RUNTIME_DIR/chatty-run"
curl -s --unix-socket "$dir/gateway.sock" http://localhost/mcp/echo \
-H "Authorization: Bearer $(cat "$dir/gateway.token")" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"reverse","arguments":{"input":"hello"}}}'
There is no OpenAI or A2A route for a plugin: it has tools, not a loop.
Automated tests
From the repo root:
make wasm-modules
cargo test -p chatty-protocol-gateway --test echo_plugin_e2e
cargo test -p chatty-tui --test plugins_headless
echo_plugin_e2e covers registry load, the three exports and the MCP
route; plugins_headless runs chatty-tui --headless with a spec that
calls echo__reverse against a scripted model.
Next steps
- Scaffold your own plugin:
templates/module/ - Give an agent a plugin: Tutorial: benford
- Quick start, project layout and testing: Build a WASM plugin
- The contract: WIT reference