Files
optimclaw/.claude/commands/add-tool.md
2cdd1acb1e refactor: consolidate tool approval into single param-aware method (#274)
* refactor: consolidate tool approval into single param-aware method

Replace the two confusing approval methods (requires_approval() and
requires_approval_for()) with a single requires_approval(&self, params)
returning a 3-variant ApprovalRequirement enum (Never, UnlessAutoApproved,
Always). This enables param-aware approval decisions: HTTP calls without
auth headers now skip approval entirely, while authenticated requests
always require it. Shell tool merges its destructive-command detection
into the same method.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add credential injection to built-in HTTP tool

Wire the WASM credential injection system into the built-in HTTP tool
so credentials are auto-injected at the boundary (zero-exposure model).

- Add SharedCredentialRegistry: thread-safe, append-only registry of
  credential mappings populated by WASM tools at registration time
- Add credential_detect module with broad auth detection for headers
  (12 exact + 5 substring matches), header values (7 auth scheme
  prefixes), and URL query params (17 exact + 5 substring matches)
- HttpTool now accepts optional credential registry + secrets store,
  auto-injects matching credentials in execute(), and uses broader
  auth detection in requires_approval()
- ToolRegistry passes credential registry to HttpTool at startup and
  populates it when WASM tools register
- Remove old hardcoded AUTH_HEADER_NAMES / has_auth_headers in favor
  of the new params_contain_manual_credentials()

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR #274 review comments (query param injection, lock poisoning, visibility)

- Fix injected query params not being sent on outbound HTTP requests by
  also calling .query() on the RequestBuilder alongside parsed_url mutation
- Recover from poisoned RwLock in SharedCredentialRegistry instead of
  silently ignoring failures, with tracing::warn for visibility
- Narrow inject_credential and host_matches_pattern to pub(crate) to
  avoid committing to them as stable public API

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-21 01:28:23 +00:00

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 IronClaw 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 IronClaw (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_hour
  • http.timeout_secs
  • secrets.allowed_names - Which secrets the tool can check existence of
  • auth - 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.rs if 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 fmt clean
  • cargo clippy clean (for built-in) or cargo component build clean (for WASM)