mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
453 lines
12 KiB
Plaintext
453 lines
12 KiB
Plaintext
---
|
|
title: How to build a channel
|
|
description: "Build a WASM messaging channel that plugs into IronClaw"
|
|
---
|
|
|
|
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:
|
|
|
|
```bash
|
|
rustup target add wasm32-wasip2
|
|
```
|
|
|
|
Optional but useful for component conversion workflows:
|
|
|
|
```bash
|
|
cargo install wasm-tools
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Create the project structure
|
|
|
|
Create a new crate under `channels-src/` (or another location) with this layout:
|
|
|
|
```text
|
|
channels-src/
|
|
└── my-channel/
|
|
├── Cargo.toml
|
|
├── src/
|
|
│ └── lib.rs
|
|
└── my-channel.capabilities.json
|
|
```
|
|
|
|
After building, deploy to:
|
|
|
|
```text
|
|
~/.ironclaw/channels/
|
|
├── my-channel.wasm
|
|
└── my-channel.capabilities.json
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Configure Cargo.toml
|
|
|
|
```toml Cargo.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
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
```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
|
|
}
|
|
|
|
/// 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);
|
|
```
|
|
|
|
|
|
<Note>
|
|
`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.
|
|
</Note>
|
|
|
|
---
|
|
|
|
## 4. Preserve routing metadata (critical)
|
|
|
|
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.
|
|
|
|
```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);
|
|
}
|
|
```
|
|
|
|
<Note>
|
|
`response.metadata_json` contains the metadata from the original inbound message. Treat it as the source of truth for reply routing.
|
|
</Note>
|
|
|
|
---
|
|
|
|
## 5. Add secure credential placeholders
|
|
|
|
Now that the message path is set, configure API credentials using placeholders instead of hardcoded tokens.
|
|
|
|
<Warning>
|
|
**Never hardcode credentials!** Use placeholders that the host replaces
|
|
</Warning>
|
|
|
|
### 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
|
|
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.
|
|
|
|
```json 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
|
|
|
|
```bash
|
|
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/
|
|
```
|
|
|
|
<Note>
|
|
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.
|
|
</Note>
|
|
|
|
### Telegram channel example
|
|
|
|
```bash
|
|
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/
|
|
```
|
|
|
|
<Danger>
|
|
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.
|
|
</Danger>
|
|
|
|
---
|
|
|
|
## 8. Host functions you can call
|
|
|
|
Channel modules get a small host API for logging, storage, HTTP, and message emission:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
if !payload.statuses.is_empty() && payload.messages.is_empty() {
|
|
return;
|
|
}
|
|
```
|
|
|
|
### Ignore bot senders
|
|
|
|
```rust
|
|
if sender.is_bot {
|
|
return;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Testing and troubleshooting
|
|
|
|
Add basic parsing and metadata round-trip tests:
|
|
|
|
```rust
|
|
#[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:
|
|
|
|
```rust
|
|
let preview: String = content.chars().take(50).collect();
|
|
```
|
|
|
|
If credential placeholders are not resolved:
|
|
|
|
1. Verify secret names match the declared placeholders.
|
|
2. Confirm the secret is permitted in `allowed_names`.
|
|
3. Check runtime logs for unresolved placeholder warnings.
|