Native plugins and modules (Rust)
Agent OS is extended not only by YAML configs and scripts (JS/Rhai), but also by
native Rust extensions — compiled code inside the server process.
There are two forms, and both grow out of the same contribute():
| Form | What it is | How it's connected |
|---|---|---|
| linked module | Rust crate linked into the binary (rlib) |
server.yaml → modules.enabled |
| DLL / plugin | dynamic library (.dll / .so / .dylib) |
config/plugins.yaml, kind: dll_tool, kind: dll_hook |
Both forms contribute the same contributions to extension points:
| Category | What it provides | How it's connected |
|---|---|---|
| Tools | kind: dll_tool |
YAML tool file |
| Hooks | kind: dll_hook |
YAML hook file |
| Syncs | type: plugin in notify: |
cron / monitor / channel |
| Sources | type: plugin in source: |
config/monitors.yaml |
| Channels | kind: in channels: |
config/channels.yaml |
| HTTP routes | stable object Route |
register_registry* / contribute_route |
| Registries and items | register_registry* |
JSON items, stable objects, services |
| Config bundle | compiled-in config files | data/builtin/<id>/ |
How to build a full-fledged module (dependency graph, services, host ports, packaging, tests) — see 32-building-a-module.
The main idea: stable C ABI + thin SDK
A plugin links against a small ABI header
agent_os_plugin_api (zero dependencies, #![no_std]), or against the
ergonomic SDK agent_os_sdk, which does the same thing but
transport-neutrally (linked and DLL from the same code). The language can be anything
(C/C++/Zig/Go/cgo/Rust cdylib), but the SDK is available for Rust.
| Crate | Role |
|---|---|
agent_os_plugin_api |
stable C-ABI "header": #[repr(C)] types, AoHostApi, AoRegistrar, AO_ABI_VERSION |
agent_os_abi |
stable interfaces: AStr/AoSlice, Handle, OwnedObject + macros #[abi_trait]/#[abi_struct]/abi_registry! |
agent_os_sdk |
thin SDK: RegistryHost, declare_module!, declare_plugin!, service(id), Value, Json |
agent_os_web |
HTTP routes as a stable object Route |
agent_os_host_api |
host service contracts (agent_os.Log, .State, .Http, …) |
The current ABI version is v8 (AO_ABI_VERSION in agent_os_plugin_api); the host
accepts any plugin with a version in the range [AO_ABI_MIN, AO_ABI_VERSION]
(currently 4..=8). Therefore:
- OS sources are not needed — only the header/SDK and the contract;
- the rustc version doesn't matter for the C-ABI — but, since
agent_os_abigenerates Rust-native vtables, the host and module are built with compatible toolchains for the same platform (this is the same "per-OS" as a regular DLL); - the language can be anything (Rust
cdylib— first class).
Minimal plugin
# Cargo.toml
[package]
name = "my_plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
# `host` = linked-транспорт; DLL собирается с `--no-default-features`.
default = ["host"]
host = ["agent_os_sdk/host"]
[dependencies]
agent_os_sdk = { path = "…/agent_os_sdk" }
agent_os_abi = { path = "…/agent_os_abi" }
// src/lib.rs
use agent_os_sdk::{declare_plugin, Registrar};
fn init(reg: &Registrar) {
// Самоописание модуля (id + версия).
reg.descriptor(r#"{"id":"demo","version":"0.1.0"}"#);
// Объявить JSON-реестр и положить в него элемент.
reg.registry("demo.items", "{}");
reg.registry_item("demo.items", r#"{"title":"Hello"}"#);
// Вкомпилировать файл config-bundle (материализуется в data/builtin/demo/).
reg.bundle_file("config/demo.yaml", b"key: value\n");
}
declare_plugin!(init);
declare_plugin! generates a single exported symbol —
ao_plugin_init(host, reg) -> u32; the host calls it after loading, and the
return value is the ABI version (a mismatch is rejected before the call).
The symbol ao_module_registry (for stable objects, see below) adds
abi_registry!.
Two transports from one contribute()
agent_os_sdk::declare_module! generates both the DLL entry point and the
linked implementation of agent_os_modules::Module from a single function:
use agent_os_sdk::{declare_module, RegistryHost};
fn contribute(h: &mut dyn RegistryHost) {
h.data_registry("demo.items", "{}");
h.data_item("demo.items", r#"{"title":"Hello"}"#);
}
agent_os_sdk::declare_module!(DemoModule, "demo", contribute);
- linked build (
feature = "host", default) producespub struct DemoModulewithimpl Module— the host includes the module viaserver.yaml → modules.enabled. - lean build (
--no-default-features) producescdylibwithao_plugin_init.
The same code and module.yaml describe the module in both forms. A detailed
walkthrough — 32-building-a-module.
Entry point and registration
The plugin exports:
pub extern "C-unwind" fn ao_plugin_init(host: *const AoHostApi, reg: *const AoRegistrar) -> u32
reg is AoRegistrar, a table of registration functions. Below are its fields (raw
ABI; the SDK wrapper agent_os_sdk::Registrar from the example above covers only
registries, items, stable objects, services, bundles, and the descriptor). Conventionally,
registrations fall into three groups:
1. "Kinds" (v4 compatibility) — behavior by kind name:
reg.register_tool(name, schema, vtable) // тул (kind: dll_tool)
reg.register_hook(name, vtable) // хук (kind: dll_hook)
reg.register_source(name, vtable) // источник монитора
reg.register_sink(name, vtable) // синк
reg.register_channel(name, vtable) // входящий канал
reg.register_route(method, path, access, vtable) // HTTP-роут
2. Config-driven engines (ABI v5) — kind: <name> in YAML resolves to a
registered vtable:
reg.register_tool_engine(name, vtable)
reg.register_hook_engine(name, vtable)
reg.register_route_engine(name, vtable)
reg.register_script_fn(name, vtable)
reg.register_slot_provider(slot_id, schema_json)
reg.register_contribution(slot_id, item_json)
reg.register_module_lifecycle(module_id, vtable)
reg.register_module_descriptor(descriptor_json) // self-describing single-DLL
reg.register_bundle_file(rel_path, bytes)
3. Registries (ABI v5/v7) — generic extension points:
reg.register_registry(name, schema_json) // объявить реестр
reg.register_registry_item(name, item_json) // JSON-элемент
reg.register_registry_engine(registry, item, interface, vtable) // поведение по interface
reg.register_registry_stable(registry, item, vtable, handle) // готовый stable-объект (v7)
The functions return 0 = error::OK, otherwise an error code. The ABI version is
AO_ABI_VERSION.
Host services
Instead of direct access to the core, the plugin receives named host services.
The transport-neutral way is agent_os_sdk::service(id): in a linked module it
resolves the service from the graph, in a DLL — via the ABI get_service. A typed
wrapper is agent_os_host_api::client::*:
if let Some(svc) = agent_os_sdk::service(agent_os_host_api::ids::LOG) {
if let Some(log) = agent_os_host_api::client::log(svc.handle()) {
let _ = log.log("info", "demo", "hello from plugin");
}
}
The main ports (full list — agent_os_host_api::ids):
| id | Purpose |
|---|---|
agent_os.Log / agent_os.Config / agent_os.State / agent_os.Metrics |
log, (redacted) config, KV, metrics |
agent_os.System |
host introspection (version/build/templates/config) |
agent_os.Agents / agent_os.Tools / agent_os.Llm / agent_os.Script |
processes, tools, inference, scripts |
agent_os.Http / agent_os.Secrets |
outbound HTTP (SSRF policy) and allowlisted secrets |
agent_os.Fs / agent_os.Process |
project FS within root boundaries and command execution under a sandbox (sensitive) |
agent_os.Messages / agent_os.Events / agent_os.Sessions |
transcripts, event log, sessions |
agent_os.Tasks + agent_os.Job |
background tasks (sync step, runtime — host) |
agent_os.Cron / agent_os.Workflow / agent_os.Connectors |
feature services |
Sensitive ports (Secrets, Http, Messages, Sessions, control-plane,
etc.) are fail-closed: without a declared capability the module cannot see them. The host
places a capability scope around the module's code (route handler, Job::step,
event callbacks).
Values and JSON without serde
To read config/YAML, a plugin doesn't need to link serde: the host parses
the tree itself (json_parse / yaml_parse, ABI v6), and the SDK provides the wrapper Value:
let v = agent_os_sdk::Value::parse_json(r#"{"prefix":">> "}"#)?;
let prefix = v.get("prefix").and_then(|p| p.as_str()).unwrap_or_default();
For output — a tiny writer agent_os_sdk::Json (objects/arrays → text).
Memory contract
- Strings/buffers that the plugin returns (response body,
content,data,visual,notice,reply,cursor, arraysinject/items): the plugin allocates them viaagent_os_abi::{alloc_str, alloc_slice}(orhost.str_alloc/host.slice_allocin the raw ABI) of exactly the required length; the host frees it itself. Static string literals must not be returned. - Strings returned by the host (
value_str,llm→out,rows_col_name,run_turn→out): borrowed, valid only for the duration of the call — copy them if you keep them. - Empty string (
len == 0/ptr == null) = "no value".
Panics
All function pointers are extern "C-unwind" (ABI-identical to C, but allow
unwind). The host wraps every call in catch_unwind, so a plugin panic
won't crash the process. The SDK macros (declare_plugin! / declare_module!) and
#[abi_trait] install a panic guard themselves; even so, it's better to catch panics inside your
extern functions and return an error code / Err(String).
Connecting to the OS
DLL: config/plugins.yaml
# config/plugins.yaml
data_dir: data/plugins # где живут БД плагинов (db_open)
plugins:
- plugins/echo.dll # загрузить при старте (регистрирует все kinds)
Loading is idempotent by path: if several agents (or a YAML tool/hook)
reference the same DLL, it is loaded once, ao_plugin_init runs
once, and kinds are registered once.
Linked modules: server.yaml
# config/server.yaml
modules:
enabled: [docs, widget, analytics] # активировать на буте (+ requires транзитивно)
auto_install: false # не ходить в сеть на старте
enabled ≠ installed: the list is just "what to activate on boot"; installing
artifacts is a separate action. Always-on modules (kernel, web) are enabled
implicitly.
Tool
# config/agents/<name>/tools/echo.yaml
kind: dll_tool
path: plugins/echo.dll # относительный путь от YAML
name: echo # опционально (по умолчанию — имя из плагина)
config: # опционально: доступно в create(config)
prefix: ">> "
max_len: 120
config: — arbitrary YAML/JSON that the host passes to create(config);
the plugin reads it (Value in the SDK or value_* in the raw ABI). config can be
null.
Hook
# config/agents/<name>/hooks/audit.yaml
kind: dll_hook
path: plugins/echo.dll
hook: audit # имя kind, зарегистрированного плагином
A plugin hook can implement any lifecycle point (all optional,
None = no-op): before_user_message, after_turn, after_loop,
before_tool_call, after_tool_result, before_inference,
after_assistant_message, on_token, before_context_evict,
on_session_created, on_session_closed, on_budget_threshold,
before_retry, before_syscall, on_spawn, on_terminate, on_error — they
mirror HookHandler
(see 06-hooks-reference).
Sync / source / channel
# в notify: cron-задачи, монитора, воркфлоу или канала
- type: plugin
kind: collect
config: { ... }
# config/monitors.yaml
monitors:
- name: example
source:
type: plugin
kind: counter
config: { ... }
template: ngu-agent
prompt: "{{items}}"
# config/channels.yaml
channels:
- kind: echo_channel
config:
template: ngu-agent
token_env: MY_TOKEN
HTTP route
The modern way is a stable agent_os_web::Route object
(meta() + handle()), which the host mounts with the desired Access
(public / user / admin). Via the ABI, register_registry_stable is registered
in the routes registry. The legacy path is
register_route(method, path, access, vtable) with route_access::PUBLIC (0),
TOKEN (1) or ADMIN (2).
Trust and security
A native plugin is machine code inside the server process; it cannot be
sandboxed like scripts (there is no operation limit). This is the same trust level as
compiled tools. Load plugins only from trusted
sources; keep plugins.yaml / module.yaml under control (in the
repository/deployment). The capability gate restricts access to sensitive
ports, but does not isolate code.
ABI versioning
AO_ABI_VERSION— handshake inao_plugin_init; the host accepts the range[AO_ABI_MIN, AO_ABI_VERSION](currently4..=8) and rejects a mismatch.- ABI v2: interception callbacks added in
AoHookVtable. - ABI v3: output guardrail
after_assistant_messageadded. - ABI v4: parity for the remaining points (
on_token,before_context_evict, session lifecycle,on_budget_threshold,before_retry,before_syscall). - ABI v5: modular extensions — config-driven engines
(
register_tool_engine/register_hook_engine/register_route_engine),register_script_fn, slots, module lifecycle,host.download, genericregister_registry*. - ABI v6:
json_parse/yaml_parse/value_free— structured data withoutserde. - ABI v7:
register_registry_stable— pass the host the raw(vtable, handle)of a stable object (trait id and destroy — in the vtable header). - ABI v8 (current):
get_service— obtain a named host service. - Fields in
#[repr(C)]structures are only appended at the end; they are never removed or reordered. That's why old plugins keep working: they read only the fields they know about.