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

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

Exportecho’s answer
metadataname echo, version 0.2.0, no requested capabilities
list-toolsecho, reverse, count_words, each taking {"input": string}
invoke-toolruns 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