Practical, step-by-step tutorials for building WebAssembly plugins and integrating with Chattree.
This tutorial demonstrates how to build a /greet <name> plugin using only raw WAT.
Create a folder named greet-plugin:
greet-plugin/
├── plugin.json
└── plugin.wat
plugin.json){
"schemaVersion": 1,
"id": "com.example.greet",
"version": "1.0.0",
"name": "Greeter",
"description": "Greets user with an emphatic welcome.",
"author": "Your Name",
"primaryCommand": "greet",
"aliases": [],
"type": "imported",
"capabilities": [],
"runtime": "wasm",
"entrypoint": "greet",
"wasmPath": "plugin.wasm",
"wasmSha256": "REPLACE_WITH_SHA256",
"runtimeApiVersion": "1.0.0",
"maxMemoryPages": 2,
"timeoutMs": 5000,
"fuelLimit": 50000000,
"tools": [
{
"id": "com.example.greet.greet",
"command": "greet",
"displayName": "Greet",
"description": "Greets you: /greet <name>",
"capabilities": []
}
]
}
plugin.wat)(module
;; Export 2 memory pages (128 KB)
(memory (export "memory") 2)
;; Pre-load "Welcome to Chattree, " (21 bytes) at offset 1024
(data (i32.const 1024) "Welcome to Chattree, ")
;; Exported function: (inputPtr, inputLen) -> outputLen
(func (export "greet") (param $in_ptr i32) (param $in_len i32) (result i32)
(local $prefix_len i32)
(local.set $prefix_len (i32.const 21))
;; Copy the user's input string right after the prefix
(memory.copy
(i32.add (i32.const 1024) (local.get $prefix_len))
(local.get $in_ptr)
(local.get $in_len)
)
;; Append exclamation mark "!"
(i32.store8
(i32.add
(i32.add (i32.const 1024) (local.get $prefix_len))
(local.get $in_len)
)
(i32.const 33)
)
;; Return total output length: 21 + inputLen + 1
(i32.add
(i32.add (local.get $prefix_len) (local.get $in_len))
(i32.const 1)
)
)
)
# 1. Compile WAT to binary WASM
wat2wasm plugin.wat -o plugin.wasm
# 2. Compute SHA-256
# On Linux/macOS:
sha256sum plugin.wasm
# On Windows PowerShell:
# Get-FileHash plugin.wasm -Algorithm SHA256
# 3. Update "wasmSha256" in plugin.json with the generated 64-char hash
# 4. Create package
zip greet.chattree-plugin plugin.json plugin.wasm
wasm32-unknown-unknown)Rust provides memory safety, easy string handling, and serde_json for interacting with the Host API.
cargo new --lib chattree_counter
cd chattree_counter
Cargo.toml[package]
name = "chattree_counter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
[profile.release]
opt-level = "s"
lto = true
src/lib.rs)use std::slice;
use std::str;
// Import the Chattree Host API bridge function
extern "C" {
fn host_call(req_ptr: i32, req_len: i32, res_ptr: i32, res_max_len: i32) -> i32;
}
// Global static buffer for output (starts at offset 1024 convention)
static mut OUTPUT_BUFFER: [u8; 8192] = [0; 8192];
static mut RES_BUFFER: [u8; 4096] = [0; 4096];
#[no_mangle]
pub extern "C" fn count(input_ptr: *const u8, input_len: usize) -> i32 {
let _input = unsafe {
let bytes = slice::from_raw_parts(input_ptr, input_len);
str::from_utf8(bytes).unwrap_or("")
};
// 1. Fetch current counter from storage
let get_req = r#"{"api":"storage","operation":"get","payload":{"key":"visits"}}"#;
let mut current_val = 0;
unsafe {
let len = host_call(
get_req.as_ptr() as i32,
get_req.len() as i32,
RES_BUFFER.as_mut_ptr() as i32,
RES_BUFFER.len() as i32,
);
if len > 0 {
if let Ok(res_str) = str::from_utf8(&RES_BUFFER[..len as usize]) {
// Parse: {"success":true,"data":{"value":"5"}}
if let Some(pos) = res_str.find("\"value\":\"") {
let rest = &res_str[pos + 9..];
if let Some(end) = rest.find('"') {
current_val = rest[..end].parse::<i32>().unwrap_or(0);
}
}
}
}
}
current_val += 1;
// 2. Save incremented counter
let set_req = format!(
r#"{{"api":"storage","operation":"set","payload":{{"key":"visits","value":"{}"}}}}"#,
current_val
);
unsafe {
host_call(
set_req.as_ptr() as i32,
set_req.len() as i32,
RES_BUFFER.as_mut_ptr() as i32,
RES_BUFFER.len() as i32,
);
}
// 3. Format response string
let response = format!("You have called this command {} times!", current_val);
let bytes = response.as_bytes();
unsafe {
let len = bytes.len().min(OUTPUT_BUFFER.len());
OUTPUT_BUFFER[..len].copy_from_slice(&bytes[..len]);
len as i32
}
}
# Build target
cargo build --target wasm32-unknown-unknown --release
# Copy and rename wasm
cp target/wasm32-unknown-unknown/release/chattree_counter.wasm plugin.wasm
# Compute SHA-256 and package with plugin.json
sha256sum plugin.wasm
zip counter.chattree-plugin plugin.json plugin.wasm
network.httpWhen fetching data from public APIs:
"capabilities": ["network.http"] in plugin.json.https:// (unencrypted http:// is restricted).localhost or internal LAN IPs (SSRF guard blocks private addresses).network.http_request is asynchronous:
host_call → receives 0.resume(resPtr, resLen) to receive the response.network.websocketThis tutorial walks through building a real-time event listener or bridge plugin.
Declare network.websocket in plugin.json. If your plugin connects to a local gateway on your home Wi-Fi (e.g. Home Assistant or local bridge), also declare the critical capability network.local.
{
"capabilities": ["network.websocket"]
}
Chattree manages a connection pool allowing up to 10 concurrent sockets per plugin:
websocket.connect, Chattree pre-reserves connection capacity atomically before beginning the network handshake. This prevents quota race conditions when multiple handshakes initiate simultaneously.pingIntervalSeconds (default 30 seconds).websocket.receive (or websocket.poll) with maxMessages: 50 to periodically drain the queue.1009 to protect mobile RAM.Always close unused connections via websocket.close. If your plugin crashes or is disabled, Chattree's lifecycle manager automatically purges and terminates all open sockets.
ui.componentsChattree plugins can move beyond plain text or Markdown by emitting rich declarative UI cards.
Add ui.components to capabilities in plugin.json.
Your WASM binary can write a structured JSON card directly to linear memory offset 1024 as a ToolResult.ui:
{
"type": "card",
"displayText": "Deployment #42",
"ui": {
"title": "Continuous Deployment",
"elements": [
{ "type": "badge", "text": "RUNNING", "variant": "warning" },
{ "type": "progress", "value": 60, "label": "Deploying containers..." },
{
"type": "key_value",
"items": [
{ "label": "Service", "value": "api-gateway" },
{ "label": "Revision", "value": "v2.4.1" }
]
},
{ "type": "divider" },
{
"type": "button_row",
"buttons": [
{ "label": "Cancel Build", "action": "command", "payload": "/build cancel 42", "variant": "danger" }
]
}
]
}
}
UiApiHandler.sanitizeUiCard to strip malicious scripts or malformed nodes.action: "command" that targets a slash command (starts with /) triggers an explicit confirmation dialog showing the user the exact command before sending it to the chat.scheduler.taskThis tutorial shows how to schedule recurrent jobs and receive event callbacks.
Use schemaVersion: 2 and declare scheduler.task (or notifications.read for device notification listeners). You can also provide an optional filter object in eventConsumers so HostEventBus pre-filters events (supporting scalar equality or arrays of allowed values) before instantiating WASM:
{
"schemaVersion": 2,
"capabilities": ["scheduler.task", "notifications.send"],
"eventConsumers": [
{
"id": "morning_check",
"eventTypes": ["scheduler.trigger"],
"targetAction": "com.example.plugin.morning_check"
},
{
"id": "whatsapp_autoreply",
"eventTypes": ["device.notification.received", "device.notification.updated"],
"filter": {
"packageName": ["com.whatsapp", "com.whatsapp.w4b"],
"canReply": true
},
"targetAction": "com.example.plugin.wa",
"requiredPermissions": ["notifications.read", "notifications.reply", "ai.context"]
}
]
}
Chattree evaluates 5-field Cron expressions:
minute (0-59) hour (0-23) day (1-31) month (1-12) weekday (0-6)
Examples:
*/15 * * * * — Every 15 minutes0 9 * * * — Every day at 9:00 AM0 18 * * 1-5 — Weekdays at 6:00 PMTo protect device battery and prevent runaway loops, Chattree strictly enforces a 60-second minimum interval. Any schedule attempting to run faster than once per 60 seconds is rejected with minimumIntervalViolation.
Plugins can register up to 25 active scheduled tasks with payloads up to 128 KB. All tasks are automatically cleaned up when the plugin is uninstalled.
database.sqlFor complex data structures, Chattree provisions a private SQLite database for your plugin.
Each plugin has a dedicated SQLite database (plugin_<pluginId>.db). No other plugin or host service can read or alter your tables.
database.execute for CREATE TABLE, INSERT, UPDATE, DELETE.database.query for SELECT. Queries return up to 2,000 rows per call.database.transaction with multiple statements. If any statement fails, the entire batch rolls back atomically.You can pass native types in params or values:
true/false) are automatically converted to 1/0.Chattree enforces a 200 MB quota measuring the primary .db file plus SQLite write-ahead log files (.db-wal, .db-shm, and .db-journal). If your database approaches 200 MB, delete obsolete rows or run VACUUM. Dangerous SQL commands (ATTACH DATABASE, DETACH DATABASE, VACUUM INTO, and PRAGMA writable_schema) are blocked.
When building plugins in Rust or C, understanding linear memory layout and UTF-8 safety is critical to prevent crashes, fuel exhaustion, or memory corruption.
Chattree uses a well-defined shared memory convention:
0 to 1023: Reserved for input strings passed from Chattree.1024 onwards: Destination for plugin output text.2048, 4096): Request and response scratch buffers for Host API calls.Writing past the allocated linear memory space causes runtime traps (out of bounds memory access).
// Static output buffer sized appropriately
static mut OUTPUT_BUFFER: [u8; 16384] = [0; 16384]; // 16 KB
#[no_mangle]
pub extern "C" fn my_command(input_ptr: *const u8, input_len: usize) -> i32 {
let result_string = generate_large_report();
let bytes = result_string.as_bytes();
unsafe {
// Enforce boundary check against maximum buffer size
let copy_len = bytes.len().min(OUTPUT_BUFFER.len());
OUTPUT_BUFFER[..copy_len].copy_from_slice(&bytes[..copy_len]);
copy_len as i32
}
}
Never slice a UTF-8 byte stream at arbitrary indices. If a multi-byte Unicode character (e.g. emojis or accented characters) is split mid-codepoint, std::str::from_utf8 will fail or produce replacement characters (``).
/// Truncates a UTF-8 string safely at the nearest valid character boundary.
fn safe_truncate_utf8(s: &str, max_bytes: usize) -> &str {
if max_bytes >= s.len() {
return s;
}
let mut boundary = max_bytes;
while boundary > 0 && !s.is_char_boundary(boundary) {
boundary -= 1;
}
&s[..boundary]
}
Each WebAssembly memory page represents 64 KB. If your plugin processes large datasets, ensure plugin.json allocates sufficient pages:
{
"maxMemoryPages": 8 // 8 pages = 512 KB
}
In Rust, the default allocator (wee_alloc or standard dlmalloc) will grow linear memory via the memory.grow instruction automatically within the limit defined by maxMemoryPages (up to 1024 pages / 64 MB).