How to build a Discord AI bot

A Discord AI bot is a discord.js bot that takes a command, sends the text to a language model API and posts the reply in the channel. The API key lives in an environment variable.

Updated 6 min read

Learning objectives

After reading this article you will be able to:

  • Build an /ask command in discord.js that calls an LLM API
  • Avoid the usual failures: expired interactions, messages over 2000 characters and runaway cost
  • Run the bot with its keys stored as environment variables
On this page

A Discord AI bot is a regular bot with one extra call: instead of replying with fixed text, it sends the user's question to a large language model (LLM) through an API and posts what comes back. The user types /ask, Discord delivers the interaction to the bot over the Gateway, the bot calls the model API and edits its reply with the result.

The bot runs no model itself: it holds the secrets, applies limits and formats the output, so it stays light. In exchange, it faces problems a regular bot does not have: the answer is slow, it can exceed the allowed length, and every call costs money.

The examples use discord.js v14 and the @anthropic-ai/sdk package (Anthropic's API, with a small, low-cost model from the Haiku line). The design works with any provider: only the API call changes. If you do not have a bot running yet, start with how to host a discord.js bot. For background on the model itself, see what an LLM is.

UserGatewayBotLLM API/ask1interaction2deferReply (within 3 s)3history + question4generated text5editReply with the answer6
The bot holds the interaction with deferReply, calls the LLM with the history and then edits the reply with the text.

What do you need before you start?#

  • The current Active LTS of Node.js and an app in the Discord Developer Portal with the bot token. See how to store the token.
  • An API key from an LLM provider. Here we use one from the Claude Console, read from the ANTHROPIC_API_KEY variable, the name the SDK looks for on its own.
  • A test server where you can invite the bot with the bot and applications.commands scopes.

How do you set up the project?#

bash
mkdir ai-bot && cd ai-bot
npm init -y
npm pkg set type=module
npm install discord.js@14 @anthropic-ai/sdk

Create a .env file (and add .env to .gitignore):

bash
DISCORD_TOKEN=paste_the_bot_token
CLIENT_ID=your_application_id
GUILD_ID=your_test_server_id
ANTHROPIC_API_KEY=paste_the_api_key
ANTHROPIC_MODEL=id_of_a_small_model

Node.js loads this file with the --env-file flag, no extra package needed. For ANTHROPIC_MODEL, use the ID of a small, fast, low-cost model (from the Haiku line, for example), copied from Anthropic's model list. The model lives outside the code because Anthropic retires models over time: switching models becomes a matter of changing the variable.

How do you write the bot?#

Create index.js. The bot uses only the Guilds intent, keeps the last 10 messages of each channel and enforces a 10-second cooldown per user:

js
import { Client, Events, GatewayIntentBits, MessageFlags } from "discord.js";
import Anthropic from "@anthropic-ai/sdk";

const MODEL = process.env.ANTHROPIC_MODEL;
if (!MODEL) throw new Error("Set ANTHROPIC_MODEL to an ID from Anthropic's model list");
const SYSTEM =
  "You are an assistant in a Discord server. Answer in English, " +
  "directly, and in at most 1500 characters.";

const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY
const client = new Client({ intents: [GatewayIntentBits.Guilds] });

const history = new Map(); // channelId -> [{ role, content }]
const lastUse = new Map(); // userId -> timestamp

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

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

  const wait = 10_000 - (Date.now() - (lastUse.get(interaction.user.id) ?? 0));
  if (wait > 0) {
    await interaction.reply({
      content: `Wait ${Math.ceil(wait / 1000)}s before asking again.`,
      flags: MessageFlags.Ephemeral,
    });
    return;
  }
  lastUse.set(interaction.user.id, Date.now());

  await interaction.deferReply(); // Discord requires a response within 3 s

  const question = `${interaction.user.displayName}: ${interaction.options.getString("question", true)}`;
  const past = history.get(interaction.channelId) ?? [];

  try {
    const response = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 2000,
      output_config: { effort: "low" }, // little thinking: short, cheap answers
      system: SYSTEM,
      messages: [...past, { role: "user", content: question }],
    });

    const text = response.content
      .filter((block) => block.type === "text")
      .map((block) => block.text)
      .join("")
      .trim();

    const answer = text || "I could not generate an answer.";
    history.set(
      interaction.channelId,
      [...past, { role: "user", content: question }, { role: "assistant", content: answer }].slice(-10),
    );

    await interaction.editReply(answer.slice(0, 2000));
  } catch (error) {
    console.error(error);
    await interaction.editReply("The AI did not answer right now. Try again in a moment.");
  }
});

client.login(process.env.DISCORD_TOKEN);

Four details matter:

  • deferReply() comes before the API call. According to the Discord documentation, the first response must go out within 3 seconds or the interaction token is invalidated. After the defer, the token stays valid for 15 minutes for editReply.
  • slice(-10) always keeps question and answer pairs, so the history starts with a user message, as the API requires.
  • effort: "low" and max_tokens: 2000 work together. If the model thinks before answering, that reasoning counts toward max_tokens. Low effort keeps the thinking short, and the cap leaves room for it and for the answer. If the text still comes back empty, the bot sends the fallback message. If the model you picked does not accept the effort parameter, the API returns a 400 error: remove that line.
  • slice(0, 2000) respects the limit of 2000 characters on the content of a Discord message.

How do you register the command?#

Starting the bot does not create the command. Run a registration script once, and again whenever the definition changes (deploy-commands.js):

js
import { REST, Routes, SlashCommandBuilder } from "discord.js";

const ask = new SlashCommandBuilder()
  .setName("ask")
  .setDescription("Ask the AI a question")
  .addStringOption((o) =>
    o.setName("question").setDescription("Your question").setRequired(true).setMaxLength(1000),
  );

const rest = new REST().setToken(process.env.DISCORD_TOKEN);
await rest.put(Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID), {
  body: [ask.toJSON()],
});
console.log("Command registered.");

setMaxLength(1000) caps the input text, which also caps the cost of each call. To make the command work in every server, switch the route to Routes.applicationCommands(process.env.CLIENT_ID).

How do you test it?#

bash
node --env-file=.env deploy-commands.js
node --env-file=.env index.js

The terminal should print Online as BotName. In your server, run /ask question: what is a container?. You will see "the bot is thinking" and then the answer. Ask a follow-up that depends on the first question ("and a VM?") to confirm the channel memory works.

What errors show up, and how do you limit cost and abuse?#

  • Unknown interaction (code 10062): the first response took more than 3 seconds. Make sure deferReply() is the first async call in the handler.
  • API 401 error (authentication_error): ANTHROPIC_API_KEY never reached the process. Your local .env does not exist on the host, so set the variable there.
  • API 404 error (not_found_error) naming the model: the ID in ANTHROPIC_MODEL is wrong or the model was retired. Pick another one from the model list and restart the bot, with no code change.
  • Invalid Form Body with content over 2000: you sent long text without cutting it. Keep the slice and ask for short answers in the system prompt.
  • Surprise bill: cost comes from input and output tokens. Small models, such as the Haiku line, are the cheapest in Anthropic's model table, but per-million prices change: check the current rate before you launch. Use the cooldown, max_tokens and the input limit, and see how much an LLM API costs.
  • Users manipulating the bot: any text that reaches the model can try to change its instructions. Never give the bot permissions you would not give the user. Read what prompt injection is.
  • Leaked key: if the key ended up in a repository, revoke it and generate a new one in the provider's console.

Where should you host the bot?#

An AI bot must stay on around the clock and keep two secrets safe. On your home computer it goes down when you turn the machine off; on a VPS you handle the supervisor, updates and the firewall (see how to keep a Discord bot online 24/7). RAM stays small, because the model runs at the provider: see how much RAM a Discord bot needs. For an assistant that serves a website and not only Discord, see also how to host an AI chatbot.

How to do this on Square Cloud#

The bot you just wrote depends on two things: a process that stays on and the calls to the model. On Square Cloud you can handle both in one place.

The process runs in an isolated, always-on container. With automatic restart (on by default for dashboard deploys), it comes back by itself if it exits with an error; if it runs out of memory, it stays stopped until you raise its RAM and start it again.

  1. Upload the project (index.js, package.json) as a ZIP in the dashboard, or use the CLI, GitHub or the VS Code extension. The squarecloud.app config file is optional: the dashboard generates it with the runtime, memory and main file, or you write it at the project root.
  2. Pick Node.js and index.js as the main file. A bot uses at least 256 MB from the plan's RAM pool.
  3. Set DISCORD_TOKEN, CLIENT_ID, ANTHROPIC_API_KEY and ANTHROPIC_MODEL in the application's environment variables. The change applies on the next restart, with no rebuild.
  4. Follow the real-time logs and confirm the Online as ... line.

From the Standard plan (R$44.99 per month), the model call can also go through the AI Gateway, an OpenAI-compatible API for the cubic model, the same one behind the dashboard assistant. It is in beta, with early access at no extra cost. Install the openai package, replace the anthropic.messages.create part and delete the two MODEL lines, which are no longer used:

js
import OpenAI from "openai";

const ai = new OpenAI({
  baseURL: "https://api.squarecloud.app/v2/ai",
  apiKey: process.env.SQUARECLOUD_API_KEY, // account key with the ai:chat scope
});

const completion = await ai.chat.completions.create({
  model: "cubic",
  messages: [{ role: "system", content: SYSTEM }, ...past, { role: "user", content: question }],
});
const text = completion.choices[0].message.content?.trim() ?? "";

The per-channel history stays in the bot, because the API keeps no state. Check the Standard limits before you switch: up to 2.5 million tokens and up to 1,000 requests per day, one request at a time. A question that arrives while another is in flight gets a 429 error; the OpenAI SDK retries a few times, and the bot's catch covers the rest. Limits and plans may change when the beta ends. Hobby, starting at R$12.49 per month, hosts the bot but does not include the gateway. See how to host the bot on Discord bots.

AI

Host AI apps in always-on containers

Learn more

FAQs

Do I need the MessageContent intent for an AI bot?

Not if the bot answers slash commands. The Guilds intent is enough. MessageContent is privileged and is only needed to read the text of regular messages.

Why does my AI reply fail with an unknown interaction error?

Discord requires a first response within 3 seconds, and an LLM usually takes longer. Call deferReply() right away and use editReply() when the answer arrives.

How does the bot remember what was said earlier?

The model API keeps no state: you send the whole history on every call. Store the last few messages of each channel and send them along with the new question.

How much does it cost to run an AI bot on Discord?

Two costs: hosting the process and the API tokens, which grow with usage. Cap the reply length and add a per-user cooldown.

Can the AI reply go over 2000 characters?

Discord accepts up to 2000 characters per message. Ask for short answers in the system prompt and cut the text in code as a safety net, or split it into several messages.

Next articleHow to host an AI chatbotAn AI chatbot is an API that takes messages, calls a language model and streams the reply back. Here is the code and how to put it online.