A decorator-driven Discord bot template built with TypeScript, discord.js v14, and Bun. You write small feature classes, drop them in a folder, and the engine discovers, instantiates, injects the client, and wires them at startup. No manual registration, no database, no boilerplate to maintain.
import { SlashCommandBuilder } from "discord.js";
import { Command, BaseCommand, CommandContext } from "engine";
@Command({ cooldown: 5, 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!");
}
}That is a complete, working command. Save it under app/commands/ and it is live on the next boot.
- Decorator-driven.
@Command,@ContextMenu,@Prefix,@Event,@Cron,@Interval,@Register,@Route- declare a class, drop the file, done. - Slash + text + context-menu commands, subcommand routing, and autocomplete.
- Guards and scoped cooldowns run before every handler (
owner_only,has_perms(...), custom functions; per user/guild/channel/global). - Typed permissions, channel-aware, with autocompleting permission names.
- Batteries-included UI: Components V2 helpers, embeds, pagination, interactive settings panels, confirm/alert dialogs, and modal forms.
- Pluggable storage (JSON / memory / your own adapter), per-guild prefixes, i18n, duration parsing, and a scoped cooldown manager.
- Optional HTTP API on Elysia with a Next.js-style router - secure by default: token auth, rate limiting, CORS, and a declarative rule engine.
- Scheduled tasks via cron expressions or intervals.
- AI-agent friendly: the whole architecture and coding rules are documented in
.agents/for assistants like Claude Code (see Built for AI agents).
- Bun 1.1 or newer
- A Discord application with a bot token and client id
The fastest way is the scaffolder - it clones the template, writes your .env, and installs dependencies interactively:
bunx create-discordtsOr set it up manually from a clone:
bun install # install dependencies
cp .env.example .env # then fill in your credentials
bun run deploy # register slash/context-menu commands with Discord
bun run dev # run with hot reloadYour .env:
TOKEN=your_bot_token
CLIENT_ID=your_application_id
OWNERS=comma,separated,user,ids # optional, powers the owner_only guard
API_TOKENS=comma,separated,tokens # optional, only needed if you enable the HTTP API
Re-run bun run deploy whenever you add or change a command's name, description, options, or guild scope.
engine/ the discord.js wrapper - you rarely touch this
decorators.ts @Command, @ContextMenu, @Prefix, @Event, @Cron, @Interval, @Register, @Route
base/ the base classes you extend
loaders/ discovery + mounting, one loader per feature kind
dispatch.ts interaction router (guards, cooldowns, error replies)
prefix_dispatch.ts MessageCreate router for prefix commands
guards.ts precondition guards
rules.ts declarative HTTP request rules
ui.ts Components V2 builder helpers
permissions.ts typed permission manager
app/ your features - this is where you work
commands/ slash commands, grouped in subfolders
context/ context-menu commands
prefixes/ text (prefix) commands
events/ gateway event handlers
tasks/ scheduled tasks (cron / interval)
components/ button, modal, and select handlers
routes/ HTTP API routes
helpers/ embeds, pagination, panel, dialog, form, color
lib/ logger, colors, storage, cooldowns, prefixes, duration, i18n, ratelimit, runtime
config.ts central configuration
index.ts entrypoint: installs the console patch and boots the engine
.agents/ architecture + coding rules for AI assistants
You only edit files under app/, helpers/ (if extending), lib/ (for new capabilities), and config.ts. Everything in engine/ is the framework.
This template is designed to be handed to an AI coding assistant (Claude Code, Cursor, etc.) with minimal ramp-up. The conventions live in version-controlled Markdown that agents read automatically:
| File | Purpose |
|---|---|
AGENTS.md |
Entry point. A short overview that pulls in the three docs below. |
CLAUDE.md |
Claude Code entry point - imports AGENTS.md. |
.agents/context.md |
Architecture & public surface. How the engine wires features, every decorator and base class, the config shape, and where each kind of logic belongs. |
.agents/agents.md |
Coding rules. Hard constraints: no comments, one job per file, snake_case (PascalCase for types), early returns, error handling, security, and the file-placement decision tree. |
.agents/tests.md |
Testing guide. What to test, how to drive the HTTP API without a port, and the Bun test-runner conventions. |
.claude/skills/ |
Scaffolding skills. One skill per feature kind (new-command, new-prefix-command, new-context-menu, new-event, new-task, new-component, new-route) that walks an agent through creating it correctly. |
If you use another tool, point it at AGENTS.md. Keeping these accurate is part of the workflow - when you change the framework surface, update .agents/context.md in the same change.
Every mountable feature is a default export class that extends a base class and carries a decorator. The base class injects this.client and fixes the execute signature; the decorator supplies registration metadata.
Extend BaseCommand, set a SlashCommandBuilder on data, implement execute.
@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!");
}
}cooldownis in seconds;cooldown_scopeis"user"(default),"guild","channel", or"global".guildsis a list of guild ids, or["*"]to register globally. Omitted options fall back toconfig.commands.contextgives youclient,interaction,permissions,globals, and command metadata.- Per-command shared state goes in a
globalsfield and is exposed oncontext.globals.
Subcommands - set a subcommands map and call this.run_subcommands(context) instead of a hand-written switch:
subcommands: Subcommands = {
status: (context) => this.status(context),
"config/set": (context) => this.set(context),
};
async execute(context: CommandContext) {
await this.run_subcommands(context);
}Autocomplete - implement an optional autocomplete(interaction); the engine routes focused-option interactions to it (and runs the command's guards first).
Right-click commands on users or messages. Extend BaseContextMenu, set a ContextMenuCommandBuilder. Registered by bun run deploy alongside slash commands.
@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());
}
}Text commands (.ping) driven off MessageCreate - an alternative to slash commands. Extend BasePrefixCommand, describe the command with PrefixCommandBuilder, and read typed args off context.args.
@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());
}
}The prefix is per-guild: it defaults to config.prefix.default, can be changed at runtime (persisted to storage), and - when config.prefix.allow_mention is on - @bot also works as a prefix. The whole subsystem is gated by config.prefix.enabled. The builder mirrors discord.js's SlashCommandBuilder naming: setName, setDescription, addAlias, and typed addString / addInteger / addNumber / addBoolean / addUser / addMember / addChannel / addRole / addRest.
Extend BaseEvent, carry @Event(name, { once? }) where name is a discord.js Events value.
@Event(Events.ClientReady, { once: true })
export default class Ready extends BaseEvent {
async execute(client: Client) {
console.log(`${client.user?.username} is online.`);
}
}You never write an InteractionCreate or MessageCreate handler to route commands - the engine owns that.
Extend BaseTask and carry @Cron("*/5 * * * *") (5-field cron, evaluated each minute) or @Interval("30s") (a duration string or raw ms). Never hand-roll a setInterval in a feature.
@Interval("30s")
export default class Presence extends BaseTask {
async execute() {
this.client.user?.setActivity(`${this.client.guilds.cache.size} servers`);
}
}Handlers for buttons, modals, and select menus. Extend the matching base class, register with @Register(id, options?), implement execute. The id matches the customId you send.
@Register("panel_button", { cooldown: 3 })
export default class PanelButton extends ButtonComponent {
async execute(interaction: ButtonInteraction) {
await interaction.reply("You clicked the button!");
}
}- Base classes:
ButtonComponent,ModalComponent,SelectComponent(any select), and the typedStringSelectComponent/UserSelectComponent/RoleSelectComponent/ChannelSelectComponent/MentionableSelectComponent. @Registeraccepts{ cooldown?, cooldown_scope?, guards? }- the same guard + cooldown pipeline as commands runs beforeexecute.- Dynamic custom ids: routing uses the segment before the first
:, so a button withcustomId"vote:up:<messageAuthorId>"still routes to thevotehandler, which reads the rest offinteraction.customId. Because a persistent component cannot know who is allowed to click it, this is how you lock one to its invoker - encode the allowed user id and reject others inexecute. (The collector-based helpers below handle per-user locking for you.)
Both dispatchers run the same pipeline before execute: guards, then cooldown.
import { Command, BaseCommand, CommandContext, in_guild, has_perms } from "engine";
@Command({ cooldown: 10, guards: [in_guild, has_perms("ManageGuild")] })
export default class Guarded extends BaseCommand {
data = new SlashCommandBuilder().setName("guarded").setDescription("Managers only, once every 10s.");
async execute(context: CommandContext) {
await context.interaction.reply("You passed the guards.");
}
}A guard is (context) => true | string | Promise<...> - return true to pass, or a message shown to the user on denial. Built-ins: owner_only, in_guild, dm_only, nsfw_only, has_perms(...), bot_has_perms(...). Write custom guards as plain functions and prefer them over ad-hoc if checks inside execute.
Cooldowns are in-memory and per-process - a best-effort courtesy limit, not a hard quota (under sharding each shard tracks its own, and they reset on restart). Prefix-command aliases share the command's canonical cooldown bucket.
Commands receive a ready Permissions instance on context.permissions, built from the invoking member and resolved against the current channel. Permission names are typed, so your editor autocompletes them and catches typos.
async execute(context: CommandContext) {
if (!context.permissions.has("ManageGuild")) {
await context.interaction.reply("You need the Manage Server permission.");
return;
}
await context.interaction.reply("Welcome, moderator.");
}Methods: has, all, any, missing. Build one anywhere with new Permissions(holder) where holder is a GuildMember, a PermissionsBitField, or null (a null holder returns false rather than throwing).
- Storage -
store<T>(name)from@/lib/storeis a typed, namespaced facade (get/set/has/delete/all/keys/clear/reload) over the adapter selected byconfig.storage.driver:"json"(default; atomic writes, onedata/<name>.jsonper namespace),"memory", or"custom"(implementStorageAdapterto back it with SQLite, Redis, etc.). The JSON driver is single-process-preferred; multi-process deployments should use a database-backed custom adapter. - i18n -
t(key, locale?, vars?)resolves JSON catalogs underlocales/, interpolates{vars}, and falls back to the key. Off by default viaconfig.localization.enabled. - Durations -
parse_duration("1h30m")→ ms (ornull);format_duration(ms)→"1m 30s". Underpins cooldowns,@Interval, and any timeout you expose.
engine exports snake_case helpers over Discord's Components V2 so you never touch raw flags or JSON. Wrap top-level components in view(...) and reply with the result.
import { view, container, text, separator, section, ButtonBuilder } from "engine";
const layout = container(
text("## Components V2 panel"),
separator(),
section(["Press the button on the right."], new ButtonBuilder().setCustomId("panel_button").setLabel("Click me").setStyle(ButtonStyle.Primary))
);
await interaction.reply(view(layout));Helpers: container, section, text, separator, thumbnail, gallery, file, view.
Under @/helpers/*:
embed(options)turns a plain object into anEmbedBuilder;success/error/warning/info/neutralare colour presets;lines_to_pages/fields_to_pageschunk data into pages.paginate(target, pages, options?)drives anEmbedBuilder[]with first/prev/next/last buttons; works with both slash interactions and messages, and locks to the invoker by default.panel(target, { settings, ... })renders an interactive settings panel (boolean / text / number / choice) backed by your ownget/setcallbacks.dialog/confirm/alertare button prompts;confirmresolves to a boolean.form(target, { fields })shows a modal and returns typed values.color(...)is a polymorphic colour value (hex / int / rgb / hsl / css) usable anywhere a helper takes a colour.
An optional web layer on Elysia with a Next.js-style router. Off by default - set config.server.enabled to true to turn it on.
A route extends BaseRoute, carries @Route(pattern), and implements one async method per HTTP verb. The pattern declares the URL: [id] is a dynamic segment, [...name] a catch-all. Set an optional zod schema to validate body / query / params; invalid requests get an automatic 400.
import { Route, BaseRoute, RouteContext, RouteSchemas, z } from "engine";
const body = z.object({ content: z.string() });
@Route("/guilds/[id]")
export default class GuildRoute extends BaseRoute {
schema: RouteSchemas = { POST: { body } };
async GET({ client, params }: RouteContext<{ id: string }>) {
const guild = client.guilds.cache.get(params.id);
return { id: params.id, name: guild ? guild.name : null };
}
async POST({ params, body }: RouteContext<{ id: string }, z.infer<typeof body>>) {
return { id: params.id, content: body.content };
}
}Return a plain object to send JSON, or a Response. Every route mounts under config.server.prefix (default /api), so the example serves GET /api/guilds/:id. The engine adds a /health route, logs each request, and returns structured JSON for unknown routes.
All protection lives in config.server and is enforced in one place - never re-implement auth or rate limiting inside a route. The defaults are secure-by-default and only apply once the server is enabled:
host: "127.0.0.1"- loopback only. Change it to expose the API off-box.auth- bearer-token (or custom-header) auth, tokens read from theAPI_TOKENSenv var, constant-time compared. Missing/invalid → 401.public_pathsdefaults to["/health"].rate_limit- fixed-window per-IP limiter; over the limit → 429 withRetry-After. In-process (per-shard under sharding).cors- an allowlist (originsis empty by default;["*"]reflects any origin), with configurable methods, headers, and credentials.rules- a top-down, first-match-wins list of{ conditions, action: "deny" | "allow", status?, message? }evaluated before auth. Conditions match onpath/method/ip/header/query/user_agentwithequals/not_equals/starts_with/in/not_in/matches.trust_proxygates whetherX-Forwarded-Foris honored;expose_errors(default off) keeps 500 bodies generic.
A route can opt out per-route with an auth = false field or rate_limit = false | { window, max }.
First time you enable the server: set
API_TOKENS, list yourcors.origins, and changehostif it must be reachable off-box - otherwise you'll get 401s and no cross-origin access by design.
Error responses share one shape: { "code": string, "message": string } with status 400 / 401 / 403 / 404 / 429 / 500.
Everything lives in config.ts:
bot-intentsandpresence, passed straight to the discord.js client.commands-default_cooldownanddefault_guildsfill in commands that don't set them.prefix-enabled,default,allow_mentionfor text commands.console/accent- log prefix labels and the default embed colour (see colors).owners- bot-owner ids (from theOWNERSenv var), consumed by theowner_onlyguard.storage-{ driver, adapter? }selecting the persistence backend.localization-{ enabled, default, directory }for i18n.server- the HTTP API and its security blocks (above).
At startup the engine patches console so every call is also written to a dated file in logs/:
console.log/warn/debug→logs/log_DD_MM_YY.logconsole.error→logs/errors_DD_MM_YY.log
Colour codes are stripped before writing. The original non-persisting functions stay available as console._raw_log / _raw_warn / _raw_debug / _raw_error. Never log a token or secret.
Tests run on the Bun test runner:
bun testThe suite covers permissions, the Components V2 helpers, cooldowns, storage, prefix parsing, the guard/cooldown pipeline, the rules engine, the rate limiter, and the HTTP API (auth, rate limiting, CORS, dynamic params, catch-all segments, validation, and error handling). Drive the API with app.handle(new Request(...)) - no bound port needed. Add tests under tests/ as *.test.ts; see .agents/tests.md.
bun run dev # watch mode
bun run build # type-check, then bundle to dist/
bun run start # run the built bundle
bun run deploy # register slash + context-menu commands
bun run shard # build and launch under a ShardingManager
bun run test # run the test suite
bun run typecheck # tsc --noEmit
bun run lint # ESLint
bun run format # Prettier (write) · format:check to verifyTwo workflows live in .github/workflows/:
ci.ymlruns on every push and pull request: install, format check, lint, type-check, build, test.release.ymlpublishes a GitHub release. On a push tomainit reads the version frompackage.jsonand, if the tagv{version}does not exist, creates a stable release for it. Run it manually on thecanarychannel for a prerelease taggedv{version}-canary.{run}. The release body is the commit changelog since the previous tag. Bumpversioninpackage.jsonto cut a new stable release.
bun run build && bun run startFor large bots, run under discord.js sharding instead of start:
bun run shardThis launches a ShardingManager that spawns dist/index.js across the recommended number of shards. Each shard reads its shard list from the environment, and only the primary shard serves the HTTP API.
docker compose up --buildThe compose file reads your credentials from .env. Adjust the exposed port if you enable the web server.
ISC.