Rust Module (linked and DLL)

A module is the unit of packaging and extension for Agent OS. It can contribute element types, registries and elements within them, config bundles, services, HTTP routes, background tasks, and native libraries — and be packaged either as a linked crate (linked into the binary) or as a DLL (.dll/.so/.dylib) loaded at runtime. The same contribute() governs both transports.

This is a practical guide. The living source of truth for the module system is crates/module-system/runtime/agent_os_modules/README.md; the design and rationale are in internal notes docs/22-modules.md, docs/25-module-abi.md, docs/26-feature-modules.md, docs/27-core-modularization.md, docs/30-dll-ready-modules.md (not shown in the site menu).

32.1 Crates

Crate Role
agent_os_modules composition model: Module, ModuleBuilder, graph, registries, elements, services, Access, module.yaml, ModuleLifecycle. Without axum/kernel
agent_os_abi stable interfaces: AStr/AoSlice, Handle, OwnedObject, VtableHeader + macros #[abi_trait]/#[abi_struct]/abi_registry!
agent_os_abi_macro implementation of these macros
agent_os_sdk thin SDK: RegistryHost, declare_module!, declare_plugin!, service(id), Value, Json
agent_os_web HTTP routes as a stable object Route + WebModule (owner of the routes registry)
agent_os_host_api host-port contracts (agent_os.Log, .State, .Http, …) + client::*
agent_os_plugin_api stable C-ABI "header" (for raw/legacy plugins)
agent_os_modules_runtime driver: activation/deactivation, state.json, version-manifest, bridge DLL
agent_os_plugin_host DLL loading and ABI → host type adapters
agent_os_module_install artifact installation (download + sha256 + zip/tar.gz), bundle materialization

Dependency rule for a DLL module: only ABI-safe crates. Forbidden: agent_os_core, agent_os_tools, agent_web_bot, tokio, axum/hyper. Use host ports instead. For details, see docs/30-dll-ready-modules.md §2.2.

32.2 The Module trait and ModuleBuilder

use agent_os_modules::{Module, ModuleBuilder, RegistryType};
use serde_json::json;

struct Analytics;

impl Module for Analytics {
    fn id(&self) -> &str { "analytics" }
    fn version(&self) -> &str { "1.2.0" }          // semver, по умолчанию "0.0.0"
    fn requires(&self) -> Vec<String> { vec!["admin".into()] }
    fn capabilities(&self) -> Vec<String> { vec!["agent_os.Secrets".into()] }

    fn register(&self, b: &mut ModuleBuilder) {
        b.declare_type(RegistryType::data("analytics.panel"));
        b.provide_registry("admin.panels", "analytics.panel");
        b.contribute("admin.panels", json!({ "title": "Аналитика" }));
    }
}

The build lifecycle has three phases:

  1. register — declare contributions in ModuleBuilder (the only mandatory phase).
  2. wire(&mut ModuleBuilder, &WireContext) — optional contributions that depend on the final graph (ctx.is_enabled, ctx.has_registry, ctx.has_service, …).
  3. bind(&BindContext) — retain services needed during operation (ctx.service(id) → Arc<OwnedObject>), in topological order after wire.

ModuleBuilder:

Method Purpose
declare_type(RegistryType) declare a new element type
provide_registry(id, type_id) declare a registry of type type_id
provide_data_registry(id) / provide_slot(id) registry of JSON data elements
provide_stable_registry(id, interface) registry of stable interface objects
contribute(id, json) contribute JSON data
contribute_stable(id, OwnedObject) contribute a stable object
contribute_handle(Handle) same, but the registry name is taken from the vtable
provide_service(id, obj) / provide_service_shared(id, Arc<..>) declare a service and set the origin
decorate_service(id, priority, obj) add a layer (decorator) to a service
into_batch() snapshot for live Registry

Additional trait properties: optional_dependencies() (not pulled transitively), always_on() (always enabled and first; kernel only), dependencies() (version requirements of the form "admin@^2.1").

32.3 ABI contracts

An interface that crosses the boundary (a registry of stable objects, a service) is described by a trait with #[abi_trait]:

use agent_os_abi::abi_trait;

#[abi_trait(id = "DocSource", name = "documentation", version = 1)]
pub trait DocSource {
    fn manifest(&self) -> Result<String, String>;
    fn markdown(&self, id: &str, lang: &str) -> Result<String, String>;
}

The grammar is restricted (otherwise a compile error):

#[abi_struct] marks a #[repr(C)] struct that crosses the ABI:

use agent_os_abi::abi_struct;

#[abi_struct]
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ItemRow { pub id: i64, pub score: f64, pub name: agent_os_abi::AStr }

The macro generates #[repr(C)] vtable with VtableHeader { trait_id, name, abi_version, size, destroy }, Copy-handle, owning-обёртку Owned<T>, Borrowed<T>, dispatch impls, and producer erasure (into_owned()/into_object()). The handle can be stored in OwnedObject and passed across the boundary.

A module that exports objects (for a DLL) declares their factory:

use agent_os_abi::abi_registry;
abi_registry! {
    BuiltinSource as DocSource;
}

abi_registry! generates abi_objects() -> Vec<Handle> (for linked) and the symbol ao_module_registry (for DLL).

Tools are also exposed by a module as stable objects agent_os.ToolEngine (agent_os_tool_api) into the tool.engines registry; the host bridge turns each object into a YAML tool factory. ToolEngine — ABI v2: kind, describe (schema), and execute, plus execute_ctx(context_json, config, args, progress) with a callback object agent_os.ToolProgress. The result can be returned as an "envelope" { content, success, data, visual, cost }. A module that uses sensitive ports (e.g. agent_os.Fs/agent_os.Process) declares them in capabilities (declare_module!(…, capabilities = ["agent_os.Fs"])). An example of a dual-transport module with tools is agent_os_code (see Module code).

32.4 HTTP routes

A route is a single stable object agent_os_web::Route in the routes registry:

use agent_os_web::{Route, RouteMeta, HttpRequest, HttpResponse, ACCESS_ADMIN};

struct ListAgents;

impl Route for ListAgents {
    fn meta(&self) -> Result<RouteMeta, String> {
        Ok(RouteMeta::new("GET", "/v1/analytics/agents", ACCESS_ADMIN))
    }
    fn handle(&self, _req: HttpRequest) -> Result<HttpResponse, String> {
        Ok(HttpResponse::json(200, &serde_json::json!({ "agents": [] })))
    }
}

Access — public / user / admin; the host mounts the route with the required gate (require_admin / require_user), and an unknown level is not mounted (fail closed). For typical cases there are ready-made constructors: contribute_get, contribute_route, contribute_asset (static), respond_cached/weak_etag (ETag), mime_for. The registry owner is the always-on module agent_os_web::WebModule; contributions can be made before it (elements are deferred).

32.5 Services and host ports

A domain service (module ↔ module) is a registry of the form Service with exactly one origin and N layers:

// provider (origin)
b.provide_service("dbsvc", MyDb::new().into_owned().into_object());
// декоратор (слой), priority ниже — ближе к origin
b.decorate_service("dbsvc", 0, MetricsDb::new().into_owned().into_object());

// потребитель: requires: ["db-provider"], затем
fn bind(&self, ctx: &BindContext) {
    if let Some(obj) = ctx.service("dbsvc") {
        self.db.set(SharedDbService::from_object(obj).expect("interface mismatch"));
    }
}

Host ports (kernel → module) are available transport-neutrally via agent_os_sdk::service(id) + agent_os_host_api::client::*:

if let Some(svc) = agent_os_sdk::service(agent_os_host_api::ids::STATE) {
    if let Some(state) = agent_os_host_api::client::state(svc.handle()) {
        let _ = state.set("analytics", "last_run", "2026-01-01");
    }
}

Main ports: Log, Config, State, Metrics, System, Agents, Tools, Llm, Script, Http, Secrets, Fs, Process, Messages, Events, Sessions, Tasks (+ Job), RequestContext, Extension. The full list is in agent_os_host_api::ids.

Background tasks do not run their own runtime: the module exposes a synchronous Job::step (JSON { "continue": bool, "next_ms": u64 }), and the host executes the steps on its own runtime under the module's capability scope:

let tasks = agent_os_host_api::client::tasks(svc.handle()).unwrap();
let handle = tasks.spawn("analytics", my_job.into_owned())?;  // my_job: impl Job
// …
tasks.cancel(&handle)?;

Capabilities. Sensitive ports (Secrets, Http, Messages, Sessions, control-plane, etc.) are fail-closed: a module sees them only if it declared the id in capabilities() / module.yaml → capabilities. The host sets capability scope around each entry point into the module's code (route handler, Job::step, event callback).

32.6 Descriptor module.yaml

Unified for linked and DLL modules. A Rust module derives it from Module; a DLL can read the file or send register_module_descriptor.

id: analytics
version: 1.2.0
abi: 8                              # требуемая версия ABI для libs
requires: [auth, "admin@^2.1"]      # граф: топосорт + авто-включение
optional: ["gpu@^1"]                # только если цель уже включена
capabilities: [agent_os.Secrets]    # чувствительные порты (fail-closed)
libs:
  linux:   lib/libanalytics.so
  windows: lib/analytics.dll
  macos:   lib/libanalytics.dylib
bundle:
  root: config
  points: [agents, hooks, tools, routes, workflows]
install:
  verify: sha256
  code: true
  artifacts:
    - id: runtime
      dest: runtime
      platforms:
        linux:   { url: "https://…/rt.tgz", sha256: "…", extract: tar.gz }
        windows: { url: "https://…/rt.zip", sha256: "…", extract: zip }
provides: [analytics.panel]         # свои слоты/сервисы
contributes: [admin.nav]            # в чужие слоты

Enabling — in config/server.yaml:

modules:
  enabled: [auth, admin, docs, distrib, analytics]
  auto_install: false

enabled ≠ installed: the list is "what to activate on boot"; installing artifacts (network) is a separate action. requires are pulled in transitively; an unknown id — warning; a module with a missing bundle/DLL or ABI mismatch — module load error, the rest of the system starts.

32.7 Two transports and packaging

agent_os_sdk::declare_module! generates both transports from a single function:

fn contribute(h: &mut dyn RegistryHost) {
    h.data_registry("analytics.items", "{}");
    h.data_item("analytics.items", r#"{"title":"x"}"#);
}
agent_os_sdk::declare_module!(AnalyticsModule, "analytics", contribute);

Cargo:

[lib]
crate-type = ["cdylib", "rlib"]

[features]
default = ["host"]
host = ["agent_os_sdk/host", "dep:agent_os_modules", "dep:serde", "dep:serde_json"]

[dependencies]
agent_os_sdk = { path = "…/agent_os_sdk" }
agent_os_abi = { path = "…/agent_os_abi" }
agent_os_modules = { path = "…/agent_os_modules", optional = true }
serde_json = { version = "1", optional = true }

The host loads both the same way — via ModulePackage:

// internal Rust-модуль
let pkg = agent_os_modules::ModulePackage::internal(AnalyticsModule)
    .with_bundle(files);

// DLL: package собирается из register_module_descriptor + register_bundle_file + регистраций
let pkg = agent_os_modules_runtime::plugin_package("analytics").unwrap();

let rt = ModuleRuntime::build_packages_with_host(
    packages, &enabled, "data/modules", host_services,
)?;
rt.activate()?;

Bundle materialization is edit-preserving, in data/builtin/<id>/ with a version-manifest (.manifest.json).

32.8 Lifecycle

use agent_os_modules::{ModuleLifecycle, InstallContext, InstallReport,
                       UninstallContext, RuntimeContext};

impl ModuleLifecycle for AnalyticsLifecycle {
    fn install(&self, ctx: &mut InstallContext) -> Result<InstallReport, String> {
        // скачать/материализовать артефакты в ctx.install_dir
        Ok(InstallReport::default())
    }
    fn activate(&self, _rt: &mut RuntimeContext) -> Result<(), String> { Ok(()) }
    fn deactivate(&self, _rt: &mut RuntimeContext) -> Result<(), String> { Ok(()) }
    fn uninstall(&self, _ctx: &mut UninstallContext) -> Result<(), String> { Ok(()) }
}

All methods are no-ops by default. The runtime activates modules in topological order and deactivates them in reverse; state — data/modules/<id>/state.json (version, status, artifacts). An activation failure marks the module failed, but the system starts. The DLL is not unloaded; deactivate — graceful stop.

32.9 Full example

The canonical dual-transport module is this reference itself (agent_os_docs):

// crates/modules/surfaces/agent_os_docs/src/interface.rs
#[abi_trait(id = "DocSource", name = "documentation", version = 1)]
pub trait DocSource {
    fn manifest(&self) -> Result<String, String>;
    fn markdown(&self, id: &str, lang: &str) -> Result<String, String>;
}

// crates/modules/surfaces/agent_os_docs/src/lib.rs
fn contribute(h: &mut dyn RegistryHost) {
    h.stable_registry(DOCS_REGISTRY, DOC_SOURCE_ID);
}
agent_os_sdk::declare_module!(DocsModule, "docs", contribute, abi_objects);

abi_registry! {
    BuiltinSource as DocSource;
}

The minimal cdylib route engine — crates/modules/fixtures/agent_os_route_demo (pure impl without unsafe/thunks). More examples: agent_os_widget (dual-transport surface), agent_os_test_plugin (raw C ABI, all categories), agent_os_test_host_client (consuming host services from a cdylib).

32.10 Build and tests

cargo build -p <module>                      # linked (feature host, по умолчанию)
cargo build -p <module> --no-default-features # lean DLL

./scripts/build-module-dlls.sh               # Windows: scripts\build-module-dlls.ps1
./scripts/test-module-system.sh              # stage DLLs + все module-system тесты

Lean libraries are staged into a separate directory target/modules/<profile>/ — otherwise the linked build (including cargo build --workspace) will overwrite the real DLL with an empty stub without exports. Tests that load a DLL (agent_os_plugin_host, agent_os_modules_runtime, agent_os_route_abi) obtain the artifact via agent_os_test_support::require_dll and fail (do not skip) if it is missing.

Transport parity is enforced by agent_os_modules_runtime/tests/transport_parity.rs: linked and DLL must produce the same registry/route catalog.

32.11 Where to go next