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
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.
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_KEYvariable, the name the SDK looks for on its own. - A test server where you can invite the bot with the
botandapplications.commandsscopes.
How do you set up the project?#
mkdir ai-bot && cd ai-bot
npm init -y
npm pkg set type=module
npm install discord.js@14 @anthropic-ai/sdkCreate a .env file (and add .env to .gitignore):
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_modelNode.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:
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 foreditReply.slice(-10)always keeps question and answer pairs, so the history starts with a user message, as the API requires.effort: "low"andmax_tokens: 2000work together. If the model thinks before answering, that reasoning counts towardmax_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 theeffortparameter, 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):
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?#
node --env-file=.env deploy-commands.js
node --env-file=.env index.jsThe 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 suredeferReply()is the first async call in the handler.- API 401 error (
authentication_error):ANTHROPIC_API_KEYnever reached the process. Your local.envdoes not exist on the host, so set the variable there. - API 404 error (
not_found_error) naming the model: the ID inANTHROPIC_MODELis wrong or the model was retired. Pick another one from the model list and restart the bot, with no code change. Invalid Form Bodywithcontentover 2000: you sent long text without cutting it. Keep thesliceand 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_tokensand 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.
- Upload the project (
index.js,package.json) as a ZIP in the dashboard, or use the CLI, GitHub or the VS Code extension. Thesquarecloud.appconfig file is optional: the dashboard generates it with the runtime, memory and main file, or you write it at the project root. - Pick Node.js and
index.jsas the main file. A bot uses at least 256 MB from the plan's RAM pool. - Set
DISCORD_TOKEN,CLIENT_ID,ANTHROPIC_API_KEYandANTHROPIC_MODELin the application's environment variables. The change applies on the next restart, with no rebuild. - 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:
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
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.
More in AI
See all 13 articles- How much does an AI chatbot cost to build and run?Running an AI chatbot costs model tokens, hosting, a database and, with RAG, extra context per message. See how to estimate the cost per conversation.
- How much does a RAG system cost? Embeddings, database and LLMA RAG system has three costs: embeddings, a vector database and the LLM. See the math for 1,000 questions a day, with hypothetical prices, and where the bill grows.
- How much does an LLM API cost for your app?An LLM API bills per token, and output costs more than input. See a worked monthly estimate for a bot and practical ways to cut the bill.
- How to host n8nHosting n8n means running its server on an always-on process with a public HTTPS URL, a database and a fixed encryption key. Here is the step by step.