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:
- The Rig agent (
rig_agent.rs): loads the Markdown files, embeds them, and builds a RAGAgent. This part has no Discord code, so you can test it on its own. - The Discord layer (
main.rs): registers an/askslash 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.
sequenceDiagramparticipant Userparticipant DiscordBotparticipant RigAgentUser->>DiscordBot: /ask or @mentionDiscordBot->>RigAgent: questionRigAgent->>RigAgent: retrieve docs + call modelRigAgent->>DiscordBot: answerDiscordBot->>User: replyPrerequisites
Section titled “Prerequisites”- Rust
- A Discord bot application and its token. Follow Discord’s getting started guide, and enable the Message Content intent on the bot’s settings page.
- An OpenAI API key
Project setup
Section titled “Project setup”cargo new discord_rig_botcd discord_rig_botmkdir documentsCargo.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:
export DISCORD_TOKEN=your_discord_bot_tokenexport OPENAI_API_KEY=your_openai_api_keyThe Rig agent (rig_agent.rs)
Section titled “The Rig agent (rig_agent.rs)”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.
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;Nonekeeps the model’s default vector size.EmbeddingsBuilderembeds each document. AStringembeds as itself; for structured documents, deriveEmbedand mark the fields to embed (see Embeddings).dynamic_context(2, index)makes retrieval part of the agent: before each model call it searchesindexwith 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.
Try the agent without Discord
Section titled “Try the agent without Discord”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 Discord layer (main.rs)
Section titled “The Discord layer (main.rs)”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.
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(())}Run and test
Section titled “Run and test”cargo runOnce the log prints <bot name> is connected, invite the bot to a server:
- In the Discord Developer Portal, open your application and go to OAuth2 → URL Generator.
- Under Scopes, select
botandapplications.commands. - Under Bot Permissions, select
Send MessagesandRead Message History. - 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.
Remembering the conversation
Section titled “Remembering the conversation”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.
Troubleshooting
Section titled “Troubleshooting”- The bot never answers mentions: the Message Content intent is off. Enable it in the
Developer Portal and keep
GatewayIntents::MESSAGE_CONTENTinmain. /asksays “The application did not respond”: the reply was not deferred before the model call; keepcommand.deferas the first step.reading documentserror: run the bot from the project root, or pass an absolute path tobuild_agent.
See also
Section titled “See also”- Vector Stores & RAG — the retrieval this bot relies on.
- Build a RAG system — the standalone RAG pipeline.
- Tools — give the bot actions, like the flight assistant does.
- API Reference (docs.rs)
