Rename examples/ to tools-src/

Update doc references in CLAUDE.md and slack README.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-06 11:53:09 -08:00
co-authored by Claude Opus 4.6
parent 9d156411fc
commit f34a80191e
8 changed files with 3 additions and 3 deletions
-2
View File
@@ -1,2 +0,0 @@
Cargo.lock
target/
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "slack-tool"
version = "0.1.0"
edition = "2021"
description = "Slack integration 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
-238
View File
@@ -1,238 +0,0 @@
# Slack WASM Tool
A standalone WASM component that provides Slack integration for IronClaw. This serves as both a functional tool and a template for building custom WASM tools.
## Features
- **send_message**: Send messages to channels or threads
- **list_channels**: List channels the bot has access to
- **get_channel_history**: Retrieve recent messages from a channel
- **post_reaction**: Add emoji reactions to messages
- **get_user_info**: Get information about Slack users
## Prerequisites
1. **Rust toolchain** with WASM target:
```bash
rustup target add wasm32-wasip2
```
2. **cargo-component** for building WASM components:
```bash
cargo install cargo-component
```
3. **Slack Bot Token** with the following OAuth scopes:
- `chat:write` - Send messages
- `channels:read` - List public channels
- `channels:history` - Read channel history
- `groups:read` - List private channels
- `groups:history` - Read private channel history
- `reactions:write` - Add reactions
- `users:read` - Get user information
## Building
```bash
cd examples/wasm-tools/slack
cargo component build --release
```
The compiled WASM component will be at:
```
target/wasm32-wasip2/release/slack_tool.wasm
```
## Installation
### Option A: File-based (Development)
Copy the WASM and capabilities files to the agent's tools directory:
```bash
mkdir -p ~/.ironclaw/tools
cp target/wasm32-wasip2/release/slack_tool.wasm ~/.ironclaw/tools/slack.wasm
cp slack.capabilities.json ~/.ironclaw/tools/
```
### Option B: Database Storage (Production)
Use the agent CLI or API to store the tool:
```bash
ironclaw tool install \
--name slack \
--wasm target/wasm32-wasip2/release/slack_tool.wasm \
--capabilities slack.capabilities.json
```
## Configuration
Store your Slack bot token as a secret:
```bash
ironclaw secret set slack_bot_token "xoxb-your-token-here"
```
Or via SQL:
```sql
INSERT INTO secrets (user_id, name, encrypted_value, key_salt)
VALUES ('your_user_id', 'slack_bot_token', ...);
```
## Usage Examples
### Send a Message
```json
{
"action": "send_message",
"channel": "#general",
"text": "Hello from IronClaw!"
}
```
### Reply in a Thread
```json
{
"action": "send_message",
"channel": "C1234567890",
"text": "This is a thread reply",
"thread_ts": "1234567890.123456"
}
```
### List Channels
```json
{
"action": "list_channels",
"limit": 50
}
```
### Get Channel History
```json
{
"action": "get_channel_history",
"channel": "C1234567890",
"limit": 10
}
```
### Add a Reaction
```json
{
"action": "post_reaction",
"channel": "C1234567890",
"timestamp": "1234567890.123456",
"emoji": "thumbsup"
}
```
### Get User Info
```json
{
"action": "get_user_info",
"user_id": "U1234567890"
}
```
## Security Model
This tool runs in a sandboxed WASM environment with strict capability controls:
1. **HTTP Allowlist**: Can only access `slack.com/api/*`
2. **Credential Injection**: The bot token is injected by the host runtime; the WASM code never sees it
3. **Rate Limiting**: 50 requests/minute, 1000 requests/hour
4. **No Filesystem Access**: Cannot read/write files except through workspace capability
5. **No Network Access**: Beyond the allowlisted endpoints
## Capabilities File
The `slack.capabilities.json` file declares what this tool needs:
```json
{
"http": {
"allowlist": [
{ "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
],
"credentials": {
"slack_bot_token": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com"]
}
},
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
},
"secrets": {
"allowed_names": ["slack_bot_token"]
}
}
```
## Building Your Own Tool
Use this as a template for creating new WASM tools:
1. Copy this directory
2. Update `Cargo.toml` with your tool name
3. Modify `src/types.rs` with your action types
4. Implement API calls in `src/api.rs`
5. Update the action dispatch in `src/lib.rs`
6. Create your `*.capabilities.json` file
7. Build with `cargo component build --release`
### Key Files
- `Cargo.toml` - Rust package config with WASM target
- `src/lib.rs` - WIT bindings and main dispatch
- `src/types.rs` - Request/response types
- `src/api.rs` - API implementation
- `*.capabilities.json` - Security capabilities declaration
### WIT Interface
Tools implement the `sandboxed-tool` world from `wit/tool.wit`:
```wit
world sandboxed-tool {
import host; // log, http-request, secret-exists, etc.
export tool; // execute, schema, description
}
```
## Troubleshooting
### "Slack bot token not configured"
Ensure you've stored the secret:
```bash
ironclaw secret set slack_bot_token "xoxb-..."
```
### "Endpoint not in allowlist"
Check that `slack.capabilities.json` includes the endpoint you're trying to access.
### "Rate limit exceeded"
The tool has a default rate limit of 50 requests/minute. Wait and retry.
### Build errors
Ensure you have the WASM target and cargo-component installed:
```bash
rustup target add wasm32-wasip2
cargo install cargo-component
```
## License
MIT OR Apache-2.0
@@ -1,26 +0,0 @@
{
"http": {
"allowlist": [
{
"host": "slack.com",
"path_prefix": "/api/",
"methods": ["GET", "POST"]
}
],
"credentials": {
"slack_bot_token": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com"]
}
},
"rate_limit": {
"requests_per_minute": 50,
"requests_per_hour": 1000
},
"timeout_secs": 30
},
"secrets": {
"allowed_names": ["slack_bot_token"]
}
}
-198
View File
@@ -1,198 +0,0 @@
//! Slack Web API implementation.
//!
//! All API calls go through the host's HTTP capability, which handles
//! credential injection and rate limiting. The WASM tool never sees
//! the actual bot token.
use crate::near::agent::host;
use crate::types::*;
const SLACK_API_BASE: &str = "https://slack.com/api";
/// Make a Slack API call.
fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result<String, String> {
let url = format!("{}/{}", SLACK_API_BASE, endpoint);
// Content-Type header for POST requests
let headers = if body.is_some() {
r#"{"Content-Type": "application/json; charset=utf-8"}"#
} else {
"{}"
};
let body_bytes = body.map(|b| b.as_bytes().to_vec());
host::log(host::LogLevel::Debug, &format!("Slack 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!(
"Slack API returned status {}: {}",
response.status,
String::from_utf8_lossy(&response.body)
));
}
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e))
}
/// Send a message to a Slack channel.
pub fn send_message(
channel: &str,
text: &str,
thread_ts: Option<&str>,
) -> Result<SendMessageResult, String> {
let mut payload = serde_json::json!({
"channel": channel,
"text": text,
});
if let Some(ts) = thread_ts {
payload["thread_ts"] = serde_json::Value::String(ts.to_string());
}
let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
let response = slack_api_call("POST", "chat.postMessage", Some(&body))?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
if !parsed["ok"].as_bool().unwrap_or(false) {
let error = parsed["error"].as_str().unwrap_or("unknown_error");
return Err(format!("Slack API error: {}", error));
}
Ok(SendMessageResult {
ok: true,
channel: parsed["channel"].as_str().unwrap_or(channel).to_string(),
ts: parsed["ts"].as_str().unwrap_or("").to_string(),
message: parsed.get("message").map(|m| MessageInfo {
text: m["text"].as_str().unwrap_or("").to_string(),
user: m["user"].as_str().map(|s| s.to_string()),
ts: m["ts"].as_str().unwrap_or("").to_string(),
}),
})
}
/// List channels the bot has access to.
pub fn list_channels(limit: u32) -> Result<ListChannelsResult, String> {
let url = format!(
"conversations.list?types=public_channel,private_channel&limit={}",
limit
);
let response = slack_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
if !parsed["ok"].as_bool().unwrap_or(false) {
let error = parsed["error"].as_str().unwrap_or("unknown_error");
return Err(format!("Slack API error: {}", error));
}
let channels = parsed["channels"]
.as_array()
.map(|arr| {
arr.iter()
.map(|c| Channel {
id: c["id"].as_str().unwrap_or("").to_string(),
name: c["name"].as_str().unwrap_or("").to_string(),
is_private: c["is_private"].as_bool().unwrap_or(false),
is_member: c["is_member"].as_bool().unwrap_or(false),
topic: c["topic"]["value"].as_str().map(|s| s.to_string()),
purpose: c["purpose"]["value"].as_str().map(|s| s.to_string()),
})
.collect()
})
.unwrap_or_default();
Ok(ListChannelsResult { ok: true, channels })
}
/// Get message history from a channel.
pub fn get_channel_history(channel: &str, limit: u32) -> Result<ChannelHistoryResult, String> {
let url = format!("conversations.history?channel={}&limit={}", channel, limit);
let response = slack_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
if !parsed["ok"].as_bool().unwrap_or(false) {
let error = parsed["error"].as_str().unwrap_or("unknown_error");
return Err(format!("Slack API error: {}", error));
}
let messages = parsed["messages"]
.as_array()
.map(|arr| {
arr.iter()
.map(|m| HistoryMessage {
ts: m["ts"].as_str().unwrap_or("").to_string(),
text: m["text"].as_str().unwrap_or("").to_string(),
user: m["user"].as_str().map(|s| s.to_string()),
msg_type: m["type"].as_str().unwrap_or("message").to_string(),
})
.collect()
})
.unwrap_or_default();
Ok(ChannelHistoryResult { ok: true, messages })
}
/// Add a reaction to a message.
pub fn post_reaction(channel: &str, timestamp: &str, emoji: &str) -> Result<PostReactionResult, String> {
let payload = serde_json::json!({
"channel": channel,
"timestamp": timestamp,
"name": emoji,
});
let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?;
let response = slack_api_call("POST", "reactions.add", Some(&body))?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
if !parsed["ok"].as_bool().unwrap_or(false) {
let error = parsed["error"].as_str().unwrap_or("unknown_error");
// "already_reacted" is not really an error
if error != "already_reacted" {
return Err(format!("Slack API error: {}", error));
}
}
Ok(PostReactionResult { ok: true })
}
/// Get information about a user.
pub fn get_user_info(user_id: &str) -> Result<GetUserInfoResult, String> {
let url = format!("users.info?user={}", user_id);
let response = slack_api_call("GET", &url, None)?;
let parsed: serde_json::Value =
serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?;
if !parsed["ok"].as_bool().unwrap_or(false) {
let error = parsed["error"].as_str().unwrap_or("unknown_error");
return Err(format!("Slack API error: {}", error));
}
let user = &parsed["user"];
let profile = &user["profile"];
Ok(GetUserInfoResult {
ok: true,
user: UserInfo {
id: user["id"].as_str().unwrap_or("").to_string(),
name: user["name"].as_str().unwrap_or("").to_string(),
real_name: profile["real_name"].as_str().map(|s| s.to_string()),
display_name: profile["display_name"].as_str().map(|s| s.to_string()),
email: profile["email"].as_str().map(|s| s.to_string()),
is_bot: user["is_bot"].as_bool().unwrap_or(false),
},
})
}
-205
View File
@@ -1,205 +0,0 @@
//! Slack WASM Tool for IronClaw.
//!
//! This is a standalone WASM component that provides Slack integration.
//! It demonstrates how to build external tools that can be dynamically
//! loaded by the agent runtime.
//!
//! # Capabilities Required
//!
//! - HTTP: `slack.com/api/*` (GET, POST)
//! - Secrets: `slack_bot_token` (injected automatically)
//!
//! # Supported Actions
//!
//! - `send_message`: Send a message to a channel
//! - `list_channels`: List channels the bot has access to
//! - `get_channel_history`: Get recent messages from a channel
//! - `post_reaction`: Add an emoji reaction to a message
//! - `get_user_info`: Get information about a Slack user
//!
//! # Example Usage
//!
//! ```json
//! {"action": "send_message", "channel": "#general", "text": "Hello from the agent!"}
//! ```
mod api;
mod types;
use types::SlackAction;
// Generate bindings from the WIT interface.
// This creates the `bindings` module with types and traits.
wit_bindgen::generate!({
world: "sandboxed-tool",
path: "../../../wit/tool.wit",
});
/// Implementation of the tool interface.
struct SlackTool;
impl exports::near::agent::tool::Guest for SlackTool {
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 {
// JSON Schema for the tool's parameters
r#"{
"type": "object",
"required": ["action"],
"oneOf": [
{
"properties": {
"action": { "const": "send_message" },
"channel": {
"type": "string",
"description": "Channel ID or name (e.g., '#general' or 'C1234567890')"
},
"text": {
"type": "string",
"description": "Message text (supports Slack mrkdwn formatting)"
},
"thread_ts": {
"type": "string",
"description": "Optional thread timestamp to reply in a thread"
}
},
"required": ["action", "channel", "text"]
},
{
"properties": {
"action": { "const": "list_channels" },
"limit": {
"type": "integer",
"description": "Maximum number of channels to return (default: 100)",
"default": 100
}
},
"required": ["action"]
},
{
"properties": {
"action": { "const": "get_channel_history" },
"channel": {
"type": "string",
"description": "Channel ID (e.g., 'C1234567890')"
},
"limit": {
"type": "integer",
"description": "Maximum number of messages to return (default: 20)",
"default": 20
}
},
"required": ["action", "channel"]
},
{
"properties": {
"action": { "const": "post_reaction" },
"channel": {
"type": "string",
"description": "Channel ID containing the message"
},
"timestamp": {
"type": "string",
"description": "Timestamp of the message to react to"
},
"emoji": {
"type": "string",
"description": "Emoji name without colons (e.g., 'thumbsup')"
}
},
"required": ["action", "channel", "timestamp", "emoji"]
},
{
"properties": {
"action": { "const": "get_user_info" },
"user_id": {
"type": "string",
"description": "User ID (e.g., 'U1234567890')"
}
},
"required": ["action", "user_id"]
}
]
}"#
.to_string()
}
fn description() -> String {
"Slack integration tool for sending messages, listing channels, reading history, \
adding reactions, and getting user information. Requires a Slack bot token with \
appropriate scopes (chat:write, channels:read, channels:history, reactions:write, \
users:read)."
.to_string()
}
}
/// Inner execution logic with proper error handling.
fn execute_inner(params: &str) -> Result<String, String> {
// Check if the Slack token is configured
if !crate::near::agent::host::secret_exists("slack_bot_token") {
return Err(
"Slack bot token not configured. Please add the 'slack_bot_token' secret.".to_string(),
);
}
// Parse the action from JSON
let action: SlackAction =
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 Slack action: {:?}", action),
);
// Dispatch to the appropriate handler
let result = match action {
SlackAction::SendMessage {
channel,
text,
thread_ts,
} => {
let result = api::send_message(&channel, &text, thread_ts.as_deref())?;
serde_json::to_string(&result).map_err(|e| e.to_string())?
}
SlackAction::ListChannels { limit } => {
let result = api::list_channels(limit)?;
serde_json::to_string(&result).map_err(|e| e.to_string())?
}
SlackAction::GetChannelHistory { channel, limit } => {
let result = api::get_channel_history(&channel, limit)?;
serde_json::to_string(&result).map_err(|e| e.to_string())?
}
SlackAction::PostReaction {
channel,
timestamp,
emoji,
} => {
let result = api::post_reaction(&channel, &timestamp, &emoji)?;
serde_json::to_string(&result).map_err(|e| e.to_string())?
}
SlackAction::GetUserInfo { user_id } => {
let result = api::get_user_info(&user_id)?;
serde_json::to_string(&result).map_err(|e| e.to_string())?
}
};
Ok(result)
}
// Export the tool implementation.
export!(SlackTool);
-147
View File
@@ -1,147 +0,0 @@
//! Types for Slack API requests and responses.
use serde::{Deserialize, Serialize};
/// Input parameters for the Slack tool.
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum SlackAction {
/// Send a message to a channel.
SendMessage {
/// Channel ID or name (e.g., "#general" or "C1234567890").
channel: String,
/// Message text (supports Slack mrkdwn formatting).
text: String,
/// Optional thread timestamp to reply in a thread.
#[serde(default)]
thread_ts: Option<String>,
},
/// List channels the bot has access to.
ListChannels {
/// Maximum number of channels to return (default: 100).
#[serde(default = "default_limit")]
limit: u32,
},
/// Get message history from a channel.
GetChannelHistory {
/// Channel ID (e.g., "C1234567890").
channel: String,
/// Maximum number of messages to return (default: 20).
#[serde(default = "default_history_limit")]
limit: u32,
},
/// Add a reaction (emoji) to a message.
PostReaction {
/// Channel ID containing the message.
channel: String,
/// Timestamp of the message to react to.
timestamp: String,
/// Emoji name without colons (e.g., "thumbsup").
emoji: String,
},
/// Get information about a user.
GetUserInfo {
/// User ID (e.g., "U1234567890").
user_id: String,
},
}
fn default_limit() -> u32 {
100
}
fn default_history_limit() -> u32 {
20
}
/// Result from send_message.
#[derive(Debug, Serialize)]
pub struct SendMessageResult {
pub ok: bool,
pub channel: String,
pub ts: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<MessageInfo>,
}
/// Basic message info.
#[derive(Debug, Serialize)]
pub struct MessageInfo {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
pub ts: String,
}
/// A Slack channel.
#[derive(Debug, Serialize)]
pub struct Channel {
pub id: String,
pub name: String,
pub is_private: bool,
pub is_member: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
}
/// Result from list_channels.
#[derive(Debug, Serialize)]
pub struct ListChannelsResult {
pub ok: bool,
pub channels: Vec<Channel>,
}
/// Result from get_channel_history.
#[derive(Debug, Serialize)]
pub struct ChannelHistoryResult {
pub ok: bool,
pub messages: Vec<HistoryMessage>,
}
/// A message from channel history.
#[derive(Debug, Serialize)]
pub struct HistoryMessage {
pub ts: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(rename = "type")]
pub msg_type: String,
}
/// Result from post_reaction.
#[derive(Debug, Serialize)]
pub struct PostReactionResult {
pub ok: bool,
}
/// User information.
#[derive(Debug, Serialize)]
pub struct UserInfo {
pub id: String,
pub name: String,
pub real_name: Option<String>,
pub display_name: Option<String>,
pub email: Option<String>,
pub is_bot: bool,
}
/// Result from get_user_info.
#[derive(Debug, Serialize)]
pub struct GetUserInfoResult {
pub ok: bool,
pub user: UserInfo,
}
/// Generic Slack API error response.
#[derive(Debug, Deserialize)]
pub struct SlackApiError {
pub ok: bool,
pub error: String,
}