Plugin quickstart — zero to installed in 10 minutes
Phase 31.9. Linear, copy-paste path from empty directory to a plugin running inside an operator's daemon. Take this page once end-to-end before reading Plugin authoring overview, the Rust SDK reference, or the Plugin contract — those documents make sense faster after you have shipped one toy plugin.
Single-language path (Rust). Python / TypeScript / PHP
quickstarts share the exact same shell commands; only the
--lang flag and the in-repo source tree differ. Pointers to the
sister SDKs appear at the bottom.
What you build
A plugin called hello_plugin that echoes every event arriving
on its inbound topic onto plugin.inbound.hello_plugin_echo. By
the end of this page:
- A new GitHub repository under your account holds the plugin's source.
- A signed (optional) GitHub release ships per-target tarballs.
- An operator on a separate host runs
nexo plugin install <you>/<repo>and the daemon spawns your plugin inside the next 10 seconds, then logs the handshake.
This is the same pipeline that ships the in-house plugins — see
github.com/lordmacu/nexo-plugin-browser for a real-world
output of the same quickstart, scaled up to 12 tools.
Prerequisites
| Tool | Version | Why |
|---|---|---|
| Rust toolchain | 1.80+ (rustup recommended) | Build the plugin binary. |
nexo CLI | 0.1.6+ on PATH | plugin new, plugin run, plugin install. |
git | any | Push to GitHub. |
| GitHub account + a repo you can push to | — | Releases host the install artifacts. |
cosign (optional) | 2.x | Sign releases for operators on --require-signature. Skip until step 9. |
Verify each:
cargo --version # cargo 1.80+
nexo --version # nexo 0.1.6+
git --version
gh auth status # if using `gh` CLI for the repo create
If nexo is not yet on PATH:
curl -fsSL https://lordmacu.github.io/nexo-rs/install.sh | bash
(or cargo install nexo-rs) — then make sure ~/.cargo/bin is on
PATH.
1. Scaffold
The CLI bundles the same Rust template that produces the
in-tree template-plugin-rust — one command lands a fresh
project on disk:
nexo plugin new hello_plugin --lang rust --owner alice
cd hello_plugin
Flags that matter:
<id>— the plugin's globally unique id. Must satisfy^[a-z][a-z0-9_]{0,31}$. It becomes the prefix for any tool name, channel kind, or config namespace your plugin contributes.--lang rust— switch topython,typescript, orphpfor the matching template. The remaining steps are identical.--owner alice— your GitHub username. Used in the generated README + CI workflow's release URL.--description "..."— optional one-liner; flows into the manifest + README + Cargo description.--git— runsgit initfor you and stages the initial commit.--dest /custom/path— emit elsewhere (default is./<id>/).
Re-running the command on a non-empty directory aborts; pass
--force only if you mean it.
2. Inspect what landed
hello_plugin/
├── Cargo.toml # name = "hello_plugin", bin name matches plugin.id
├── nexo-plugin.toml # manifest — read by the daemon at handshake
├── README.md # operator-facing docs (edit these later)
├── scripts/
│ └── pack-tarball.sh # the per-target tarball packer the CI uses
├── src/
│ └── main.rs # tokio::main + PluginAdapter
└── tests/
└── pack_tarball.rs # regression test for the asset shape
Two files you must know intimately. Open both:
nexo-plugin.toml:
[plugin]
id = "hello_plugin"
version = "0.1.0"
name = "Hello Plugin"
description = "Echoes inbound events back onto the broker."
min_nexo_version = ">=0.1.0"
[plugin.requires]
nexo_capabilities = ["broker"]
[[plugin.channels.register]]
kind = "hello_plugin_inbound"
description = "Inbound events the plugin emits onto the broker."
[plugin.entrypoint]
command = "./bin/hello_plugin" # resolved relative to this file
plugin.entrypoint.command = "./bin/hello_plugin" is the
Phase 31.1.c install convention. The daemon's
discovery walker reads the manifest, then spawns whatever
command resolves to, relative to the manifest's containing
directory. The pack-tarball step (step 8) copies your release
binary into bin/hello_plugin so this entrypoint resolves on
the operator host.
src/main.rs (truncated):
use nexo_broker::Event; use nexo_microapp_sdk::plugin::{BrokerSender, PluginAdapter}; const MANIFEST: &str = include_str!("../nexo-plugin.toml"); #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .init(); PluginAdapter::new(MANIFEST)? .on_broker_event(handle_event) .on_shutdown(|| async { Ok(()) }) .run_stdio() .await?; Ok(()) } async fn handle_event(topic: String, event: Event, broker: BrokerSender) { let echo = Event::new( "plugin.inbound.hello_plugin_echo", "hello_plugin", serde_json::json!({ "echoed_from": topic, "echoed_payload": event.payload, }), ); let _ = broker.publish("plugin.inbound.hello_plugin_echo", echo).await; }
The contract is small: build a PluginAdapter from your manifest
text, register handlers, call run_stdio().await. The SDK owns
the JSON-RPC envelope — you only see decoded Events.
Stdout discipline — every byte on stdout must be a JSON-RPC frame. Use
eprintln!/tracing::*for plugin-side logs; a strayprintln!will corrupt the wire and the daemon will tear the subprocess down at handshake.
3. Build
cargo build
A debug binary lands at target/debug/hello_plugin in well
under a second on a warm cache (mold + sccache, configured
machine-wide on the dev box, are not required — vanilla cargo
works too).
4. Smoke-test the handshake
Two probes the daemon performs at boot. Both must pass before
the plugin shows up in nexo plugin list.
4.a — --print-manifest
The discovery walker (Phase 81.33 Stage 8) invokes each
nexo-plugin-* binary with --print-manifest and reads the
embedded TOML from stdout. Confirm yours obeys:
./target/debug/hello_plugin --print-manifest
Expected output: verbatim contents of nexo-plugin.toml,
followed by exit 0. The scaffold wires the
print_manifest_if_requested(MANIFEST) call into the first line
of main(); if you see logs, JSON-RPC frames, or empty stdout,
the helper is missing.
4.b — initialize handshake
Hand-feed a JSON-RPC initialize frame to verify the wire shape:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
| ./target/debug/hello_plugin
Expected output (one line of JSON, formatted here for readability):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"manifest": { "plugin": { "id": "hello_plugin", ... } },
"server_version": "hello_plugin-0.1.0",
"tools": []
}
}
If you see anything else — extra blank lines, panic backtrace, malformed JSON — fix that before moving on. The daemon will reject the same frame your terminal saw.
5. Local dev loop
Boot a daemon with this directory injected at the head of
plugins.discovery.search_paths. No install, no GitHub round
trip, no signature verification — pure inner-loop dev:
nexo plugin run .
Expected stderr trace:
INFO local plugin override applied (plugin_id=hello_plugin)
INFO subprocess plugin spawned (id=hello_plugin, pid=...)
INFO hello_plugin starting
INFO subprocess plugin handshake ok (id=hello_plugin, version=0.1.0)
The plugin is now live inside the daemon. Ctrl+C tears both processes down cleanly.
Edit src/main.rs, re-run cargo build, and the daemon's
hot-reload walker (Phase 81.10) re-spawns the subprocess
automatically — no daemon restart.
For isolated contract debugging without your real
agents.yaml, add --no-daemon-config. See
Local dev loop conventions
for the inner-loop reference.
6. Customize the handler
Replace the body of handle_event with whatever your plugin
should do — call a third-party API, persist to disk, trigger a
downstream agent. Re-publish the API's reply back through
broker.publish so agents can observe it.
Eight common shapes (channel plugin, poller, hybrid bridge, etc.) are pre-baked in Patterns. Browse those once before designing — most plugins fit one of those shapes exactly.
7. Push to GitHub
gh repo create alice/hello_plugin --public --source=. --push
Or, with plain git:
git init && git add . && git commit -m "initial plugin"
git remote add origin git@github.com:alice/hello_plugin.git
git push -u origin main
The scaffolded repo already contains
.github/workflows/release.yml (the Phase 31.2 template). The
workflow fires on tag push (v*).
8. Cut a release
Bump the version in Cargo.toml and nexo-plugin.toml (must
match), then tag and push:
# bump both files first — keep them in lock-step
git add Cargo.toml nexo-plugin.toml
git commit -m "v0.1.0"
git tag v0.1.0
git push origin main v0.1.0
Watch the workflow in the GitHub Actions tab. The four jobs shipped by the template:
validate-tag— refuses to release if the manifest version does not match the tag.build— compiles the plugin forlinux-x64andmacos-arm64(extend the matrix yourself for more targets), runspack-tarball.shto produce thebin/hello_plugin+nexo-plugin.tomlarchive layout operators expect, and uploads<id>-<version>-<target>.tar.gzplus.sha256sidecars.sign(optional, gated by repo variableCOSIGN_ENABLED) — cosign keyless signature against the GitHub Actions OIDC identity. See Signing & publishing for the full keyless tutorial.release— creates the GitHub Release, attaches every tarball, every.sha256, the barenexo-plugin.toml(sonexo plugin installcan resolve manifest URL early), and the optional cosign material.
When the workflow turns green, the release page contains the
exact asset shape nexo plugin install resolves against. See
Publishing a plugin for the full asset naming
convention.
9. Install on an operator host
Three install routes, depending on how you shipped the plugin:
9.a — cargo install (zero config)
Recommended for crates.io-published plugins. The daemon's
discovery defaults already cover $HOME/.cargo/bin:
cargo install nexo-plugin-hello_plugin
nexo plugin list
The discovery walker invokes the binary with --print-manifest
on next daemon boot (or next hot-reload tick under Phase 81.10),
extracts the embedded TOML, and registers the plugin. No
operator YAML edit. Authoring detail:
Auto-discovery quickstart.
9.b — nexo plugin install (GitHub releases)
When you ship tarballs to GitHub releases instead of (or in addition to) crates.io:
nexo plugin install alice/hello_plugin@v0.1.0
The installer (Phase 31.1.c):
- Hits
https://api.github.com/repos/alice/hello_plugin/releases/tags/v0.1.0. - Picks the tarball matching the host's target triple.
- Downloads tarball +
.sha256, verifies the digest. - Optionally verifies cosign signature (default off; flip on
with
--require-signatureafter configuring trusted keys — see Plugin trust). - Extracts to
<state_root>/plugins/hello_plugin-0.1.0/{nexo-plugin.toml, bin/hello_plugin, .nexo-install.json}. - Records the install in the per-host install ledger.
The daemon's
plugins.discovery.search_paths defaults include
$HOME/.local/share/nexo/plugins and
/usr/local/libexec/nexo/plugins. Move (or symlink) the
extracted directory under one of those to skip the YAML edit,
or point a custom search_paths entry at
<state_root>/plugins/.
9.c — Drop-in (manual)
Copy the binary into any default search path:
cp ./target/release/hello_plugin ~/.cargo/bin/nexo-plugin-hello_plugin
chmod +x ~/.cargo/bin/nexo-plugin-hello_plugin
Re-discovery happens on next daemon boot (or hot-reload).
10. Verify
nexo plugin list
Expected output:
ID VERSION TARGET CHANNEL INSTALLED
hello_plugin 0.1.0 x86_64-unknown-linux-gnu latest 2026-05-07T18:20:42+00:00
Daemon stderr should show the handshake within ~5 seconds:
INFO subprocess plugin spawned (id=hello_plugin, pid=...)
INFO subprocess plugin handshake ok (id=hello_plugin, version=0.1.0)
Publish anything onto an inbound topic the plugin subscribed to
(e.g. agent broker publish-equivalent inside your microapp)
and the echo lands on plugin.inbound.hello_plugin_echo.
Iterate
# bump in source, push tag
git tag v0.1.1 && git push origin v0.1.1
# operator-side
nexo plugin upgrade hello_plugin # pulls latest tag
# or pin: nexo plugin upgrade hello_plugin --version v0.1.1
nexo plugin upgrade (Phase 31.8) atomically swaps the on-disk
copy + restarts the subprocess inside the daemon. To roll back,
re-run install against the older tag.
To remove:
nexo plugin remove hello_plugin
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
nexo plugin install errors no asset matching target <triple>. | CI matrix did not build for the operator's host. | Add the missing triple to the workflow matrix, re-tag. |
Daemon stderr shows subprocess exited at handshake (status=...). | Plugin wrote non-JSON-RPC bytes to stdout (most likely a stray println!) or panicked before the handshake. | Re-run ./target/debug/<id> against a synthetic frame from step 4 — the panic is reproducible there. |
nexo plugin list does not show your plugin after install. | Daemon's plugins.discovery.search_paths does not include <state_root>/plugins/. | Add it to config/plugins/discovery.yaml and restart. |
nexo plugin install errors signature required. | Operator runs with --require-signature and your release was unsigned. | Sign with cosign — see Signing & publishing. |
Plugin runs locally with nexo plugin run but the published binary panics on the operator host. | Per-target build skipped a runtime dep (e.g. linked OpenSSL on Linux but not on the operator's distro). | Switch to vendored-openssl or static-link in Cargo.toml; rebuild. |
Operator on --require-signature rejects your release with cosign verify failed. | Trusted-keys file does not include your identity issuer. | Operator adds your GitHub identity to config/extensions/trusted_keys.toml. See Plugin trust. |
Going deeper
You shipped one plugin. From here:
- Plugin authoring overview — the full picture (plugin vs extension vs microapp, plugin config dir, sandboxing, contributing tools / channel kinds / LLM providers / memory backends / hooks).
- Rust SDK reference — full
PluginAdaptersurface, manifest schema, per-target tarball convention. - Plugin contract — the wire spec every SDK implements. Read this once and you can debug any plugin in any language.
- Patterns (8 common shapes) — pre-baked designs for channels, pollers, hybrid bridges.
- Publishing a plugin — full asset naming convention and the 4-job CI workflow shape.
- Signing & publishing — cosign keyless tutorial.
Other languages
Same flow, swap step 1's --lang:
| SDK | Scaffold | Reference |
|---|---|---|
| Python | nexo plugin new hello --lang python --owner alice | Python SDK |
| TypeScript | nexo plugin new hello --lang typescript --owner alice | TypeScript SDK |
| PHP | nexo plugin new hello --lang php --owner alice | PHP SDK |
Steps 2–10 read identically; the source tree differs (no
Cargo.toml, language-appropriate runtime). The wire contract
is the same.