Full rename of all identifiers, filenames, and references: ironclaw → optimclaw IronClaw → OptimClaw IRONCLAW → OPTIMCLAW ironclaw_common → optimclaw_common ironclaw_safety → optimclaw_safety Upstream: nearai/ironclaw
10 KiB
description, allowed-tools, argument-hint, model
| description | allowed-tools | argument-hint | model |
|---|---|---|---|
| Scaffold a new tool (WASM or built-in Rust) with all boilerplate wired up | Read, Edit, Write, Glob, Grep, Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo component:*), Bash(ls:*), Bash(mkdir:*) | <tool_name> [description] | opus |
Scaffold a new tool called $ARGUMENTS for the OptimClaw agent. First, determine the tool type and then follow the appropriate path.
Step 0: Determine tool type
Ask the user which type of tool to create:
- WASM tool (recommended) - Sandboxed, dynamically loadable, external API integrations. Lives in
tools-src/<name>/. This is the right choice for anything that talks to an external service (Notion, GitHub, Discord, etc.). - Built-in tool - Compiled into the main binary. Only for core agent infrastructure (e.g., memory, file ops, shell). Lives in
src/tools/builtin/<name>.rs.
If the description clearly implies an external service integration, default to WASM. If it's a core agent capability, default to built-in.
Path A: WASM Tool
A1: Create directory structure
Create tools-src/<name>/ with:
tools-src/<name>/
├── Cargo.toml
├── <name>-tool.capabilities.json
└── src/
├── lib.rs
├── types.rs
└── api.rs
A2: Write Cargo.toml
Follow this exact pattern (adjust name and description):
[package]
name = "<name>-tool"
version = "0.1.0"
edition = "2021"
description = "<Description> tool for OptimClaw (WASM component)"
license = "MIT OR Apache-2.0"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "=0.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
A3: Write <name>-tool.capabilities.json
Declare the tool's security requirements. Determine what APIs it needs and create the allowlist. Reference tools-src/slack/slack-tool.capabilities.json for the format.
Key sections to include:
http.allowlist- API endpoints (host, path_prefix, methods)http.credentials- Secret injection config (secret_name, location type: bearer/header/query)http.rate_limit- requests_per_minute, requests_per_hourhttp.timeout_secssecrets.allowed_names- Which secrets the tool can check existence ofauth- Authentication setup (OAuth or manual token entry)
If the tool needs OAuth, include:
{
"auth": {
"secret_name": "<service>_token",
"display_name": "<Service>",
"oauth": {
"authorization_url": "https://...",
"token_url": "https://...",
"client_id_env": "<SERVICE>_OAUTH_CLIENT_ID",
"client_secret_env": "<SERVICE>_OAUTH_CLIENT_SECRET",
"scopes": [],
"use_pkce": false
},
"env_var": "<SERVICE>_TOKEN"
}
}
If no OAuth, include manual setup instructions:
{
"auth": {
"secret_name": "<service>_api_key",
"display_name": "<Service>",
"instructions": "Get your API key from <url>",
"setup_url": "https://...",
"token_hint": "Starts with '<prefix>'",
"env_var": "<SERVICE>_API_KEY"
}
}
A4: Write src/types.rs
Define the action enum using serde's tagged enum pattern:
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum <Name>Action {
// Add variants based on the tool's capabilities.
// Each variant maps to one API operation.
}
Add result structs with #[derive(Debug, Serialize)]. Use #[serde(skip_serializing_if = "Option::is_none")] for optional fields.
A5: Write src/api.rs
Implement the API calls using the host HTTP capability:
use crate::near::agent::host;
use crate::types::*;
const API_BASE: &str = "https://api.example.com";
fn api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result<String, String> {
let url = format!("{}/{}", API_BASE, endpoint);
let headers = if body.is_some() {
r#"{"Content-Type": "application/json"}"#
} else {
"{}"
};
let body_bytes = body.map(|b| b.as_bytes().to_vec());
host::log(host::LogLevel::Debug, &format!("API: {} {}", method, endpoint));
let response = host::http_request(method, &url, headers, body_bytes.as_deref())?;
if response.status < 200 || response.status >= 300 {
return Err(format!(
"API returned status {}: {}",
response.status,
String::from_utf8_lossy(&response.body)
));
}
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8: {}", e))
}
Add one function per action variant that calls api_call and parses the response into the result structs.
A6: Write src/lib.rs
Wire everything together:
mod api;
mod types;
use types::<Name>Action;
wit_bindgen::generate!({
world: "sandboxed-tool",
path: "../../wit/tool.wit",
});
struct <Name>Tool;
impl exports::near::agent::tool::Guest for <Name>Tool {
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
match execute_inner(&req.params) {
Ok(result) => exports::near::agent::tool::Response {
output: Some(result),
error: None,
},
Err(e) => exports::near::agent::tool::Response {
output: None,
error: Some(e),
},
}
}
fn schema() -> String {
// Return JSON Schema matching the action enum
todo!("Fill in JSON Schema")
}
fn description() -> String {
"<Description>".to_string()
}
}
fn execute_inner(params: &str) -> Result<String, String> {
// Check required secrets
if !crate::near::agent::host::secret_exists("<secret_name>") {
return Err("<Secret> not configured. Please add the '<secret_name>' secret.".to_string());
}
let action: <Name>Action =
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
crate::near::agent::host::log(
crate::near::agent::host::LogLevel::Info,
&format!("Executing action: {:?}", action),
);
let result = match action {
// Dispatch to api:: functions for each variant
};
Ok(result)
}
export!(<Name>Tool);
Fill in the schema() with a proper JSON Schema using oneOf for each action variant. Reference tools-src/slack/src/lib.rs for the exact pattern.
A7: Verify
Run cargo fmt in the tool directory. If cargo-component is available, run cargo component build --release to verify the WASM compiles.
Path B: Built-in Tool
B1: Create the tool file
Create src/tools/builtin/<name>.rs implementing the Tool trait:
use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
pub struct <Name>Tool;
#[async_trait]
impl Tool for <Name>Tool {
fn name(&self) -> &str {
"<snake_case_name>"
}
fn description(&self) -> &str {
"<Description>"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
// Define parameters here
},
"required": []
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
// Extract and validate parameters
// Do the work
// Return result
Ok(ToolOutput::text("result", start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false // Set true if tool processes external data
}
fn requires_approval(&self, _params: &serde_json::Value) -> crate::tools::tool::ApprovalRequirement {
crate::tools::tool::ApprovalRequirement::Never // Set to UnlessAutoApproved or Always as needed
}
}
If the tool needs shared state (HTTP client, config), add a struct field and new() constructor:
pub struct <Name>Tool {
client: reqwest::Client,
}
impl <Name>Tool {
pub fn new() -> Self {
Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client"),
}
}
}
B2: Update src/tools/builtin/mod.rs
Add the module declaration and pub use, keeping alphabetical order:
mod <name>;
pub use <name>::<Name>Tool;
B3: Update src/tools/registry.rs
Add the import to the use crate::tools::builtin::{...} block and register the tool in the appropriate registration method:
- If it's a core tool: add to
register_builtin_tools() - If it needs shared state (workspace, context_manager, etc.): create a new
register_<category>_tools()method or add to an existing one - Wire the new registration call in
src/main.rsif a new method was created
B4: Add tests
Add a mod tests {} block at the bottom of the tool file:
#[cfg(test)]
mod tests {
use super::*;
use crate::context::JobContext;
fn test_context() -> JobContext {
JobContext::test_default()
}
#[tokio::test]
async fn test_<name>_basic() {
let tool = <Name>Tool::new();
let params = serde_json::json!({ /* test params */ });
let result = tool.execute(params, &test_context()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_<name>_missing_params() {
let tool = <Name>Tool::new();
let params = serde_json::json!({});
let result = tool.execute(params, &test_context()).await;
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
}
}
B5: Quality gate
Run cargo fmt and cargo clippy --all --benches --tests --examples --all-features. Fix any issues.
Run the new tests: cargo test --lib -- builtin::<name>::tests
Checklist
Before finishing, verify:
- Tool type chosen (WASM or built-in) and confirmed with user
- All files created with correct structure
- For WASM: capabilities.json declares all needed permissions (HTTP, secrets, auth)
- For WASM: JSON Schema in
schema()matches the action enum variants - For built-in: mod.rs updated with module + pub use
- For built-in: registry.rs imports and registers the tool
- For built-in: tests added and passing
cargo fmtcleancargo clippyclean (for built-in) orcargo component buildclean (for WASM)