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);
});
Step 8: Register slash commands (optional but recommended)
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:
- Bot appears online in your server
- Mention the bot in an allowed channel:
@BotName hello→ receives a coherent reply - Send a DM to the bot → receives a reply
- Run
/chat message: "explain async/await"→ deferred reply, then answer - Run
/chat reset→ “Conversation history cleared” - Ask “What’s the langchain-ai/langchain repo?” → tool fires, returns GitHub data
- 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
/systemsubcommand 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-mockor 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.