Skip to main content

Overview

NanoClaw Pro uses a self-registering channel system that lets you connect multiple messaging platforms simultaneously. Each channel is installed as a skill and automatically registers itself at startup.

WhatsApp

/add-whatsappQR code authentication

Telegram

/add-telegramBot token authentication

Slack

/add-slackOAuth app authentication

Discord

/add-discordBot token authentication

Gmail

/add-gmailOAuth authentication

Custom

Build your ownSee architecture below

How Channels Self-Register

Channels use a factory registry pattern that eliminates hardcoded channel lists.

Channel Registry

From src/channels/registry.ts:
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;

const registry = new Map<string, ChannelFactory>();

export function registerChannel(name: string, factory: ChannelFactory): void {
  registry.set(name, factory);
}

export function getChannelFactory(name: string): ChannelFactory | undefined {
  return registry.get(name);
}

export function getRegisteredChannelNames(): string[] {
  return [...registry.keys()];
}

Channel Interface

Every channel implements this interface (from src/types.ts):
interface Channel {
  name: string;
  connect(): Promise<void>;
  sendMessage(jid: string, text: string): Promise<void>;
  isConnected(): boolean;
  ownsJid(jid: string): boolean;
  disconnect(): Promise<void>;
  setTyping?(jid: string, isTyping: boolean): Promise<void>;
  syncGroups?(force: boolean): Promise<void>;
}
Required methods:
  • connect() - Initialize connection to the platform
  • sendMessage(jid, text) - Send a message to a JID (chat identifier)
  • isConnected() - Check connection status
  • ownsJid(jid) - Return true if this channel owns the given JID
  • disconnect() - Clean up and close connection
Optional methods:
  • setTyping(jid, isTyping) - Show typing indicator
  • syncGroups(force) - Sync available groups from the platform

Self-Registration Pattern

When you run /add-whatsapp, the skill adds a file like this:
// src/channels/whatsapp.ts
import { registerChannel, ChannelOpts } from './registry.js';
import { Channel } from '../types.js';

export class WhatsAppChannel implements Channel {
  name = 'whatsapp';
  
  constructor(private opts: ChannelOpts) {}
  
  async connect(): Promise<void> {
    // Initialize Baileys connection
  }
  
  async sendMessage(jid: string, text: string): Promise<void> {
    // Send via Baileys
  }
  
  ownsJid(jid: string): boolean {
    return jid.includes('@s.whatsapp.net') || jid.includes('@g.us');
  }
  
  // ... other methods
}

// Self-register at module load
registerChannel('whatsapp', (opts: ChannelOpts) => {
  const authPath = path.join(STORE_DIR, 'auth');
  if (!existsSync(authPath)) return null;  // Skip if not authenticated
  return new WhatsAppChannel(opts);
});

Barrel Import

The barrel file imports all channels, triggering registration:
// src/channels/index.ts
import './whatsapp.js';
import './telegram.js';
import './slack.js';
// Each skill adds its import here

Startup Connection

From src/index.ts:513:
// Create and connect all registered channels
for (const channelName of getRegisteredChannelNames()) {
  const factory = getChannelFactory(channelName)!;
  const channel = factory(channelOpts);
  if (!channel) {
    logger.warn(
      { channel: channelName },
      'Channel installed but credentials missing — skipping',
    );
    continue;
  }
  channels.push(channel);
  await channel.connect();
}

if (channels.length === 0) {
  logger.fatal('No channels connected');
  process.exit(1);
}
Channels with missing credentials are skipped silently. You’ll see a warning in the logs but NanoClaw will start with the channels that are configured.

Installing Channels

WhatsApp

/add-whatsapp
Authentication: QR code scan What it does:
  1. Adds src/channels/whatsapp.ts
  2. Adds Baileys dependency to package.json
  3. Creates authentication flow
  4. Updates src/channels/index.ts to import WhatsApp
Credentials stored: store/auth/ (session files)

Telegram

/add-telegram
Authentication: Bot token from @BotFather What it does:
  1. Adds src/channels/telegram.ts
  2. Adds node-telegram-bot-api dependency
  3. Prompts for bot token
  4. Updates barrel import
Credentials stored: .env (TELEGRAM_BOT_TOKEN)

Slack

/add-slack
Authentication: OAuth app What it does:
  1. Adds src/channels/slack.ts
  2. Adds @slack/bolt dependency
  3. Guides OAuth app setup
  4. Updates barrel import
Credentials stored: .env (SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET)

Discord

/add-discord
Authentication: Bot token from Discord Developer Portal What it does:
  1. Adds src/channels/discord.ts
  2. Adds discord.js dependency
  3. Prompts for bot token
  4. Updates barrel import
Credentials stored: .env (DISCORD_BOT_TOKEN)

Gmail

/add-gmail
Authentication: Google OAuth What it does:
  1. Adds src/channels/gmail.ts
  2. Adds Gmail API dependencies
  3. Guides OAuth setup
  4. Updates barrel import
Credentials stored: .env and store/gmail-token.json

Switching Between Channels

You don’t “switch” between channels — they all run simultaneously. Messages route to the appropriate channel based on JID (chat identifier).

How Message Routing Works

From src/router.ts:46:
export function findChannel(
  channels: Channel[],
  jid: string,
): Channel | undefined {
  return channels.find((ch) => ch.ownsJid(jid));
}
Example JIDs: Each channel’s ownsJid() method checks if the JID matches its pattern.

Registering Groups Across Channels

You can register groups from different channels:
# In WhatsApp main channel
@Andy add group "Family Chat"  # Registers WhatsApp group

# In Telegram main channel  
@Andy add group "Dev Team"  # Registers Telegram group
Each gets its own folder:
  • groups/whatsapp_family-chat/
  • groups/telegram_dev-team/

Available Channels (Outbound)

Checking Available Groups

From the main channel:
@Andy list available groups
This shows all groups across all connected channels:
Available Groups:

1. Family Chat (WhatsApp)
   Registered: Yes
   Last activity: 2024-01-31 14:23

2. Dev Team (Telegram)
   Registered: Yes
   Last activity: 2024-01-31 12:45

3. Marketing (Slack)
   Registered: No
   Last activity: 2024-01-30 09:15

Syncing Groups

Channels that support syncGroups() can refresh their group list:
@Andy sync groups
This calls:
await Promise.all(
  channels
    .filter((ch) => ch.syncGroups)
    .map((ch) => ch.syncGroups!(force)),
);

Channel-Specific Features

WhatsApp

Typing indicators: Yes Group sync: Yes (auto-syncs on connection) Media support: Depends on skill (see /add-image-vision, /add-voice-transcription) Authentication: QR code (expires ~20 days, requires re-scan)

Telegram

Typing indicators: Yes Group sync: Yes Media support: Yes (native) Authentication: Bot token (never expires)

Slack

Typing indicators: Limited (only in DMs) Group sync: Yes (channels only, not private channels unless bot is invited) Media support: Yes (file uploads) Authentication: OAuth (requires app setup)

Discord

Typing indicators: Yes Group sync: Yes (only servers where bot is added) Media support: Yes Authentication: Bot token (never expires)

Gmail

Typing indicators: N/A (email) Group sync: N/A (threads identified by subject) Media support: Attachments Authentication: OAuth (refresh token stored)

Building a Custom Channel

Step 1: Create Channel File

Create src/channels/signal.ts:
import { registerChannel, ChannelOpts } from './registry.js';
import { Channel, NewMessage } from '../types.js';

export class SignalChannel implements Channel {
  name = 'signal';
  private client: SignalClient;  // Your Signal client
  
  constructor(private opts: ChannelOpts) {
    this.client = new SignalClient();
  }
  
  async connect(): Promise<void> {
    await this.client.connect();
    
    // Set up message listener
    this.client.on('message', (msg) => {
      const newMsg: NewMessage = {
        chat_jid: `signal:${msg.groupId}`,
        sender: msg.sender,
        content: msg.text,
        timestamp: new Date().toISOString(),
        is_from_me: msg.isFromMe,
        is_group: msg.isGroup,
      };
      this.opts.onMessage(newMsg.chat_jid, newMsg);
    });
  }
  
  async sendMessage(jid: string, text: string): Promise<void> {
    const groupId = jid.replace('signal:', '');
    await this.client.sendMessage(groupId, text);
  }
  
  isConnected(): boolean {
    return this.client.isConnected();
  }
  
  ownsJid(jid: string): boolean {
    return jid.startsWith('signal:');
  }
  
  async disconnect(): Promise<void> {
    await this.client.disconnect();
  }
}

// Self-register
registerChannel('signal', (opts: ChannelOpts) => {
  const token = process.env.SIGNAL_TOKEN;
  if (!token) return null;  // Skip if not configured
  return new SignalChannel(opts);
});

Step 2: Add to Barrel Import

Edit src/channels/index.ts:
import './whatsapp.js';
import './telegram.js';
import './signal.js';  // Add this line

Step 3: Set Credentials

Add to .env:
SIGNAL_TOKEN=your-signal-api-token

Step 4: Restart

npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
Your Signal channel is now connected!

Architecture Diagram

Best Practices

Use One Main Channel

Designate one channel (usually WhatsApp or Telegram) as your main administrative channel.

Separate Contexts

Use different channels for different contexts: WhatsApp for family, Slack for work, Telegram for dev team.

Monitor Credentials

Set up alerts for authentication failures. WhatsApp QR codes expire every ~20 days.

Test Before Deploying

Test new channels in a development environment before adding to production.

Troubleshooting

Channel Not Loading

Check logs:
tail -f logs/nanoclaw.log | grep "Channel installed but credentials missing"
Verify import:
grep "import './whatsapp.js'" src/channels/index.ts
Check credentials:
grep TELEGRAM_BOT_TOKEN .env

Multiple Channels Fighting for Same JID

Symptom: Messages sent to wrong channel Cause: Two channels returning true for ownsJid(jid) Solution: Make JID prefixes unique:
  • WhatsApp: @s.whatsapp.net, @g.us
  • Telegram: telegram:
  • Slack: slack:
  • Discord: discord:

Messages Not Routing

Check router:
const channel = findChannel(channels, jid);
if (!channel) {
  logger.warn({ jid }, 'No channel owns JID');
}
Verify JID format:
sqlite3 store/messages.db "SELECT DISTINCT chat_jid FROM messages LIMIT 10;"
Ensure JIDs match the pattern expected by ownsJid().

Build docs developers (and LLMs) love