Overview
What MeoCord is, what a bot built with it is made of, and how its parts run from build to shutdown.
You'll learn
- Tell what MeoCord adds to discord.js, and when it fits
- Name the parts of a MeoCord bot and what each one does
- Follow a bot from build to shutdown
MeoCord is a framework for Discord bots built on discord.js. You write a bot as
controllers and services, and decorators connect them to Discord:
@Command binds a method to a slash command, a button or a modal, and the framework routes each interaction to it.
Two parts carry the everyday work. respond() answers every interaction with the call Discord
expects for where the answer stands, so a handler says what to send and never which method
sends it. And meocord/testing runs a handler exactly as the bot does, through the same pipeline, with
mocks of discord.js's own classes, so a test passes because the bot works.
Every call a handler receives passes through that pipeline. Guards decide whether it runs, interceptors wrap it, validation and pipes check and shape its input, cooldowns limit how often it runs, and exception filters decide what the user is told when it throws. Guards, interceptors and exception filters apply to one method, a whole controller or the entire bot; cooldowns to a method or a controller; validation to a method.
When to use it
MeoCord is for bots that grow: many commands and components, rules about who may use them, and code a team wants to test. It gives a bot the structure a web framework gives a server, and with it:
- One call that knows where the answer stands.
respond(interaction)tracks each answer as unanswered, deferred or replied, read from the interaction before every call, and makes the call Discord expects:replyorupdatefirst,editReplyonce the answer is deferred or sent,followUpfor another message. A secondsend()edits rather than replying twice, and@Deferacknowledges a slow handler before Discord's three seconds are up. See Answering with respond(). - Tests that run the way the bot runs.
invokeanddispatchsend a call through the same pipeline the bot uses, guards, validation, cooldowns and filters included, andgetResponsereports whatrespond()sent. Mocks of discord.js's own classes keep their prototypes and follow Discord's reply rules, andfromAppbuilds the testing module from the app class itself. See Testing, Mocks and Invoke and dispatch. - One pipeline around every call. Guards, interceptors, validation, pipes, cooldowns and exception filters run in a fixed order around each handler they apply to. Guards, interceptors and filters are set on one method, a controller or the whole bot, cooldowns on a method or a controller, and validation and pipes on a method. See How a call runs.
- Cooldowns and translations built in.
@Cooldowncounts per user, server, channel or resource, in memory or in Redis, and typed catalogs translate what the bot says and the names of its commands. - Routes and params the compiler checks. A button's
counter/{count:int}gives its handlercountas a number, and a message command'spay {to:member} {amount:int}a member and a number. A handler whose params don't fit its pattern fails to compile, and so does a catalog with a key the default catalog lacks, or, written inline,as constor withdefineCatalog, a{param}its default message doesn't take.expectCompleteCatalogfinds a missing message, and such a{param}in a plain or JSON catalog too, in a test. See Components, Message command params and Localisation. - Services by constructor. A controller or a service names what it needs in its constructor, and MeoCord makes each one once, in dependency order. See Services.
- A CLI from create to deploy.
npx meocord createstarts a project,generatescaffolds a controller, service, guard, interceptor, filter, pipe or observer with its spec,start --devrebuilds and restarts on every change, andbuildandregistership it. See The CLI.
It covers every interaction Discord sends, from slash commands, subcommands and autocomplete to buttons, the five select menus, modals, context menus and activity entry points, plus messages, reactions and any gateway event.
Example
A slash command that greets whoever it names, at most three times in ten seconds per user. The controller handles it, and a service it asks for in its constructor makes the greeting:
import { type ChatInputCommandInteraction } from 'discord.js'
import { respond } from 'meocord/common'
import { Command, Controller, Cooldown } from 'meocord/decorator'
import { GreetingCommandBuilder } from '@src/controllers/slash/builders/greeting.builder'
import { GreetingService } from '@src/services/greeting.service'
@Controller()
export class GreetingSlashController {
constructor(private readonly greetingService: GreetingService) {}
@Command('greet', GreetingCommandBuilder)
@Cooldown({ uses: 3, seconds: 10 })
async greet(interaction: ChatInputCommandInteraction, { name }: { name: string }) {
await respond(interaction).send({ content: this.greetingService.buildGreeting(name) })
}
}The app class lists the controller, with the options discord.js's client is made from:
import { GatewayIntentBits } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller'
@MeoCord({
controllers: [GreetingSlashController],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
})
export default class App {}Your first command builds this bot step by step.
How it works
A MeoCord bot is a discord.js bot with a container and a router in front of it. Here is a bot from build to shutdown, and where each part of this guide sits.
Build
meocord build compiles the bot and its meocord.config.ts into dist/. The built bot reads only
dist/meocord.config.mjs, so it starts without TypeScript; see Configuration.
Start
dist/main.js calls MeoCordFactory.create(App) on the class @MeoCord decorates, then app.start().
- Create. The factory reads
@MeoCord's options and the built config, then makes one container. It binds the discord.jsClient, made fromclientOptions, the translator, the handler registry, the shard context, the cooldown store, and every controller and service the app lists, with everything they inject, as singletons. It builds the message and component routes once, so a message pattern it can't read, or two patterns that match the same messages or customIds, stop the bot here. With process sharding, the first process instead becomes a manager that starts the shards, each of which runs these steps itself. - Log in.
start()first makes every provided value, waiting for async factories, and the services@MeoCordlists, so their constructors run before login. It then attaches MeoCord's listeners to the client and logs in. A failed factory or login rejectsstart()and sets the exit code to 1. - Ready. When Discord says the client is ready, whatever isn't made yet, such as the controllers and the services
only injected, is resolved, and the
onReadyhooks run in dependency order; see Lifecycle hooks. Alongside, the commands the builders describe are registered; see Slash commands.
Dispatch
Every interaction, message and reaction goes to the handler it belongs to:
| What arrives | Found by | Handler |
|---|---|---|
| A slash, context menu or entry point command | its name, and a subcommand's path | @Command(name, Builder) |
| An autocomplete request | the command's path and the option being typed | @Autocomplete |
| A button, select menu or modal | its customId, against every pattern, most specific first | @Command(pattern, CommandType.…) |
| A message | its words after the prefix, or any text | @MessageHandler |
| A reaction added or removed | its emoji, or any emoji | @ReactionHandler |
| Any other client event | its name | @On and @Once |
The handler then runs through the pipeline: @Defer's
acknowledgement, guards, interceptors around validation, pipes, cooldowns and the
handler, all inside exception filters. Observers are told about the call as it starts and once it has
settled. respond() makes the right call to Discord for where the answer stands.
What lives how long
| Lives for the whole app | Made for each call |
|---|---|
| controllers and services | guards |
| interceptors, exception filters, pipes and observers | ExecutionContext |
the Client, translator and cooldown store | the handler's arguments |
So a service or a field on a controller holds state across calls, and a guard holds none, unless it's bound: supplied
by a provider, listed in services or injected by another class. A test shows it: one
controller and one service serve two calls, and each call gets its own guard.
// One instance for the whole app: what it holds is shared by every call
@Service()
export class VisitCounter {
count = 0
}
// A new instance for every call: it holds nothing between them
@Guard()
export class CountingGuard implements GuardInterface {
static created = 0
constructor() {
CountingGuard.created += 1
}
canActivate(): boolean {
return true
}
}
@Controller()
export class VisitButtonController {
constructor(private readonly visits: VisitCounter) {}
@Command('visit', CommandType.BUTTON)
@UseGuard(CountingGuard)
async visit(interaction: ButtonInteraction) {
this.visits.count += 1
await respond(interaction).send({ content: `Visit number ${this.visits.count}` })
}
}button visit; button visitOpen in playgrounddescribe('lifetimes', () => {
it('shares one controller and service across calls, and makes a guard for each call', async () => {
const module = MeoCordTestingModule.create({
controllers: [VisitButtonController],
providers: [{ provide: VisitCounter, useClass: VisitCounter }],
}).compile()
const click = () => createMockInteraction(ButtonInteraction, { customId: 'visit' })
await module.invoke(VisitButtonController, 'visit', click())
await module.invoke(VisitButtonController, 'visit', click())
expect(module.get(VisitButtonController)).toBe(module.get(VisitButtonController))
expect(module.get(VisitCounter).count).toBe(2)
expect(CountingGuard.created).toBe(2)
})
})Where discord.js begins
MeoCord makes the client, logs it in and routes what it receives. Everything a handler touches is discord.js's own: the
interaction, message, reaction and client are the library's classes, and anything discord.js can do, a handler or a
service that injects the Client can do too. See Services.
Stop
On SIGINT or SIGTERM, the onShutdown hooks run in reverse dependency order, within shutdownTimeout, and the client
is destroyed. See Lifecycle hooks.
Other ways to build a bot
If you've built a bot before, most of what you know carries over. Each framework below is a good home for the bots built on it, and each "coming from" page shows one bot both ways. The comparison is as of 25 September 2026, against the versions named, from each project's own documentation and published packages; corrections are welcome in the issue tracker.
From discord.js alone (14.27.0). Every framework here, MeoCord included, is built on it, and a MeoCord handler receives discord.js's own interaction, message and client. What you stop writing is the plumbing around them: the command handler, component routing, error handling and answer tracking. See Coming from discord.js.
From Sapphire (@sapphire/framework 5.5.1), the most downloaded of these frameworks on npm. Its commands,
listeners and preconditions become controllers, @On handlers and guards, and the global container becomes
constructor injection. Translations and subcommands, plugins in Sapphire, are built into MeoCord, and scheduled work is
a service, as Scheduled tasks shows. See Coming from Sapphire.
From Necord (7.0.0), a NestJS module. Nest's guards, interceptors, pipes and exception filters have MeoCord
counterparts with the same names and roles, built for Discord alone, so the bot needs no Nest application around it.
@TextCommand becomes a typed message pattern, and @necord/localization typed catalogs. See
Coming from Necord.
From discordx (11.13.3). Decorators on classes, as in MeoCord. Guard functions become guard classes that inject
services, TSyringe or TypeDI become the built-in injection, and @SimpleCommand a typed pattern. Where discordx runs
several bots in one process, a MeoCord bot is its own process, with its own config, token and logs. See
Coming from discordx.
At a glance
"None in its docs" means the project's documentation, as of the date above, names no such feature; a community package may add one.
| MeoCord | Sapphire | Necord | discordx | |
|---|---|---|---|---|
| Declaring handlers | decorators | pieces, loaded from folders | decorators | decorators |
| Dependency injection | constructor injection, built in | a global container | NestJS's | TSyringe or TypeDI |
| Before a handler | guards, interceptors, validation, pipes | preconditions | NestJS's guards, interceptors and pipes | guard functions |
| Around errors | exception filters | error events, with listeners | NestJS's exception filters | none in its docs |
| Cooldowns | @Cooldown | cooldownDelay, built in | none in its docs | a RateLimit guard, @discordx/utilities |
| Prefix commands | typed patterns, flags and prefixes | yes, with argument parsing | @TextCommand, with arguments | @SimpleCommand, with options |
| Testing toolkit | meocord/testing: invoke, mocks | none in its docs | NestJS's testing module | none in its docs |
| Translations | typed catalogs, built in | @sapphire/plugin-i18next | @necord/localization | none in its docs |
| Language | TypeScript | TypeScript or JavaScript | TypeScript | TypeScript |
| Documented runtime | Node.js 22.13+ or Bun | Node.js 18+ | Node.js 20.19+ or 22.13+ | Node.js 20+ |
| Needs | nothing else | nothing else | a NestJS application | nothing else |
Good to know
- A modern core. MeoCord runs on discord.js 14.27 or a later 14.x, with Node.js 22.13+ or Bun. What a plugin would add elsewhere is a plain service here: injected where it's needed, and tested like the rest of the bot.
- Built for TypeScript. The compiler checks each handler's params against its route or pattern, and each catalog's
keys against the default one's, and
npx meocord createstarts every project in TypeScript. - One process, one bot. A process logs in with the token its
meocord.config.tsgives, so each bot keeps its own config, token and logs, and restarts on its own. One bot grows across processes, a shard in each; see Sharding.
Next steps
- Getting started: create a bot and start it.
- Your first command: write a slash command, its service and its test.
- Upgrading from 4.0: the migration guide lists what to check.