About Seyfert
Seyfert is a pretty interesting and new library coming into play (well, at least, at the time I’m typing this out) and competing with Discord.js. While it still has its own limitations (at least it’s not an entire team that makes outrageously impractical decisions like Discord.js), what it offers do lift a significant amount of work for normal Discord.js developers.
However you don’t get a lot of useful information on Seyfert, because it doesn’t have proper documentation. You have a guide, which includes outdated information, along with its undocumented code to work with, that’s it. Obviously that’s quite insufficient.
The library itself works and its philosophy does hold up. However, for very technical and obscure details I learned working this early into Seyfert, here we are.
Note: I revisited this one on September 2026 and today it is a sloppified page. They haven’t updated any documentation or checked if any of them are accurate. Honestly, I didn’t expect anything else, really. I still hope their philosophy holds.
ctx and the old monolith tale
Sure, the Discord.js v11 vision. Most of the bots out there during that time period expose one thing to their commands and it’s the dreadful ctx. Nowadays we would do something like interaction, [...relevant_stuff] and then define short-hands inside a run class, or function, for example.
async run(interaction: InteractionCommand, user: User) { const dbUser = interaction.client.database.find(user.id); // ...};Now, with the ctx short-hand, because everything is nested inside it and you have to get it out somehow (looking at you, untyped JavaScript users), you either rely on your IDE to give the list to you, check the typed source file, or just straight up remembering it. I’m not joking, I switched to TypeScript just because of that.
async run(ctx: CommandContext) { const dbUser = ctx.client.database.find(ctx.author.id); // ...};Now, the example is still very simple. The moment you get to extending the context you may as well realize you’re dealing with a monolith of properties that you don’t know what half of it does. For the case of interaction, [...relevant_stuff] you get to extend only the portion you need with the properties of that portion already present to you.
Though, there’s one thing they do well with the context. I present to you, the Context Options™:
// make some optionsconst options = createStringOption({ // Record<string, {...props}> default_option: { required: true, description: 'stuff', description_localizations: { 'en-US': 'stuff', 'vi': 'các thứ' } } // continue...})
// we talk about these later@Declare({ name: 'default-name', description: 'default-description'})@Options(options)export default class DefaultName extends SubCommand { // provide typings of options as a generic here (!!!) async run(ctx: CommandContext<typeof options>) { // then you can use the typed options const { default_option } = ctx.options; // continue... };};Now I’d say this is quite hacky, and it doesn’t work with @discordjs/builders. Personally I’d prefer if I could construct commands with it, because it has great short-hands (and it also returns Discord API-compatible JSON if you wanted it to), but this is fine. It’s also great if you, who may as well be the little hacky naughty person like I am, invent weird things. That’s for a later section.
Bundling the code
Seyfert claimed it supports Cloudflare Workers, so naturally as a person who has quite literally used that too much I started getting to work. Except… that wasn’t exactly going according to plan.
Here is what they claim we can do.
// Code snippet stripped from the guide// --- cut ---// we need to load commands manuallyawait client.commands!.set('', client, [Ping]);
// load languagesawait client.langs!.set('', [{ name: 'en', file: EnLang}]);
// load componentsawait client.components!.set('', client, [ButtonC]);Well, easy, check the docs, they say. Except! None of the methods listed there are actually in the package. So this is fully made up documentation. Verify that for yourself, it still exists here on September 2026: https://www.seyfert.dev/docs/recipes/cloudflare-workers.
However, if they had the guts to say they support Cloudflare Workers, there has to be a way around it, right? Workers bundle your code using esbuild, which in turn makes this code implies you actually can, in one way or another, bundle your code into a file and use it.
As you might have expected it’s quite hard to figure out what you have to do. I’ll preface with Decorators. Neat TypeScript stuff. Seyfert conveniently provides @GroupsT and @LocalesT so you can dynamically load all languages your options might be on runtime, so as I happily used it during the implementing stage I didn’t pay attention to the part dynamically.
If you’re using any of these two decorators, @GroupsT and @LocalesT, remove them and use @Groups and @Locales instead if you want to bundle your code. Because bundling is a static process.
And so the horror begins.
@Locales([ ['en-US', 'description here'], // add more language...])@Groups({ group: { name: [ ['en-US': 'name'], // ... ], description: [ ['en-US', 'description'], // ... ], defaultDescription: 'description' }, // add more stuff...})You can’t really get creative here, either. Putting all of these in a different file? Same thing, just different process. “Just tell the AI” that doesn’t really help me in the long run. Naturally I’ve spent around 5 hours in total for this.
Then, finally, the actual syntax you use to import commands statically is:
// we expect an index.ts to be presentimport Command from '/path/to/command/directory';// for some unknown reason, tsc gets angry at this line// even though it works perfectly fine// @ts-ignoreclient.commands.set([Command]);God, the documentation can’t even have it right all these years. Even the library complains when you do the right thing (trust me, the bot runs and types are fine.)
Now, did I also tell you that you have to patch the library itself for static language import? Yeah that too.
Get into the language handler file in the library. In the LangInstance declaration, mark path optional by adding a ? behind.
// --- cut ---path?: string;This is not breaking, in fact the function used to check our imports was also assuming path is not always present:
// --- cut ---if ('path' in file) // ...Here comes the difficult to realize part. Because the code of the library assumes our keys are inside a property default, which is what dynamic import() returns, you need to use top-level await to import the file statically into the handler. The devastating part is that it does not tell you if it couldn’t find the file, or you got this wrong and used normal import statement instead.
client.langs.set([ { language: 'en-US', file: await import('/path/to/en-US.ts') }, // ...add more language]);This is very funny, and it is potentially the funniest part of the whole library. Now about the events, this is the actual syntax:
import interactionCreate from '/path/to/event.ts';
client.events.set([ { data: { name: 'interactionCreate', once: false }, run: (i: any) => interactionCreate.run(i, client, 1) }, // ...more events]);Knowing this, you could then just wrap it in an object mapper, which is way cleaner.
That’s not the end though! Seyfert doesn’t know if what you bundled includes seyfert.config. so by default you are forced to include it in with the exposed token (not the process.enved one if you don’t include .env!)
Finally, only after you remove the paths from your seyfert.config that you can finally bundle your code. Otherwise, Seyfert will trigger its search to something that doesn’t exist after bundle.
The art of extending
Discord.js v11-pilled! Remember when Discord.js team decided to strip Structure.extends from its v13 codebase? Yeah, Seyfert doesn’t have that, neither.
Let’s talk locales. Seyfert has ctx.t for you to access locales from contexts. You know what it doesn’t? AutocompleteInteraction, ChatInputCommandInteraction and Message, practically the other 30% use-case. This is the “sometimes you may want custom short-hands and property accessors” case, but on a more important scale.
Object.defineProperties remains a vastly superior tool for all of your extending purposes. Provided down here is an example:
import * as AokiCommandContext from './CommandContext';import { CommandContext } from 'seyfert';
Object.defineProperties(CommandContext.prototype, { specific_property_name: { get: AokiCommandContext.specific_property_name }});Though, this is TypeScript, so declare it too:
const specific_property_name = 'Very important string';
declare module 'seyfert' { interface CommandContext { specific_property_name: string }};
export { specific_property_name };With this, you can easily add t to those three classes, or many other helpers. Then you can do vastly superior things like this:
choice_demonstration: createStringOption({ description: 'demonstrates localized choices', autocomplete: async (interaction: AutocompleteInteraction) => { // get the localized choices const localizedChoices = interaction.t.command.choices.choice_demonstration; // this SubCommand#respondWithLocalizedChoices // method is not a built-in function. It is extended // from the original SubCommand class await this.respondWithLocalizedChoices( interaction, localizedChoices ); }});This also links to why you might not always want ctx and tie everything you have to that 3-letter variable. Splitting roles and extending only in the classes you need make it way easier to manage your code.
The bench… maxxing?
Seyfert claims it uses, what, 1.25x less resource than Discord.js? Here’s their benchmark: https://www.seyfert.dev/benchmark. I’ve found this to be not as impressive as they claimed it to be, with real usage just roughly 5~10MB away from matching Discord.js. Aoki is a Discord application with 400 servers, some of which sees a quite substantial amount of users, so not exactly small to begin with.
Except… it may not matter when you’re not scaling. Seyfert is not good for scaling. Scaling on Discord.js has been a years-long battle at this point and every popular Discord.js bot at some point has to deal with concurrency, and the community has came up with so many tools to extend the great capacity that is.
Seyfert at the same time just doesn’t have that many adopters, and less so of community tools to help it. It does its job well however and I can’t deny that part, but it’s not reliable enough to be mainstream.
What do I think?
This was a fun experience, albeit quite funny at times. Seyfert definitely did help at some point, but it was not that significant.
I may be returning to it, but seeing as the current state of the website (as of September 2026) I… don’t have hopes. No proper documentation, just vague guides, and little adopters. The future of Seyfert looks bleak, but that may be just me.
Who knows. 4 out of 10. Barely ever again.