Skip to main content

Overview

Channel plugins connect external messaging platforms (Discord, Slack, web chat, etc.) to the Tiny Claw agent loop. They handle bidirectional communication: receiving messages from users and delivering responses back through the platform.

ChannelPlugin Interface

Channel plugins implement the ChannelPlugin interface from @tinyclaw/types:
interface ChannelPlugin extends PluginMeta {
  readonly type: 'channel';
  
  /** Boot the channel */
  start(context: PluginRuntimeContext): Promise<void>;
  
  /** Tear down - disconnect and cleanup */
  stop(): Promise<void>;
  
  /** Return pairing tools for configuration (optional) */
  getPairingTools?(
    secrets: SecretsManagerInterface,
    configManager: ConfigManagerInterface
  ): Tool[];
  
  /** Send outbound messages to users (optional) */
  sendToUser?(userId: string, message: OutboundMessage): Promise<void>;
  
  /** Channel prefix for userId routing (optional) */
  readonly channelPrefix?: string;
}

Interface Fields

type
'channel'
required
Must be 'channel' for channel plugins
start
function
required
Called after agent context is built. Initialize platform connections here.Parameters:
  • context: PluginRuntimeContext - Runtime context with agent access
Returns: Promise<void>
stop
function
required
Called during graceful shutdown. Clean up connections and pending state.Returns: Promise<void>
getPairingTools
function
Optional method that returns tools for conversational configuration.Parameters:
  • secrets: SecretsManagerInterface - For storing API keys/tokens
  • configManager: ConfigManagerInterface - For storing configuration
Returns: Tool[]
sendToUser
function
Optional method for sending proactive messages to users (nudges, task results, etc.).Parameters:
  • userId: string - Target user (e.g., discord:123456)
  • message: OutboundMessage - Message to deliver
Returns: Promise<void>
channelPrefix
string
Optional prefix for userId routing (e.g., 'discord', 'friend'). Used by the outbound gateway to route messages to the correct channel.

PluginRuntimeContext

The runtime context provides everything needed to interact with the agent:
interface PluginRuntimeContext {
  /** Push a message into the session queue and run the agent loop */
  enqueue(userId: string, message: string): Promise<string>;
  
  /** The initialized AgentContext */
  agentContext: AgentContext;
  
  /** Secrets manager for resolving tokens */
  secrets: SecretsManagerInterface;
  
  /** Config manager for reading/writing channel config */
  configManager: ConfigManagerInterface;
  
  /** Outbound gateway for sending proactive messages */
  gateway?: OutboundGateway;
}
enqueue
function
required
Core method to send user messages into the agent loop.Parameters:
  • userId: string - Unique user identifier (prefixed, e.g., discord:123)
  • message: string - User’s message text
Returns: Promise<string> - Agent’s response
agentContext
AgentContext
required
Full agent context including database, provider, tools, and memory engine
secrets
SecretsManagerInterface
required
Access to encrypted secrets storage for API tokens
configManager
ConfigManagerInterface
required
Access to persistent configuration
gateway
OutboundGateway
Optional outbound gateway for proactive messaging

Complete Example: Discord Plugin

Here’s the full implementation of the Discord channel plugin:
import { logger } from '@tinyclaw/logger';
import type {
  ChannelPlugin,
  ConfigManagerInterface,
  OutboundMessage,
  PluginRuntimeContext,
  SecretsManagerInterface,
  Tool,
} from '@tinyclaw/types';
import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
import { createDiscordPairingTools } from './pairing.js';

let client: Client | null = null;

const discordPlugin: ChannelPlugin = {
  id: '@tinyclaw/plugin-channel-discord',
  name: 'Discord',
  description: 'Connect Tiny Claw to a Discord bot',
  type: 'channel',
  version: '0.1.0',
  channelPrefix: 'discord',

  getPairingTools(secrets, configManager) {
    return createDiscordPairingTools(secrets, configManager);
  },

  async start(context: PluginRuntimeContext): Promise<void> {
    const isEnabled = context.configManager.get<boolean>(
      'channels.discord.enabled'
    );
    if (!isEnabled) {
      logger.info('Discord plugin: not enabled');
      return;
    }

    const token = await context.secrets.retrieve('channel.discord.token');
    if (!token) {
      logger.warn('Discord plugin: no token found');
      return;
    }

    client = new Client({
      intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.DirectMessages,
      ],
      partials: [Partials.Channel],
    });

    client.once(Events.ClientReady, (readyClient) => {
      logger.info(`Discord bot ready: ${readyClient.user.tag}`);
    });

    client.on(Events.MessageCreate, async (msg) => {
      if (msg.author.bot) return;

      const isDM = msg.channel.isDMBased();
      const isMention = client?.user 
        ? msg.mentions.users.has(client.user.id) 
        : false;

      if (!isDM && !isMention) return;

      const rawContent = msg.content
        .replace(/<@!?[\d]+>/g, '')
        .trim();
      if (!rawContent) return;

      // Prefix userId to isolate Discord sessions
      const userId = `discord:${msg.author.id}`;

      try {
        await msg.channel.sendTyping();
        const response = await context.enqueue(userId, rawContent);
        await msg.reply(response);
      } catch (err) {
        logger.error('Discord error:', err);
        await msg.reply('Sorry, I ran into an error.');
      }
    });

    await client.login(token);
    logger.info('Discord bot connected');
  },

  async sendToUser(userId: string, message: OutboundMessage): Promise<void> {
    if (!client) {
      throw new Error('Discord client is not connected');
    }

    const discordId = userId.replace(/^discord:/, '');
    const user = await client.users.fetch(discordId);
    await user.send(message.content);
    
    logger.info(`Discord: sent outbound message to ${userId}`);
  },

  async stop(): Promise<void> {
    if (client) {
      client.destroy();
      client = null;
      logger.info('Discord bot disconnected');
    }
  },
};

export default discordPlugin;

Pairing Tools Example

Pairing tools enable conversational configuration:
import type {
  ConfigManagerInterface,
  SecretsManagerInterface,
  Tool
} from '@tinyclaw/types';
import { buildChannelKeyName } from '@tinyclaw/types';

const DISCORD_TOKEN_SECRET_KEY = buildChannelKeyName('discord');
const DISCORD_ENABLED_CONFIG_KEY = 'channels.discord.enabled';
const DISCORD_PLUGIN_ID = '@tinyclaw/plugin-channel-discord';

export function createDiscordPairingTools(
  secrets: SecretsManagerInterface,
  configManager: ConfigManagerInterface
): Tool[] {
  return [
    {
      name: 'discord_pair',
      description: 'Pair Tiny Claw with a Discord bot',
      parameters: {
        type: 'object',
        properties: {
          token: {
            type: 'string',
            description: 'Discord bot token',
          },
        },
        required: ['token'],
      },
      async execute(args: Record<string, unknown>): Promise<string> {
        const token = args.token as string;
        
        // Store token securely
        await secrets.store(DISCORD_TOKEN_SECRET_KEY, token.trim());
        
        // Enable channel
        configManager.set(DISCORD_ENABLED_CONFIG_KEY, true);
        
        // Add to enabled plugins
        const current = configManager.get<string[]>('plugins.enabled') ?? [];
        if (!current.includes(DISCORD_PLUGIN_ID)) {
          configManager.set('plugins.enabled', [...current, DISCORD_PLUGIN_ID]);
        }
        
        return 'Discord bot paired! Use tinyclaw_restart to connect.';
      },
    },
    {
      name: 'discord_unpair',
      description: 'Disconnect the Discord bot',
      parameters: {
        type: 'object',
        properties: {},
        required: [],
      },
      async execute(): Promise<string> {
        configManager.set(DISCORD_ENABLED_CONFIG_KEY, false);
        
        const current = configManager.get<string[]>('plugins.enabled') ?? [];
        configManager.set(
          'plugins.enabled',
          current.filter((id) => id !== DISCORD_PLUGIN_ID)
        );
        
        return 'Discord disabled. Use tinyclaw_restart to apply.';
      },
    },
  ];
}

UserId Prefixing

Channel plugins should prefix userIds to prevent collisions:
// Format: <channel>:<platform-id>
const userId = `discord:${msg.author.id}`;
const userId = `friend:${username}`;
const userId = `web:owner`;
This allows:
  • Isolated conversation histories per channel
  • Proper routing for outbound messages
  • Multi-channel user identification

Outbound Messaging

Implement sendToUser() to support proactive messaging:
interface OutboundMessage {
  content: string;
  priority: 'urgent' | 'normal' | 'low';
  source: 'background_task' | 'sub_agent' | 'reminder' | 'pulse' | 'system' | 'agent';
  metadata?: Record<string, unknown>;
}
Example implementation:
async sendToUser(userId: string, message: OutboundMessage): Promise<void> {
  // Parse userId to extract platform ID
  const platformId = userId.replace(/^discord:/, '');
  
  // Fetch user and send message
  const user = await client.users.fetch(platformId);
  await user.send(message.content);
}

Testing Your Channel Plugin

1. Local Development

# Link your plugin locally
npm link

# In Tiny Claw directory
npm link @yourname/plugin-channel-name

2. Enable the Plugin

tinyclaw config set plugins.enabled '["@yourname/plugin-channel-name"]'

3. Test Pairing Flow

Start Tiny Claw and test the pairing conversation:
User: "Connect my [platform] account"
Agent: [calls your_pair tool]

4. Verify Message Flow

  • Send messages from the platform
  • Verify they appear in Tiny Claw
  • Check responses are delivered back
  • Test error handling

5. Test Outbound Messages

If implementing sendToUser(), test proactive messaging:
// Trigger a background task or nudge
context.gateway?.send(userId, {
  content: 'Test nudge',
  priority: 'normal',
  source: 'system'
});

Best Practices

Connection Management

  • Initialize connections in start()
  • Store connection objects at module level
  • Clean up properly in stop()
  • Handle reconnection logic

Error Handling

try {
  await msg.channel.sendTyping();
  const response = await context.enqueue(userId, rawContent);
  await msg.reply(response);
} catch (err) {
  logger.error('Error handling message:', err);
  await msg.reply('Sorry, I encountered an error.');
}

Rate Limiting

  • Respect platform rate limits
  • Implement queuing for outbound messages
  • Use exponential backoff for retries

Security

  • Never log tokens or credentials
  • Validate incoming message sources
  • Sanitize user input before processing
  • Use secrets manager for all credentials

Next Steps

Provider Plugins

Learn about provider plugins

Publishing

Publish your plugin to npm