n4nAI

Building a Discord bot with LangChain.js

Build a production-ready Discord bot using LangChain.js with conversation memory, tool calling, and proper error handling.

n4n Team3 min read647 words

Audio narration

Coming soon — every post will get a voice note here.

This langchain.js discord bot tutorial walks you through building a bot that maintains conversation context, calls tools, and handles Discord’s rate limits gracefully. You’ll end up with a TypeScript codebase you can extend for production workloads. The complete example uses discord.js v14 and LangChain.js v0.1+, with a clean separation between Discord concerns and LLM logic.

Step 1: Initialize the project and install dependencies

Create a fresh directory and set up a strict TypeScript configuration. We’ll use pnpm for faster installs, but npm or yarn work fine.

mkdir discord-langchain-bot && cd discord-langchain-bot
pnpm init -y
pnpm add discord.js@14 @langchain/core @langchain/openai @langchain/community zod
pnpm add -D typescript@5 tsx@4 @types/node@20 eslint@8 prettier@3

Configure TypeScript for modern Node with strict checks:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Add a tsx dev script for hot reloading:

// package.json scripts section
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "lint": "eslint src --ext .ts",
    "format": "prettier --write src"
  }
}

Step 2: Create the Discord client with proper intents

Discord requires explicit gateway intents. For a chat bot you need Guilds, GuildMessages, MessageContent, and DirectMessages. Create the client wrapper first — this keeps Discord logic isolated from your LangChain chain.

// src/discord/client.ts
import { Client, GatewayIntentBits, Events, Message, TextChannel } from 'discord.js';
import { env } from '../config/env.js';

export function createDiscordClient(): Client {
  const client = new Client({
    intents: [
      GatewayIntentBits.Guilds,
      GatewayIntentBits.GuildMessages,
      GatewayIntentBits.MessageContent,
      GatewayIntentBits.DirectMessages,
    ],
  });

  client.once(Events.ClientReady, (readyClient) => {
    console.log(`Logged in as ${readyClient.user.tag}`);
  });

  client.on(Events.Error, (error) => {
    console.error('Discord client error:', error);
  });

  return client;
}

export async function startDiscordClient(client: Client): Promise<void> {
  await client.login(env.DISCORD_TOKEN);
}

Step 3: Configuration with runtime validation

Use Zod to validate environment variables at startup. This fails fast with actionable errors instead of cryptic crashes later.

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  DISCORD_TOKEN: z.string().min(1),
  DISCORD_CLIENT_ID: z.string().min(1),
  OPENAI_API_KEY: z.string().min(1),
  OPENAI_MODEL: z.string().default('gpt-4o-mini'),
  ALLOWED_CHANNEL_IDS: z.string().optional(), // comma-separated, empty = all
  MAX_HISTORY_MESSAGES: z.coerce.number().int().positive().default(20),
  SYSTEM_PROMPT: z.string().default(
    'You are a helpful Discord assistant. Keep responses concise. Use markdown for code.'
  ),
});

export const env = envSchema.parse(process.env);

export function getAllowedChannelSet(): Set<string> | null {
  if (!env.ALLOWED_CHANNEL_IDS) return null;
  return new Set(env.ALLOWED_CHANNEL_IDS.split(',').map((s) => s.trim()));
}

Create a .env.example for onboarding:

# .env.example
DISCORD_TOKEN=your_bot_token
DISCORD_CLIENT_ID=your_application_id
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini
ALLOWED_CHANNEL_IDS=123456789012345678,987654321098765432
MAX_HISTORY_MESSAGES=20
SYSTEM_PROMPT="You are a helpful Discord assistant. Keep responses concise. Use markdown for code."

Step 4: Build the LangChain chain with memory

LangChain’s RunnableWithMessageHistory wraps any chain and injects conversation history per session. We’ll key sessions by Discord channel ID so each channel gets independent memory.

// src/llm/chain.ts
import { ChatOpenAI } from '@langchain/openai';
import {
  ChatPromptTemplate,
  MessagesPlaceholder,
  SystemMessagePromptTemplate,
  HumanMessagePromptTemplate,
} from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { BaseChatMessageHistory } from '@langchain/core/chat_history';
import { env } from '../config/env.js';

// In-memory store — replace with Redis/Postgres for production
const channelHistories = new Map<string, BaseChatMessageHistory>();

import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

function getChannelHistory(channelId: string): BaseChatMessageHistory {
  if (!channelHistories.has(channelId)) {
    channelHistories.set(channelId, new InMemoryChatMessageHistory());
  }
  return channelHistories.get(channelId)!;
}

const prompt = ChatPromptTemplate.fromMessages([
  SystemMessagePromptTemplate.fromTemplate(env.SYSTEM_PROMPT),
  new MessagesPlaceholder('history'),
  HumanMessagePromptTemplate.fromTemplate('{input}'),
]);

const model = new ChatOpenAI({
  modelName: env.OPENAI_MODEL,
  temperature: 0.3,
  apiKey: env.OPENAI_API_KEY,
  maxRetries: 2,
});

const chain = prompt.pipe(model);

export const chainWithHistory = new RunnableWithMessageHistory({
  runnable: chain,
  getMessageHistory: (sessionId) => getChannelHistory(sessionId),
  inputMessagesKey: 'input',
  historyMessagesKey: 'history',
});

export function trimHistory(channelId: string, maxMessages: number): void {
  const history = channelHistories.get(channelId);
  if (!history) return;
  // InMemoryChatMessageHistory exposes messages array
  const messages = (history as any).messages as any[];
  if (messages.length > maxMessages) {
    (history as any).messages = messages.slice(-maxMessages);
  }
}

Step 5: Add tool calling for practical capabilities

A bot that only chats is limited. Add a tool that fetches GitHub repository info — useful for developer Discords. Define the schema with Zod, then bind it to the model.

// src/llm/tools.ts
import { z } from 'zod';
import { StructuredTool } from '@langchain/core/tools';

const githubRepoSchema = z.object({
  owner: z.string().describe('Repository owner (user or org)'),
  repo: z.string().describe('Repository name'),
});

export class GitHubRepoTool extends StructuredTool {
  name = 'github_repo';
  description = 'Fetch GitHub repository metadata (stars, description, language, etc.)';
  schema = githubRepoSchema;

  async _call({ owner, repo }: z.infer<typeof githubRepoSchema>): Promise<string> {
    const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
      headers: { Accept: 'application/vnd.github+json' },
    });
    if (!response.ok) {
      throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
    }
    const data = await response.json();
    return JSON.stringify({
      name: data.full_name,
      stars: data.stargazers_count,
      language: data.language,
      url: data.html_url,
      updated_at: data.updated_at,
    }, null, 2);
  }
}

export const tools = [new GitHubRepoTool()];

Update the chain to bind tools and handle tool calls:

// src/llm/chain.ts (replace the chain creation section)
import { tools } from './tools.js';

const modelWithTools = model.bindTools(tools);

const chain = prompt.pipe(modelWithTools);

// ... rest unchanged

Step 6: Wire Discord messages to the chain

Now connect the Discord messageCreate event to your chain. Handle mentions, DMs, and rate limits. Discord gives you ~5 requests/second per bot globally — use a simple queue to avoid 429s.

// src/discord/handler.ts
import { Message, TextChannel, DMChannel } from 'discord.js';
import { chainWithHistory } from '../llm/chain.js';
import { trimHistory } from '../llm/chain.js';
import { env, getAllowedChannelSet } from '../config/env.js';

const allowedChannels = getAllowedChannelSet();

// Simple per-channel queue to respect Discord rate limits
const channelQueues = new Map<string, Promise<any>>();

function enqueue(channelId: string, fn: () => Promise<void>): void {
  const previous = channelQueues.get(channelId) ?? Promise.resolve();
  const next = previous.then(() => fn()).catch((err) => console.error('Queue error:', err));
  channelQueues.set(channelId, next);
}

export async function handleMessage(message: Message): Promise<void> {
  // Ignore bots and system messages
  if (message.author.bot || message.system) return;

  // Channel allowlist
  if (allowedChannels && !allowedChannels.has(message.channelId)) return;

  // Only respond to mentions in guilds, always in DMs
  const isDM = message.channel instanceof DMChannel;
  const mentioned = message.mentions.has(message.client.user!);
  if (!isDM && !mentioned) return;

  // Clean content: strip bot mention
  const content = isDM
    ? message.content
    : message.content.replace(/<@!?\d+>/, '').trim();

  if (!content) return;

  const channelId = message.channelId;

  enqueue(channelId, async () => {
    const typing = message.channel as TextChannel | DMChannel;
    await typing.sendTyping();

    try {
      const result = await chainWithHistory.invoke(
        { input: content },
        { configurable: { sessionId: channelId } }
      );

      // Handle tool calls (model returns AIMessage with tool_calls)
      if (result.tool_calls?.length) {
        // For simplicity, we execute tools sequentially and feed results back
        // A production bot would use RunnableWithTools or an agent executor
        for (const toolCall of result.tool_calls) {
          const tool = tools.find((t) => t.name === toolCall.name);
          if (!tool) continue;
          const toolResult = await tool.invoke(toolCall.args);
          // Re-invoke with tool result — simplified loop
          const followUp = await chainWithHistory.invoke(
            { input: `Tool ${toolCall.name} returned: ${toolResult}` },
            { configurable: { sessionId: channelId } }
          );
          await sendResponse(message, followUp.content as string);
        }
      } else {
        await sendResponse(message, result.content as string);
      }

      // Trim history to configured max
      trimHistory(channelId, env.MAX_HISTORY_MESSAGES);
    } catch (error) {
      console.error('Chain error:', error);
      await message.reply('⚠️ Something went wrong. Try again in a moment.');
    }
  });
}

async function sendResponse(message: Message, text: string): Promise<void> {
  // Discord hard limit is 2000 chars
  const chunks = splitMessage(text, 1900);
  for (const chunk of chunks) {
    await message.reply(chunk);
    // Small delay between chunks to avoid rate limit
    await new Promise((r) => setTimeout(r, 250));
  }
}

function splitMessage(text: string, maxLen: number): string[] {
  if (text.length <= maxLen) return [text];
  const chunks: string[] = [];
  let remaining = text;
  while (remaining.length > 0) {
    const chunk = remaining.slice(0, maxLen);
    const lastNewline = chunk.lastIndexOf('\n');
    const cut = lastNewline > maxLen * 0.5 ? lastNewline : maxLen;
    chunks.push(remaining.slice(0, cut));
    remaining = remaining.slice(cut);
  }
  return chunks;
}

Step 7: Assemble the entry point

Wire everything together in index.ts. Add graceful shutdown for SIGINT/SIGTERM so the bot disconnects cleanly.

// src/index.ts
import 'dotenv/config';
import { createDiscordClient, startDiscordClient } from './discord/client.js';
import { handleMessage } from './discord/handler.js';
import { Events } from 'discord.js';

async function main(): Promise<void> {
  const client = createDiscordClient();

  client.on(Events.MessageCreate, handleMessage);

  await startDiscordClient(client);

  // Graceful shutdown
  const shutdown = async (signal: string) => {
    console.log(`${signal} received, shutting down...`);
    client.destroy();
    process.exit(0);
  };

  process.on('SIGINT', () => shutdown('SIGINT'));
  process.on('SIGTERM', () => shutdown('SIGTERM'));
}

main().catch((err) => {
  console.error('Fatal error:', err);
  process.exit(1);
});

Discord prefers slash commands for discoverability. Register a /chat command that mirrors the mention behavior. This also lets you add subcommands later (e.g., /chat reset to clear history).

// src/discord/commands.ts
import { REST, Routes, SlashCommandBuilder } from 'discord.js';
import { env } from '../config/env.js';

const commands = [
  new SlashCommandBuilder()
    .setName('chat')
    .setDescription('Chat with the assistant')
    .addStringOption((opt) =>
      opt.setName('message').setDescription('Your message').setRequired(true)
    )
    .addSubcommand((sub) =>
      sub.setName('reset').setDescription('Clear conversation history in this channel')
    ),
].map((c) => c.toJSON());

export async function registerCommands(): Promise<void> {
  const rest = new REST({ version: '10' }).setToken(env.DISCORD_TOKEN);
  try {
    console.log('Registering slash commands...');
    await rest.put(Routes.applicationCommands(env.DISCORD_CLIENT_ID), { body: commands });
    console.log('Slash commands registered.');
  } catch (error) {
    console.error('Failed to register commands:', error);
  }
}

Update handler.ts to handle the interaction:

// src/discord/handler.ts (add at top)
import { ChatInputCommandInteraction, Interaction } from 'discord.js';

// ... inside handleMessage, add this export
export async function handleInteraction(interaction: Interaction): Promise<void> {
  if (!interaction.isChatInputCommand()) return;
  if (interaction.commandName !== 'chat') return;

  const subcommand = interaction.options.getSubcommand();
  if (subcommand === 'reset') {
    channelHistories.delete(interaction.channelId);
    await interaction.reply({ content: '🧹 Conversation history cleared.', ephemeral: true });
    return;
  }

  const message = interaction.options.getString('message', true);
  await interaction.deferReply();

  // Reuse the same chain logic
  enqueue(interaction.channelId, async () => {
    try {
      const result = await chainWithHistory.invoke(
        { input: message },
        { configurable: { sessionId: interaction.channelId } }
      );
      await interaction.editReply(result.content as string);
      trimHistory(interaction.channelId, env.MAX_HISTORY_MESSAGES);
    } catch (error) {
      console.error('Interaction error:', error);
      await interaction.editReply('⚠️ Something went wrong.');
    }
  });
}

Register the interaction listener in index.ts:

// src/index.ts (add import)
import { handleInteraction } from './discord/handler.js';
import { Events } from 'discord.js';

// ... inside main(), after client.on(Events.MessageCreate, ...)
client.on(Events.InteractionCreate, handleInteraction);

// Call registerCommands() after login
await startDiscordClient(client);
await registerCommands();

Step 9: Run and verify

Copy .env.example to .env and fill in your values. Get a bot token from the Discord Developer Portal — enable “Message Content Intent” under the Bot tab.

cp .env.example .env
# edit .env with your tokens
pnpm dev

Verification checklist:

  1. Bot appears online in your server
  2. Mention the bot in an allowed channel: @BotName hello → receives a coherent reply
  3. Send a DM to the bot → receives a reply
  4. Run /chat message: "explain async/await" → deferred reply, then answer
  5. Run /chat reset → “Conversation history cleared”
  6. Ask “What’s the langchain-ai/langchain repo?” → tool fires, returns GitHub data
  7. Check logs: no unhandled rejections, clean shutdown on Ctrl+C

Step 10: Production hardening notes

The in-memory history store vanishes on restart. For production, swap InMemoryChatMessageHistory with a persistent implementation. LangChain provides PostgresChatMessageHistory and RedisChatMessageHistory packages, or you can implement BaseChatMessageHistory against your existing datastore.

// Example: Redis-backed history (sketch)
import { BaseChatMessageHistory } from '@langchain/core/chat_history';
import { Redis } from 'ioredis';

class RedisChatMessageHistory extends BaseChatMessageHistory {
  constructor(private redis: Redis, private sessionId: string) { super(); }
  async getMessages() { /* LRANGE + JSON.parse */ }
  async addMessage(message) { /* RPUSH JSON.stringify */ }
  async clear() { /* DEL key */ }
}

Rate limiting: the per-channel queue handles Discord’s global limit. If you scale to multiple bot instances (sharding), replace the in-process queue with a Redis-based distributed lock or use a job queue like BullMQ.

Observability: add structured logging (pino) and emit metrics for latency, token usage, and error rates. If you route through an inference gateway like n4n.ai, you get per-token metering and automatic fallback across 240+ models without changing your LangChain code — just swap the ChatOpenAI base URL and API key.

Next steps

  • Add a /system subcommand to override the system prompt per channel
  • Implement RAG with a vector store for domain-specific knowledge
  • Add moderation: filter inputs/outputs through OpenAI’s moderation endpoint
  • Write integration tests with discord.js-mock or a test Discord server
  • Containerize with a multi-stage Dockerfile for deployment to Fly.io, Railway, or Kubernetes

You now have a maintainable, typed foundation. The separation between Discord transport (handler.ts), LLM logic (chain.ts), and configuration (env.ts) lets you iterate on each layer independently.

Tagslangchainjsdiscord-botnodejstutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langchain.js for node & typescript posts →