About Native Dynamic Plugins

View as Markdown

A native dynamic plugin is a trusted Rust shared library that Relay loads into its own process. It follows the same validation and PluginContext contract as other plugins, but typed middleware crosses a stable C boundary without a worker process or gRPC JSON envelope. This model is appropriate when reusable callback behavior is sensitive to latency or throughput and the deployment can carry a platform-specific binary.

Native plugins are not sandboxed. They share the host address space, allocator boundary, and process fate. Load only reviewed artifacts, keep ABI ownership rules intact, and assume a native crash can terminate the Relay host.

Version Contracts

Three version values answer different questions:

ContractCurrent ValueMeaning
Package manifestmanifest_version = 1Shape of the authored relay-plugin.toml file.
Manifest native APIcompat.native_api = "1"Native plugin package contract accepted by discovery and trust validation.
C host-table ABIv7Function table required by the current nemo-relay-plugin SDK. Relay 0.10 rejects v2-v6 callback layouts.

The checked example registers a tool execution intercept, so it declares compat.relay = ">=0.10.0,<1.0". Every native plugin built with this SDK must use that lower bound because an older Relay cannot load the ABI v7 function table, even when the component itself does not register an LLM execution intercept.

Relay 0.10 adds ABI v7 with request and response codec context on every LLM execution callback. Rebuild all native plugins for this release; the authored native_api = "1" label does not change.

Relay 0.8 changed the native API 1 tool-result JSON contract without changing the v4 host-table layout. A tool callback and ToolNext continuation return ToolExecutionResult, which carries result and optional opaque annotation; an execution intercept returns that pair plus pending marks. Rebuild native plugins for that contract even though its negotiated host-table ABI remained v4. Relay 0.9 adds the context-carrying tool execution callback as the v5 host-table extension.

In Relay 0.8, the v4 SDK also let a plugin add a data schema and log severity to a mark, or emit validated metric measurements. PluginRuntime::runtime_diagnostics() returns the current host-level diagnostic snapshot. The snapshot is ordered by code and is not attributed to a particular plugin.

What the SDK Owns

The Rust SDK exports the stable entry symbol, converts host-owned JSON handles into typed DTOs, registers all 16 plugin surfaces, and drives async middleware on one SDK-owned multi-thread Tokio runtime per configured component. A plugin can set a default executor size and accept a positive executor.worker_threads component override. The default is two workers; change it only after measuring queued async work and account for the number of native components in the host.

Subscribers remain synchronous and run on Relay’s subscriber dispatcher. Typed middleware returns futures and runs on the SDK executor. A callback has no stable OS thread affinity, separate invocations can overlap, and blocking an executor thread can delay unrelated calls from the same component.

The checked-in examples/rust-native-plugin project is the end-to-end implementation. Its configuration, observation, request policy, execution wrappers, and runtime helpers are separated into modules so each following page can explain one responsibility without presenting a monolithic sample.

Implement the Native Plugin Entrypoint

The root module is intentionally small. validate checks both the example settings and the SDK-owned executor override. register parses the same settings once, obtains the component runtime handle, and delegates each feature group. The export macro produces the nemo_relay_register_plugin symbol named in the manifest.

mod config;
mod execution;
mod observe;
mod requests;
mod runtime;
use nemo_relay_plugin::{
ConfigDiagnostic, DiagnosticLevel, Json, NativeExecutorConfig,
NativePlugin, PluginContext,
};
use serde_json::Map;
struct ExampleNativePlugin;
impl NativePlugin for ExampleNativePlugin {
fn plugin_kind(&self) -> &str {
"examples.rust_native_policy"
}
fn executor_config(&self) -> NativeExecutorConfig {
NativeExecutorConfig { worker_threads: 2 }
}
fn allows_multiple_components(&self) -> bool {
false
}
fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
let mut diagnostics = config::validate(plugin_config);
if let Err(message) = self.executor_config_for_component(plugin_config) {
diagnostics.push(ConfigDiagnostic {
level: DiagnosticLevel::Error,
code: "examples.rust_native_policy.invalid_executor".into(),
component: Some("examples.rust_native_policy".into()),
field: Some("executor.worker_threads".into()),
message,
});
}
diagnostics
}
fn register(
&mut self,
plugin_config: &Map<String, Json>,
context: &mut PluginContext<'_>,
) -> nemo_relay_plugin::Result<()> {
let config = config::ExampleConfig::parse(plugin_config)?;
let runtime = context.runtime();
observe::register(context, &config, &runtime)?;
requests::register(context, &config)?;
execution::register(context, &config, &runtime)?;
Ok(())
}
}
nemo_relay_plugin::nemo_relay_plugin!(
nemo_relay_register_plugin,
|| ExampleNativePlugin
);

The kind in plugin_kind() must match [plugin].id in relay-plugin.toml. Returning false from allows_multiple_components tells Relay to reject a document that tries to activate two configurations of this native implementation. The SDK owns registrations created through context; the plugin does not keep raw registry handles or unload them itself.

Complete Path

Follow these pages in order to build, activate, exercise, and remove the native plugin:

  1. Follow Build and Package to build the cdylib, validate its schema and manifest, calculate integrity, and register it.
  2. Add observability behavior with Observe and Sanitize, including subscribers and all three event sanitizer surfaces.
  3. Add policy and request rewriting with Control Requests, preserving annotations and making priority and break_chain explicit.
  4. Add tool, unary model, and lazy stream wrappers with Wrap Execution.
  5. Verify marks, scopes, isolated stacks, cleanup, and executor control with Runtime Events and Scopes.
  6. Consult Native ABI Reference only when implementing or auditing the raw boundary.

Success is not merely a library that loads. The atomic lifecycle test builds the cdylib in an isolated target directory, materializes and integrity-checks its manifest, activates a valid component, executes a managed tool call, observes the runtime mark, and clears registrations before the library unloads. The focused configuration tests cover rejected input and schema shape; the scenario pages show the remaining callback contracts.