Skip to main content

Building an Addon

This guide walks you through creating an addon from scratch using the Arken Addon SDK. Everything here is based on the actual SDK source and the bundled example addons (example-greeting and example-economy).


Prerequisites

  • Node.js 20+
  • pnpm (the repo uses pnpm workspaces)
  • The full Arken Bot repo cloned and dependencies installed

Project setup

Create a new folder inside the addons/ directory at the root of the repo. The folder name becomes your addon's ID on disk.

addons/
my-addon/
src/
index.ts
package.json
tsconfig.json

package.json

{
"name": "my-addon",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@arkenbot/addon-sdk": "workspace:*",
"@arkenbot/shared": "workspace:*"
},
"peerDependencies": {
"discord.js": "^14.0.0"
},
"devDependencies": {
"discord.js": "^14.18.0",
"typescript": "^5.7.0"
}
}

tsconfig.json

{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Install dependencies from the addon directory:

pnpm install

Minimal addon

Every addon exports a default value created by defineAddon(). The manifest is the only required field.

import { defineAddon } from '@arkenbot/addon-sdk';

export default defineAddon({
manifest: {
name: 'my-addon',
displayName: 'My Addon',
version: '1.0.0',
description: 'Does something useful.',
author: 'Your Name',
},
});

Manifest fields

FieldRequiredDescription
nameYesUnique identifier — must match your folder name
displayNameYesHuman-readable name shown in the dashboard
versionYesSemver string
descriptionYesShort description shown in the Addon Manager
authorYesYour name or org
homepageNoURL to your docs or repo
commandsNoArray of command names the addon registers (informational)
eventsNoArray of Discord event names the addon listens to (informational)
settingsNoSettings schema — see Settings
permissionsNoDiscord permission strings your addon requires
dependenciesNoOther addon names this addon depends on

Commands

Register slash commands by adding a commands array to your definition. Each entry provides a data builder and an execute function that receives the interaction and an AddonContext.

import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { defineAddon } from '@arkenbot/addon-sdk';

export default defineAddon({
manifest: {
name: 'my-addon',
displayName: 'My Addon',
version: '1.0.0',
description: 'Example with a command.',
author: 'Your Name',
commands: ['ping'],
},

commands: [
{
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Check latency'),

async execute(interaction, ctx) {
const latency = interaction.client.ws.ping;
await interaction.reply(`Pong! Latency: ${latency}ms`);
ctx.logger.info(`/ping used in guild ${interaction.guildId}`);
},
},
],
});

Autocomplete

Add an autocomplete function alongside execute to handle autocomplete interactions for that command:

{
data: new SlashCommandBuilder()
.setName('server')
.setDescription('Check a saved server')
.addStringOption((o) =>
o.setName('name').setDescription('Server name').setRequired(true).setAutocomplete(true)
),

async execute(interaction, ctx) {
const name = interaction.options.getString('name', true);
// ...
},

async autocomplete(interaction, ctx) {
const focused = interaction.options.getFocused().toLowerCase();
const saved = await ctx.storage.keys(interaction.guildId ?? undefined);
const matches = saved
.filter((k) => k.startsWith('server:') && k.includes(focused))
.slice(0, 25)
.map((k) => ({ name: k.replace('server:', ''), value: k.replace('server:', '') }));
await interaction.respond(matches);
},
},

Event listeners

Listen to any Discord.js gateway event by adding an events array. The handler receives the AddonContext as the first argument, followed by the event's normal arguments.

import { defineAddon } from '@arkenbot/addon-sdk';
import type { GuildMember } from 'discord.js';

export default defineAddon({
manifest: {
name: 'my-addon',
displayName: 'My Addon',
version: '1.0.0',
description: 'Greets new members.',
author: 'Your Name',
events: ['guildMemberAdd'],
},

events: [
{
event: 'guildMemberAdd',
async handler(ctx, member: GuildMember) {
const channelId = await ctx.getSetting<string>(member.guild.id, 'channelId');
if (!channelId) return;

const channel = member.guild.channels.cache.get(channelId);
if (!channel?.isTextBased()) return;

await channel.send(`Welcome, <@${member.id}>!`).catch(() => null);
},
},
],
});

Set once: true on an event to have the handler fire only on the first occurrence:

{
event: 'ready',
once: true,
async handler(ctx, client) {
ctx.logger.info(`Logged in as ${client.user.tag}`);
},
},

Lifecycle hooks

Hooks let the runtime notify your addon at key moments. All hooks are optional.

HookWhen it fires
onLoadOnce when the addon activates at bot startup
onUnloadWhen the addon is deactivated or the process shuts down
onSettingsUpdateEach time a guild admin saves new settings for this addon
onGuildInstallWhen a guild installs your addon for the first time
onGuildUninstallWhen a guild removes your addon
hooks: {
async onLoad(ctx) {
ctx.logger.info('Addon loaded — setting up...');
},

async onUnload(ctx) {
ctx.logger.info('Addon unloaded — cleaning up...');
},

async onSettingsUpdate(ctx, guildId, settings) {
ctx.logger.info(`Settings updated for guild ${guildId}`, settings);
// Bust any caches that depend on these settings.
},

async onGuildInstall(ctx, guildId) {
// Seed default data for this guild.
await ctx.storage.set('installed', true, guildId);
},

async onGuildUninstall(ctx, guildId) {
// Clean up guild-specific data.
const keys = await ctx.storage.keys(guildId);
for (const key of keys) {
await ctx.storage.delete(key, guildId);
}
},
},

AddonContext

Every command handler, event handler, and lifecycle hook receives an AddonContext (ctx). It provides everything your addon needs to interact with the bot.

Property / MethodTypeDescription
ctx.addonNamestringYour addon's manifest name
ctx.clientdiscord.js ClientThe live Discord client
ctx.storageAddonStoragePer-guild key-value persistence
ctx.loggerAddonLoggerStructured logger
ctx.eventsAddonEventBusInter-addon event bus
ctx.getSettings(guildId)Promise<Record<string, unknown>>All settings for a guild
ctx.getSetting(guildId, key, default?)Promise<T>A single setting value
ctx.getGuild(guildId)Guild | undefinedGuild from the client cache

Storage

ctx.storage is a scoped key-value store backed by PostgreSQL. Data is automatically namespaced to your addon — you cannot accidentally read or write another addon's data.

Pass a guildId to scope data to a specific server. Omit it for global (cross-guild) data.

// Store a value for a specific guild
await ctx.storage.set('welcome:channelId', '123456789', guildId);

// Read it back (returns null if not set)
const channelId = await ctx.storage.get<string>('welcome:channelId', guildId);

// Delete a value
await ctx.storage.delete('welcome:channelId', guildId);

// List all keys in a guild scope
const keys = await ctx.storage.keys(guildId);

Keys are plain strings — use a prefix pattern (e.g. balance:${userId}) to organise multiple records under a single guild scope:

await ctx.storage.set(`balance:${userId}`, 500, guildId);
const balance = await ctx.storage.get<number>(`balance:${userId}`, guildId) ?? 0;

Settings

Declare a settings array in your manifest to expose configuration controls in the dashboard. Each entry maps to a typed input rendered in the Addon Manager.

manifest: {
name: 'my-addon',
// ...
settings: [
{
key: 'channelId',
type: 'channel',
label: 'Announcement Channel',
description: 'Where to post announcements.',
},
{
key: 'message',
type: 'string',
label: 'Welcome Message',
description: 'Use {user} and {server} as placeholders.',
default: 'Welcome, {user}!',
},
{
key: 'dailyAmount',
type: 'number',
label: 'Daily Reward',
default: 100,
min: 1,
max: 10000,
},
{
key: 'enabled',
type: 'boolean',
label: 'Enable Feature',
default: true,
},
{
key: 'theme',
type: 'select',
label: 'Theme',
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
],
default: 'dark',
},
],
},

Setting types:

TypeDashboard controlValue
stringText inputstring
numberNumber inputnumber
booleanToggleboolean
channelChannel pickerChannel ID as string
roleRole pickerRole ID as string
colorHex color pickerHex string e.g. #5865F2
selectDropdownOne of the options[].value strings

Read settings in your handlers with ctx.getSetting(). Always provide a default value so the addon works before a server admin has configured it:

const channelId = await ctx.getSetting<string>(guildId, 'channelId');
const message = await ctx.getSetting<string>(guildId, 'message', 'Welcome, {user}!');
const amount = await ctx.getSetting<number>(guildId, 'dailyAmount', 100);

Logger

ctx.logger wraps the bot's structured logger and tags every message with your addon name, making it easy to filter in logs.

ctx.logger.info('Addon loaded successfully');
ctx.logger.warn('Config missing, using defaults');
ctx.logger.error('Failed to reach external API', error);
ctx.logger.debug('Processing guild', guildId);

Inter-addon event bus

ctx.events is a typed event bus that lets addons publish and subscribe to named events. This is useful when one addon needs to react to something another addon does.

// Addon A: publish an event
await ctx.events.emit('economy:reward', { userId: '123', amount: 50, guildId: '456' });

// Addon B: subscribe to it
const unsubscribe = ctx.events.on<{ userId: string; amount: number; guildId: string }>(
'economy:reward',
async (data) => {
ctx.logger.info(`User ${data.userId} received ${data.amount} coins`);
},
);

// Fire once only
ctx.events.once('economy:reward', async (data) => { /* ... */ });

// Remove a specific handler
ctx.events.off('economy:reward', handler);

Subscribe in onLoad and store the returned unsubscribe function; call it in onUnload to avoid leaked handlers after the addon is deactivated.


Full example

Below is a complete working addon. It demonstrates commands, storage, settings, lifecycle hooks, and logging.

import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { defineAddon } from '@arkenbot/addon-sdk';

export default defineAddon({
manifest: {
name: 'counter',
displayName: 'Counter',
version: '1.0.0',
description: 'A per-guild message counter with a leaderboard.',
author: 'Your Name',
commands: ['counter'],
events: ['messageCreate'],
settings: [
{
key: 'channelId',
type: 'channel',
label: 'Count Channel',
description: 'Only count messages in this channel. Leave unset to count everywhere.',
},
],
},

commands: [
{
data: new SlashCommandBuilder()
.setName('counter')
.setDescription('Show your message count')
.addUserOption((o) =>
o.setName('user').setDescription('User to check').setRequired(false)
),

async execute(interaction, ctx) {
if (!interaction.guildId) return;
const target = interaction.options.getUser('user') ?? interaction.user;
const count = await ctx.storage.get<number>(`count:${target.id}`, interaction.guildId) ?? 0;

const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setDescription(`**${target.tag}** has sent **${count.toLocaleString()}** messages.`);

await interaction.reply({ embeds: [embed] });
},
},
],

events: [
{
event: 'messageCreate',
async handler(ctx, message) {
if (message.author.bot || !message.guildId) return;

const channelId = await ctx.getSetting<string>(message.guildId, 'channelId');
if (channelId && message.channelId !== channelId) return;

const key = `count:${message.author.id}`;
const current = await ctx.storage.get<number>(key, message.guildId) ?? 0;
await ctx.storage.set(key, current + 1, message.guildId);
},
},
],

hooks: {
async onLoad(ctx) {
ctx.logger.info('Counter addon loaded');
},
},
});

Building and installing

Build your addon from its directory:

cd addons/my-addon
pnpm build

This compiles src/ into dist/. The bot runtime looks for dist/index.js inside every subdirectory of addons/. On next bot startup, your addon is discovered, registered in the database automatically, and its commands become available. No manual database entry or portal registration is needed.

To test changes, rebuild and restart the bot:

pnpm build
sudo -u bot pm2 restart bot

Tips

  • Keep command names unique across all addons and built-in commands. Discord command names are global — a collision will silently overwrite the other command.
  • Always guard interaction.guildId. It can be null in DMs, and most addon storage and settings calls require a guild ID.
  • Use key prefixes in storage. Multiple records under one guild scope (e.g. balance:${userId}, cooldown:${userId}) stay organised and are all returned by ctx.storage.keys(guildId).
  • Read settings with a fallback. ctx.getSetting(guildId, key, default) returns the default when the guild hasn't configured the addon yet, so the addon always works out of the box.
  • Clean up in onGuildUninstall. Iterate ctx.storage.keys(guildId) and delete everything to avoid orphaned data after a guild removes your addon.