NanoClaw Pro uses a factory registry pattern where channels self-register at module load time. This enables a plugin-like architecture without configuration files.
Overview
Channels are not built into the core. Each channel is installed as a Claude Code skill that adds:
A channel implementation file (e.g., src/channels/whatsapp.ts)
A factory registration call at module load
An import line in the barrel file (src/channels/index.ts)
Channel Registry
The registry is a simple factory map in src/channels/registry.ts:
// From src/channels/registry.ts:1-28
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 ()];
}
Key points:
ChannelFactory returns Channel | null (null if credentials missing)
Registry is populated at module load time (before main() runs)
No configuration files needed
Location: src/channels/registry.ts:1-28
Channel Interface
Every channel implements this interface:
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(): Promise<void>
Establishes connection to the messaging platform:
async connect (): Promise < void > {
// Authenticate, open websocket, etc.
this. sock = makeWASocket ({ auth: this . authState });
this.sock.ev.on( 'messages.upsert' , (m) => {
// Call onMessage callback
this . handleIncomingMessage ( m );
});
}
sendMessage(jid: string, text: string): Promise<void>
Sends a message to the platform:
async sendMessage ( jid : string , text : string ): Promise < void > {
await this.sock.sendMessage( jid , { text });
}
ownsJid(jid: string): boolean
Determines if a JID belongs to this channel:
ownsJid ( jid : string ): boolean {
// WhatsApp JIDs end with @s.whatsapp.net or @g.us
return jid . endsWith ( '@s.whatsapp.net' ) || jid . endsWith ( '@g.us' );
}
This enables multi-channel routing - the router finds the owning channel:
// From src/router.ts:47-52
export function findChannel (
channels : Channel [],
jid : string ,
) : Channel | undefined {
return channels . find (( c ) => c . ownsJid ( jid ));
}
Location: src/router.ts:47-52
Optional Methods
setTyping?(jid: string, isTyping: boolean): Promise<void>
Shows/hides typing indicator:
async setTyping ( jid : string , isTyping : boolean ): Promise < void > {
await this.sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused' , jid);
}
Called by the router during agent processing:
// From src/index.ts:203
await channel . setTyping ?.( chatJid , true );
const output = await runAgent ( ... );
await channel . setTyping ?.( chatJid , false );
Location: src/index.ts:203-234
syncGroups?(force: boolean): Promise<void>
Fetches group metadata from the platform:
async syncGroups ( force : boolean ): Promise < void > {
const groups = await this . sock . groupFetchAllParticipating ();
for ( const [ jid , metadata ] of Object.entries( groups )) {
this . opts . onChatMetadata (
jid ,
new Date (). toISOString (),
metadata . subject ,
'whatsapp' ,
true ,
);
}
}
Called by IPC when the agent requests a group list refresh.
Self-Registration Pattern
1. Channel Implementation
Each channel file calls registerChannel() at module load:
// src/channels/whatsapp.ts (conceptual example)
import { registerChannel , ChannelOpts } from './registry.js' ;
import makeWASocket from '@whiskeysockets/baileys' ;
export class WhatsAppChannel implements Channel {
constructor ( private opts : ChannelOpts ) {}
async connect () : Promise < void > { /* ... */ }
async sendMessage ( jid : string , text : string ) : Promise < void > { /* ... */ }
ownsJid ( jid : string ) : boolean { /* ... */ }
// ...
}
// Self-registration at module load
registerChannel ( 'whatsapp' , ( opts : ChannelOpts ) => {
const authPath = path . join ( STORE_DIR , 'auth' );
// Return null if credentials missing
if ( ! existsSync ( authPath )) {
return null ;
}
return new WhatsAppChannel ( opts );
});
Key behaviors:
Factory returns null when credentials are missing
NanoClaw logs a warning and skips the channel
No crash if a channel is installed but not configured
2. Barrel Import
The barrel file imports all channel modules:
// src/channels/index.ts
import './whatsapp.js' ;
import './telegram.js' ;
import './slack.js' ;
import './discord.js' ;
import './gmail.js' ;
// Skills add their import here
This triggers registration before main() runs.
Location: src/channels/index.ts
3. Orchestrator Instantiation
At startup, the orchestrator loops through registered channels:
// From src/index.ts:515-531
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 );
}
Location: src/index.ts:515-531
At least one channel must be configured or NanoClaw exits with a fatal error.
Channel Callbacks
Channels receive callbacks to interact with the core:
// From src/index.ts:481-510
const channelOpts = {
onMessage : ( chatJid : string , msg : NewMessage ) => {
// Sender allowlist filtering
if ( ! msg . is_from_me && ! msg . is_bot_message && registeredGroups [ chatJid ]) {
const cfg = loadSenderAllowlist ();
if ( shouldDropMessage ( chatJid , cfg ) &&
! isSenderAllowed ( chatJid , msg . sender , cfg )) {
return ; // Drop silently
}
}
storeMessage ( msg );
},
onChatMetadata : (
chatJid : string ,
timestamp : string ,
name ?: string ,
channel ?: string ,
isGroup ?: boolean ,
) => storeChatMetadata ( chatJid , timestamp , name , channel , isGroup ),
registeredGroups : () => registeredGroups ,
};
onMessage
Called when a message arrives:
type OnInboundMessage = ( chatJid : string , msg : NewMessage ) => void ;
interface NewMessage {
id : string ;
chat_jid : string ;
sender : string ;
sender_name : string ;
content : string ;
timestamp : string ; // ISO 8601
is_from_me : boolean ;
is_bot_message ?: boolean ;
}
Responsibilities:
Store message in SQLite (or drop if sender not allowed)
Update chat metadata timestamp
Location: src/index.ts:483-500
Called to update chat information:
type OnChatMetadata = (
chatJid : string ,
timestamp : string ,
name ?: string ,
channel ?: string ,
isGroup ?: boolean ,
) => void ;
Used for:
Group discovery (listing available groups for activation)
Name updates when group metadata changes
Last activity tracking
Location: src/index.ts:502-508
registeredGroups
Provides access to currently registered groups:
registeredGroups : () => Record < string , RegisteredGroup >
Channels use this to:
Skip message storage for unregistered chats (saves space)
Implement per-group features
Location: src/index.ts:509
Each channel uses a unique JID (Jabber ID) format:
Channel JID Format Example WhatsApp (group) <phone>@g.us[email protected] WhatsApp (DM) <phone>@s.whatsapp.net[email protected] Telegram tg:<chat_id>tg:-1001234567890Discord dc:<guild_id>:<channel_id>dc:123456789:987654321Slack slack:<team_id>:<channel_id>slack:T123:C456Gmail gmail:<thread_id>gmail:thread_abc123
Why standardize?
Enables multi-channel routing without conflicts
SQLite uses JID as foreign key across tables
ownsJid() provides fast channel lookup
Adding a New Channel
To add a new channel:
Create the implementation file:
// src/channels/signal.ts
import { registerChannel , ChannelOpts } from './registry.js' ;
import { Channel } from '../types.js' ;
export class SignalChannel implements Channel {
name = 'signal' ;
constructor ( private opts : ChannelOpts ) {}
async connect () : Promise < void > {
// Connect to Signal API
}
async sendMessage ( jid : string , text : string ) : Promise < void > {
// Send via Signal
}
ownsJid ( jid : string ) : boolean {
return jid . startsWith ( 'signal:' );
}
isConnected () : boolean {
return this . connected ;
}
async disconnect () : Promise < void > {
// Cleanup
}
}
registerChannel ( 'signal' , ( opts ) => {
const phoneNumber = process . env . SIGNAL_PHONE ;
if ( ! phoneNumber ) return null ;
return new SignalChannel ( opts );
});
Add to barrel file:
// src/channels/index.ts
import './whatsapp.js' ;
import './telegram.js' ;
import './signal.js' ; // <-- Add this
Configure credentials:
# .env
SIGNAL_PHONE = +1234567890
Restart NanoClaw:
npm run build
launchctl kickstart -k gui/ $( id -u ) /com.nanoclaw
The new channel is now active!
Channel Skills
Official channel skills:
Skill Command Adds WhatsApp /add-whatsappWhatsApp via Baileys Telegram /add-telegramTelegram Bot API Slack /add-slackSlack Bolt SDK Discord /add-discordDiscord.js Gmail /add-gmailGmail API with OAuth
Each skill:
Adds the channel implementation
Updates src/channels/index.ts
Adds credential instructions to README
Compiles TypeScript
See .claude/skills/add-<channel>/ for skill definitions.
Multi-Channel Routing
The router uses ownsJid() to find the correct channel:
// From src/router.ts:47-52
export function findChannel (
channels : Channel [],
jid : string ,
) : Channel | undefined {
return channels . find (( c ) => c . ownsJid ( jid ));
}
Example:
// Message from WhatsApp group
const jid = '[email protected] ' ;
const channel = findChannel ( channels , jid );
// Returns WhatsAppChannel instance
// Message from Telegram
const jid = 'tg:-1001234567890' ;
const channel = findChannel ( channels , jid );
// Returns TelegramChannel instance
Location: src/router.ts:47-52
Channels must implement ownsJid() correctly to avoid routing conflicts.
Benefits of This Architecture
1. No Configuration Files
Channels self-register at module load. No YAML/JSON config needed.
2. Graceful Degradation
Missing credentials don’t crash the app - just skip that channel.
3. Plugin-Like Extensibility
Add channels by:
Dropping a file in src/channels/
Adding one import line
No core code changes needed.
4. Skill-Based Installation
Channels are distributed as Claude Code skills, not NPM packages. Benefits:
Version controlled with your fork
Customizable per installation
No dependency hell
5. Type Safety
The Channel interface provides compile-time checks for implementations.
Next Steps
Message Flow How channels integrate with message routing
Security Channel isolation and security