Extensions
Rust guest SDK
Write an oino extension in Rust with oino-extension: tools, commands, events, host imports, build and test.
oino-extension wraps the generated WIT bindings in idiomatic Rust: an Extension trait, an Init registration collector and typed wrappers for every host capability. oino-extension-macros adds a #[tool] attribute. Both are on crates.io; the sample below compiles against the published oino-extension = "1".
Project setup#
cargo new --lib hello-ext
cd hello-ext
rustup target add wasm32-wasip2Cargo.toml:
[package]
name = "hello-ext"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
oino-extension = "1"
serde = { version = "1", features = ["derive"] }
schemars = "1.2"serde and schemars must be direct dependencies: the derive macros generate paths into those crates. Pin schemars to 1.x; the SDK's re-export is built against it.
Next to it, extension.json (the manifest; all fields):
{
"apiVersion": "oino.extension/2",
"id": "acme.hello",
"name": "Hello",
"version": "0.1.0",
"kind": "extension",
"description": "Greets people and reacts to the agent."
}The extension#
One type, one init, one export macro:
use oino_extension::prelude::*;
#[derive(Default)]
struct Hello;
#[derive(Deserialize, JsonSchema)]
struct HelloArgs {
/// Name of the person to greet.
name: String,
}
impl Hello {
/// Say hello to someone.
#[oino_extension::tool(label = "Say hello", kind = Other)]
async fn hello(&'static self, args: HelloArgs, cx: ToolCtx) -> Result<ToolResult> {
cx.progress(&ToolResult::text(format!("greeting {}", args.name)));
Ok(ToolResult::text(format!("Hello, {}!", args.name)))
}
}
impl Extension for Hello {
async fn init(&'static self, cx: &mut Init<Self>) -> Result<()> {
// The macro turned the method above into a tool builder:
cx.tool(Self::hello_tool());
// Plain builder form, no macro:
cx.tool(
Tool::new("shout")
.description("SHOUT A MESSAGE")
.args::<HelloArgs>()
.run(|_ext, args: HelloArgs, _cx| async move {
Ok(ToolResult::text(args.name.to_uppercase()))
}),
);
// Event subscription; handlers of one extension run concurrently.
cx.on(EventKind::AgentEnd, |_ext, _ev: Event, _cx| async move {
host::info_log("run finished");
Ok(None::<Value>)
});
Ok(())
}
}
oino_extension::export_extension!(Hello);Notes that save an hour:
- Handlers receive
&'static self. The host constructs the value withDefaultand leaks it, so your state can be plain fields orsync::Mutex/SessionMap(a plainstd::sync::Mutexmust not be held across.await). - An event handler returns
Ok(None)to pass, orOk(Some(json))to answer (fortool_call,{"block": true, "reason": "..."}vetoes the call).Ok(None)alone cannot infer the type; writeOk(None::<Value>). - Tool doc comments become the model-facing description.
labelis for the UI,kindmaps to editor tool kinds (Read,Edit,Execute, ...). args::<T>()derives the JSON schema fromT: JsonSchema; the host decodes arguments and errors land in the tool result, not a panic.
Host capabilities#
The prelude exposes one module per WIT interface. The ones you reach for first:
// Session: append a custom entry, send messages.
session::append_entry("hello.seen", Some(&json!({"at": timers::now_ms()})))?;
// UI: fire-and-forget notify; dialogs exist too (select, confirm, input, ...).
ui::notify("hello ready", "info")?;
// Control: is the agent idle, which model, trigger compaction.
let idle = control::is_idle();
// HTTP through the host (needs the network permission).
let resp = http::get("https://api.example.com/status").send().await?;
// Scoped key-value state (Global, Workspace, Session).
kv::set(kv::Scope::Workspace, "last-hello", b"today")?;The full module table is in the crate rustdoc (docs.rs/oino-extension). oino_extension::wit re-exports the raw generated types when a wrapper is missing something.
Commands, shortcuts, services#
Everything an extension can register goes through Init in one place:
use oino_extension::command::{Command, Flag, Shortcut};
cx.command(
Command::new("hello")
.description("Say hello to the team")
.run_value(|_ext, _args, _cx| async move { Ok(Some(json!({"hello": true}))) }),
);
cx.shortcut(
Shortcut::new("hello.shortcut", &["ctrl+e"])
.description("Greet on keypress")
.run(|_ext, _cx| async { ui::notify("hi", "info") }),
);
cx.flag(Flag::boolean("hello-verbose", "Verbose hello output"));
cx.service("hello.ping", |_ext, payload, caller, _cx| async move {
Ok(json!({"pong": payload, "from": caller}))
});
cx.timer("hello.tick", Duration::from_secs(60), true);Commands appear as /hello in every client, flags as --hello-verbose on the CLI, services are callable from other extensions.
Build, package, run#
cargo build --target wasm32-wasip2 emits the component directly (the target plus wit-bindgen produce a component; no wasm-tools step needed). Assemble the package:
acme.hello/
extension.json
component.wasm # copied from target/wasm32-wasip2/debug/hello_ext.wasmPoint the CLI at it:
oino -e ./acme.hello/info in the TUI lists loaded extensions; the hello tool is now in the model's tool list. From inside the repository's workspace, cargo xtask wasm -p hello-ext builds and stages the package in one step.
Testing#
Host-side integration tests load built components through oino-wasm-host's testing feature (Harness) against recording host services; the fixtures under extensions/fixtures/ in the repository (echo, async-probe, ui-probe, provider-faux) are complete worked examples. In-component unit tests run on the host target like any Rust crate.
Providers#
A provider component exports the provider interface (stream, list-models, optional OAuth) and imports provider-host. Build the same crate with the SDK's provider feature and implement Extension::provider; the repository's provider kit (oino-provider-kit in extensions/sdk/) implements the wire protocols, and extensions/providers/ holds the shipping providers as further reference. See providers.