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:

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);

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

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