Skip to content
Learn Agentic AI11 min read0 views

Building a Discord Bot Agent: AI-Powered Server Assistant with TypeScript

Build an AI-powered Discord bot that acts as a server assistant using TypeScript. Covers discord.js setup, slash command registration, conversation context management, tool integration, and permission-based access control.

Why Discord Bots Make Great AI Agent Hosts

Discord provides a real-time messaging platform with built-in user identity, permissions, channels, and threads. These primitives map directly to agent concepts: users become agent clients, channels become conversation contexts, threads become persistent sessions, and server roles become permission boundaries.

Building an AI agent as a Discord bot gives you a production-ready interface without building a custom frontend — your users interact through a platform they already use daily.

Project Setup

Initialize a TypeScript project with discord.js and the OpenAI SDK:

mkdir discord-ai-agent && cd discord-ai-agent
npm init -y
npm install discord.js openai dotenv
npm install -D typescript @types/node tsx
npx tsc --init

Configure your environment:

# .env
DISCORD_TOKEN=your-bot-token
DISCORD_CLIENT_ID=your-client-id
OPENAI_API_KEY=sk-proj-your-key

Bot Client Setup

Create the bot client with the necessary intents:

// src/bot.ts
import { Client, GatewayIntentBits, Events } from "discord.js";
import { config } from "dotenv";

config();

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

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

client.login(process.env.DISCORD_TOKEN);

Registering Slash Commands

Discord's slash command system provides a structured interface for agent interactions:

// src/commands/register.ts
import { REST, Routes, SlashCommandBuilder } from "discord.js";

const commands = [
  new SlashCommandBuilder()
    .setName("ask")
    .setDescription("Ask the AI assistant a question")
    .addStringOption((opt) =>
      opt
        .setName("question")
        .setDescription("Your question")
        .setRequired(true)
    ),
  new SlashCommandBuilder()
    .setName("summarize")
    .setDescription("Summarize recent messages in this channel")
    .addIntegerOption((opt) =>
      opt
        .setName("count")
        .setDescription("Number of messages to summarize")
        .setMinValue(5)
        .setMaxValue(100)
        .setRequired(false)
    ),
  new SlashCommandBuilder()
    .setName("research")
    .setDescription("Research a topic using multiple sources")
    .addStringOption((opt) =>
      opt.setName("topic").setDescription("Topic to research").setRequired(true)
    ),
];

const rest = new REST().setToken(process.env.DISCORD_TOKEN!);

await rest.put(
  Routes.applicationCommands(process.env.DISCORD_CLIENT_ID!),
  { body: commands.map((c) => c.toJSON()) }
);

Handling Commands with Agent Logic

Connect slash commands to your AI agent:

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

// src/handlers/ask.ts
import { ChatInputCommandInteraction } from "discord.js";
import OpenAI from "openai";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function handleAsk(interaction: ChatInputCommandInteraction) {
  const question = interaction.options.getString("question", true);

  // Defer reply since LLM calls take time
  await interaction.deferReply();

  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content: `You are a helpful assistant in a Discord server.
Keep responses under 2000 characters (Discord's message limit).
Use markdown formatting that Discord supports.
Be concise and direct.`,
      },
      { role: "user", content: question },
    ],
    max_tokens: 1024,
  });

  const reply = completion.choices[0].message.content ?? "No response generated.";

  await interaction.editReply(reply);
}

Register the handler in your main bot file:

// src/bot.ts
client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  switch (interaction.commandName) {
    case "ask":
      await handleAsk(interaction);
      break;
    case "summarize":
      await handleSummarize(interaction);
      break;
    case "research":
      await handleResearch(interaction);
      break;
  }
});

Conversation Context with Threads

Use Discord threads to maintain multi-turn conversations:

// src/handlers/conversation.ts
import { Message, ThreadChannel } from "discord.js";

const conversationHistory = new Map<string, OpenAI.ChatCompletionMessageParam[]>();

export async function handleThreadMessage(message: Message) {
  if (message.author.bot) return;
  if (!(message.channel instanceof ThreadChannel)) return;

  const threadId = message.channel.id;

  // Initialize or retrieve conversation history
  if (!conversationHistory.has(threadId)) {
    conversationHistory.set(threadId, [
      {
        role: "system",
        content: "You are a helpful assistant in a Discord thread. Maintain context across messages.",
      },
    ]);
  }

  const history = conversationHistory.get(threadId)!;
  history.push({ role: "user", content: message.content });

  // Trim history to last 20 messages to stay within token limits
  const trimmed = [history[0], ...history.slice(-20)];

  await message.channel.sendTyping();

  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: trimmed,
  });

  const reply = completion.choices[0].message.content ?? "...";
  history.push({ role: "assistant", content: reply });

  // Discord has a 2000 character limit
  if (reply.length > 2000) {
    const chunks = reply.match(/.{1,2000}/gs) ?? [];
    for (const chunk of chunks) {
      await message.reply(chunk);
    }
  } else {
    await message.reply(reply);
  }
}

Channel Summarization Tool

Build a tool that summarizes recent channel activity:

// src/handlers/summarize.ts
export async function handleSummarize(
  interaction: ChatInputCommandInteraction
) {
  const count = interaction.options.getInteger("count") ?? 50;
  await interaction.deferReply();

  // Fetch recent messages
  const messages = await interaction.channel?.messages.fetch({ limit: count });
  if (!messages || messages.size === 0) {
    await interaction.editReply("No messages found to summarize.");
    return;
  }

  const transcript = messages
    .reverse()
    .map((m) => `${m.author.displayName}: ${m.content}`)
    .join("\n");

  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content: "Summarize the following Discord conversation. Highlight key topics, decisions, and action items.",
      },
      { role: "user", content: transcript },
    ],
  });

  await interaction.editReply(
    completion.choices[0].message.content ?? "Could not generate summary."
  );
}

Permission-Based Access Control

Restrict agent commands based on Discord server roles:

function requireRole(roleName: string) {
  return async (interaction: ChatInputCommandInteraction): Promise<boolean> => {
    const member = interaction.member;
    if (!member || !("roles" in member)) {
      await interaction.reply({
        content: "Could not verify your permissions.",
        ephemeral: true,
      });
      return false;
    }

    const hasRole = member.roles.cache.some((r) => r.name === roleName);
    if (!hasRole) {
      await interaction.reply({
        content: `You need the "${roleName}" role to use this command.`,
        ephemeral: true,
      });
      return false;
    }
    return true;
  };
}

// Usage in command handler
const checkAdmin = requireRole("AI Admin");

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === "research") {
    if (!(await checkAdmin(interaction))) return;
    await handleResearch(interaction);
  }
});

FAQ

How do I handle Discord's 3-second interaction timeout?

Always call interaction.deferReply() immediately when handling a slash command. This gives you up to 15 minutes to send the actual response via interaction.editReply(). Without deferring, Discord expects a response within 3 seconds, which is too short for most LLM calls.

How do I prevent the bot from responding to itself?

Check message.author.bot at the beginning of every message handler and return early if true. This prevents infinite loops where the bot triggers itself. Also check message.author.id !== client.user?.id for extra safety.

What is the best way to handle conversation memory at scale?

For production bots serving many servers, replace the in-memory Map with Redis or a database. Use the thread ID or channel ID as the key. Set a TTL (time to live) on conversations so they are automatically cleaned up after inactivity. Consider storing only the last N messages per thread to bound memory usage.


#Discord #Bot #TypeScript #AIAgent #Discordjs #SlashCommands #AgenticAI #LearnAI #AIEngineering

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.