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#

bash
cargo new --lib hello-ext
cd hello-ext
rustup target add wasm32-wasip2

Cargo.toml:

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

json
{
  "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:

rust
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 with Default and leaks it, so your state can be plain fields or sync::Mutex/SessionMap (a plain std::sync::Mutex must not be held across .await).
  • An event handler returns Ok(None) to pass, or Ok(Some(json)) to answer (for tool_call, {"block": true, "reason": "..."} vetoes the call). Ok(None) alone cannot infer the type; write Ok(None::<Value>).
  • Tool doc comments become the model-facing description. label is for the UI, kind maps to editor tool kinds (Read, Edit, Execute, ...).
  • args::<T>() derives the JSON schema from T: 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:

rust
// 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:

rust
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:

output
acme.hello/
  extension.json
  component.wasm      # copied from target/wasm32-wasip2/debug/hello_ext.wasm

Point the CLI at it:

bash
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.