Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nullclaw/nullclaw/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Channels are NullClaw’s abstraction layer for messaging platforms. Every channel implements the Channel vtable interface, enabling runtime-swappable transport backends.

Channel Interface

pub const Channel = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        // Start the channel (connect, begin listening)
        start: *const fn(ptr: *anyopaque) anyerror!void,
        
        // Stop the channel (disconnect, clean up)
        stop: *const fn(ptr: *anyopaque) void,
        
        // Send a message to a target (user, channel, room, etc.)
        send: *const fn(
            ptr: *anyopaque,
            target: []const u8,
            message: []const u8,
            media: []const []const u8,
        ) anyerror!void,
        
        // Return the channel name (e.g. "telegram", "discord")
        name: *const fn(ptr: *anyopaque) []const u8,
        
        // Health check — return true if operational
        healthCheck: *const fn(ptr: *anyopaque) bool,
        
        // Optional: streaming output (chunk/final events)
        sendEvent: ?*const fn(...) anyerror!void = null,
        
        // Optional: typing indicators
        startTyping: *const fn(ptr: *anyopaque, recipient: []const u8) anyerror!void,
        stopTyping: *const fn(ptr: *anyopaque, recipient: []const u8) anyerror!void,
    };
};

Supported Channels (18+)

Real-Time Channels

ChannelTransportFeatures
TelegramLong-pollingGroups, media, inline keyboards, proxies
DiscordWebSocket gatewayVoice channels, threads, embeds, reactions
SlackSocket mode + HTTPThreads, blocks, app mentions, reactions
Signalsignal-cli JSON-RPCE2E encryption, groups, attachments
MatrixHTTP /syncFederation, E2E encryption, rooms
MattermostWebSocket + RESTTeams, channels, threads, integrations
IRCTCP socket (TLS)Classic IRC protocol, SASL auth
NostrNIP-17/NIP-04 DMsDecentralized, relay-based, gift wraps
DingTalkWebSocket streamEnterprise IM, China-focused

Webhook-Based Channels

ChannelTransportFeatures
WhatsAppMeta webhookBusiness API, media, templates
Lark/FeishuHTTP callbackByteDance suite, cards, bots
LineWebhook + push APIJapan/SEA market, stickers, flex messages
OneBotHTTP/WebSocketQQ protocol adapter, China IM
QQTencent APIChina’s largest IM, groups, cards
EmailIMAP/SMTPClassic email, attachments, HTML

Local/Direct Channels

ChannelTransportFeatures
CLIstdin/stdoutInteractive terminal, REPL mode
iMessageAppleScript + SQLitemacOS-only, SMS/iMessage hybrid
MaixCamUSB serial + JSONEmbedded AI camera, IoT
WebWebSocket (local/relay)Browser UI, E2E encryption, pairing

Channel Message Flow

Inbound Flow

  1. Platform delivers event (webhook POST, WebSocket frame, long-poll result)
  2. Channel implementation parses platform-specific format
  3. Normalizes to ChannelMessage:
    pub const ChannelMessage = struct {
        id: []const u8,
        sender: []const u8,
        content: []const u8,
        channel: []const u8,
        timestamp: u64,
        reply_target: ?[]const u8 = null,
        message_id: ?i64 = null,
        first_name: ?[]const u8 = null,
        is_group: bool = false,
    };
    
  4. Checks allowlist (sender must be in allow_from config)
  5. Routes to agent session via message bus
  6. Agent processes and generates response
  7. Outbound delivery via Channel.send()

Outbound Flow

try channel.send(
    target,        // "@username" or "chat_id" or "#channel"
    message,       // Text content
    media,         // [][]const u8 — URLs or file paths
);
Channel implementation:
  1. Splits long messages if platform has length limits
  2. Uploads media (if supported)
  3. Formats platform payload (JSON, multipart, etc.)
  4. Sends via platform API (HTTP POST, WebSocket send, etc.)

Configuration

Basic Setup

Channels are configured in ~/.nullclaw/config.json:
{
  "channels": {
    "telegram": {
      "accounts": {
        "main": {
          "bot_token": "123456:ABC-DEF",
          "allow_from": ["alice", "bob"],
          "reply_in_private": true,
          "proxy": "socks5://127.0.0.1:9050"
        }
      }
    },
    "discord": {
      "accounts": {
        "main": {
          "token": "discord-bot-token",
          "guild_id": "123456789",
          "allow_from": ["user_id_1"],
          "allow_bots": false
        }
      }
    },
    "signal": {
      "accounts": {
        "main": {
          "phone_number": "+1234567890",
          "allow_from": ["+9876543210"],
          "signal_cli_path": "/usr/local/bin/signal-cli"
        }
      }
    }
  }
}

Multi-Account Support

Run multiple accounts per channel:
"telegram": {
  "accounts": {
    "personal": {
      "bot_token": "token1",
      "allow_from": ["alice"]
    },
    "work": {
      "bot_token": "token2",
      "allow_from": ["bob", "carol"]
    }
  }
}

Security: Allowlists

Every channel enforces an allowlist for inbound messages:
"allow_from": ["alice", "bob"]
  • Empty allowlist = deny all inbound messages
  • "*" = allow all (explicit opt-in)
  • Otherwise = exact-match allowlist (case-insensitive)

Special Cases

Nostr: The owner_pubkey is always allowed regardless of dm_allowed_pubkeys:
"nostr": {
  "owner_pubkey": "npub1abc...",
  "dm_allowed_pubkeys": ["*"]
}
Signal: Supports both phone numbers and UUIDs:
"signal": {
  "allow_from": ["+1234567890", "uuid:a1b2c3d4-..."]
}

Channel-Specific Features

Telegram

  • Long-polling (no webhook setup required)
  • Group support with reply_in_private option
  • Media attachments (photos, documents, audio)
  • SOCKS5 proxy support for restricted regions
  • Inline keyboards (button responses)

Discord

  • WebSocket gateway (real-time events)
  • Thread-aware (creates threads for long conversations)
  • Embed support (rich message formatting)
  • Reaction-based UI interactions
  • Voice channel presence (status only, no audio)

Signal

  • E2E encryption (native Signal protocol)
  • Group chats with privacy mode
  • Attachments (images, files)
  • Typing indicators
  • Requires signal-cli binary in PATH

Nostr

  • NIP-17 gift-wrapped DMs (default)
  • NIP-04 legacy DMs (fallback)
  • Multi-relay rumor deduplication
  • DM inbox relays (kind:10050 announcement)
  • Encrypted private keys (ChaCha20-Poly1305)

WhatsApp

  • Business API (Meta webhook)
  • Template messages (for initial contact)
  • Media support (images, audio, documents)
  • Read receipts

IRC

  • TLS socket connection
  • SASL authentication
  • Channel join/part management
  • PRIVMSG/NOTICE support
  • DCC send (file transfers)

Message Splitting

Channels automatically split messages that exceed platform limits:
pub fn splitMessage(msg: []const u8, max_bytes: usize) SplitIterator
  • Respects UTF-8 boundaries (no broken multibyte chars)
  • Configurable max size per platform
  • Iterates chunks for sequential delivery
Default limits:
  • Telegram: 4096 bytes
  • Discord: 2000 bytes
  • IRC: 512 bytes
  • Signal: no enforced limit

Health Checks

if (channel.healthCheck()) {
    // Channel is operational
}
Implementations check:
  • Connection state (WebSocket alive, TCP socket open)
  • Authentication status (token valid, login successful)
  • Recent activity (last message sent/received timestamp)

Typing Indicators

Optional vtable methods for real-time UX:
try channel.startTyping("@alice");
defer channel.stopTyping("@alice") catch {};

// Generate response...
Supported by: Telegram, Discord, Slack, Signal, Matrix.

Streaming Output

Channels can implement sendEvent for incremental delivery:
try channel.sendEvent(
    target,
    "chunk of text...",
    &.{},
    .chunk,  // or .final
);
Supported by: Telegram (edit message), Discord (edit message), Web (WebSocket frames).
Channels without sendEvent fall back to send() for .final stage and ignore .chunk.

Implementation Example

Minimal Channel

const std = @import("std");
const Channel = @import("root.zig").Channel;

pub const MyChannel = struct {
    api_token: []const u8,
    allow_from: []const []const u8,

    pub fn start(ptr: *anyopaque) anyerror!void {
        const self: *MyChannel = @ptrCast(@alignCast(ptr));
        // Connect to platform, start listening...
    }

    pub fn stop(ptr: *anyopaque) void {
        const self: *MyChannel = @ptrCast(@alignCast(ptr));
        // Disconnect, cleanup...
    }

    pub fn send(
        ptr: *anyopaque,
        target: []const u8,
        message: []const u8,
        media: []const []const u8,
    ) anyerror!void {
        const self: *MyChannel = @ptrCast(@alignCast(ptr));
        // Format payload, call platform API...
    }

    pub fn name(_: *anyopaque) []const u8 {
        return "my_channel";
    }

    pub fn healthCheck(ptr: *anyopaque) bool {
        const self: *MyChannel = @ptrCast(@alignCast(ptr));
        return self.connected;
    }

    pub fn channel(self: *MyChannel) Channel {
        return .{ .ptr = @ptrCast(self), .vtable = &vtable };
    }

    pub const vtable = Channel.VTable{
        .start = start,
        .stop = stop,
        .send = send,
        .name = name,
        .healthCheck = healthCheck,
    };
};

Next Steps

Configuration

Full channel configuration reference

Security

Learn about allowlists and pairing