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:
- Adds channel implementation code to
src/channels/
- Calls
registerChannel() at module load time
- Returns
null if credentials are missing (channel is skipped)
- 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
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 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
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(),
});
});
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:
-
Create a Claude Code skill in
.claude/skills/add-{channel}/
-
Add channel implementation to
src/channels/{channel}.ts:
- Implement the
Channel interface
- Call
registerChannel() at module load
- Return
null if credentials are missing
-
Update barrel import in
src/channels/index.ts:
-
Add authentication via environment variables or config files
-
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: