Skip to content

Build a Discord bot

This tutorial builds a Discord bot with Rig and the Serenity Discord library. The bot answers questions from a Markdown knowledge base using Retrieval-Augmented Generation (RAG): on every question, Rig embeds the query, pulls the closest documents from a vector store, and hands them to the model together with the question.

You’ll build it in two parts:

  1. The Rig agent (rig_agent.rs): loads the Markdown files, embeds them, and builds a RAG Agent. This part has no Discord code, so you can test it on its own.
  2. The Discord layer (main.rs): registers an /ask slash command, listens for mentions, and forwards both to the agent.

If RAG in Rig is new to you, skim Vector Stores & RAG and the Build a RAG system guide first.

sequenceDiagram
participant User
participant DiscordBot
participant RigAgent
User->>DiscordBot: /ask or @mention
DiscordBot->>RigAgent: question
RigAgent->>RigAgent: retrieve docs + call model
RigAgent->>DiscordBot: answer
DiscordBot->>User: reply
Terminal window
cargo new discord_rig_bot
cd discord_rig_bot
mkdir documents

Cargo.toml:

[dependencies]
rig = "0.44.0"
serenity = "0.12"
tokio = { version = "1", features = ["full"] }
anyhow = "1"

Put your knowledge base in documents/ as Markdown files. Any .md file in that directory is loaded, so start with a few pages of your project’s docs, an FAQ, or a set of examples.

Set the two secrets in your environment:

Terminal window
export DISCORD_TOKEN=your_discord_bot_token
export OPENAI_API_KEY=your_openai_api_key

build_agent reads every Markdown file in a directory, embeds each one with OpenAI’s text-embedding-3-small, stores the embeddings in an in-memory vector store, and builds an agent that pulls the two closest documents into context on every prompt.

src/rig_agent.rs
use std::path::Path;
use anyhow::{Context, Result};
use rig::embeddings::EmbeddingsBuilder;
use rig::prelude::*;
use rig::providers::openai::{self, OpenAI};
use rig::vector_store::in_memory_store::InMemoryVectorStore;
const PREAMBLE: &str = "\
You are a helpful assistant in a Discord server. Answer questions using the documents \
provided in your context, and say so when they do not cover the question. Keep answers \
short: Discord messages are limited to 2000 characters. Format code as Markdown code \
blocks tagged with their language.";
pub async fn build_agent(documents_dir: &Path) -> Result<Agent> {
let client = OpenAI::from_env()?;
let embedding_model = client.embedding(openai::TEXT_EMBEDDING_3_SMALL, None);
let documents = load_markdown(documents_dir)?;
let embeddings = EmbeddingsBuilder::new(embedding_model.clone())
.documents(documents)?
.build()
.await?;
let index = InMemoryVectorStore::from_documents(embeddings).index(embedding_model);
Ok(AgentBuilder::new(client.completion(openai::GPT_5_5))
.preamble(PREAMBLE)
.dynamic_context(2, index)
.build())
}
fn load_markdown(dir: &Path) -> Result<Vec<String>> {
let mut documents = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "md") {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
documents.push(text);
}
}
Ok(documents)
}
  • client.embedding(model, None) builds the embedding model; None keeps the model’s default vector size.
  • EmbeddingsBuilder embeds each document. A String embeds as itself; for structured documents, derive Embed and mark the fields to embed (see Embeddings).
  • dynamic_context(2, index) makes retrieval part of the agent: before each model call it searches index with the prompt and adds the top 2 documents to the request.

Agent is cheap to clone and safe to share across tasks, which the Discord handler relies on.

Before adding Discord, check that retrieval and the model work:

let agent = rig_agent::build_agent(std::path::Path::new("documents")).await?;
let answer = agent.prompt("What is this project about?").await?.output();
println!("{answer}");

The handler owns the agent. On ready it registers the /ask command; on an /ask interaction it defers the reply (model calls can outlast Discord’s three-second acknowledgement window), prompts the agent, and edits in the answer; on a message that mentions the bot it strips the mention and replies in the channel.

src/main.rs
mod rig_agent;
use anyhow::Result;
use rig::prelude::*;
use serenity::all::{
async_trait, Command, CommandInteraction, CommandOptionType, Context, CreateCommand,
CreateCommandOption, EditInteractionResponse, EventHandler, GatewayIntents, Interaction,
Message, Ready,
};
/// Discord rejects messages longer than 2000 characters.
fn truncate(text: String) -> String {
match text.char_indices().nth(1990) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text,
}
}
struct Handler {
agent: Agent,
}
impl Handler {
async fn answer(&self, question: &str) -> String {
match self.agent.prompt(question).await {
Ok(response) => truncate(response.output()),
Err(e) => {
eprintln!("agent error: {e}");
"Sorry, something went wrong while answering.".to_string()
}
}
}
async fn ask(&self, ctx: &Context, command: &CommandInteraction) -> serenity::Result<()> {
command.defer(&ctx.http).await?;
let question = command
.data
.options
.first()
.and_then(|option| option.value.as_str())
.unwrap_or_default();
let answer = self.answer(question).await;
command
.edit_response(&ctx.http, EditInteractionResponse::new().content(answer))
.await?;
Ok(())
}
}
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
println!("{} is connected", ready.user.name);
let ask = CreateCommand::new("ask")
.description("Ask the bot a question")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "query", "Your question")
.required(true),
);
if let Err(e) = Command::create_global_command(&ctx.http, ask).await {
eprintln!("failed to register /ask: {e}");
}
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction {
if command.data.name == "ask" {
if let Err(e) = self.ask(&ctx, &command).await {
eprintln!("failed to answer /ask: {e}");
}
}
}
}
async fn message(&self, ctx: Context, msg: Message) {
if msg.author.bot || !msg.mentions_me(&ctx).await.unwrap_or(false) {
return;
}
let mention = format!("<@{}>", ctx.cache.current_user().id);
let question = msg.content.replace(&mention, "");
let _ = msg.channel_id.broadcast_typing(&ctx.http).await;
let answer = self.answer(question.trim()).await;
if let Err(e) = msg.channel_id.say(&ctx.http, answer).await {
eprintln!("failed to reply: {e}");
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let token = std::env::var("DISCORD_TOKEN")?;
let agent = rig_agent::build_agent(std::path::Path::new("documents")).await?;
let intents = GatewayIntents::GUILDS
| GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT;
let mut client = serenity::Client::builder(&token, intents)
.event_handler(Handler { agent })
.await?;
client.start().await?;
Ok(())
}
Terminal window
cargo run

Once the log prints <bot name> is connected, invite the bot to a server:

  1. In the Discord Developer Portal, open your application and go to OAuth2 → URL Generator.
  2. Under Scopes, select bot and applications.commands.
  3. Under Bot Permissions, select Send Messages and Read Message History.
  4. Open the generated URL and pick your server.

Then try it:

  • /ask query: How do I get started?
  • @YourBot what does the FAQ say about pricing?

Global slash commands can take a few minutes to show up in the Discord client the first time.

Bot responding to /ask command Bot responding to a question

The bot above answers each question on its own. To let it follow a conversation, keep a history per channel or thread and use agent.chat, which sends the history along with the prompt and appends the new user and assistant messages to it:

let mut histories: HashMap<u64, Vec<Message>> = HashMap::new();
let history = histories.entry(channel_id).or_default();
let answer = agent.chat("And how do I configure it?", history).await?.output();

In the handler, store the map behind a tokio::sync::RwLock (or Mutex) so concurrent events can read and update it. Rig’s discord_bot example does exactly this: a /new command opens a thread, and every message in that thread is answered with the thread’s history.

  • The bot never answers mentions: the Message Content intent is off. Enable it in the Developer Portal and keep GatewayIntents::MESSAGE_CONTENT in main.
  • /ask says “The application did not respond”: the reply was not deferred before the model call; keep command.defer as the first step.
  • reading documents error: run the bot from the project root, or pass an absolute path to build_agent.