Build a Tool-Calling Discord Bot

Build a Discord bot powered by a LangGraph agent with tool-calling.

Introduction

30 Second Summary

Think about how AI assistants decide when to search the web versus just answering from memory. That decision-making loop is what separates a simple text generator from a useful AI agent.

In this project, you will build a Discord bot powered by a LangGraph agent that can chat naturally and call tools like checking the time, doing math, and rolling dice.

What You'll Build

A Discord bot that routes user messages through a LangGraph StateGraph with conditional tool-calling logic, powered by Groq's free LLM tier.

By the end of this project, you'll have:

  • A Discord bot application registered in the Discord Developer Portal that responds to messages in your server.
  • A LangGraph StateGraph agent with nodes, edges, and conditional routing that controls how your bot decides to use tools or respond directly.
  • Custom LangChain tools the agent can invoke on demand for time checks, math calculations, and dice rolls.
  • Secret Mission: Add conversation memory so the bot remembers context per channel using LangGraph's built-in checkpointer.

Are there any prerequisites?

You should have some Python experience and basic familiarity with LangChain concepts. No prior async or LangGraph experience is needed.

Before We Start

Before We Start...

Set Up Your Environment and Discord Bot

Before your bot can chat in Discord, it needs a home. You need a project folder with the right packages installed, and a registered bot application that Discord recognizes.

In this step, you will create a Python virtual environment, install all the packages your agent needs, and register a bot in the Discord Developer Portal so it can join your test server.

In this step, get ready to:
  • Create a Python virtual environment and install project dependencies.
  • Register a Discord bot application and invite it to your server.
  • Set up your project files and environment variables.
Create a Python virtual environment and install dependencies

A virtual environment keeps your project's packages separate from other Python projects on your machine. This avoids version conflicts between different projects.

  • Open your terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir langgraph-discord-bot
cd langgraph-discord-bot
  • Create a virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

You should see (venv) appear at the beginning of your terminal prompt. This means the environment is active.

What is a virtual environment?

A virtual environment is an isolated Python installation. Packages you install inside it stay contained here and won't affect anything else on your system.

When (venv) shows in your prompt, any pip install command targets this isolated environment.

  • Install all the project dependencies by running:
pip install -U discord.py langgraph langchain-groq python-dotenv

What are these packages?

  • discord.py is the Python library for interacting with the Discord API.
  • langgraph provides the StateGraph framework for building AI agents with explicit control flow.
  • langchain-groq connects LangChain to Groq's fast LLM inference API.
  • python-dotenv loads environment variables from a .env file so you can keep secrets out of your code.
  • Verify the packages installed correctly by running this quick import check:
python3 -c "import discord; import langgraph; import langchain_groq; print('All packages installed!')"

You should see All packages installed! printed in your terminal.

Seeing an import error?

  • Make sure (venv) appears in your terminal prompt. If it doesn't, run source venv/bin/activate again.
  • If the pip install command showed errors, scroll up and check for network issues or missing system libraries.

Why aren't my packages importing?

Create a Discord bot application

Every Discord bot needs a registered application in the Discord Developer Portal. This gives your bot an identity, a token to authenticate with, and permissions to join servers.

  • Open your browser and navigate to the Discord Developer Portal:
https://discord.com/developers/applications
  • Click the New Application button in the top right corner.
  • Enter a name for your app (for example, LangGraph Bot) and click Create.
  • Click Bot in the left sidebar.
  • Scroll down to the Privileged Gateway Intents section.
  • Toggle on MESSAGE CONTENT INTENT.
  • Click Save Changes at the bottom of the page.

Why enable MESSAGE CONTENT INTENT?

Discord treats message content as a privileged resource. Without this intent enabled, your bot cannot read the text of messages users send. Since your bot needs to process user messages and pass them to the LangGraph agent, this permission is required.

  • Scroll back up to the Token section on the Bot page.
  • Click Reset Token and confirm when prompted.
  • Copy the generated token and save it somewhere safe (like a password manager). You will need it in the next substep.

Keep your bot token secret

Your bot token is like a password. Anyone with this token can control your bot. Never commit it to version control or share it publicly. If you accidentally leak it, return to this page and click Reset Token immediately.

Now you need to generate an invite URL so the bot can join your test server.

  • Click OAuth2 in the left sidebar, then click URL Generator.
  • Under Scopes, check the boxes for bot and applications.commands.
  • Under Bot Permissions (which appears after selecting bot), check Send Messages.
  • Scroll down and copy the Generated URL.
  • Paste the URL into your browser.
  • Select your test server from the dropdown and click Authorize.

Your bot should now appear in your test server's member list (shown as offline, since the bot code isn't running yet).

Don't see your server in the dropdown?

  • You need the Manage Server permission on the server to add bots. If you don't own the server, ask the owner to grant you this permission or create your own test server.

Why can't I add my bot to a server?

Create project files

With your bot registered, it's time to set up the file structure for your project. You will create the environment file for secrets and empty Python files that you will fill in during later steps.

  • Switch back to your terminal (make sure you are still inside the langgraph-discord-bot folder with (venv) active).
  • Create the project files by running:
touch .env bot.py agent.py tools.py
  • Confirm the files exist by running:
ls

You should see .env, bot.py, agent.py, tools.py, and the venv folder in the output.

  • Open the project in VS Code by running:
code .
  • In VS Code, click on .env in the Explorer sidebar to open it.
  • Paste the following content into .env, replacing the placeholder values with your actual tokens:
DISCORD_TOKEN=your-discord-bot-token-here
GROQ_API_KEY=your-groq-api-key-here

What goes in each variable?

  • DISCORD_TOKEN is the bot token you copied from the Discord Developer Portal's Bot page.
  • GROQ_API_KEY is your Groq API key from GroqCloud (it starts with gsk_).
  • Replace your-discord-bot-token-here with the bot token you copied earlier.
  • Replace your-groq-api-key-here with your Groq API key.
  • Save the file.

Don't have a Groq API key?

  • Go to console.groq.com and sign in with your account.
  • Navigate to the API Keys section and create a new key. No credit card is required.

How do I get a Groq API key?

The other three files (bot.py, agent.py, tools.py) stay empty for now. You will add code to them in the next steps.

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file with both tokens in place.

ⓧ I'd like to double check the full code

Your .env file should look like this (with your actual values instead of the placeholders):

DISCORD_TOKEN=your-discord-bot-token-here
GROQ_API_KEY=your-groq-api-key-here

Your bot.py, agent.py, and tools.py files should all be empty.

Your environment is ready and your bot is registered. Next up, you will build the LangGraph agent that powers your bot's intelligence.

Build the LangGraph Agent with Tool Calling

Your environment is ready and your Discord bot application is waiting for code to bring it to life. Now it is time to build the brain of your bot.

In this step, you will build a LangGraph agent that can decide on its own whether to answer a question directly or reach for a tool. The agent uses a StateGraph to define explicit control flow: messages enter, the LLM decides what to do, and a conditional edge routes the message to a tool node or straight to the end.

In this step, get ready to:
  • Define three custom tools the agent can invoke.
  • Build a StateGraph with nodes, edges, and conditional routing.
  • Test the agent standalone to verify tool calling works.
Define custom tools in tools.py

Tools give your agent the ability to take actions beyond generating text. Each tool is a Python function decorated with @tool from LangChain. The decorator turns the function into an object the LLM can discover and call by name.

The docstring you write on each tool is critical. It tells the LLM when and why to use that tool.

  • Open tools.py in VS Code and add the first tool by copying this code:
from datetime import datetime

from langchain_core.tools import tool


@tool
def get_current_time() -> str:
    """Get the current date and time. Use this when the user asks what time or date it is."""
    now = datetime.now()
    return now.strftime("%Y-%m-%d %H:%M:%S")

What does this code do?

The @tool decorator registers the function so LangChain can expose it to any LLM that supports tool calling.

The docstring is what the LLM reads to decide whether this tool is relevant. When a user asks "what time is it?", the LLM matches that question against tool docstrings and picks this one.

You will not see any output yet. The next two tools complete the file.

  • Add the calculate tool below your first function:

@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression. Use this when the user asks you to do math calculations.
    The expression should be a valid Python math expression like '2 + 2' or '42 * 17'."""
    allowed_chars = set("0123456789+-*/.() ")
    if not all(c in allowed_chars for c in expression):
        return "Error: Invalid characters in expression. Only numbers and basic operators allowed."
    try:
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"

What does this code do?

This tool evaluates math expressions. Before running eval(), it validates that only numbers and basic math operators are present. This prevents arbitrary code execution.

  • Add the roll_dice tool below calculate:

@tool
def roll_dice(sides: int = 6) -> str:
    """Roll a dice with the specified number of sides. Use this when the user wants to roll dice.
    Default is a 6-sided die. For a d20, use sides=20."""
    import random
    result = random.randint(1, sides)
    return f"Rolled a d{sides}: {result}"

What does this code do?

This tool takes an optional sides parameter (defaulting to 6) and returns a random roll. The LLM reads the docstring to know it can pass sides=20 for a d20.

  • Save tools.py.
  • Verify the tools load correctly by running this command in your terminal:
python3 -c "from tools import get_current_time, calculate, roll_dice; print('Tools loaded successfully')"

You should see Tools loaded successfully printed in the terminal.

Seeing an import error?

  • Make sure your virtual environment is activated. You should see the venv name in your terminal prompt.
  • Check that you saved tools.py in the project root folder, not inside a subfolder.
  • Confirm the import line at the top of your file reads from langchain_core.tools import tool exactly.

Still stuck? Help me debug my tools.py import error

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

from datetime import datetime

from langchain_core.tools import tool


@tool
def get_current_time() -> str:
    """Get the current date and time. Use this when the user asks what time or date it is."""
    now = datetime.now()
    return now.strftime("%Y-%m-%d %H:%M:%S")


@tool
def calculate(expression: str) -> str:
    """Evaluate a mathematical expression. Use this when the user asks you to do math calculations.
    The expression should be a valid Python math expression like '2 + 2' or '42 * 17'."""
    allowed_chars = set("0123456789+-*/.() ")
    if not all(c in allowed_chars for c in expression):
        return "Error: Invalid characters in expression. Only numbers and basic operators allowed."
    try:
        result = eval(expression)
        return str(result)
    except Exception as e:
        return f"Error evaluating expression: {e}"


@tool
def roll_dice(sides: int = 6) -> str:
    """Roll a dice with the specified number of sides. Use this when the user wants to roll dice.
    Default is a 6-sided die. For a d20, use sides=20."""
    import random
    result = random.randint(1, sides)
    return f"Rolled a d{sides}: {result}"
Build the StateGraph in agent.py

A StateGraph is the core of LangGraph's architecture. It defines how messages flow through your agent as a graph of nodes (functions that process state) and edges (connections between nodes).

Why LangGraph instead of a plain LLM call?

A plain LLM API call gives you one response. If you want the LLM to use a tool and then respond based on the result, you have to code that loop yourself. LangGraph handles this with explicit graph routing.

You define nodes (the LLM call, the tool execution) and edges (what happens next). A conditional edge inspects the LLM's response and decides: did it ask for a tool? If yes, route to the tool node. If no, end. This makes the control flow visible and extensible.

  • Open agent.py and set up the imports, configuration, and LLM initialization by copying this code:
import os

from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode

from tools import get_current_time, calculate, roll_dice

load_dotenv()

# List of tools the agent can use
tools = [get_current_time, calculate, roll_dice]

# Initialize the Groq LLM
llm = ChatGroq(
    model="llama-3.3-70b-versatile",
    api_key=os.getenv("GROQ_API_KEY"),
)

# Bind tools so the LLM knows they exist
llm_with_tools = llm.bind_tools(tools)

What does this code do?

  • Imports bring in LangGraph's graph primitives (StateGraph, MessagesState, START, END) and the prebuilt ToolNode.
  • ChatGroq initializes the Groq LLM with the llama-3.3-70b-versatile model using your API key from .env.
  • bind_tools() attaches the tool definitions to the LLM so it can generate tool call requests in its responses.

You will not see any output yet. The next chunk defines the graph's node functions and wires everything together.

  • Add the node functions and graph assembly below your existing code in agent.py:

def llm_call(state: MessagesState):
    """Call the LLM and return the response."""
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}


def should_continue(state: MessagesState):
    """Route to tool node if the LLM made tool calls, otherwise end."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END


# Build the graph
agent_builder = StateGraph(MessagesState)

# Add nodes (the functions that process state)
agent_builder.add_node("llm_call", llm_call)
agent_builder.add_node("tools", ToolNode(tools))

# Add edges (the connections between nodes)
agent_builder.add_edge(START, "llm_call")
agent_builder.add_conditional_edges("llm_call", should_continue, ["tools", END])
agent_builder.add_edge("tools", "llm_call")

# Compile the graph into a runnable agent
agent = agent_builder.compile()

What does this code do?

  • llm_call is a node function. It takes the current graph state, passes the messages to the LLM, and returns the response.
  • should_continue is a conditional edge function. It checks if the LLM's response contains tool_calls. If yes, route to the "tools" node. If no, route to END.
  • ToolNode(tools) is a prebuilt node that automatically executes whatever tool the LLM requested and returns the result as a message.
  • The edge from "tools" back to "llm_call" creates the loop. After a tool runs, the LLM sees the tool result and decides what to do next.
  • Save agent.py. You will test it in the next substep.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before running the test.

ⓧ I'd like to double check the full code

import os

from dotenv import load_dotenv
from langchain_groq import ChatGroq
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode

from tools import get_current_time, calculate, roll_dice

load_dotenv()

tools = [get_current_time, calculate, roll_dice]

llm = ChatGroq(
    model="llama-3.3-70b-versatile",
    api_key=os.getenv("GROQ_API_KEY"),
)

llm_with_tools = llm.bind_tools(tools)


def llm_call(state: MessagesState):
    """Call the LLM and return the response."""
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}


def should_continue(state: MessagesState):
    """Route to tool node if the LLM made tool calls, otherwise end."""
    last_message = state["messages"][-1]
    if last_message.tool_calls:
        return "tools"
    return END


agent_builder = StateGraph(MessagesState)

agent_builder.add_node("llm_call", llm_call)
agent_builder.add_node("tools", ToolNode(tools))

agent_builder.add_edge(START, "llm_call")
agent_builder.add_conditional_edges("llm_call", should_continue, ["tools", END])
agent_builder.add_edge("tools", "llm_call")

agent = agent_builder.compile()
Test the agent standalone

Before connecting the agent to Discord, you should confirm it works on its own. A quick test script at the bottom of agent.py lets you invoke the agent from the terminal.

  • Add a test block at the very bottom of agent.py:

if __name__ == "__main__":
    from langchain_core.messages import HumanMessage

    result = agent.invoke({"messages": [HumanMessage(content="What time is it?")]})
    for msg in result["messages"]:
        print(f"{msg.type}: {msg.content}")

What does this code do?

The if __name__ == "__main__" block only runs when you execute this file directly. It sends "What time is it?" to the agent, which should trigger the get_current_time tool.

The loop prints every message in the conversation: the human message, the LLM's tool call, the tool's response, and the LLM's final answer.

  • Save agent.py.
  • Run the test by executing this command:
python3 agent.py

You should see output like this (the exact time will differ):

human: What time is it?
ai: 
tool: 2026-06-21 14:32:07
ai: The current time is 2:32 PM on June 21, 2026.

The key thing to notice: the ai message appears twice. The first time it is empty because the LLM chose to call the tool instead of responding directly. The second time it uses the tool result to give a human-friendly answer.

Seeing an authentication or rate limit error?

  • Check that your .env file contains a valid GROQ_API_KEY value starting with gsk_.
  • Confirm the .env file is in the same directory where you are running the command.
  • If you see a 429 error, you have hit a rate limit. Wait a few seconds and try again.

Need more help? Help me debug my Groq API connection

Your agent is working. It can think, reach for tools, and use tool results to form answers. Next up, you will connect this agent to Discord so real users can chat with it.

Connect the Agent to Discord

Your LangGraph agent works standalone. It can call tools, route decisions through a conditional edge, and return a clean response. Now it needs a front door that real users can talk to.

In this step you will wire the agent into a Discord bot so that messages from your server flow into the StateGraph and responses flow back. The bot listens for a prefix or an @mention, passes the text to the agent, and sends the result to the channel.

In this step, get ready to:
  • Wire the LangGraph agent into discord.py event handlers.
  • Handle async communication between Discord and the agent.
  • Test tool calls live in Discord.
Write the Discord bot setup in bot.py

The bot needs to connect to Discord's gateway, declare which events it cares about, and log in. This first section handles all of that wiring.

  • Open bot.py in VS Code and add the bot setup by pasting the following code:
import os

import discord
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage

from agent import agent

# Load environment variables from .env
load_dotenv()

# Subscribe to default events + message content (privileged intent)
intents = discord.Intents.default()
intents.message_content = True

# Create the Discord client with our intents
client = discord.Client(intents=intents)

# The prefix that triggers the bot
BOT_PREFIX = "!ask "


@client.event
async def on_ready():
    print(f"Logged in as {client.user}")

What does this code do?

This is the skeleton that gets the bot online.

  • discord.Intents.default() gives the bot standard gateway permissions. Setting message_content = True enables the privileged intent you switched on in the Developer Portal.
  • discord.Client is the main connection between your code and Discord's servers.
  • BOT_PREFIX means the bot only responds to messages that start with !ask (or when it is @mentioned).
  • on_ready fires once the bot successfully connects and prints a confirmation to your terminal.
Add the message handler

The on_message event fires every time anyone sends a message in the server, including the bot itself. The handler needs to filter out irrelevant messages, extract the user's question, invoke the agent, and send the answer back.

  • Add the on_message handler below your on_ready function by pasting this code:
@client.event
async def on_message(message):
    # Ignore messages sent by the bot itself
    if message.author == client.user:
        return

    # Only respond to messages with the prefix or @mention
    if not (message.content.startswith(BOT_PREFIX) or client.user.mentioned_in(message)):
        return

    # Extract the user's actual question
    if message.content.startswith(BOT_PREFIX):
        user_input = message.content[len(BOT_PREFIX):]
    else:
        user_input = message.content.replace(f"<@{client.user.id}>", "").strip()

    if not user_input:
        return

    # Show typing indicator while the agent processes
    async with message.channel.typing():
        try:
            # Invoke the agent asynchronously
            result = await agent.ainvoke(
                {"messages": [HumanMessage(content=user_input)]}
            )
            response = result["messages"][-1].content

            # Discord limits messages to 2000 characters
            if len(response) > 2000:
                for i in range(0, len(response), 2000):
                    await message.channel.send(response[i:i+2000])
            else:
                await message.channel.send(response)

        except Exception as e:
            if "429" in str(e) or "rate" in str(e).lower():
                await message.channel.send(
                    "I'm being rate limited by Groq. Please try again in a moment."
                )
            else:
                await message.channel.send(f"Something went wrong: {type(e).__name__}")
                print(f"Error: {e}")


client.run(os.getenv("DISCORD_TOKEN"))

What does this code do?

This is the core message loop that bridges Discord into your LangGraph agent.

  • The first guard clause prevents the bot from responding to its own messages (which would create an infinite loop).
  • The second guard ensures the bot only activates when a user types !ask or @mentions the bot.
  • message.content[len(BOT_PREFIX):] strips the prefix to get the raw question.
  • message.channel.typing() shows a "Bot is typing..." indicator in Discord while the agent thinks.
  • agent.ainvoke is the async version of invoke. It lets the agent run inside discord.py's event loop without blocking.
  • The 2000-character check splits long responses into multiple messages to stay within Discord's limit.
  • The except block catches Groq rate limit errors (HTTP 429) and sends a friendly retry message instead of crashing.
  • Save bot.py.

Seeing a syntax error when you save?

  • Make sure there are no extra blank lines between the on_ready function and the @client.event decorator for on_message. Two blank lines is correct.
  • Check that client.run(os.getenv("DISCORD_TOKEN")) is at the very bottom of the file, outside of any function.
  • Confirm your f-strings have matching curly braces and quotes.

Still stuck? Help me debug a syntax error in my bot.py file.

✔️ Awesome, I've got everything!

Great. Double check you have saved bot.py before moving on.

ⓧ I'd like to double check the full code

import os

import discord
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage

from agent import agent

load_dotenv()

intents = discord.Intents.default()
intents.message_content = True

client = discord.Client(intents=intents)

BOT_PREFIX = "!ask "


@client.event
async def on_ready():
    print(f"Logged in as {client.user}")


@client.event
async def on_message(message):
    if message.author == client.user:
        return

    if not (message.content.startswith(BOT_PREFIX) or client.user.mentioned_in(message)):
        return

    if message.content.startswith(BOT_PREFIX):
        user_input = message.content[len(BOT_PREFIX):]
    else:
        user_input = message.content.replace(f"<@{client.user.id}>", "").strip()

    if not user_input:
        return

    async with message.channel.typing():
        try:
            result = await agent.ainvoke(
                {"messages": [HumanMessage(content=user_input)]}
            )
            response = result["messages"][-1].content

            if len(response) > 2000:
                for i in range(0, len(response), 2000):
                    await message.channel.send(response[i:i+2000])
            else:
                await message.channel.send(response)

        except Exception as e:
            if "429" in str(e) or "rate" in str(e).lower():
                await message.channel.send(
                    "I'm being rate limited by Groq. Please try again in a moment."
                )
            else:
                await message.channel.send(f"Something went wrong: {type(e).__name__}")
                print(f"Error: {e}")


client.run(os.getenv("DISCORD_TOKEN"))
Understand the async integration

Discord.py runs an asyncio event loop under the hood. Every event handler is a coroutine. If you call a blocking function inside one of those coroutines, the entire bot freezes until it returns.

Why ainvoke instead of invoke?

LangGraph compiles your graph with both a synchronous invoke method and an asynchronous ainvoke method. Because discord.py's event loop is async, calling the synchronous version would block the entire bot while the LLM responds.

Using await agent.ainvoke(...) lets discord.py continue handling other events (like new messages or heartbeats to Discord's gateway) while the agent waits for Groq's response.

What does the typing indicator do?

The async with message.channel.typing() context manager sends a "Bot is typing..." indicator to the Discord channel. It automatically stops when the block exits. This gives users visual feedback that the bot received their message and is working on a response.

Run and test the bot
  • Start the bot by running this command in your terminal:
python3 bot.py

You should see a message like Logged in as YourBotName#1234 in your terminal. This confirms the bot connected to Discord successfully.

Bot not starting?

  • If you see an "improper token" error, double check your DISCORD_TOKEN in .env. Reset the token in the Developer Portal if needed.
  • If you see a ModuleNotFoundError, confirm your virtual environment is activated.
  • If the bot logs in but immediately disconnects, make sure the MESSAGE CONTENT intent is enabled in the Developer Portal under the **Bot** tab.

Still stuck? Help me debug why my Discord bot won't start.

  • Switch to your Discord test server and send a message in any text channel:
!ask what time is it

The bot should show a typing indicator, then respond with the current date and time from the get_current_time tool.

  • Test the math tool by sending:
!ask what's 42 * 17
  • Test the dice tool by sending:
!ask roll a d20

Each message should trigger a typing indicator followed by a response. The agent routes through the StateGraph, decides whether to call a tool, executes it, and returns the result to your channel.

Bot is online but not responding?

  • Make sure you include the space after !ask. The prefix is !ask (with a trailing space).
  • Verify the bot has **Send Messages** permission in the channel. Check the channel's permission settings if responses are missing.
  • Check your terminal for error output. If you see a Groq-related error, confirm your GROQ_API_KEY in .env is correct.

Still stuck? Help me figure out why my Discord bot isn't responding to messages.

Your agent is live. Users in your server can chat with it, ask it to roll dice, do math, or check the time. Next up, you will add error handling and polish so the bot behaves gracefully under real-world conditions.

Add Error Handling and Polish

Your bot is live and responding to messages. But what happens when things go wrong?

Free-tier APIs have rate limits, and Discord enforces a 2000-character cap on messages. In this step, you will walk through how your bot handles these real-world constraints, then verify everything works end-to-end.

In this step, get ready to:
  • Review the rate limit error handling built into your bot.
  • Review the response formatting and prefix trigger logic.
  • Test the complete bot with all edge cases.
Handle Groq rate limits

Groq's free tier gives you access to all models at no cost. The trade-off is that you have a limited number of requests per minute. When you exceed that limit, the API returns an HTTP 429 error.

  • In bot.py, find the try/except block inside your on_message handler:
        try:
            result = await agent.ainvoke(
                {"messages": [HumanMessage(content=user_input)]}
            )
            response = result["messages"][-1].content

            if len(response) > 2000:
                for i in range(0, len(response), 2000):
                    await message.channel.send(response[i:i+2000])
            else:
                await message.channel.send(response)

        except Exception as e:
            if "429" in str(e) or "rate" in str(e).lower():
                await message.channel.send(
                    "I'm being rate limited by Groq. Please try again in a moment."
                )
            else:
                await message.channel.send(f"Something went wrong: {type(e).__name__}")
                print(f"Error: {e}")

What does this error handling do?

The except Exception as e block catches any error that occurs during the agent invocation. It then checks two conditions.

  • If the error string contains "429" or the word "rate", the bot sends a friendly rate-limit message. This covers the HTTP 429 status code that Groq returns when you exceed your free-tier request limits.
  • For any other error, the bot sends the exception type name to the channel and prints the full error to your terminal for debugging.

Why catch rate limits specifically?

Rate limit errors are temporary. They resolve on their own after a short wait. Telling the user to try again in a moment is more helpful than a generic error message.

Other exceptions might indicate a real bug in your code or a service outage. Printing those to the console helps you debug without exposing internal details to Discord users.

Review response formatting and prefix trigger

Discord enforces a 2000-character limit on messages. Your bot also needs a way to avoid responding to every single message in a channel.

  • In bot.py, find the prefix and mention check near the top of on_message:
BOT_PREFIX = "!ask "


@client.event
async def on_message(message):
    if message.author == client.user:
        return

    if not (message.content.startswith(BOT_PREFIX) or client.user.mentioned_in(message)):
        return

    if message.content.startswith(BOT_PREFIX):
        user_input = message.content[len(BOT_PREFIX):]
    else:
        user_input = message.content.replace(f"<@{client.user.id}>", "").strip()

What does this filtering logic do?

  • The first guard (message.author == client.user) prevents the bot from responding to its own messages. Without this, the bot could trigger an infinite loop.
  • The second guard checks if the message starts with !ask or mentions the bot directly. If neither condition is met, the bot ignores the message completely.
  • The if/else block strips the prefix or mention text so only the user's actual question reaches the LLM.
  • Now find the message-splitting logic inside the try block:
            if len(response) > 2000:
                for i in range(0, len(response), 2000):
                    await message.channel.send(response[i:i+2000])
            else:
                await message.channel.send(response)

Why split at 2000 characters?

Discord rejects any message longer than 2000 characters. If the LLM produces a long response, this code slices it into 2000-character chunks and sends each one as a separate message.

Test the complete bot

Time to verify every feature works. You will test three scenarios in your Discord server.

  • Switch to your Discord server where the bot is running.
  • Type a message without the prefix or mention (for example, hello). The bot should not respond.
  • Now type !ask roll a d20 and send the message.

You should see the bot show a typing indicator, then reply with a dice roll result.

  • Test another tool by typing !ask what's 42 * 17 and sending the message.

The bot should respond with the calculation result (714).

  • Test the time tool by typing !ask what time is it and sending the message.
  • Test the @mention trigger by typing @ followed by your bot's name, then a question like hey, who are you?.

The bot should respond to the mention just like it responds to the prefix.

What about testing rate limits?

Groq's free tier allows a limited number of requests per minute. You can trigger a rate limit by sending many messages in quick succession. If you hit the limit, the bot should respond with the friendly rate-limit message instead of crashing.

If you do not want to wait for the limit to reset, you can verify the error handling works by checking that the bot stays online and responds normally after a brief pause.

Bot not responding to messages?

  • Make sure you are using the !ask prefix (with a space after it) or a proper @mention. Plain messages are intentionally ignored.
  • Check your terminal for error output. If you see a 401 error, your GROQ_API_KEY in .env may be incorrect.
  • Verify that the MESSAGE CONTENT privileged intent is enabled in the Discord Developer Portal under your bot's settings.

Help me figure out why my Discord bot is not responding to messages

✔️ Awesome, I've got everything!

Great. Double check you have saved bot.py and confirmed all three test scenarios pass.

ⓧ I'd like to double check the full code

Here is the complete bot.py file for reference:

import os

import discord
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage

from agent import agent

load_dotenv()

intents = discord.Intents.default()
intents.message_content = True

client = discord.Client(intents=intents)

BOT_PREFIX = "!ask "


@client.event
async def on_ready():
    print(f"Logged in as {client.user}")


@client.event
async def on_message(message):
    if message.author == client.user:
        return

    if not (message.content.startswith(BOT_PREFIX) or client.user.mentioned_in(message)):
        return

    if message.content.startswith(BOT_PREFIX):
        user_input = message.content[len(BOT_PREFIX):]
    else:
        user_input = message.content.replace(f"<@{client.user.id}>", "").strip()

    if not user_input:
        return

    async with message.channel.typing():
        try:
            result = await agent.ainvoke(
                {"messages": [HumanMessage(content=user_input)]}
            )
            response = result["messages"][-1].content

            if len(response) > 2000:
                for i in range(0, len(response), 2000):
                    await message.channel.send(response[i:i+2000])
            else:
                await message.channel.send(response)

        except Exception as e:
            if "429" in str(e) or "rate" in str(e).lower():
                await message.channel.send(
                    "I'm being rate limited by Groq. Please try again in a moment."
                )
            else:
                await message.channel.send(f"Something went wrong: {type(e).__name__}")
                print(f"Error: {e}")


client.run(os.getenv("DISCORD_TOKEN"))

Your bot is production-ready. It handles errors gracefully, respects Discord's message limits, and only responds when addressed. Next up, the Secret Mission adds conversation memory so your bot can remember what was said earlier in the same channel.

Secret mission

Add Conversation Memory with LangGraph Checkpointer

Your bot currently treats every message as a brand new conversation. It has no memory of what was said 10 seconds ago. In this secret mission, you'll add per-channel conversation memory using LangGraph's built-in MemorySaver checkpointer, so the bot remembers context across messages within the same Discord channel.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses Groq's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder with bot.py, agent.py, tools.py, and .env.
  • Python virtual environment (venv folder inside your project directory).
  • Discord bot application in the Discord Developer Portal.
  • Groq API key in the GroqCloud console.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your bot.

  • Your bot only runs when you manually start it with python3 bot.py, so it won't consume resources while idle.
  • Your Groq free tier key remains active but incurs no charges.
  • Your Discord application stays registered in the Developer Portal.

Pause - I'll come back to this later

Shut down the bot process and deactivate your virtual environment to free up terminal sessions. Your files and accounts stay intact for when you return.

  • Stop the bot by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your virtual environment by running:
deactivate
  • Your project files, Discord application, and Groq API key all remain available for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Stop the bot by pressing Ctrl+C if it is still running.
  • Deactivate your virtual environment and delete the project folder by running:
deactivate
cd ~/Desktop
rm -rf discord-langgraph-bot
  • Delete your Discord bot application:
  • Go to the Discord Developer Portal at https://discord.com/developers/applications.
  • Select your bot application from the list.
  • Click General Information in the left sidebar.
  • Scroll to the bottom and click Delete Application.
  • Confirm the deletion when prompted.
  • Revoke your Groq API key:

Nice Work!

Nice Work!

Nice work! You just built a fully functional Discord bot powered by a LangGraph agent that can chat naturally and use tools on demand.

You've learned how to:

  • Build a Discord bot that routes user messages through a LangGraph agent with tool-calling capabilities.
  • Construct a StateGraph with nodes, conditional edges, and a looping tool-execution pattern that gives you explicit control over agent behaviour.
  • Integrate Groq's free LLM tier with custom LangChain tools and handle async bridging, rate limits, and Discord message constraints.
  • Secret Mission: Added per-channel conversation memory using LangGraph's MemorySaver checkpointer with thread-based persistence.

Ready to quiz yourself?