Rust plugin SDK
Phase 31.9. Author plugins in Rust that the daemon spawns as subprocesses, talking the same JSON-RPC 2.0 wire format used by the Python / TypeScript / PHP SDKs.
The SDK lives in
crates/microapp-sdk/
behind the plugin Cargo feature; the reference plugin template
is at
extensions/template-plugin-rust/.
Use nexo plugin new <id> --lang rust to scaffold a fresh
out-of-tree project from that template.
Read this when
- You picked Rust from the language picker in Plugin authoring overview and want the SDK reference.
- You are porting an in-tree plugin (
crates/plugins/<id>) into an out-of-tree subprocess and need the wire-API mapping. - You want the canonical Rust handler signature for
broker.eventnotifications.
Why subprocess + Rust
Running Rust plugins as separate processes — instead of crates linked into the daemon — gives you:
- Isolation — a panic in your plugin terminates one process, not the daemon.
- One contract, every language — the daemon treats your binary the same way it treats Python or TypeScript plugins. Switching languages later is an SDK choice, not a daemon recompile.
- No link-time coupling — your plugin can use any Rust
toolchain or
tokioversion that compiles; the daemon does not care. - Single static binary —
cargo build --releaseproduces one file the publish workflow uploads as the per-target tarball.
Daemon-side spawn code in
crates/core/src/agent/nexo_plugin_registry/subprocess.rs
treats the plugin as an opaque executable; Rust plugins re-use
that path without modification.
Architecture
Operator host Plugin process
┌──────────────────┐ stdin ┌─────────────────────────────┐
│ daemon (Rust) │──JSON-RPC──▶│ target/release/<id> │
│ subprocess host │ │ tokio::main async runtime │
│ │◀──JSON-RPC──│ PluginAdapter.run_stdio() │
└──────────────────┘ stdout └─────────────────────────────┘
The daemon writes newline-delimited JSON-RPC requests to your
binary's stdin; you write replies + outbound broker.publish
notifications back on stdout. stderr is collected by the
operator's tracing pipeline (Phase 81.23 fold pending) — use it
freely for plugin-side logs.
Public API
#![allow(unused)] fn main() { use nexo_broker::Event; use nexo_microapp_sdk::plugin::{BrokerSender, PluginAdapter}; }
PluginAdapter builder methods:
| Method | Required | Description |
|---|---|---|
PluginAdapter::new(manifest_toml: &str) | ✅ | Body of nexo-plugin.toml. Read once at startup; the SDK validates plugin.id + plugin.version and surfaces ManifestError on parse failure. |
.on_broker_event(handler) | ⬜ | async fn(topic: String, event: Event, broker: BrokerSender). Invoked for every broker.event notification. Each handler call is spawned on the runtime; the dispatch loop continues reading stdin without blocking. |
.on_shutdown(handler) | ⬜ | async fn() -> Result<(), Box<dyn Error + Send + Sync>>. Awaited before the SDK replies {ok: true} to the host's shutdown request. In-flight on_broker_event tasks are awaited too. |
.run_stdio().await | ✅ | Single-shot — calling it twice returns PluginError::AlreadyRunning. Drives the JSON-RPC loop until stdin closes or the host sends shutdown. |
Event (re-exported from nexo-broker) carries topic,
source, payload: serde_json::Value, optional correlation_id
metadata. Construct withEvent::new(topic, source, payload)which stamps a fresh UUID + RFC3339 timestamp.
BrokerSender::publish(topic: &str, event: Event) -> Result<(), WireError> serializes a broker.publish notification to stdout
under an internal write lock. The daemon's bridge re-checks the
topic against the manifest's [[plugin.channels.register]]
allowlist before forwarding to the broker.
Manifest example
[plugin]
id = "my_plugin"
version = "0.1.0"
name = "My Plugin"
description = "Forwards inbound events to a third-party API."
min_nexo_version = ">=0.1.0"
[plugin.requires]
nexo_capabilities = ["broker"]
[[plugin.channels.register]]
kind = "my_plugin_inbound"
description = "Inbound events the plugin emits onto the broker."
plugin.id MUST match ^[a-z][a-z0-9_]{0,31}$. Cargo's
[[bin]] name MUST equal plugin.id so the publish workflow's
pack-tarball.sh finds the artifact at
target/<target>/release/<id>.
See Plugin contract for the full manifest schema and the JSON-RPC envelope every method exchanges.
Quickstart
Scaffold + build + run, copy-paste:
nexo plugin new my_plugin --lang rust --owner alice
cd my_plugin
cargo build
nexo plugin run .
nexo plugin run boots the daemon with your plugin injected at
the head of plugins.discovery.search_paths, bypassing the
install pipeline. See Local dev loop for the
inner-loop conventions and --no-daemon-config.
The handler in the scaffolded src/main.rs echoes every
inbound event back on plugin.inbound.<id>_echo:
use nexo_broker::Event; use nexo_microapp_sdk::plugin::{print_manifest_if_requested, BrokerSender, PluginAdapter}; const MANIFEST: &str = include_str!("../nexo-plugin.toml"); #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // First line — honour the daemon's plugin auto-discovery probe. // When invoked with `--print-manifest`, dump the embedded TOML // to stdout and exit 0 before constructing any runtime state. print_manifest_if_requested(MANIFEST); tracing_subscriber::fmt() .with_writer(std::io::stderr) .init(); PluginAdapter::new(MANIFEST)? .on_broker_event(handle_event) .on_shutdown(|| async { tracing::info!("plugin shutdown handler invoked"); Ok(()) }) .run_stdio() .await?; Ok(()) } async fn handle_event(topic: String, event: Event, broker: BrokerSender) { let echo = Event::new( "plugin.inbound.my_plugin_echo", "my_plugin", serde_json::json!({ "echoed_from": topic, "echoed_payload": event.payload, }), ); let _ = broker .publish("plugin.inbound.my_plugin_echo", echo) .await; }
Replace the body of handle_event with your channel's real
outbound logic (forward to a third-party API, persist to disk,
trigger a downstream agent, etc.) and re-publish the API's
reply back through broker so agents can observe it.
Smoke test
Hand-run the binary against a synthetic JSON-RPC frame to confirm the handshake is well-formed:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
| ./target/debug/my_plugin
The plugin should print one JSON-RPC response containing your
manifest's id, version, name, and the SDK's
server_version. If you see anything other than a single line
of valid JSON on stdout, check that you have not added stray
println!s in the handler — every byte on stdout must be a
JSON-RPC frame. Use eprintln! / tracing::* for logs.
Auto-discovery probe
The daemon's discovery walker checks each candidate binary with
--print-manifest (Phase 81.33 Stage 8). Verify your plugin
answers it correctly:
./target/debug/my_plugin --print-manifest
The expected output is the verbatim contents of
nexo-plugin.toml followed by exit 0. The
print_manifest_if_requested(MANIFEST) call in main() handles
this for you — if the smoke test prints anything else (logs,
empty stdout, a JSON-RPC frame) the helper is missing from your
entry point.
Per-target tarball convention
Operators install Rust plugins via the same nexo plugin install <owner>/<repo>[@<tag>] CLI. The resolver expects
per-target tarballs:
<id>-<version>-<target>.tar.gz
├── nexo-plugin.toml
└── bin/<id> # static binary, mode 0755
Targets follow Rust's standard target triples
(x86_64-unknown-linux-gnu, aarch64-apple-darwin,
x86_64-unknown-linux-musl, etc.). The shipped CI workflow
in
extensions/template-plugin-rust/.github/workflows/release.yml
covers Linux musl + macOS by default; add additional matrix
entries to support more.
CI publish workflow
The shipped workflow has 4 jobs: validate-tag →
build (matrix) → optional sign (cosign keyless,
gated by repo variable COSIGN_ENABLED) → release (uploads
all tarballs + sha256 sidecars + signing material + a copy of
nexo-plugin.toml). See
Publishing a plugin for the full asset
naming convention and
Signing & publishing for the
end-to-end signed-release tutorial.
Local validation
Before pushing a tag, dry-run the pack step:
cargo build --release --target x86_64-unknown-linux-gnu
bash scripts/pack-tarball.sh x86_64-unknown-linux-gnu
ls dist/
# my_plugin-0.1.0-x86_64-unknown-linux-gnu.tar.gz
# my_plugin-0.1.0-x86_64-unknown-linux-gnu.tar.gz.sha256
The Rust integration test
extensions/template-plugin-rust/tests/pack_tarball.rs
covers this end-to-end against a synthetic binary; copy it when
you fork the template to keep the convention regression-tested.
SDK tests
cargo test -p nexo-microapp-sdk --features plugin
Covers handshake, manifest validation, dispatch (including non-blocking reader proof), shutdown lifecycle, unknown-method handling, oversized-frame rejection.
See also
- Plugin authoring overview — start here if you have not picked a language yet.
- Plugin contract — full wire spec every SDK implements.
- Patterns (8 common shapes) — channel / poller / hybrid plugin shapes.
- Publishing a plugin — CI workflow shape and asset naming convention.
- Signing & publishing — cosign keyless tutorial.
- Plugin trust (
trusted_keys.toml) — operator-side verification policy.