Developers
SDK Reference
Build extensions for Omni using the Rust SDK. Compile to WebAssembly and publish to the marketplace.
Overview
Omni extensions are written in Rust and compiled to WebAssembly (WASM). They run in isolated sandboxes powered by Wasmtime with capability-based permissions — extensions can only access resources you explicitly grant them.
The omni-sdk crate provides typed clients for every host function, a macro for WASM entry-point generation, and re-exports of serde / serde_json so you can focus on your extension's logic.
Language
Rust
Compile Target
wasm32-wasip1
Runtime
Wasmtime (WASI P1)
Quick Start
Create a new Rust library project and add the Omni SDK:
cargo new --lib my-extension
cd my-extension
# Add the SDK dependency
cargo add omni-sdk
# Add serde (re-exported by SDK, but needed for derive macros)
cargo add serde --features derive
cargo add serde_json
Set the crate type to cdylib in your Cargo.toml:
[lib]
crate-type = ["cdylib"]
Write your first extension in src/lib.rs:
use omni_sdk::prelude::*;
#[derive(Default)]
struct MyExtension;
impl Extension for MyExtension {
fn handle_tool(
&mut self,
ctx: &Context,
tool_name: &str,
params: serde_json::Value,
) -> ToolResult {
match tool_name {
"hello" => {
let name = params["name"]
.as_str()
.unwrap_or("world");
Ok(serde_json::json!({
"message": format!("Hello, {}!", name)
}))
}
_ => Err(SdkError::UnknownTool(tool_name.into())),
}
}
}
// Generate the WASM entry point
omni_sdk::omni_main!(MyExtension);
Build for the WASM target:
# One-time setup
rustup target add wasm32-wasip1
# Build
cargo build --target wasm32-wasip1 --release
# Output: target/wasm32-wasip1/release/my_extension.wasm
Manifest Format
Every extension needs an omni-extension.toml manifest file that describes the extension, its runtime constraints, permission requirements, and tool definitions.
[extension]
id = "com.example.weather"
name = "Weather Tool"
version = "1.0.0"
author = "Your Name <you@example.com>"
description = "Get current weather data for any city."
license = "MIT"
categories = ["weather", "utilities"]
[runtime]
entrypoint = "weather.wasm"
max_memory_mb = 64 # default: 64 MB
max_cpu_ms_per_call = 5000 # default: 5 seconds
max_concurrent_calls = 4 # default: 4
[[permissions]]
capability = "network.http"
reason = "Fetch weather data from OpenWeatherMap API"
required = true
[permissions.scope]
domains = ["api.openweathermap.org"]
methods = ["GET"]
[[permissions]]
capability = "storage.persistent"
reason = "Cache weather responses"
required = false
[[tools]]
name = "get_weather"
description = "Get current weather for a city"
[tools.parameters]
type = "object"
required = ["city"]
[tools.parameters.properties.city]
type = "string"
description = "City name (e.g. London, Tokyo)"
Manifest Sections
[extension]id, name, version, author, descriptionRequired metadata. ID uses reverse-domain format (min 5 chars, must contain a dot). Version must be valid semver. Optional fields: license, homepage, repository, icon, categories, min_omni_version.
[runtime]entrypoint, max_memory_mb, max_cpu_ms_per_call, max_concurrent_callsWASM sandbox configuration. entrypoint is the path to the .wasm file (required). Memory default is 64 MB, CPU timeout default is 5000 ms, concurrency default is 4. All limits must be greater than zero.
[[permissions]]capability, scope, reason, requiredArray of permission declarations. Each entry names a capability (e.g. network.http), explains why it's needed, and optionally constrains scope (allowed domains, paths, etc.). required defaults to true.
[[tools]]name, description, parametersArray of tool definitions exposed to the LLM. parameters is a JSON Schema object describing the tool's input. The LLM uses name and description to decide when to invoke the tool.
[config]fields.{name}.type, label, help, sensitive, required, default, optionsOptional configuration schema. Defines fields the user can set through the UI (e.g. API keys, preferences). Sensitive fields are hidden in the UI. Extensions read config values via ctx.config().get(key).
[hooks]on_install, on_message, on_scheduleOptional lifecycle hooks. on_install and on_message are booleans. on_schedule accepts a cron expression (e.g. "0 */6 * * *").
Extension ID Rules
Must use reverse-domain format (e.g. com.example.my-tool)
Minimum 5 characters, must contain at least one dot
No leading, trailing, or consecutive dots
No double-dot sequences (..)
Extension Trait & Entry Point
Every extension implements the Extension trait and uses the omni_main! macro to generate the WASM entry point. Your struct must implement Default since the runtime creates the instance on first tool call.
pub trait Extension {
fn handle_tool(
&mut self,
ctx: &Context,
tool_name: &str,
params: serde_json::Value,
) -> ToolResult;
}
// ToolResult = Result<serde_json::Value, SdkError>
The handle_tool method receives the tool name as a string and parameters as a JSON value. Match on the tool name to dispatch to the correct handler. Return a serde_json::Value on success — the runtime serializes it back to the host.
use omni_sdk::prelude::*;
#[derive(Default)]
struct FileTools;
impl Extension for FileTools {
fn handle_tool(
&mut self,
ctx: &Context,
tool_name: &str,
params: serde_json::Value,
) -> ToolResult {
match tool_name {
"read_file" => self.read_file(ctx, params),
"write_file" => self.write_file(ctx, params),
"list_dir" => self.list_dir(ctx, params),
_ => Err(SdkError::UnknownTool(tool_name.into())),
}
}
}
impl FileTools {
fn read_file(&self, ctx: &Context, params: serde_json::Value) -> ToolResult {
let path = params["path"].as_str()
.ok_or_else(|| SdkError::Other("missing path".into()))?;
let content = ctx.fs().read_string(path)?;
Ok(serde_json::json!({ "content": content }))
}
// ... other handlers
}
omni_sdk::omni_main!(FileTools);
omni_main! Macro
The omni_main! macro generates a #[no_mangle] pub extern "C" fn handle_tool function that the Wasmtime runtime calls. It creates a static extension instance via Default::default() on first invocation, reads the tool name and parameters from WASM linear memory, calls your handle_tool implementation, and writes the result back to memory using a packed pointer encoding.
Context API
Every tool invocation receives a Context reference. The Context provides typed clients for every host function. Each client method that accesses a protected resource requires the corresponding permission in your manifest.
ctx.http()network.http→ HttpClientMake HTTP requests (GET, POST, PUT, DELETE). Returns HttpResponse with status and body.
ctx.fs()filesystem.read / filesystem.write→ FsClientRead and write files on the host filesystem. Supports both raw bytes and string content.
ctx.process()process.spawn→ ProcessClientExecute commands and capture stdout/stderr. Returns ProcessOutput with exit code.
ctx.storage()none→ StorageClientExtension-scoped persistent key-value store. Always available, no permission needed.
ctx.llm()ai.inference→ LlmClientSend prompts to the user's configured LLM provider. Returns response text.
ctx.channels()channel.send→ ChannelClientSend messages through connected channels (Discord, Telegram, Slack, etc.).
ctx.config()none→ ConfigClientRead extension config values set by the user. Always available, no permission needed.
ctx.log() / .info() / .warn() / .error() / .debug()none→ ()Structured logging at four levels (Error, Warn, Info, Debug). Always available.
HttpClient
// Simple GET request
let resp = ctx.http().get("https://api.example.com/data")?;
let body = resp.text()?; // String
let data: MyType = resp.json()?; // Deserialize
// POST with JSON body and custom headers
let resp = ctx.http()
.post("https://api.example.com/submit")
.header("Authorization", "Bearer token123")
.json(&my_data)?
.send()?;
// PUT and DELETE also available
let resp = ctx.http().delete("https://api.example.com/item/42")?;
FsClient
// Read file as string
let content = ctx.fs().read_string("/path/to/file.txt")?;
// Read file as raw bytes
let bytes = ctx.fs().read("/path/to/image.png")?;
// Write string content
ctx.fs().write_string("/path/to/output.md", &markdown)?;
// Write raw bytes
ctx.fs().write("/path/to/data.bin", &bytes)?;
ProcessClient
let output = ctx.process().exec("git", &["log", "--oneline", "-5"])?;
if output.exit_code == 0 {
ctx.info(&format!("git output: {}}", output.stdout));
} else {
ctx.error(&format!("git failed: {}}", output.stderr));
}
LlmClient & ChannelClient
// LLM inference — 0 means use provider's default max tokens
let response = ctx.llm().request("Summarize this text: ...", 0)?;
// LLM with explicit token limit
let response = ctx.llm().request("Translate to French: hello", 200)?;
// Send a message through a connected channel
// Channel ID uses compound format: type:instance_id
let result = ctx.channels().send(
"discord:production", // channel_id
"#general", // recipient
"Build deployed!", // message text
)?;
StorageClient & ConfigClient
// Persistent key-value storage (extension-scoped)
ctx.storage().set("last_run", "2025-01-15T10:30:00Z")?;
let value = ctx.storage().get("last_run")?; // Option<String>
ctx.storage().delete("old_key")?;
// Read user-set configuration values
let api_key = ctx.config().get("api_key")?; // Option<String>
let theme = ctx.config().get_or("theme", "dark")?; // String (with default)
Host Functions
Host functions are provided by the Omni runtime and bound into the WASM sandbox under the omni import module. The SDK wraps these in typed clients — you should use the Context API above rather than calling FFI functions directly.
logLog a message at Error/Warn/Info/Debug level. Always available.
nonestorage_getRead a value from extension-scoped persistent storage.
nonestorage_setWrite a value to extension-scoped persistent storage.
nonehttp_requestMake an HTTP request (GET/POST/PUT/DELETE/PATCH/HEAD). 30s timeout, 5 MB response limit.
network.httpfs_readRead a file from the host filesystem. 10 MB file size limit.
filesystem.readfs_writeWrite data to a file on the host filesystem. Creates parent directories if needed.
filesystem.writeprocess_spawnExecute a command with arguments and capture stdout/stderr. Output capped at 50 KB each.
process.spawnllm_requestSend a prompt to the user's configured LLM provider and receive the response.
ai.inferencechannel_sendSend a text message through a connected channel plugin.
channel.sendconfig_getRead an extension configuration value set by the user. No permission required.
noneReturn Code Convention
Permission-gated host functions use a consistent return code scheme. The SDK clients handle these automatically and return typed errors:
-1Permission denied
-2Needs user prompt
-3Operation failed
-4No callback set
Permissions
Omni uses a deny-by-default capability system. Extensions must declare every capability they need in their manifest, and users must grant each one before it takes effect. Scope constraints let you limit access to specific domains, paths, or executables.
Available Capabilities
network.httpHTTP requests to allowed domains
domains, methods, portsnetwork.websocketWebSocket connections
domainsfilesystem.readRead files from the host filesystem
paths, extensions, max_sizefilesystem.writeWrite files to the host filesystem
paths, extensions, max_sizeprocess.spawnExecute commands on the host
executables, allowed_args, denied_args, max_concurrentstorage.persistentPersistent key-value storage
max_bytesai.inferenceLLM inference requests
max_tokens, rate_limitchannel.sendSend messages through channels
channels, rate_limitbrowser.scrapeScrape web content via browser
domains, max_pagessearch.webWeb search queries
providers, rate_limitmessaging.smsSend SMS messages
recipients, rate_limitmessaging.emailSend email messages
recipients, rate_limitmessaging.chatSend chat messages
recipients, rate_limitclipboard.readRead clipboard contents
noneclipboard.writeWrite to clipboard
nonesystem.notificationsShow system notifications
nonesystem.schedulingSchedule recurring/one-time tasks (cron)
nonedevice.cameraAccess device camera
nonedevice.microphoneAccess device microphone
nonedevice.locationAccess device location
noneScope Examples
# HTTP: restrict to specific domains and methods
[[permissions]]
capability = "network.http"
reason = "Fetch data from GitHub API"
[permissions.scope]
domains = ["api.github.com", "*.githubusercontent.com"]
methods = ["GET"]
# Filesystem: restrict to specific paths and file types
[[permissions]]
capability = "filesystem.read"
reason = "Read markdown files from Documents"
[permissions.scope]
paths = ["~/Documents"]
extensions = [".md", ".txt"]
max_size = 10000000 # 10 MB
# Process: whitelist specific executables
[[permissions]]
capability = "process.spawn"
reason = "Run git commands"
[permissions.scope]
executables = ["git"]
denied_args = ["push.*--force"] # regex patterns
# AI: limit token usage and rate
[[permissions]]
capability = "ai.inference"
reason = "Summarize file contents"
[permissions.scope]
max_tokens = 4000
rate_limit = 60 # per minute
# Channel: restrict to specific instances
[[permissions]]
capability = "channel.send"
reason = "Send notifications to Discord"
[permissions.scope]
channels = ["discord:production"]
rate_limit = 10
Error Handling
The SDK provides an SdkError enum for all error conditions. Tool functions return ToolResult which is Result<serde_json::Value, SdkError>.
pub enum SdkError {
UnknownTool(String), // Tool name not recognized
Serde(String), // JSON serialization error
PermissionDenied(String), // Capability not granted
HttpError(String), // HTTP request failed
StorageError(String), // Storage operation failed
FsError(String), // Filesystem operation failed
ProcessError(String), // Process execution failed
LlmError(String), // LLM inference failed
ChannelError(String), // Channel send failed
NotAvailable(String), // No callback configured
Other(String), // Generic error
}
SdkError implements Display with descriptive messages for each variant. Use the ? operator to propagate errors naturally:
// The ? operator works naturally with ToolResult
fn my_tool(ctx: &Context, params: serde_json::Value) -> ToolResult {
let url = params["url"].as_str()
.ok_or_else(|| SdkError::Other("missing url parameter".into()))?;
let resp = ctx.http().get(url)?; // HttpError or PermissionDenied
let text = resp.text()?; // Serde error
Ok(serde_json::json!({ "content": text }))
}
Building & Testing
# One-time: add the WASM target
rustup target add wasm32-wasip1
# Debug build
cargo build --target wasm32-wasip1
# Release build (use for publishing)
cargo build --target wasm32-wasip1 --release
# Optional: optimize with wasm-opt (binaryen)
wasm-opt -Oz target/wasm32-wasip1/release/my_extension.wasm -o my_extension.wasm
Testing Locally
Build the WASM binary in release mode
Place the .wasm file and omni-extension.toml in a directory together
Copy the directory into Omni's extensions folder (see paths below)
Restart Omni — extension discovery runs on startup
Review and grant permissions when prompted, then test your tools
# Place extensions under the "user" subdirectory:
# Windows: %APPDATA%/com.omni.agent/extensions/user/
# macOS: ~/Library/Application Support/com.omni.agent/extensions/user/
# Linux: ~/.local/share/com.omni.agent/extensions/user/
# Directory structure:
# extensions/user/com.example.weather/
# ├── omni-extension.toml
# └── weather.wasm
Sandbox Limits
Extensions run in a Wasmtime sandbox with enforced resource limits. If an extension exceeds its CPU timeout, Wasmtime triggers an epoch interrupt and the call returns a timeout error. Memory limits are enforced via StoreLimits. Concurrency is controlled by a per-extension semaphore.
Memory
64 MB
max_memory_mbCPU Timeout
5,000 ms
max_cpu_ms_per_callConcurrency
4 parallel
max_concurrent_callsPrelude
Import everything you need with a single use omni_sdk::prelude::* statement. The prelude re-exports all types, clients, and serde / serde_json:
ContextstructExtensiontraitToolResulttype aliasSdkErrorenumLogLevelenumHttpClientstructHttpResponsestructRequestBuilderstructFsClientstructProcessClientstructProcessOutputstructStorageClientstructLlmClientstructChannelClientstructConfigClientstructSerializeserdeDeserializeserdeserde_jsonmoduleComplete Example
A complete extension that fetches weather data, uses configuration for the API key, caches results in storage, and logs its activity:
[extension]
id = "com.example.weather"
name = "Weather Tool"
version = "1.0.0"
author = "Jane Doe <jane@example.com>"
description = "Get current weather data for any city."
license = "MIT"
categories = ["weather", "utilities"]
[runtime]
entrypoint = "weather.wasm"
[[permissions]]
capability = "network.http"
reason = "Fetch weather data from OpenWeatherMap"
[permissions.scope]
domains = ["api.openweathermap.org"]
methods = ["GET"]
[config.fields.api_key]
type = "string"
label = "OpenWeatherMap API Key"
sensitive = true
required = true
[[tools]]
name = "get_weather"
description = "Get current weather for a city"
[tools.parameters]
type = "object"
required = ["city"]
[tools.parameters.properties.city]
type = "string"
description = "City name (e.g. London, Tokyo)"
use omni_sdk::prelude::*;
#[derive(Deserialize)]
struct WeatherResponse {
main: MainData,
weather: Vec<WeatherInfo>,
name: String,
}
#[derive(Deserialize)]
struct MainData { temp: f64, humidity: u32 }
#[derive(Deserialize)]
struct WeatherInfo { description: String }
#[derive(Default)]
struct WeatherExtension;
impl Extension for WeatherExtension {
fn handle_tool(
&mut self,
ctx: &Context,
tool_name: &str,
params: serde_json::Value,
) -> ToolResult {
match tool_name {
"get_weather" => {
let city = params["city"].as_str()
.ok_or_else(|| SdkError::Other("missing city".into()))?;
// Read API key from user configuration
let api_key = ctx.config().get("api_key")?
.ok_or_else(|| SdkError::Other("api_key not configured".into()))?;
// Check cache first
let cache_key = format!("weather_{}}", city.to_lowercase());
if let Some(cached) = ctx.storage().get(&cache_key)? {
ctx.debug("returning cached weather data");
let v: serde_json::Value = serde_json::from_str(&cached)
.map_err(|e| SdkError::Serde(e.to_string()))?;
return Ok(v);
}
// Fetch from API
ctx.info(&format!("fetching weather for {}}", city));
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
city, api_key
);
let resp = ctx.http().get(&url)?;
let weather: WeatherResponse = resp.json()?;
let result = serde_json::json!({
"city": weather.name,
"temperature": weather.main.temp,
"humidity": weather.main.humidity,
"description": weather.weather.first()
.map(|w| w.description.as_str()).unwrap_or("unknown"),
});
// Cache the result
let _ = ctx.storage().set(
&cache_key,
&serde_json::to_string(&result).unwrap_or_default(),
);
Ok(result)
}
_ => Err(SdkError::UnknownTool(tool_name.into())),
}
}
}
omni_sdk::omni_main!(WeatherExtension);