Skip to main content
The channel registry is a factory-based system that allows channels to self-register at startup. This enables a plugin-like architecture where channels can be added via Claude Code skills.

Overview

NanoClaw’s core ships with no channels built in. Each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a Claude Code skill that:
  1. Adds channel implementation code to src/channels/
  2. Calls registerChannel() at module load time
  3. Returns null if credentials are missing (channel is skipped)
  4. Exports via barrel import in src/channels/index.ts

Registry API

Location: src/channels/registry.ts

Types

export interface ChannelOpts {
  onMessage: OnInboundMessage;
  onChatMetadata: OnChatMetadata;
  registeredGroups: () => Record<string, RegisteredGroup>;
}

export type ChannelFactory = (opts: ChannelOpts) => Channel | null;

registerChannel

Register a channel factory in the global registry.
function registerChannel(name: string, factory: ChannelFactory): void
name
string
required
Unique identifier for the channel (e.g., "whatsapp", "telegram"). This name:
  • Must be lowercase
  • Should match the channel’s common name
  • Will be used as prefix for group folders ({channel}_{group-name})
factory
ChannelFactory
required
Factory function that creates a channel instance.Signature:
(opts: ChannelOpts) => Channel | null
Parameters:
  • opts.onMessage - Callback to deliver inbound messages to orchestrator
  • opts.onChatMetadata - Callback to report chat/group metadata
  • opts.registeredGroups - Function returning currently registered groups
Returns:
  • Channel instance if credentials are configured
  • null if credentials are missing (channel will be skipped)
Example:
import { registerChannel, ChannelOpts } from './registry.js';
import { WhatsAppChannel } from './whatsapp.js';
import { existsSync } from 'fs';

registerChannel('whatsapp', (opts: ChannelOpts) => {
  const authPath = '/path/to/whatsapp/auth';
  
  // Return null if credentials not configured
  if (!existsSync(authPath)) {
    return null;
  }
  
  return new WhatsAppChannel(opts, authPath);
});

getChannelFactory

Retrieve a registered channel factory by name.
function getChannelFactory(name: string): ChannelFactory | undefined
name
string
required
The channel name used during registration.
Returns: The factory function, or undefined if not registered. Example:
const factory = getChannelFactory('whatsapp');
if (factory) {
  const channel = factory(opts);
  if (channel) {
    await channel.connect();
  }
}

getRegisteredChannelNames

Get list of all registered channel names.
function getRegisteredChannelNames(): string[]
Returns: Array of channel names (e.g., ["whatsapp", "telegram", "slack"]). Example:
const channels = getRegisteredChannelNames();
console.log(`Registered channels: ${channels.join(', ')}`);

Self-Registration Pattern

Channels use a barrel-import pattern for automatic registration at startup.

1. Channel Implementation

Create a file in src/channels/ that implements the Channel interface and calls registerChannel() at module load:
// src/channels/telegram.ts
import { registerChannel, ChannelOpts } from './registry.js';
import { Channel } from '../types.js';
import { existsSync } from 'fs';
import path from 'path';

export class TelegramChannel implements Channel {
  name = 'telegram';
  private client: any;
  private opts: ChannelOpts;

  constructor(opts: ChannelOpts, botToken: string) {
    this.opts = opts;
    // Initialize with botToken...
  }

  async connect(): Promise<void> {
    // Connect to Telegram...
  }

  async sendMessage(jid: string, text: string): Promise<void> {
    // Send message via Telegram...
  }

  isConnected(): boolean {
    return this.client !== null;
  }

  ownsJid(jid: string): boolean {
    return jid.startsWith('tg:');
  }

  async disconnect(): Promise<void> {
    // Disconnect from Telegram...
  }
}

// Self-register at module load
registerChannel('telegram', (opts: ChannelOpts) => {
  const botToken = process.env.TELEGRAM_BOT_TOKEN;
  
  if (!botToken) {
    // Return null if credentials missing - channel will be skipped
    return null;
  }
  
  return new TelegramChannel(opts, botToken);
});

2. Barrel Import

Add an import to src/channels/index.ts:
// src/channels/index.ts
import './registry.js';  // Export the registry API
import './whatsapp.js';  // Triggers WhatsApp registration
import './telegram.js';  // Triggers Telegram registration
import './slack.js';     // Triggers Slack registration
import './discord.js';   // Triggers Discord registration
// ... add new channels here

export * from './registry.js';

3. Orchestrator Connection

The orchestrator (src/index.ts) loops through registered channels and connects those that are configured:
import { getRegisteredChannelNames, getChannelFactory } from './channels/index.js';

const channels: Channel[] = [];

for (const name of getRegisteredChannelNames()) {
  const factory = getChannelFactory(name);
  const channel = factory?.(channelOpts);
  
  if (channel) {
    logger.info(`Connecting ${name} channel...`);
    await channel.connect();
    channels.push(channel);
  } else {
    logger.warn(`Skipping ${name} channel (credentials not configured)`);
  }
}

ChannelOpts Callbacks

The factory receives ChannelOpts with three callbacks for communicating with the orchestrator.

onMessage

Deliver inbound messages to the orchestrator.
type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
When to call: Whenever a message is received from the platform. Example:
this.client.on('message', (msg) => {
  this.opts.onMessage(msg.chatId, {
    id: msg.id,
    chat_jid: msg.chatId,
    sender: msg.from,
    sender_name: msg.fromName,
    content: msg.text,
    timestamp: new Date().toISOString(),
  });
});

onChatMetadata

Report chat/group metadata to the orchestrator.
type OnChatMetadata = (
  chatJid: string,
  timestamp: string,
  name?: string,
  channel?: string,
  isGroup?: boolean,
) => void;
When to call:
  • During connect() if metadata is delivered inline with messages
  • During syncGroups() if metadata requires separate fetching
Example:
// Inline delivery (Telegram)
this.client.on('message', (msg) => {
  this.opts.onChatMetadata(
    msg.chatId,
    new Date().toISOString(),
    msg.chatName,
    'telegram',
    msg.isGroup
  );
  this.opts.onMessage(msg.chatId, /* ... */);
});

// Separate sync (WhatsApp)
async syncGroups(force: boolean): Promise<void> {
  const chats = await this.client.getChats();
  for (const chat of chats) {
    this.opts.onChatMetadata(
      chat.jid,
      new Date().toISOString(),
      chat.name,
      'whatsapp',
      chat.isGroup
    );
  }
}

registeredGroups

Get the current list of registered groups.
registeredGroups: () => Record<string, RegisteredGroup>
When to use: To check if a chat is registered before processing messages. Example:
const groups = this.opts.registeredGroups();
if (groups[chatJid]) {
  // Process this chat
} else {
  // Ignore this chat
}

Adding a New Channel

To add a new channel to NanoClaw:
  1. Create a Claude Code skill in .claude/skills/add-{channel}/
  2. Add channel implementation to src/channels/{channel}.ts:
    • Implement the Channel interface
    • Call registerChannel() at module load
    • Return null if credentials are missing
  3. Update barrel import in src/channels/index.ts:
    import './{channel}.js';
    
  4. Add authentication via environment variables or config files
  5. Test the channel:
    npm run build
    npm run dev  # Check logs for "Connecting {channel} channel..."
    

Example: Complete Channel Skill

Here’s what a minimal channel skill looks like:
// src/channels/signal.ts
import { registerChannel, ChannelOpts } from './registry.js';
import { Channel, NewMessage } from '../types.js';

class SignalChannel implements Channel {
  name = 'signal';
  private client: any = null;
  private opts: ChannelOpts;

  constructor(opts: ChannelOpts) {
    this.opts = opts;
  }

  async connect(): Promise<void> {
    // Initialize Signal client
    this.client = await initializeSignalClient();
    
    // Listen for messages
    this.client.on('message', (msg: any) => {
      this.opts.onMessage(msg.source, {
        id: msg.timestamp.toString(),
        chat_jid: msg.source,
        sender: msg.source,
        sender_name: msg.sourceName || msg.source,
        content: msg.message,
        timestamp: new Date(msg.timestamp).toISOString(),
      });
    });
    
    await this.client.start();
  }

  async sendMessage(jid: string, text: string): Promise<void> {
    await this.client.send(jid, text);
  }

  isConnected(): boolean {
    return this.client?.isConnected ?? false;
  }

  ownsJid(jid: string): boolean {
    return jid.startsWith('+'); // Signal uses phone numbers
  }

  async disconnect(): Promise<void> {
    await this.client?.stop();
    this.client = null;
  }
}

// Self-register
registerChannel('signal', (opts: ChannelOpts) => {
  const signalNumber = process.env.SIGNAL_NUMBER;
  
  if (!signalNumber) {
    return null; // Skip if not configured
  }
  
  return new SignalChannel(opts);
});
Then add to src/channels/index.ts:
import './signal.js';

Build docs developers (and LLMs) love