Files
a35db4d32d feat: Add Google Suite & Telegram WASM tools (#9)
* Add Google Calendar and Gmail WASM tools, and /add-tool skill

Scaffold two new WASM tools that share a single Google OAuth token:
- google-calendar: list/get/create/update/delete calendar events
- gmail: list/search/get/send/draft/reply/trash emails

Both tools use the sandboxed WIT interface with strict HTTP allowlists,
credential injection, and rate limiting. OAuth config requests only
the minimum scopes needed (calendar.events, gmail.modify, gmail.compose).

Also adds the /add-tool skill for scaffolding future WASM or built-in
tools with all boilerplate wired up.

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

* Document WASM vs MCP server decision guide in CLAUDE.md

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

* Add Google Drive WASM tool with full file and sharing management

Supports 12 actions: list/get/download/upload/update files, create
folders, delete/trash, share/list/remove permissions, and list shared
drives. Works with both personal and organizational drives via the
corpora parameter. Uses shared google_oauth_token for auth.

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

* Add Google Sheets, Docs, and Slides WASM tools

Three new Google Workspace tools sharing google_oauth_token:
- Sheets: create spreadsheets, read/write/append values, manage sheets, format cells
- Docs: create/read/edit documents, text formatting, paragraphs, tables, lists
- Slides: create/edit presentations, shapes, images, text formatting, thumbnails, templates

Also adds tools-src/TOOLS.md tracking implementation status.

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

* Add Telegram WASM tool with direct MTProto over HTTPS

Replace TDLight Docker dependency with pure-Rust grammers crates
for direct encrypted MTProto communication to Telegram's web
transport endpoints. No middleware, no Docker needed.

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

* Gitignore Cargo.lock files in WASM tools

Library crates should not commit lock files. Consolidate per-tool
.gitignore into a single one at wasm-tools/ level.

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

* Flatten tools-src/wasm-tools/ into tools-src/

All tools are WASM, the extra nesting added no value. Moves all tool
crates up one level, updates WIT paths and documentation references.

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

* Fix Slack tool: add OAuth auth, URL encoding, pin wit-bindgen

- Add OAuth 2.0 auth section to Slack capabilities with proper scopes
  and manual fallback instructions
- URL-encode query parameters in GET requests to prevent injection
- Remove dead SlackApiError struct
- Pin wit-bindgen to =0.36 across all WASM tools for Rust 1.86 compat
- Update add-tool template with pinned version

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-09 06:16:27 +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) -> bool {
        false // Set true if tool is destructive or contacts external services
    }
}

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)