Put Your Coding Agent in Discord: A Team Ask-Bot Built in an Hour

TL;DR
Put a coding agent in Discord: a /ask slash command runs headless OpenCode on a fresh repo clone and posts answers to the channel, deployed on Railway.
The interesting agent work happens in a private tab, and the rest of the team finds out after the PR. That is the exact problem the Slack Code channels post names, and it is not only a Slack problem. The people who should ask questions about your codebase are already sitting in your Discord server, and asking "why does this endpoint timeout" in a shared channel is how a team learns.
This guide builds the honest version of a chat agent: a bot with one /ask slash command that takes a task, clones your repository fresh, runs OpenCode in headless mode against that clean copy, and posts the answer back into the channel. The bot never commits anything - it reads, it answers, and optionally hands back a diff for a human to apply. It lives on Railway as a persistent service, the honest hosting fit: a Discord bot keeps an outbound connection to Discord's gateway open all day, so it needs an always-on process, not a server that wakes up per request.
We run a fleet of this shape for parts of this site - scheduled agents, webhook agents, and chat surfaces reporting back. The mechanics below are the portable core: nine steps, under an hour, every step ending in something you can run.
Official Sources#
| Resource | Description |
|---|---|
| OpenCode Docs | Install, provider keys, and the opencode run non-interactive mode |
| OpenCode CLI reference | run, --model, --dir, and environment variables |
| discord.js Docs | Version and Node.js requirement, slash command examples |
| discord.js Guide: Application Setup | Creating the bot application and getting its token |
| discord.js Guide: Adding Your App | Invite link scopes and permissions |
| Discord: Receiving and Responding to Interactions | The 3-second and 15-minute response windows |
| Discord: Message | The 2000-character message content limit |
| Railway Services | Persistent services, Dockerfile builds, ephemeral storage |
| Railway Variables | Secrets and configuration for your service |
| Railway Pricing Plans | Plan caps, per-resource rates, the $5 trial grant |
Step 1: Create the bot application#
Head to the Discord Developer Portal, click New Application, name it something your team will recognize, and copy the token from the Bot tab. The discord.js guide's application setup walks the exact clicks. Treat the token like a password: it is the bot's login, and it will live only in Railway Variables, never in your repository.
Next, invite the bot from the OAuth2 page using Discord's URL generator. Select the bot and applications.commands scopes - the adding your app page explains why that pair matters: bot puts the user in your server, applications.commands lets it carry slash commands. Give it the smallest permission set that works: Send Messages, Read Messages/View Channels, and Use Slash Commands is enough for this build. Open the generated link, pick your server, and authorize.
Runnable check: the bot appears in your server's member list.
Step 2: Scaffold the bot project#
discord.js is the library; the current release at the time of writing is 14.27.0 and it requires Node.js 24.17.0 or newer (as of September 21, 2026), so anything on the current Node LTS line is fine. Scaffold and install:
mkdir discord-agent && cd discord-agent
npm init -y
npm install discord.js
Now the two files. First the command registration - slash commands are registered against the Discord API, not against your server, via REST and Routes:
// register-commands.js
import { REST, Routes } from 'discord.js';
const commands = [{
name: 'ask',
description: 'Ask the coding agent to investigate the repository',
options: [{
name: 'task',
type: 3,
description: 'What should the agent do?',
required: true,
}],
}];
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
await rest.put(Routes.applicationCommands(process.env.DISCORD_APP_ID), { body: commands });
console.log('Registered /ask');
Run it once with your application ID (the snowflake from the Portal's General Information tab) and bot token:
DISCORD_APP_ID=123456789012345678 DISCORD_TOKEN=your-token node register-commands.js
Runnable check: type / in any channel and /ask appears in the picker.
Step 3: Handle the interaction#
The receiving and responding docs pin the two numbers that shape every chat-agent design: you must send an initial response within 3 seconds of receiving the interaction, and the interaction token you respond with stays valid for 15 minutes (as of September 21, 2026). An agent run takes longer than 3 seconds, so you defer immediately - the user sees Discord's "thinking" state - and edit that message when the run finishes:
// agent.js
import { Client, Events, GatewayIntentBits, MessageFlags } from 'discord.js';
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'ask') return;
if (!ALLOWED_CHANNEL_IDS.length || !ALLOWED_CHANNEL_IDS.includes(interaction.channelId)) {
return interaction.reply({
content: 'Ask me in an allowlisted channel.',
flags: MessageFlags.Ephemeral,
});
}
await interaction.deferReply();
const task = interaction.options.getString('task');
const answer = await runAgent(REPO, task);
await interaction.editReply(truncate(answer));
});
await client.login(process.env.DISCORD_TOKEN);
The Guilds intent is all a slash-command bot needs - no privileged message-content intent, no reading chat history. deferReply() sends callback type 5 (DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE), the "acknowledge now, edit later" shape those timing rules demand.
Runnable check: run DISCORD_TOKEN=... node agent.js locally and the bot shows online in your server.
Step 4: Write the agent runner#
This is the same runner pattern as our cron automation guide with the interactive front door swapped in: one fresh clone, one opencode run, a soft gate, no commits:
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
const exec = promisify(execFile);
export async function runAgent(repo, task) {
const dir = await mkdtemp(path.join(tmpdir(), 'agent-'));
try {
await exec('git', ['clone', '--depth=1', `https://github.com/${repo}.git`, dir],
{ timeout: 120_000 });
const { stdout } = await exec('opencode', [
'run',
'--dir', dir,
'--model', AGENT_MODEL,
task,
], { timeout: 600_000, maxBuffer: 16 * 1024 * 1024 });
return stripAnsi(stdout).trim();
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
What each line is doing, and why it is the safe version:
- Fresh clone, deleted after the run.
mkdtempplus afinallycleanup means no state accumulates between questions - the same guardrail the scheduled version relies on. --depth=1. A shallow clone keeps even large repos fast to fetch.opencode run --dir. The CLI reference documentsrunas non-interactive mode and--modelasprovider/modelform (opencode/deepseek-v4-flash, the budget model our DeepSeek V4 Flash guide benchmarks, is the sensibleAGENT_MODELdefault).- The 600-second timeout. An agent looping forever dies at the bound you set, and the run stays inside Discord's 15-minute interaction window. 10 minutes is generous for a read-only question.
- No git write commands. The bot clones and runs. It never stages, commits, or pushes. For read-only questions that is the whole job; for "what would a fix look like" the extension in Step 8 returns a diff instead.
Runnable check (local): DISCORD_TOKEN=... node agent.js, then /ask task: "Summarize the purpose of this codebase in 4 bullets" against a small public repo. You should see the agent's answer appear in the channel a minute or two later.
Step 5: One run at a time, with an allowlist#
Two realities of a chat surface: an agent is expensive, and anyone with access can invoke it. Both get cheap answers.
A queue. Two people slamming /ask at once means two concurrent agent runs on your smallest instance. Keep a simple in-process promise queue so exactly one runAgent executes at a time; the interaction replies "Queued at position N" for anything behind.
A channel allowlist. ALLOWED_CHANNEL_IDS (comma-separated environment variable) restricts which channels the bot answers in; anything else gets an ephemeral refusal. The agent reads your codebase, so the surface that can reach it should be smaller than the whole server. This is also the deck-privilege rule from the fleet economics post: worker agents get cheap models, judges get strong ones, and a chat bot that answers questions is a worker.
Step 6: Containerize and deploy on Railway#
Dockerfile in the repo root - install git for the clone step, then OpenCode via npm because that is the documented Node install path and it lands on PATH automatically:
FROM node:24-slim
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g opencode-ai
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY agent.js .
CMD ["node", "agent.js"]
Push the repo to GitHub, then create a new project on Railway and Connect Repo. Railway detects the Dockerfile and builds from it automatically, per the services reference - every push to the linked branch rebuilds and redeploys.
Under the service's Variables tab (variables reference), set:
| Variable | Value |
|---|---|
DISCORD_TOKEN | the bot token from Step 1 |
REPO | owner/name the bot should clone |
AGENT_MODEL | opencode/deepseek-v4-flash or your provider/model |
ALLOWED_CHANNEL_IDS | comma-separated channel snowflakes |
| provider key | OpenCode loads provider API keys from the environment, so add whatever your model provider expects |
One thing you do not need: a public domain. Your bot dials out to Discord's gateway and keeps the connection open, so Railway's public networking is irrelevant here - you just need the process to stay alive, which is what a persistent service is for.
Cost context, pinned to the pricing plans reference (as of September 21, 2026): a new account gets a $5 trial grant, the Free plan caps a service at 0.5 GB RAM and 1 vCPU which is tight for an agent container, and the $5/month Hobby plan includes $5 of usage with resources billed at $10 per GB of RAM and $20 per vCPU per month, plus $0.05 per GB of egress. A small always-on bot lands within a few dollars of usage a month, and the clone directories live in ephemeral storage - 1 GB on Free, 100 GB on paid - which is fine because every run cleans up after itself.
Runnable check: deploy, open the service logs, watch the Discord-ready line - the bot shows online in the server member list, and /ask works from the deployment.
Step 7: The guardrails that make a chat surface safe#
A bot in a channel is a public input surface, so the safety comes from the pipeline, not from trusting the model:
- Read-only by default. The bot never has git write privileges. The worst thing it can produce is a wrong explanation, not a bad commit.
- Allowlist the channels. Covering a read-only surface is a permission decision; talk to your team about which channel earns it.
- Bounded runs. The 10-minute timeout protects your token spend and the interaction window simultaneously. Watch spend like a metric - the $400 overnight bill is the canonical failure mode to rule out first.
- Fresh clone every run. No state, no drift, reproducible answers.
- Secrets in Variables only. Token and API keys live in Railway Variables; the Dockerfile and repo contain none of them.
- Logs in the dashboard. Railway keeps service logs, so "why did the bot say that" is a grep away in the deployment's log viewer.
Step 8: What you have now#
A bot in any allowlisted channel can ask questions about the repository, backed by a real agent on a real clone, reachable from phone, web, or desktop with no setup beyond a slash command. Architecture, endpoints, tests, and error paths all work; anything that would require writing code comes back as analysis, which is exactly what a team chat should do.
Three extensions that stay in the same shape, each an hour or less:
- Answer with a diff. For "what would a fix look like", have the runner collect
git diffafter the agent finishes and upload it as a message attachment (within Discord's message limits; attachments carry their own size cap). A human reviews and applies. The bot still never commits. - A scheduled digest. Railway's cron jobs can trigger a second service running the same agent on a schedule, posting "what merged this week" to the channel - the chat bot becomes the front door for the scheduled agent pattern.
- Swap the model or the harness. The runner is one
execcall; changingAGENT_MODELin a Variable re-platforms the whole bot without a code change. The harness post is the general argument for why this seam matters.
FAQ#
Can I let a Discord bot agent commit code?#
You can, but do not start there. Read-only is the correct default for a shared surface: the bot's worst output is a wrong answer, not a bad push. When a team eventually needs commits, the human-in-the-loop pattern from our issue-to-PR guide is the safer shape.
Does the bot need a public URL or a GPU?#
Neither. The bot keeps an outbound connection to Discord's gateway, and the heavy lifting happens at your model provider's API. Railway just keeps a small Node container alive - a GPU would be wasted money here.
How much does this cost to run?#
Model tokens plus hosting. Hosting on Railway: a new account's $5 trial credit covers the build, then the $5/month Hobby plan includes $5 of usage with resources at $10 per GB of RAM and $20 per vCPU per month. Model cost with a budget model like DeepSeek V4 Flash is cents per question, per the scheduled agent cost math. The unbounded risk is loops, which is what the timeout exists for.
Why can't the bot just reply when the run is done?#
Two Discord rules make that awkward: you must acknowledge an interaction within 3 seconds or the token is invalidated, and interaction tokens expire after 15 minutes (receiving and responding docs, as of September 21, 2026). Deferring immediately and keeping agent runs under 10 minutes fits both windows with room to spare. Answer messages also get truncated to Discord's 2000-character content limit.
How is this different from Slack Code or Claude Code Channels?#
Vendor chat surfaces like Slack Code bundle their own agents and permissions into a hosted product. This build is yours: any harness, any model provider, your allowlist, your repo, one Dockerfile. If your team lives in Discord, a self-hosted bot is the way to get the same idea there - and it stays standing if vendor access changes.
Sources#
| Source | URL |
|---|---|
| OpenCode Docs | https://opencode.ai/docs/ |
| OpenCode CLI reference | https://opencode.ai/docs/cli/ |
| discord.js Docs | https://discord.js.org/docs |
| discord.js Guide: Application Setup | https://discordjs.guide/legacy/preparations/app-setup |
| discord.js Guide: Adding Your App | https://discordjs.guide/legacy/preparations/adding-your-app |
| Discord: Receiving and Responding to Interactions | https://discord.com/developers/docs/interactions/receiving-and-responding |
| Discord: Message resource | https://docs.discord.com/developers/resources/message |
| Railway Services reference | https://docs.railway.com/reference/services |
| Railway Variables | https://docs.railway.com/develop/variables |
| Railway Pricing Plans | https://docs.railway.com/reference/pricing/plans |
| Railway Cron Jobs | https://docs.railway.com/reference/cron-jobs |
Some links to tools above are referral links - see our affiliate disclosure.
Last updated: September 21, 2026
Continue Reading#
- Put an AI Agent on a Cron Job - the scheduled sibling of this pattern: same runner, a clock instead of a chat message
- Put an AI Agent Behind a Webhook - the event sibling: issues become pull requests, no chat required
- Slack Code vs the Local Harnesses - when a vendor chat surface beats a self-built bot
- Long-Running Agents Need Harnesses - why the script around the agent matters more than the agent
- The $400 Overnight Bill - agent FinOps, learned the hard way
- OpenCode - the agent behind this bot, pricing and model support on one page
- DeepSeek Super-Agent: The Always-On Router and Worker Design - DeepSeek's open-source always-on agent and its observe-first accept mode
- Best AI Agent Frameworks (2026) - other ways to wire an agent into team chat
- DeepSeek V4 Flash 0731 in OpenCode - the budget model doing the answering above
Get the next deep dive like this in your inbox
One email a week on discord and the rest of the AI dev stack. Free.
Read next on AI coding tools
Put an AI Agent on a Cron Job: Automating Dev Chores with OpenCode
Put an AI agent on a cron job with OpenCode: a runner script, a schedule, first chores and guardrails, so recurring dev work arrives as PRs to review.
11 min readPut an AI Agent Behind a Webhook: Turn GitHub Issues into Pull Requests
Deploy an AI agent behind a GitHub webhook on Railway with OpenCode headless, so a labeled issue becomes a tested pull request. The full build, step by step.
10 min readSlack Code vs Claude Code vs Cursor vs OpenCode: When Agents Belong in Chat
Slack Code gives coding agents their own Slack channels with diffs, previews, and an audit log. When it beats Claude Code, Cursor, or OpenCode, and when not.
7 min readNew here? Start with
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.








