Skip to main content
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:
  1. A channel implementation file (e.g., src/channels/whatsapp.ts)
  2. A factory registration call at module load
  3. 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

onChatMetadata

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

JID Format Convention

Each channel uses a unique JID (Jabber ID) format:
ChannelJID FormatExample
WhatsApp (group)<phone>@g.us[email protected]
WhatsApp (DM)<phone>@s.whatsapp.net[email protected]
Telegramtg:<chat_id>tg:-1001234567890
Discorddc:<guild_id>:<channel_id>dc:123456789:987654321
Slackslack:<team_id>:<channel_id>slack:T123:C456
Gmailgmail:<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:
  1. 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);
});
  1. Add to barrel file:
// src/channels/index.ts
import './whatsapp.js';
import './telegram.js';
import './signal.js'; // <-- Add this
  1. Configure credentials:
# .env
SIGNAL_PHONE=+1234567890
  1. Restart NanoClaw:
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
The new channel is now active!

Channel Skills

Official channel skills:
SkillCommandAdds
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:
  1. Adds the channel implementation
  2. Updates src/channels/index.ts
  3. Adds credential instructions to README
  4. 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

Build docs developers (and LLMs) love