Skip to main content

Extension Development Hands-On

This is an end-to-end extension tutorial β€” from an empty directory to an extension that NeoMind can load and the Agent can call. By the end you'll be able to write your own extensions.

Prerequisite: read Extension SDK first for the trait, macros, capability, and ML-model lifecycle concepts. This page is the hands-on build flow.

Goal​

We'll build a Counter extension: maintains a counter, exposes an increment command (callable by the AI Agent) and a counter metric (displayable on the dashboard). Small but covers the complete metric / command / FFI flow.

Step 1: Scaffold the Project​

cargo new --lib counter-extension
cd counter-extension

Step 2: Configure Cargo.toml​

[package]
name = "counter-extension"
version = "1.0.0"
edition = "2021"

[lib]
name = "neomind_extension_counter" # prefix MUST be neomind_extension_
crate-type = ["cdylib", "rlib"]

[dependencies]
neomind-extension-sdk = "0.6.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
semver = "1"

[profile.release]
panic = "unwind" # REQUIRED β€” runner catches panics via unwind
opt-level = 3
lto = "thin"

Two critical settings:

  • lib name prefix neomind_extension_ β€” runner looks up extensions by this convention
  • panic = "unwind" β€” so an extension panic is caught rather than crashing the runner

Step 3: Implement the Extension​

src/lib.rs:

use async_trait::async_trait;
use neomind_extension_sdk::prelude::*;
use std::sync::atomic::{AtomicI64, Ordering};

pub struct CounterExtension {
counter: AtomicI64,
}

impl CounterExtension {
pub fn new() -> Self {
Self { counter: AtomicI64::new(0) }
}
}

#[async_trait]
impl Extension for CounterExtension {
fn metadata(&self) -> &ExtensionMetadata {
static META: std::sync::OnceLock<ExtensionMetadata> = std::sync::OnceLock::new();
META.get_or_init(|| ExtensionMetadata {
id: "counter".into(),
name: "Counter".into(),
version: semver::Version::parse("1.0.0").unwrap(),
description: Some("A minimal counter extension".into()),
author: Some("You".into()),
license: Some("MIT".into()),
..Default::default()
})
}

fn metrics(&self) -> &[MetricDescriptor] {
static METRICS: std::sync::OnceLock<Vec<MetricDescriptor>> = std::sync::OnceLock::new();
METRICS.get_or_init(|| vec![
MetricDescriptor {
name: "counter".into(),
display_name: "Counter".into(),
data_type: MetricDataType::Integer,
unit: String::new(),
min: None, max: None, required: false,
},
])
}

fn commands(&self) -> &[ExtensionCommand] {
static COMMANDS: std::sync::OnceLock<Vec<ExtensionCommand>> = std::sync::OnceLock::new();
COMMANDS.get_or_init(|| vec![
ExtensionCommand {
name: "increment".into(),
display_name: "Increment".into(),
description: "Increment the counter".into(),
parameters: vec![/* amount: Integer, default 1 */],
llm_hints: "Increment the counter value".into(),
..Default::default()
},
])
}

async fn execute_command(
&self,
command: &str,
args: &serde_json::Value,
) -> Result<serde_json::Value> {
match command {
"increment" => {
let amount = args.get("amount").and_then(|v| v.as_i64()).unwrap_or(1);
let new_value = self.counter.fetch_add(amount, Ordering::SeqCst) + amount;
Ok(serde_json::json!({ "counter": new_value }))
}
_ => Err(ExtensionError::CommandNotFound(command.into())),
}
}

fn produce_metrics(&self) -> Result<Vec<ExtensionMetricValue>> {
Ok(vec![ExtensionMetricValue::new(
"counter",
ParamMetricValue::Integer(self.counter.load(Ordering::SeqCst)),
)])
}

fn as_any(&self) -> &dyn std::any::Any { self }
}

// The crucial line β€” FFI export
neomind_extension_sdk::neomind_export!(CounterExtension);

Pattern highlights:

  • OnceLock<T> makes metadata / metrics / commands process-level singletons (avoids per-call allocation)
  • execute_command is the Agent's entry point β€” the LLM fills arguments per the schema declared in commands()
  • produce_metrics is the polling entry β€” runner calls it periodically and writes the values into telemetry.redb
  • The last line neomind_export! turns the entire impl into an FFI entry point

Step 4: Build​

cargo build --release

Artifact path:

  • macOS: target/release/libneomind_extension_counter.dylib
  • Linux: target/release/libneomind_extension_counter.so
  • Windows: target/release/neomind_extension_counter.dll

Step 5: Install into NeoMind​

Copy the artifact into NeoMind's extension directory (default ~/.neomind/extensions/<id>/, or /var/lib/neomind/extensions/<id>/ for server deployments):

mkdir -p ~/.neomind/extensions/counter
cp target/release/libneomind_extension_counter.* ~/.neomind/extensions/counter/

# Trigger scan
curl -X POST http://localhost:9375/api/extensions/discover

Or even simpler β€” in the Web UI Extensions page click Install from file and upload the .dylib / .so / .dll.

Step 6: Verify​

# List extensions β€” counter should appear
curl http://localhost:9375/api/extensions

# Call the command
curl -X POST http://localhost:9375/api/extensions/counter/commands/increment \
-H 'Content-Type: application/json' \
-d '{"amount": 5}'
# β†’ {"success": true, "data": {"counter": 5}}

Or in AI Chat say: "Call the counter extension's increment command, add 3" β€” the LLM will auto-locate the increment command and invoke it (because commands() already declared it to the Agent tool system).

On the dashboard, add a value card widget, pick data source extension:counter:counter, and the live value shows up.

Step 7: Cross-Platform .nep Packaging​

A single-platform .dylib only runs on macOS. To distribute you must compile all targets and package:

# Use cross or a GitHub Actions matrix to build every target
cross build --release --target x86_64-unknown-linux-gnu
cross build --release --target aarch64-unknown-linux-gnu
cross build --release --target x86_64-pc-windows-msvc
# Apple Silicon macOS (arm64)
cross build --release --target aarch64-apple-darwin

# Package into .nep (a zip archive)
mkdir -p nep/{linux-x64,linux-arm64,windows-x64,darwin-arm64}
cp target/x86_64-unknown-linux-gnu/release/libneomind_extension_counter.so nep/linux-x64/
# ... other platforms
cat > nep/metadata.json <<EOF
{ "id": "counter", "version": "1.0.0", "platforms": { ... } }
EOF
cd nep && zip -r ../counter-1.0.0.nep .

.nep is the conventional archive format (see the NeoMind-Extensions repo's CI scripts for the reference). When a user installs via the Web UI one-click flow, the runner picks the binary matching the current platform.

Advanced Patterns​

The following cover the most common patterns in real-world extension development.

Pattern 1: Network Extension (Weather API)​

A weather extension is the canonical network extension β€” fetches an external API and produces metrics.

use neomind_extension_sdk::prelude::*;
use neomind_extension_sdk::capability::CapabilityContext;

pub struct WeatherExtension {
config: std::sync::Mutex<WeatherConfig>,
}

struct WeatherConfig {
api_key: String,
city: String,
}

#[async_trait]
impl Extension for WeatherExtension {
fn metadata(&self) -> &ExtensionMetadata {
static META: OnceLock<ExtensionMetadata> = OnceLock::new();
META.get_or_init(|| ExtensionMetadata {
id: "weather".into(),
name: "Weather".into(),
version: Version::parse("1.0.0").unwrap(),
config_parameters: Some(vec![
ParameterDefinition {
name: "api_key".into(),
display_name: "API Key".into(),
description: "OpenWeatherMap API key".into(),
param_type: MetricDataType::String,
required: true,
..Default::default()
},
]),
..Default::default()
})
}

fn metrics(&self) -> &[MetricDescriptor] {
// temperature, humidity, pressure
// DataSourceId: extension:weather:temperature etc.
}

async fn configure(&mut self, config: &Value) -> Result<()> {
let cfg = self.config.lock().unwrap();
// update api_key / city from config JSON
}

async fn execute_command(&self, cmd: &str, args: &Value) -> Result<Value> {
match cmd {
"fetch" => {
let ctx = CapabilityContext::default();
// HTTP request via network capability
let resp = ctx.invoke_capability("network", &json!({
"method": "GET",
"url": format!("https://api.openweathermap.org/data/2.5/weather?q={}&appid={}",
cfg.city, cfg.api_key)
}));
// Parse response, write virtual device metrics
ctx.invoke_capability("device_metrics_write", &json!({
"device_id": "virtual-weather",
"metric": "temperature",
"value": resp["main"]["temp"]
}));
Ok(json!({"status": "ok"}))
}
_ => Err(ExtensionError::CommandNotFound(cmd.into())),
}
}

fn as_any(&self) -> &dyn std::any::Any { self }
}

neomind_export!(WeatherExtension);

Pattern 2: Streaming (Video Frames)​

A video analysis extension uses the streaming API β€” each frame enters process_chunk() and returns detections.

pub struct YoloVideoExtension {
model: OnceLock<YoloModel>,
}

#[async_trait]
impl Extension for YoloVideoExtension {
fn stream_capability(&self) -> Option<StreamCapability> {
Some(StreamCapability {
mode: StreamMode::Stateless, // stateless: each frame independent
input_format: "image/jpeg".into(),
output_format: "application/json".into(),
})
}

async fn process_chunk(&self, chunk: DataChunk) -> Result<StreamResult> {
let model = self.model.get_or_try_init(|| YoloModel::load("yolov8n.onnx"))?;
let detections = model.infer(&chunk.data)?;

Ok(StreamResult {
output: serde_json::to_value(&detections)?,
metadata: Some(json!({"frame_id": chunk.sequence})),
})
}

// ... other methods
}

Pattern 3: Push Mode (Sensors)​

Push mode is for extensions that proactively produce data (e.g. serial sensors). The extension pushes via output_sender.

use tokio::sync::mpsc;
use std::sync::Arc;

pub struct SensorPushExtension {
sender: std::sync::Mutex<Option<Arc<mpsc::Sender<PushOutputMessage>>>>,
}

#[async_trait]
impl Extension for SensorPushExtension {
fn set_output_sender(&self, sender: Arc<mpsc::Sender<PushOutputMessage>>) {
*self.sender.lock().unwrap() = Some(sender);
}

async fn start_push(&self, _session_id: &str) -> Result<()> {
let sender = self.sender.lock().unwrap().clone()
.ok_or(ExtensionError::ExecutionFailed("no sender".into()))?;

// Start background collection task
tokio::spawn(async move {
loop {
let value = read_sensor(); // your collection logic
let msg = PushOutputMessage {
metric: "temperature".into(),
value: json!(value),
timestamp: chrono::Utc::now().timestamp(),
};
if sender.send(msg).await.is_err() { break; }
tokio::time::sleep(Duration::from_secs(5)).await;
}
});

Ok(())
}

async fn stop_push(&self, _session_id: &str) -> Result<()> {
// Stop the background task (via channel close or cancel token)
Ok(())
}
}

Pattern 4: Event Subscriptions​

Extensions can subscribe to platform events (e.g. device online, rule triggered) and respond in handle_event().

fn event_subscriptions(&self) -> &[&str] {
&["device.online", "rule.triggered"]
}

fn handle_event(&self, event_type: &str, payload: &Value) -> Result<()> {
match event_type {
"device.online" => {
let device_id = payload["device_id"].as_str().unwrap();
// Initialize when a device comes online...
}
"rule.triggered" => {
// Rule-triggeredθ”εŠ¨ logic...
}
_ => {}
}
Ok(())
}

Pattern 5: Configuration Hot-Reload​

When a user changes config in the Web UI, configure() is called. Use a Mutex to guard config state:

pub struct MyExtension {
config: std::sync::Mutex<MyConfig>,
}

async fn configure(&mut self, config: &Value) -> Result<()> {
let new_cfg = MyConfig {
api_key: config["api_key"].as_str().unwrap_or("").to_string(),
interval: config["interval"].as_u64().unwrap_or(60),
};
// Validate
if new_cfg.api_key.is_empty() {
return Err(ExtensionError::InvalidArguments("api_key required".into()));
}
*self.config.lock().unwrap() = new_cfg;
Ok(())
}

.nep Package Structure​

A complete .nep package is a ZIP archive containing multi-platform binaries + metadata:

my-extension-1.0.0.nep (ZIP)
β”œβ”€β”€ metadata.json ← extension metadata + platform mapping
β”œβ”€β”€ darwin-arm64/
β”‚ └── libneomind_extension_my_extension.dylib
β”œβ”€β”€ darwin-x86_64/
β”‚ └── libneomind_extension_my_extension.dylib
β”œβ”€β”€ linux-x86_64/
β”‚ └── libneomind_extension_my_extension.so
β”œβ”€β”€ linux-arm64/
β”‚ └── libneomind_extension_my_extension.so
β”œβ”€β”€ windows-x86_64/
β”‚ └── neomind_extension_my_extension.dll
└── models/ ← (optional) ML model files
└── yolov8n.onnx

metadata.json example:

{
"id": "my-extension",
"name": "My Extension",
"version": "1.0.0",
"sdk_version": "0.6.3",
"abi_version": 3,
"platforms": {
"darwin-arm64": "darwin-arm64/libneomind_extension_my_extension.dylib",
"linux-x86_64": "linux-x86_64/libneomind_extension_my_extension.so",
"windows-x86_64": "windows-x86_64/neomind_extension_my_extension.dll"
}
}

The runner automatically selects the binary matching the current platform on load.

Reference Implementations​

The NeoMind-Extensions repo has complete working examples for every pattern:

  • weather-forecast-v2 β€” a simple network extension (no ML model)
  • image-analyzer-v2 β€” ML-model lazy-load pattern (YOLOv11)
  • yolo-video-v2 β€” streaming video processing
  • yolo-device-inference β€” integration with NE301/NE101 cameras
  • home-assistant-bridge β€” third-party system integration

Reading one of these is more illuminating than any doc.

Common Pitfalls​

SymptomCause
Load error "symbol not found"lib name prefix is not neomind_extension_
Extension panic permanently disables itpanic = "abort" (must be unwind)
Command call returns "permission denied"Missing the matching capability declaration
Agent can't see your commandcommands() not implemented, or llm_hints is empty (affects LLM discovery)
Dashboard can't find your metricmetrics() not implemented, or the name is misspelled vs the DataSourceId
Cross-platform distribution failsMissing some target platform binary, or metadata.json platforms field is incomplete

Next Steps​


Last updated: 2026-06-15