Skip to content
Dev edited this page Sep 7, 2026 · 4 revisions

English · 中文

nanopi Wiki

A minimal Pi port written in Rust. This is the documentation for users and plugin authors.

This wiki tracks nanopi v0.12.0 (3a762b1).

The wiki is a separate repository — it does not go through PR review with the code, so it will drift. Every page states the version it was verified against and the command to re-verify it. When something doesn't match, wit/nanopi-extension.wit and src/config.rs in the main repo are the source of truth.


Start here

What you want Page
Get the lay of the land — what nanopi actually does Features
Configure a model, API key, or understand config precedence Configuration
Write your own WASM plugin Plugin-Development
Understand what a plugin can reach (files, network, your store, the model's context, built-in tools, your money) Plugin-Capabilities
Let a plugin observe lifecycle events Plugin-Events
Write shell hooks — the mechanism that can refuse something Hooks
Figure out why your plugin isn't working Debugging-Plugins

The plugin system in one screen

A nanopi plugin is a standalone WASM component. It does not link against nanopi and does not run inside the host's trust boundary. The entire contract is nine imports and six exports, spread across three worlds in wit/nanopi-extension.wit:

// world extension — the imports all live here, and are inherited upward
import host-log:               func(level: u8, message: string)
import host-fs-read:           func(path: string) -> string                 // allow_fs
import host-http-get:          func(url: string) -> string                  // allow_network + url_allowlist
import host-store-get:         func(key: string) -> string                  // allow_store
import host-store-set:         func(key: string, value: string) -> string   // allow_store
import host-notify:            func(text: string) -> string                 // ungated, rate-limited
import host-set-context:       func(text: string) -> string                 // allow_context
import host-call-tool:         func(name: string, args-json: string) -> string  // allow_tools, per tool
import host-send-user-message: func(text: string) -> string                 // allow_send_message

export list-tools:      func() -> string
export execute-tool:    func(name: string, args-json: string) -> string

// world extension-commands = extension + user-typed slash commands
export list-commands:   func() -> string
export execute-command: func(name: string, args: string) -> string

// world extension-events = extension-commands + lifecycle observation
export list-events:     func() -> string
export handle-event:    func(event: string, payload-json: string) -> string

The worlds are a linear ladder, not a lattice:

extension  ⊂  extension-commands  ⊂  extension-events

Target the smallest one that fits. WIT cannot express an optional export — wasm-tools component new fails on a declared export that is missing — so a plugin that only wants events still has to export the two command functions as stubs. That is the accepted cost of not having four worlds today and eight after the next feature. Imports need no new world: they are all on world extension and a guest compiled against an older WIT simply never references the new ones, which is why v0.11-era binaries still load.

Structured data crosses the boundary as JSON strings. That keeps the ABI down to a single primitive type, which is what lets the host bind these by hand with Instance::get_typed_func instead of pulling in a wit-bindgen build step.

Capabilities are off by default. Install a plugin and it can read no files, reach no network, persist nothing, touch no built-in tool, put nothing in the model's context, and start no turn until you explicitly opt in. Network access is gated twice over — the switch, and then a host allowlist where empty means deny everything. Lifecycle events need the grant and the plugin's own request. See Plugin-Capabilities and Plugin-Events.


One important difference from upstream Pi

Upstream Pi loads extensions with jiti.import() directly into the main process as TypeScript, giving them the full Node global scope — fetch, node:net, child_process, all of it. There is no sandbox and no URL allowlist anywhere. Pi's SECURITY.md states this as deliberate design: "the Pi coding agent intentionally does not have a sandbox."

nanopi went the other way: plugins run under wasmtime with per-capability opt-in.

So porting a Pi extension does not carry its privileges over. A casual fetch() on the Pi side requires the user to both set allow_network = true and add the host to url_allowlist here.


Build note

The host needs --features wasm to have a WASM runtime at all:

cargo build --features wasm

The prebuilt static musl release binary does not include it. If you've installed a plugin and see no registered extension tool lines at startup, check this first.

Clone this wiki locally