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.
Prerequisites
Install Rust and add the WASM target:
rustup target add wasm32-wasip2
Optional but useful for component conversion workflows:
1. Create the project structure
Create a new crate under channels-src/ (or another location) with this layout:
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
[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
3. Implement the channel interface
Now that the crate is ready, implement the channel guest interface exposed by wit/channel.wit, and implement the guest trait methods to handle incoming messages and send responses.
Required Imports
// 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
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
}
/// Send a proactive message without a prior inbound event.
fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
// Send a message to a known user or chat ID.
}
/// React to agent status changes such as thinking or tool activity.
fn on_status(update: StatusUpdate) {
// Show typing indicators or status messages when useful.
}
/// 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);
on_start configures how the host calls your channel. on_http_request and on_poll ingest external messages; on_respond delivers replies to an existing conversation; on_broadcast sends proactive messages; on_status lets channels surface thinking indicators and other progress updates.
Once your channel can receive messages, keep enough metadata to send responses back to the right chat and sender.
The most important pattern: Store routing info in message metadata so responses can be delivered.
// 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);
}
response.metadata_json contains the metadata from the original inbound message. Treat it as the source of truth for reply routing.
5. Add secure credential placeholders
Now that the message path is set, configure API credentials using placeholders instead of hardcoded tokens.
Never hardcode credentials! Use placeholders that the host replaces
URL Placeholders (Telegram-style)
// 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));
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}).
6. Define capabilities
The capabilities file declares setup prompts, allowlists, and rate limits.
my-channel.capabilities.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"
}
}
7. Build and install
With code and capabilities in place, build the channel and copy the two required artifacts.
Generic channel build
cd channels-src/my-channel
cargo build --release --target wasm32-wasip2
# Convert the raw wasm module into a component and strip it
wasm-tools component new target/wasm32-wasip2/release/my_channel.wasm -o my-channel.wasm \
2>/dev/null || cp target/wasm32-wasip2/release/my_channel.wasm my-channel.wasm
wasm-tools strip my-channel.wasm -o my-channel.wasm
mkdir -p ~/.ironclaw/channels
cp my-channel.wasm ~/.ironclaw/channels/my-channel.wasm
cp my-channel.capabilities.json ~/.ironclaw/channels/
The channels shipped in channels-src/ use small build.sh wrappers that do exactly this component-conversion step. If your channel lives in the repo, following that pattern is the safest option.
Telegram channel example
rustup target add wasm32-wasip2
./channels-src/telegram/build.sh
mkdir -p ~/.ironclaw/channels
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
If you are contributing a channel to the public repository, 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.
8. Host functions you can call
Channel modules get a small host API for logging, storage, HTTP, and message emission:
channel_host::log(channel_host::LogLevel::Info, "message");
let _now = channel_host::now_millis();
let _ = channel_host::workspace_write("state/offset", "12345");
let _ = channel_host::workspace_read("state/offset");
let _response = channel_host::http_request("POST", &url, &headers, Some(&body));
channel_host::emit_message(&EmittedMessage { /* ... */ });
Real channels also use a few additional host APIs:
let has_secret = channel_host::secret_exists("telegram_bot_token");
let _ = channel_host::store_attachment_data("attachment-id", &bytes);
let _ = channel_host::pairing_upsert_request("telegram", "123456", "{}")?;
let _ = channel_host::pairing_resolve_identity("telegram", "123456")?;
let _ = channel_host::pairing_read_allow_from("telegram")?;
Use store_attachment_data when you download binary payloads such as voice notes or images during webhook or polling callbacks. Use the pairing APIs when your channel supports owner approval for unknown direct-message senders.
9. Common patterns
Polling with stored offsets
const OFFSET_PATH: &str = "state/last_offset";
fn on_poll() {
let offset = channel_host::workspace_read(OFFSET_PATH)
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let updates = fetch_updates(offset);
let mut new_offset = offset;
for update in updates {
if update.id >= new_offset {
new_offset = update.id + 1;
}
emit_message(update);
}
if new_offset != offset {
let _ = channel_host::workspace_write(OFFSET_PATH, &new_offset.to_string());
}
}
Ignore status-only payloads
if !payload.statuses.is_empty() && payload.messages.is_empty() {
return;
}
Ignore bot senders
if sender.is_bot {
return;
}
10. Testing and troubleshooting
Add basic parsing and metadata round-trip tests:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_webhook() {
let json = r#"{\"messages\":[]}"#;
let v: serde_json::Value = serde_json::from_str(json).expect("valid json in test");
assert!(v.get("messages").is_some());
}
}
If you see byte index N is not a char boundary, avoid byte slicing and truncate by characters:
let preview: String = content.chars().take(50).collect();
If credential placeholders are not resolved:
- Verify secret names match the declared placeholders.
- Confirm the secret is permitted in
allowed_names.
- Check runtime logs for unresolved placeholder warnings.