Back to Ironclaw

Building WASM Channels

docs/BUILDING_CHANNELS.md

0.21.011.5 KB
Original Source

Building WASM Channels

This guide covers how to build WASM channel modules for IronClaw.

Overview

Channels are WASM components that handle communication with external messaging platforms (Telegram, WhatsApp, Slack, etc.). They run in a sandboxed environment and communicate with the host via the WIT (WebAssembly Interface Types) interface.

Directory Structure

channels/                    # Or channels-src/
└── my-channel/
    ├── Cargo.toml
    ├── src/
    │   └── lib.rs
    └── my-channel.capabilities.json

After building, deploy to:

~/.ironclaw/channels/
├── my-channel.wasm
└── my-channel.capabilities.json

Cargo.toml Template

toml
[package]
name = "my-channel"
version = "0.1.0"
edition = "2021"
description = "My messaging platform channel for IronClaw"

[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

Channel Implementation

Required Imports

rust
// Generate bindings from the WIT file
wit_bindgen::generate!({
    world: "sandboxed-channel",
    path: "../../wit/channel.wit",  // Adjust path as needed
});

use serde::{Deserialize, Serialize};

// Re-export generated types
use exports::near::agent::channel::{
    AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
    OutgoingHttpResponse, PollConfig,
};
use near::agent::channel_host::{self, EmittedMessage};

Implementing the Guest Trait

rust
struct MyChannel;

impl Guest for MyChannel {
    /// Called once when the channel starts.
    /// Returns configuration for webhooks and polling.
    fn on_start(config_json: String) -> Result<ChannelConfig, String> {
        // Parse config from capabilities file
        let config: MyConfig = serde_json::from_str(&config_json)
            .unwrap_or_default();

        Ok(ChannelConfig {
            display_name: "My Channel".to_string(),
            http_endpoints: vec![
                HttpEndpointConfig {
                    path: "/webhook/my-channel".to_string(),
                    methods: vec!["POST".to_string()],
                    require_secret: true,  // Validate webhook secret
                },
            ],
            poll: None,  // Or Some(PollConfig { interval_ms, enabled })
        })
    }

    /// Handle incoming HTTP requests (webhooks).
    fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
        // Parse webhook payload
        // Emit messages to agent
        // Return response to webhook caller
    }

    /// Called periodically if polling is enabled.
    fn on_poll() {
        // Fetch new messages from API
        // Emit any new messages
    }

    /// Send a response back to the messaging platform.
    fn on_respond(response: AgentResponse) -> Result<(), String> {
        // Parse metadata to get routing info
        // Call platform API to send message
    }

    /// Called when channel is shutting down.
    fn on_shutdown() {
        channel_host::log(channel_host::LogLevel::Info, "Channel shutting down");
    }
}

// Export the channel implementation
export!(MyChannel);

Critical Pattern: Metadata Flow

The most important pattern: Store routing info in message metadata so responses can be delivered.

rust
// When receiving a message, store routing info:
#[derive(Debug, Serialize, Deserialize)]
struct MyMessageMetadata {
    chat_id: String,           // Where to send response
    sender_id: String,         // Who sent it (becomes recipient)
    original_message_id: String,
}

// In on_http_request or on_poll:
let metadata = MyMessageMetadata {
    chat_id: message.chat.id.clone(),
    sender_id: message.from.clone(),  // CRITICAL: Store sender!
    original_message_id: message.id.clone(),
};

channel_host::emit_message(&EmittedMessage {
    user_id: message.from.clone(),
    user_name: Some(name),
    content: text,
    thread_id: None,
    metadata_json: serde_json::to_string(&metadata).unwrap_or_default(),
});

// In on_respond, use the ORIGINAL message's metadata:
fn on_respond(response: AgentResponse) -> Result<(), String> {
    let metadata: MyMessageMetadata = serde_json::from_str(&response.metadata_json)?;

    // sender_id becomes the recipient!
    send_message(metadata.chat_id, metadata.sender_id, response.content);
}

Credential Injection

Never hardcode credentials! Use placeholders that the host replaces:

URL Placeholders (Telegram-style)

rust
// The host replaces {TELEGRAM_BOT_TOKEN} with the actual token
let url = "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage";
channel_host::http_request("POST", url, &headers_json, Some(&body));

Header Placeholders (WhatsApp-style)

rust
// The host replaces {WHATSAPP_ACCESS_TOKEN} in headers too
let headers = serde_json::json!({
    "Content-Type": "application/json",
    "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
});
channel_host::http_request("POST", &url, &headers.to_string(), Some(&body));

The placeholder format is {SECRET_NAME} where SECRET_NAME matches the credential name in uppercase with underscores (e.g., whatsapp_access_token{WHATSAPP_ACCESS_TOKEN}).

Capabilities File

Create my-channel.capabilities.json:

json
{
  "type": "channel",
  "name": "my-channel",
  "description": "My messaging platform channel",
  "setup": {
    "required_secrets": [
      {
        "name": "my_channel_api_token",
        "prompt": "Enter your API token",
        "validation": "^[A-Za-z0-9_-]+$"
      },
      {
        "name": "my_channel_webhook_secret",
        "prompt": "Webhook secret (leave empty to auto-generate)",
        "optional": true,
        "auto_generate": { "length": 32 }
      }
    ],
    "validation_endpoint": "https://api.my-platform.com/verify?token={my_channel_api_token}"
  },
  "capabilities": {
    "http": {
      "allowlist": [
        { "host": "api.my-platform.com", "path_prefix": "/" }
      ],
      "rate_limit": {
        "requests_per_minute": 60,
        "requests_per_hour": 1000
      }
    },
    "secrets": {
      "allowed_names": ["my_channel_*"]
    },
    "channel": {
      "allowed_paths": ["/webhook/my-channel"],
      "allow_polling": false,
      "workspace_prefix": "channels/my-channel/",
      "emit_rate_limit": {
        "messages_per_minute": 100,
        "messages_per_hour": 5000
      },
      "webhook": {
        "secret_header": "X-Webhook-Secret",
        "secret_name": "my_channel_webhook_secret"
      }
    }
  },
  "config": {
    "custom_option": "value"
  }
}

Building and Deploying

Supply Chain Security: No Committed Binaries

Do not commit compiled WASM binaries. They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:

  • cargo build automatically builds telegram.wasm via build.rs
  • The built binary is in .gitignore and is not committed
  • CI should run cargo build (or ./scripts/build-all.sh) to produce releases

Reproducible build:

bash
cargo build --release

Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools (optional; fallback copies raw WASM if unavailable).

Telegram Channel (Manual Build)

bash
# Add WASM target if needed
rustup target add wasm32-wasip2

# Build Telegram channel
./channels-src/telegram/build.sh

# Install (or use ironclaw onboard to install bundled channel)
mkdir -p ~/.ironclaw/channels
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/

Note: The main IronClaw binary bundles telegram.wasm via include_bytes!. When modifying the Telegram channel source, run ./channels-src/telegram/build.sh before building the main crate, so the updated WASM is included.

Other Channels

bash
# Build the WASM component
cd channels-src/my-channel
cargo build --release --target wasm32-wasip2

# Deploy to ~/.ironclaw/channels/
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
cp my-channel.capabilities.json ~/.ironclaw/channels/

Host Functions Available

The channel host provides these functions:

rust
// Logging
channel_host::log(LogLevel::Info, "Message");

// Time
let now = channel_host::now_millis();

// Workspace (scoped to channel namespace)
let data = channel_host::workspace_read("state/offset");
channel_host::workspace_write("state/offset", "12345")?;

// HTTP requests (credentials auto-injected)
let response = channel_host::http_request("POST", &url, &headers, Some(&body))?;

// Emit message to agent
channel_host::emit_message(&EmittedMessage { ... });

Common Patterns

Webhook Secret Validation

The host validates webhook secrets automatically. Check req.secret_validated:

rust
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
    if !req.secret_validated {
        channel_host::log(LogLevel::Warn, "Invalid webhook secret");
        // Host should have already rejected, but defense in depth
    }
    // ...
}

Polling with Offset Tracking

For platforms that require polling (not webhook-based):

rust
const OFFSET_PATH: &str = "state/last_offset";

fn on_poll() {
    // Read last offset
    let offset = channel_host::workspace_read(OFFSET_PATH)
        .and_then(|s| s.parse::<i64>().ok())
        .unwrap_or(0);

    // Fetch updates since offset
    let updates = fetch_updates(offset);

    // Process and track new offset
    let mut new_offset = offset;
    for update in updates {
        if update.id >= new_offset {
            new_offset = update.id + 1;
        }
        emit_message(update);
    }

    // Save new offset
    if new_offset != offset {
        let _ = channel_host::workspace_write(OFFSET_PATH, &new_offset.to_string());
    }
}

Status Message Filtering

Skip status updates to prevent loops:

rust
// Skip status updates (delivered, read, etc.)
if !payload.statuses.is_empty() && payload.messages.is_empty() {
    return;  // Only status updates, no actual messages
}

Bot Message Filtering

Skip bot messages to prevent infinite loops:

rust
if sender.is_bot {
    return;  // Don't respond to bots
}

Testing

Add tests in the same file:

rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_webhook() {
        let json = r#"{ ... }"#;
        let payload: WebhookPayload = serde_json::from_str(json).unwrap();
        assert_eq!(payload.messages.len(), 1);
    }

    #[test]
    fn test_metadata_roundtrip() {
        let meta = MyMessageMetadata { ... };
        let json = serde_json::to_string(&meta).unwrap();
        let parsed: MyMessageMetadata = serde_json::from_str(&json).unwrap();
        assert_eq!(meta.chat_id, parsed.chat_id);
    }
}

Run tests with:

bash
cargo test

Troubleshooting

"byte index N is not a char boundary"

Never slice strings by byte index! Use character-aware truncation:

rust
// BAD: panics on multi-byte UTF-8 (emoji, etc.)
let preview = &content[..50];

// GOOD: safe truncation
let preview: String = content.chars().take(50).collect();

Credential placeholders not replaced

  1. Check the secret name matches (lowercase with underscores)
  2. Verify the secret is in allowed_names in capabilities
  3. Check logs for "unresolved placeholders" warnings

Messages not routing to responses

Ensure on_respond uses the ORIGINAL message's metadata, not response metadata:

rust
// response.metadata_json comes from the ORIGINAL emit_message call
let metadata: MyMetadata = serde_json::from_str(&response.metadata_json)?;