Start a Discord Bot from A to Z with DiscordTS
Most Discord bot tutorials start strong and collapse the moment your bot grows past three commands. You end up hand-registering everything, wiring event listeners by hand, and copying the same boilerplate into every file. DiscordTS is the template I built to kill that pain: you drop a feature file into a folder, and the engine discovers it, wires it, and runs it. This is the full tour, from an empty folder to a deployed bot, covering every decorator along the way. The project lives at github.com/hiraeeth/DiscordTS.
What DiscordTS actually is
DiscordTS is a decorator-driven template built on discord.js v14 and Bun. The whole philosophy fits in one line: no manual registration, no database, no boilerplate. You write a class, tag it with a decorator that says what it is, and put it in the right folder. The engine handles discovery, registration, the guard and cooldown pipeline, and injecting the client for you.
That means the code you write is only the interesting part. A command file contains the command, not the plumbing that makes it a command. An event file contains the reaction, not the listener setup. The framework code sits in one folder you rarely touch, and your features sit in another that is entirely yours.
It also ships with the things real bots always end up needing anyway: typed permissions, scoped cooldowns, guards, scheduled tasks, pluggable storage, internationalization, interactive UI helpers, and an optional secure HTTP API. You are not gluing five libraries together, they already agree with each other.
Getting online in five minutes
The fastest path is the scaffolder, which sets up a fresh project for you.
bunx create-discordtsIf you would rather clone and wire it yourself, the manual path is four commands: install dependencies, copy the example environment file, register your commands with Discord, and start in watch mode.
bun install
cp .env.example .env
bun run deploy
bun run devThe .env file holds your credentials. At minimum you need a bot token and your application's client id, both from the Discord developer portal. Two optional entries let you mark bot owners and set tokens for the HTTP API.
TOKEN=your_bot_token
CLIENT_ID=your_application_id
OWNERS=comma,separated,user,ids
API_TOKENS=comma,separated,tokensYou need Bun 1.1 or newer, a bot token, and the client id. That is the entire setup. Run bun run deploy whenever you add or change a slash command, since that is what tells Discord your commands exist, and keep bun run dev running for hot reload while you build.
How the project is laid out
The folder structure separates the parts you own from the parts you do not. Understanding this map is most of understanding the framework.
engine/ Framework code (you rarely touch this)
app/ Your features: commands, events, tasks, components
helpers/ UI helpers: embeds, pagination, dialogs, forms
lib/ Utilities: logger, storage, i18n, cooldowns
config.ts Central configuration
.agents/ Documentation for AI coding assistantsThe rule is simple: your features go in app/, and the engine finds them by scanning that folder. There is no central list of commands to keep in sync, no place to remember to register a new event. Add a file, and it exists. Delete a file, and it is gone. That is the whole developer experience the template is built around.
Your first slash command
A slash command is a class tagged with @Command, extending BaseCommand. You describe the command with discord.js's own SlashCommandBuilder, then write an execute method that receives a typed context.
import { SlashCommandBuilder } from "discord.js";
import { Command, BaseCommand, CommandContext } from "engine";
@Command({ cooldown: 5, cooldown_scope: "user", guilds: ["*"] })
export default class Ping extends BaseCommand {
data = new SlashCommandBuilder()
.setName("ping")
.setDescription("Replies with Pong!");
async execute(context: CommandContext) {
await context.interaction.reply("Pong!");
}
}Three things are worth naming here. The decorator options configure behavior, a five second cooldown scoped per user and availability in all guilds via ["*"]. The data field is plain discord.js, so everything you already know about building slash commands still applies. And context carries the interaction, the resolved options, the permissions, and the client, all typed, so your editor autocompletes its way through the handler.
The full decorator set
Every kind of feature is a decorator plus a base class, and they all follow the same shape you just saw. Here is the complete set.
| Decorator | What it makes | Base class |
|---|---|---|
@Command | Slash commands | BaseCommand |
@ContextMenu | Right-click commands | BaseContextMenu |
@Prefix | Text commands like .avatar | BasePrefixCommand |
@Event | Gateway event handlers | BaseEvent |
@Cron | Scheduled tasks by cron expression | BaseTask |
@Interval | Scheduled tasks by duration | BaseTask |
@Register | Component handlers (buttons, modals, selects) | ButtonComponent and friends |
@Route | HTTP API endpoints | BaseRoute |
Events react to the gateway. Tag the class with the discord.js event you care about, optionally mark it as a one-time handler, and write execute.
import { Events } from "discord.js";
import { Event, BaseEvent } from "engine";
@Event(Events.ClientReady, { once: true })
export default class Ready extends BaseEvent {
async execute(client: Client) {
console.log(`${client.user?.username} is online.`);
}
}Scheduled tasks come in two flavors that share the same BaseTask. Use @Interval with a human duration for "every so often" work, and @Cron with a cron expression for "at a specific time" work. Both give you this.client directly.
import { Interval, BaseTask } from "engine";
@Interval("30s")
export default class Presence extends BaseTask {
async execute() {
this.client.user?.setActivity(`${this.client.guilds.cache.size} servers`);
}
}import { Cron, BaseTask } from "engine";
@Cron("0 0 * * *")
export default class DailyReset extends BaseTask {
async execute() {
console.log("Midnight housekeeping.");
}
}Components handle the buttons, modals and select menus your bot sends. Register a handler by the custom id it should respond to, extend the matching component base class, and the engine routes clicks to you.
import { Register, ButtonComponent } from "engine";
@Register("panel_button", { cooldown: 3 })
export default class PanelButton extends ButtonComponent {
async execute(interaction: ButtonInteraction) {
await interaction.reply("You clicked the button!");
}
}Prefix commands are the old-school text commands like .avatar, for when a slash command is not the right fit. They use their own builder that supports aliases and typed arguments, so context.args comes back already parsed.
import {
Prefix,
BasePrefixCommand,
PrefixCommandBuilder,
PrefixContext,
} from "engine";
@Prefix({ cooldown: 3, guilds: ["*"] })
export default class Avatar extends BasePrefixCommand {
data = new PrefixCommandBuilder()
.setName("avatar")
.setDescription("Show a user's avatar.")
.addAlias("av")
.addUser("target", "Whose avatar to show");
async execute(context: PrefixContext) {
const user = (context.args.target as User) ?? context.message.author;
await context.message.reply(user.displayAvatarURL());
}
}Context menus are the right-click actions on users and messages. Same pattern, built with discord.js's ContextMenuCommandBuilder.
import { ContextMenu, BaseContextMenu, ContextMenuContext } from "engine";
import { ContextMenuCommandBuilder, ApplicationCommandType } from "discord.js";
@ContextMenu({ guilds: ["*"] })
export default class Avatar extends BaseContextMenu {
data = new ContextMenuCommandBuilder()
.setName("Avatar")
.setType(ApplicationCommandType.User);
async execute(context: ContextMenuContext) {
if (!context.interaction.isUserContextMenuCommand()) return;
await context.interaction.reply(
context.interaction.targetUser.displayAvatarURL(),
);
}
}Notice the pattern never changes: a decorator that declares the kind, a data field that describes it, and an execute method that does the work. Learn it once with @Command and every other feature type is the same idea.
Guards, cooldowns and permissions
Before any handler runs, the engine runs a pipeline: cooldowns first, then guards. Cooldowns are the cooldown and cooldown_scope options you already saw, where the scope can be per user, guild, channel, or global. That alone kills most spam without a line of your own logic.
Guards are small checks that either let a command through or reject it. You list them in the decorator, and the engine runs them before your handler. The template ships the common ones: owner_only, in_guild, dm_only, nsfw_only, plus has_perms(...) and bot_has_perms(...) for permission checks.
import { SlashCommandBuilder } from "discord.js";
import {
Command,
BaseCommand,
CommandContext,
in_guild,
has_perms,
} from "engine";
@Command({ guards: [in_guild, has_perms("KickMembers")] })
export default class Kick extends BaseCommand {
data = new SlashCommandBuilder()
.setName("kick")
.setDescription("Kick a member.");
async execute(context: CommandContext) {
// reaches here only inside a guild, and only if the caller can kick
}
}Writing your own guard is just a function that returns true to pass or a string to deny with that message. Because the check lives outside the handler, your command body stays focused on the happy path instead of drowning in permission checks.
For finer control you have typed permissions on the context. context.permissions gives you has, all, any and missing, all autocompleted against the real permission names, so a typo becomes a compile error instead of a runtime surprise.
if (!context.permissions.has("ManageGuild")) {
await context.interaction.reply("You need Manage Server for that.");
return;
}Talking back: embeds, dialogs and forms
A bot is only as good as what it can say, so the template includes UI helpers that turn tedious component wiring into single calls. They live in helpers/, and they cover the things you build over and over.
There is embed for building rich messages with color presets, paginate for turning a list of pages into a navigable embed with buttons, and panel for interactive settings menus. For quick interactions there are dialog, confirm and alert, and for collecting input there is form, which spins up a modal and hands you back typed answers.
const ok = await confirm(context.interaction, {
content: "Delete every log in this channel?",
});
if (ok) await purge();The template also wraps Discord's Components V2 in readable, snake_case builders. Instead of assembling nested component objects by hand, you compose a layout out of small pieces and reply with it.
import { view, container, text, separator, section } from "engine";
const layout = container(
text("## Welcome"),
separator(),
section(text("Pick an option below to get started.")),
);
await interaction.reply(view(layout));The point of all of this is the same as the point of the decorators: the boring, repetitive part is already written, so you spend your attention on what the bot actually does, not on how to render a button.
Remembering things: storage, i18n and durations
Even though the template has no database, bots still need to remember things, so it ships a small storage layer with pluggable backends. The default writes atomically to JSON files, there is an in-memory option for throwaway state, and a custom adapter slot for when you do want to plug in a real database or Redis later.
import { store } from "@/lib/store";
const db = store<{ warnings: number }>("moderation");
db.set("user_123", { warnings: 1 });
const record = db.get("user_123");Two more utilities save you from writing the same helper for the hundredth time. If you enable internationalization, t(key, locale, vars) resolves strings from your locales/ files so your bot can speak more than one language. And duration parsing turns human strings into milliseconds and back, which is exactly what you want for mutes, reminders and cooldowns.
import { parse_duration, format_duration } from "@/lib/durations";
const ms = parse_duration("1h30m"); // 5400000
const label = format_duration(ms); // "1h 30m"A bot with an API
Sometimes a bot needs a front door beyond Discord, a dashboard, a webhook receiver, a health check. DiscordTS includes an optional HTTP API built on Elysia. Turn it on in config.ts, then add routes the same way you add commands: a decorated class in a folder.
import { z } from "zod";
import { Route, BaseRoute } from "engine";
@Route("/guilds/[id]")
export default class GuildRoute extends BaseRoute {
schema = { POST: { body: z.object({ content: z.string() }) } };
async GET({ params }) {
return { id: params.id };
}
async POST({ params, body }) {
return { ok: true, sent: body.content };
}
}Routes validate their input with Zod through the schema field, and they mount under a configurable prefix, /api by default. The important part is the security posture, which is locked down out of the box. The server binds to loopback only, every request needs a bearer token from your API_TOKENS env var or it gets a 401, requests are rate limited per IP with a 429 when they go over, and CORS starts with an empty allowlist. On top of that sits a first-match-wins rule engine that can allow or deny by path, method, IP, header, query or user agent. Secure by default, opened up on purpose.
Shipping it
When it is time to go live, the scripts cover the whole lifecycle. During development you live in bun run dev. To ship, you type-check and bundle, then run the built output.
bun run build && bun run startThere are scripts for the rest too: bun run deploy to register commands, bun run shard to run the sharding manager once your bot outgrows a single process, and the usual test, typecheck, lint and format. If you prefer containers, a compose file is included, so a full deploy is one command.
docker compose up --buildFor a bot in a few servers, build and start is all you need. For a bot in thousands, sharding is already wired, so scaling up is a script, not a rewrite.
Conclusion
That is DiscordTS end to end: install with one command, drop feature files into app/, and let the engine register and run them. Every capability is the same friendly pattern, a decorator that declares what a class is and an execute method that does the work, whether it is a slash command, an event, a scheduled task, a button, a context menu, or an HTTP route.
The reason I built it this way is the reason I build most things this way. The repetitive plumbing should be written once and hidden, so the code you actually touch is the code that matters. If you want to go deeper, everything here is open on GitHub, including docs written for AI coding assistants in the .agents/ folder. Clone it, drop in a command, and you have a bot online before your coffee is cold.
Thanks for reading. Questions or disagreements? Email me.





