Custom tools are how you extend Aomi with your own APIs, data sources, and onchain actions. You define each tool in the Rust SDK by implementing the DynAomiTool trait, then register your tools with the dyn_aomi_app! macro. The macro compiles your tool list into a manifest, a dispatch router, and the C ABI entry points the host loads at runtime.
For the full app lifecycle (crate setup, building, and shipping), see Building Apps. This page focuses on the tool surface.
Each tool is a struct that implements DynAomiTool. You declare the app it belongs to, a typed argument struct, a name, and a description. The name and description are what the LLM uses to pick the tool; the argument struct is what the LLM fills in from natural language.
use aomi_sdk::{DynAomiTool, DynToolCallCtx};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Clone, Default)]
struct MyApp;
#[derive(Debug, Deserialize, JsonSchema)]
struct GetPriceArgs {
symbol: String,
}
struct GetPrice;
impl DynAomiTool for GetPrice {
type App = MyApp;
type Args = GetPriceArgs;
const NAME: &'static str = "get_token_price";
const DESCRIPTION: &'static str = "Get the current price of a token by symbol.";
fn run(_app: &MyApp, args: GetPriceArgs, _ctx: DynToolCallCtx) -> Result<Value, String> {
Ok(json!({ "symbol": args.symbol, "price": 42.0 }))
}
}
Each tool declares:
- An
App is the app struct the tool belongs to.
- An
Args struct holds the typed parameters the LLM generates. It must derive Deserialize and JsonSchema.
- A
NAME is the identifier the LLM uses to invoke the tool.
- A
DESCRIPTION tells the LLM when to use the tool.
- A
run function holds the logic. It returns a JSON Value on success or an error String.
The DynToolCallCtx argument carries per-call context: session_id, call_id, host-injected state_attributes, and resolved secrets. Read secrets with resolve_secret_value rather than touching the map directly.
For long-running or streaming tools, set IS_ASYNC = true and implement run_async instead of run. You push intermediate updates through the DynAsyncSink and signal the terminal result with complete:
use aomi_sdk::{DynAomiTool, DynToolCallCtx, DynAsyncSink};
use serde_json::json;
impl DynAomiTool for StreamPrices {
type App = MyApp;
type Args = StreamArgs;
const NAME: &'static str = "stream_prices";
const DESCRIPTION: &'static str = "Stream price updates over time.";
const IS_ASYNC: bool = true;
fn run_async(
_app: &MyApp,
_args: StreamArgs,
_ctx: DynToolCallCtx,
sink: DynAsyncSink,
) -> Result<(), String> {
sink.emit(json!({ "price": 41.5 }))?;
sink.complete(json!({ "price": 42.0 }))?;
Ok(())
}
}
Intermediate emit calls must send bare values. The terminal complete call accepts either a bare value or a routed ToolReturn. Check sink.is_canceled() to bail out early when the host cancels the call.
A tool can ask the host to run a follow-up step by returning a routed ToolReturn instead of a bare value. Override run_with_routes and build the route with ToolReturn::with_route and RouteStep::on_return:
use aomi_sdk::{RouteStep, ToolReturn};
use serde_json::json;
ToolReturn::with_route(
json!({ "status": "awaiting_signature" }),
RouteStep::on_return("evm_commit_message", json!({ "typed_data": "..." }))
.prompt("Suggested next step: call evm_commit_message with these args."),
)
The host treats each route as advisory: on_return steps render into the next prompt the LLM sees, and out-of-band events (wallet callbacks, staged transaction completions) splice their results into the hinted args before the continuation prompt is injected. See Host Interop for the host tool contract (view_state, run_tx, stage_tx, simulate_batch, commit_tx).
Register every tool with the dyn_aomi_app! macro at your crate root. The macro generates the DynAomiApp implementation and the FFI exports:
aomi_sdk::dyn_aomi_app!(
app = MyApp,
name = "myapp",
version = "0.1.0",
preamble = "You help users check and trade tokens.",
tools = [GetPrice, StreamPrices],
namespaces = ["evm-core"],
);
The arms are app, name, version, preamble, tools, optional secrets, and optional namespaces. namespaces defaults to ["evm-core"] when omitted, which gives your tools the host’s EVM wallet flows. Add secrets = [...] to declare per-app credentials the host gates app load on.
The aomi_sdk::testing module lets you unit-test a tool without loading the full FFI plugin. Build a context with TestCtxBuilder, then drive the tool with run_tool (sync) or run_async_tool (async):
use aomi_sdk::testing::{TestCtxBuilder, run_tool};
use serde_json::json;
let ctx = TestCtxBuilder::new("get_token_price").build();
let result = run_tool::<GetPrice>(&MyApp, json!({ "symbol": "ETH" }), ctx);
assert!(result.is_ok());
Next Steps
Last modified on June 4, 2026