Carbon
Use SayKit with Carbon, localise Discord commands, components, and replies
Carbon is a Discord bot framework. @saykit/carbon is its SayKit integration:
- registers a shared
Saywith your Carbon client - helps command, component, and modal classes expose translated metadata
- adds locale-aware
interaction.sayandguild.sayproperties
The result: commands appear in Discord's localised UI per user, and replies match the user's locale.
Install
pnpm add saykit @saykit/carbon
pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js unplugin-saykitThe Carbon example builds with tsdown, so it uses unplugin-saykit/rolldown. Any Carbon-compatible bundler works, pick the matching entry point from unplugin-saykit.
Configure
saykit.config.ts
import { defineConfig } from '@saykit/config';
import po from '@saykit/format-po';
import js from '@saykit/transform-js';
export default defineConfig({
locales: ['en', 'fr'],
buckets: [
{
include: ['src/**/*.ts'],
output: 'src/locales/{locale}.{extension}',
formatter: po(),
transformer: js(),
},
],
});tsdown.config.ts
import { defineConfig } from 'tsdown';
import saykit from 'unplugin-saykit/rolldown';
export default defineConfig({
entry: ['src/entry.ts'],
plugins: [saykit()],
});App setup
A shared Say
Eagerly load locales so the bot can answer immediately on cold start:
import { Say } from 'saykit';
const say = new Say({
locales: ['en', 'fr'],
messages: {
en: await import('./locales/en.po').then((m) => m.default),
fr: await import('./locales/fr.po').then((m) => m.default),
},
});
say.load();
say.activate('en');
export default say;Register SayPlugin
import { Client } from '@buape/carbon';
import { SayPlugin } from '@saykit/carbon';
import { PingCommand } from './commands/ping.js';
import say from './i18n.js';
const client = new Client(
{
/* options */
},
{ commands: [new PingCommand(say)] },
[new SayPlugin(say)],
);SayPlugin installs the Say instance into the global registry and applies extensions so interaction.say and guild.say work.
Localising commands
withSay() wraps a Carbon class with a constructor that accepts a Say and a mapping function. The mapping function runs once per locale; SayKit uses the results to populate Carbon's per-locale command metadata.
import { Command, type CommandInteraction } from '@buape/carbon';
import { withSay } from '@saykit/carbon';
import type { Say } from 'saykit';
export class PingCommand extends withSay(Command) {
constructor(say: Say) {
super(say, (say) => ({
name: say`ping`,
description: say`Ping the bot!`,
}));
}
async run(interaction: CommandInteraction) {
await interaction.reply({
content: interaction.say`Pong!`,
});
}
}When you register PingCommand, Carbon receives every translation of name and description at once, Discord then renders the command in whichever locale each user has.
Subcommands and options
CommandWithSubcommands works the same way:
export class MathsCommand extends withSay(CommandWithSubcommands) {
constructor(say: Say) {
super(say, (say) => ({
name: say`maths`,
description: say`Maths commands!`,
subcommands: [new AddCommand(say), new SubtractCommand(say)],
}));
}
}Options work too, everything visible in the Discord UI is localisable:
super(say, (say) => ({
name: say`add`,
description: say`Add two numbers!`,
options: [
{
name: say`a`,
description: say`The first number.`,
type: ApplicationCommandOptionType.Number,
required: true,
},
],
}));Components and modals
For BaseComponent and Modal subclasses, withSay accepts the translated props directly, no per-locale mapping, because Discord doesn't localise component metadata the way it does commands:
import { Button } from '@buape/carbon';
export class RollAgainButton extends withSay(Button) {
customId = 'roll-again';
constructor(say: Say) {
super({ label: say`Roll Again` });
}
}interaction.say and guild.say
Once SayPlugin is registered:
interaction.sayis aSaycloned and activated for the interaction's localeguild.sayis aSaycloned and activated for the guild's preferred locale
Use either anywhere you'd normally use say:
await interaction.reply({
content: interaction.say`The dice rolled ${result}!`,
});await someOperation(guild.say`Welcome to ${guildName}!`);The clone is cached on the interaction or guild instance, subsequent accesses re-use it.
Live example
The examples/carbon package is a working Cloudflare Workers bot with localised commands.
Next
- Runtime, cloning and freezing patterns
- Carbon API reference, every export