Repository navigation
Plugin Development
English · 中文
Tracks v0.12.0 (
3a762b1). The build commands here were run on Linux with wasmtime 19. The interface is defined bywit/nanopi-extension.witin the repo — if this page disagrees with it, the repo wins.
A plugin is a standalone WASM component, not a crate that links nanopi. It never does use nanopi::...; its only dependency is usually serde_json.
The whole contract between host and plugin is wit/nanopi-extension.wit, which declares three worlds:
world extension {
import host-log: func(level: u8, message: string);
import host-fs-read: func(path: string) -> string;
import host-http-get: func(url: string) -> string;
import host-store-get: func(key: string) -> string;
import host-store-set: func(key: string, value: string) -> string;
import host-notify: func(text: string) -> string;
import host-set-context: func(text: string) -> string;
import host-call-tool: func(name: string, args-json: string) -> string;
import host-send-user-message: func(text: string) -> string;
export list-tools: func() -> string;
export execute-tool: func(name: string, args-json: string) -> string;
}
world extension-commands {
include extension;
export list-commands: func() -> string;
export execute-command: func(name: string, args: string) -> string;
}
world extension-events {
include extension-commands;
export list-events: func() -> string;
export handle-event: func(event: string, payload-json: string) -> string;
}-
list-toolsis called once at load time and returns a JSON array of tools with their JSON Schema. A component that doesn't export it is rejected as "not a nanopi extension". -
execute-toolis called once per tool invocation and returns{"content": "...", "is_error": false}. - Every import except
host-logandhost-notifyis refused unless the user granted it in that plugin's[[extensions]]entry. A refusal is an in-banderror:-prefixed string, never a trap. Full semantics: Plugin-Capabilities.
The worlds are a linear ladder, not a lattice:
extension ⊂ extension-commands ⊂ extension-events
WIT cannot express an optional export. wasm-tools component new fails if a world declares an export the module does not define — so targeting a higher world means implementing every export below it, even the ones you have no use for. An events-only plugin exports list-commands returning "[]" and an execute-command that returns an error; two-line stubs, and the accepted cost of not maintaining four worlds today and eight after the next feature.
The host is looser than the toolchain here: it resolves list-commands, execute-command and list-events optionally, so plugins compiled against the older worlds keep loading unchanged. It is the guest-side build that forces the stubs. (One asymmetry: if your list-events names any events, a missing handle-event is a hard load error — having asked to be delivered to, there is no sensible fallback.)
Imports are the other direction and need no world at all. All nine live on world extension and are inherited upward; a guest built against an older WIT simply never references the newer ones. This is why every v0.11-era plugin binary still loads today.
Lifecycle events are observe-only. handle-event's return value is discarded — a plugin cannot block a tool call or rewrite a prompt, and that is expressed in the types rather than checked at runtime. If you need to refuse something, that is Hooks, the shell-command mechanism the user configures themselves. Plugin-side details: Plugin-Events.
mkdir -p my-plugin/src && cd my-pluginCargo.toml — the load-bearing line is crate-type = ["cdylib"], which is what makes the wasm32 target emit a .wasm:
[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"Below is a complete, compilable template. Copy the BOILERPLATE section verbatim; the YOUR TOOLS section is yours.
#![no_std]
extern crate alloc;
use alloc::format;
use alloc::string::{String, ToString};
use core::alloc::{GlobalAlloc, Layout};
// ═══ BOILERPLATE — copy verbatim ════════════════════════════════════
// no_std on wasm32 links no libc, but rustc still emits memcmp
// (from string comparison). Cheaper to provide it than to pull in
// compiler-builtins for one function.
#[no_mangle]
pub unsafe extern "C" fn memcmp(a: *const u8, b: *const u8, n: usize) -> i32 {
for i in 0..n {
let (x, y) = (*a.add(i), *b.add(i));
if x != y {
return x as i32 - y as i32;
}
}
0
}
// no_std still needs a heap for String/Vec. wasm32 is single-threaded,
// so a bump allocator over a static arena is enough and keeps the
// module tiny — no dlmalloc, no free.
const ARENA_SIZE: usize = 1 << 20; // 1 MiB
static mut ARENA: [u8; ARENA_SIZE] = [0; ARENA_SIZE];
static mut OFFSET: usize = 0;
struct BumpAlloc;
unsafe impl GlobalAlloc for BumpAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let align = layout.align();
let start = (OFFSET + align - 1) & !(align - 1);
let end = start + layout.size();
if end > ARENA_SIZE {
return core::ptr::null_mut();
}
OFFSET = end;
core::ptr::addr_of_mut!(ARENA).cast::<u8>().add(start)
}
unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {}
}
#[global_allocator]
static ALLOC: BumpAlloc = BumpAlloc;
// Host imports. Note the trailing ret_area out-param — see "The ABI
// trap" below. Declare only the ones you call; the rest of the nine
// are in "The other host imports" below.
#[link(wasm_import_module = "$root")]
extern "C" {
#[link_name = "host-log"]
fn host_log_raw(level: u32, ptr: *const u8, len: usize);
#[link_name = "host-fs-read"]
fn host_fs_read_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-http-get"]
fn host_http_get_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
}
unsafe fn host_log(level: u32, msg: &str) {
host_log_raw(level, msg.as_ptr(), msg.len());
}
unsafe fn call_host_str(
f: unsafe extern "C" fn(*const u8, usize, *mut u8),
arg: &str,
) -> String {
let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
f(arg.as_ptr(), arg.len(), ret_area);
let ptr = ret_area.cast::<u32>().read() as *const u8;
let len = ret_area.cast::<u32>().add(1).read() as usize;
read_string(ptr, len)
}
#[allow(dead_code)]
unsafe fn host_fs_read(path: &str) -> String {
call_host_str(host_fs_read_raw, path)
}
unsafe fn host_http_get(url: &str) -> String {
call_host_str(host_http_get_raw, url)
}
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// Called by the host to allocate space in our memory when it needs to
// pass us a string. Required by the canonical ABI.
#[no_mangle]
pub unsafe extern "C" fn cabi_realloc(
_old_ptr: *mut u8,
_old_len: usize,
align: usize,
new_len: usize,
) -> *mut u8 {
if new_len == 0 {
return align as *mut u8;
}
ALLOC.alloc(Layout::from_size_align_unchecked(new_len, align))
}
// The canonical ABI's return convention for an export returning a
// string: return a pointer to an 8-byte (ptr, len) pair.
unsafe fn string_result(s: String) -> *mut u8 {
let bytes = s.into_bytes();
let len = bytes.len();
let ptr = if len == 0 {
1 as *mut u8 // non-null dangling is fine for a 0-length string
} else {
let p = ALLOC.alloc(Layout::from_size_align_unchecked(len, 1));
core::ptr::copy_nonoverlapping(bytes.as_ptr(), p, len);
p
};
core::mem::forget(bytes);
let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
ret_area.cast::<u32>().write(ptr as u32);
ret_area.cast::<u32>().add(1).write(len as u32);
ret_area
}
unsafe fn read_string(ptr: *const u8, len: usize) -> String {
let slice = core::slice::from_raw_parts(ptr, len);
String::from_utf8_unchecked(slice.to_vec()) // WIT string is valid UTF-8
}
fn ok(content: String) -> String {
serde_json::json!({ "content": content, "is_error": false }).to_string()
}
fn err(content: String) -> String {
serde_json::json!({ "content": content, "is_error": true }).to_string()
}
// Export names must be the WIT names verbatim, hyphens and all. Rust
// identifiers can't contain hyphens, hence export_name, not no_mangle.
#[export_name = "list-tools"]
pub unsafe extern "C" fn list_tools() -> *mut u8 {
string_result(TOOL_SPECS.to_string())
}
#[export_name = "execute-tool"]
pub unsafe extern "C" fn execute_tool(
name_ptr: *const u8,
name_len: usize,
args_ptr: *const u8,
args_len: usize,
) -> *mut u8 {
// Read the arguments before allocating anything else — they live
// in our arena, put there by the host's cabi_realloc calls.
let name = read_string(name_ptr, name_len);
let args_json = read_string(args_ptr, args_len);
string_result(dispatch(&name, &args_json))
}
// ═══ YOUR TOOLS — everything below is yours ═════════════════════════
const TOOL_SPECS: &str = r#"[
{
"name": "greet",
"description": "Greet someone by name. Use when the user asks for a greeting.",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Who to greet." }
},
"required": ["name"]
}
},
{
"name": "fetch_head",
"description": "Fetch a URL and return the first 200 characters of the response body. Requires allow_network = true and the host in url_allowlist on this plugin's [[extensions]] entry.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "The http/https URL to fetch." }
},
"required": ["url"]
}
}
]"#;
fn dispatch(name: &str, args_json: &str) -> String {
let args: serde_json::Value = match serde_json::from_str(args_json) {
Ok(v) => v,
Err(e) => return err(format!("bad arguments JSON: {e}")),
};
match name {
"greet" => {
let who = match args.get("name").and_then(|v| v.as_str()) {
Some(s) => s,
None => return err("missing required argument: name".to_string()),
};
ok(format!("Hello, {who}!"))
}
"fetch_head" => {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(s) => s,
None => return err("missing required argument: url".to_string()),
};
unsafe { host_log(1, "fetch_head: calling host-http-get") };
let body = unsafe { host_http_get(url) };
// The host signals refusal and failure in-band with this
// prefix. Pass it through so the model sees the reason.
if body.starts_with("error: ") {
return err(body);
}
let head: String = body.chars().take(200).collect();
ok(format!("{} bytes; first 200 chars:\n{head}", body.len()))
}
other => err(format!("unknown tool: {other}")),
}
}Imports and exports return strings differently:
| Direction | Convention |
|---|---|
| import returning a string | takes the return area as a trailing ret_area: *mut u8 out-param |
| export returning a string | returns a pointer to an 8-byte (ptr, len) pair |
Getting it backwards does not fail at runtime. It fails at wasm-tools component new with a type mismatch. When you see that error, come back to this table first.
The second trap: export names must be the hyphenated WIT names verbatim. wasm-tools component embed looks for list-tools, not list_tools, and Rust identifiers can't contain hyphens — so #[export_name = "list-tools"], not #[no_mangle].
The template above declares three of the nine. The remaining six follow the same two shapes — one string in, or two strings in, one string out:
#[link(wasm_import_module = "$root")]
extern "C" {
#[link_name = "host-store-get"]
fn host_store_get_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-notify"]
fn host_notify_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-set-context"]
fn host_set_context_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
#[link_name = "host-send-user-message"]
fn host_send_user_message_raw(ptr: *const u8, len: usize, ret_area: *mut u8);
// Two string params flatten to two (ptr, len) pairs, then ret_area.
#[link_name = "host-store-set"]
fn host_store_set_raw(
k_ptr: *const u8, k_len: usize,
v_ptr: *const u8, v_len: usize,
ret_area: *mut u8,
);
#[link_name = "host-call-tool"]
fn host_call_tool_raw(
n_ptr: *const u8, n_len: usize,
a_ptr: *const u8, a_len: usize,
ret_area: *mut u8,
);
}
unsafe fn call_host_str2(
f: unsafe extern "C" fn(*const u8, usize, *const u8, usize, *mut u8),
a: &str,
b: &str,
) -> String {
let ret_area = ALLOC.alloc(Layout::from_size_align_unchecked(8, 4));
f(a.as_ptr(), a.len(), b.as_ptr(), b.len(), ret_area);
let ptr = ret_area.cast::<u32>().read() as *const u8;
let len = ret_area.cast::<u32>().add(1).read() as usize;
read_string(ptr, len)
}Every one of these returns either a useful string or an error: -prefixed one — including a refusal, when the user did not grant the capability. Check the prefix; do not assume success. host-store-get is the one exception worth remembering: an absent key reads as "", not an error.
If a signature is wrong, you find out at wasm-tools component new, not at runtime — same as the ABI trap above.
Targeting extension-commands or extension-events means every export below it must exist. If you register no commands:
#[export_name = "list-commands"]
pub unsafe extern "C" fn list_commands() -> *mut u8 {
string_result("[]".to_string())
}
#[export_name = "execute-command"]
pub unsafe extern "C" fn execute_command(
_n_ptr: *const u8, _n_len: usize,
_a_ptr: *const u8, _a_len: usize,
) -> *mut u8 {
string_result(r#"{"error":"this plugin registers no commands"}"#.to_string())
}An execute-command action object is single-key: {"print": …}, {"send_user_message": …} or {"error": …}.
Run this from the nanopi repo root, because wit/ is resolved relative to it:
cd /path/to/nanopi
# 0. once: add the wasm target
rustup target add wasm32-wasip1
# 1. compile to a wasm module
cargo build --manifest-path /path/to/my-plugin/Cargo.toml \
--target wasm32-wasip1 --release
# 2. embed the WIT world — do not skip this
wasm-tools component embed wit/ \
/path/to/my-plugin/target/wasm32-wasip1/release/my_plugin.wasm \
-o /tmp/embedded.wasm --world extension
# ... or --world extension-commands / --world extension-events,
# but then every export of the worlds below must exist (see above)
# 3. module -> component
wasm-tools component new /tmp/embedded.wasm \
-o my-plugin.component.wasmwasm-tools comes from cargo install wasm-tools --locked.
Skipping step 2 yields a component with an empty world. The host then can't find list-tools and rejects it as "not a nanopi extension". This is the most common failure.
Do not cd into the plugin directory to build. The embed wit/ step resolves wit/ against the repo root; mixing the two silently embeds a stale module — it builds, it loads, but it's missing the import you just added, with nothing pointing at the cause.
wasm-tools component wit my-plugin.component.wasmworld root {
import host-log: func(level: u8, message: string);
import host-http-get: func(url: string) -> string;
export list-tools: func() -> string;
export execute-tool: func(name: string, args-json: string) -> string;
}
Note that host-fs-read is absent — the template declares it but never calls it, so LTO drops it. Only imports you actually call end up in the world. That's expected, not a bug.
# ~/.nanopi/config.toml or <project>/.nanopi/config.toml
[[extensions]]
path = "/absolute/path/my-plugin.component.wasm"
allow_network = true
url_allowlist = ["127.0.0.1", "api.github.com"]Use an absolute path. ~ in path expands to $HOME and does not honour NANOPI_HOME — setting NANOPI_HOME and writing ~/... resolves under $HOME anyway, giving you skipping extension (not found).
You're wired up when startup prints:
nanopi: registered extension tool "greet"
nanopi: registered extension tool "fetch_head"
Not seeing it? In order: was the host built with --features wasm; is the path absolute and present; does wasm-tools component wit show list-tools.
Config layering and merge rules: Configuration. Capability gates: Plugin-Capabilities.
The description IS the prompt. The model decides whether to call your tool by reading it. Vague text means the tool never gets used. Write it like API documentation, and state preconditions explicitly ("requires allow_network") so the model can understand a refusal instead of retrying blindly.
Pass capability errors through; don't swallow them. The host signals refusals in-band with an error: prefix:
if body.starts_with("error: ") {
return err(body); // let the model see it's a permission problem
}Don't panic in guest code. A panic traps, and all the host can tell the model is "the tool call failed" — no actionable detail. Return is_error: true with a sentence instead.
The arena is 1 MiB and never reclaimed. execute-tool deliberately does not reset it on entry (the incoming arguments live there), so keep one call's total allocation under 1 MiB. The host's response-body cap for host-http-get is sized to match.
- Debug your tools without an LLM → Debugging-Plugins
- Full capability semantics, gate by gate → Plugin-Capabilities
- Subscribing to lifecycle events from a plugin → Plugin-Events
- Lifecycle hooks (the other extension mechanism, and the only one that can refuse) → Hooks
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit