Building your own plugin
Introduction
Section titled “Introduction”Every first-party Warden plugin — @warden/drizzle, @warden/cache, @warden/audit, and the rest — is built on the exact same authoring surface that’s available to you. There’s no internal magic, no hidden APIs, no framework-only escape hatches. If you can write a service class, you can write a plugin.
You’ll reach for a plugin when you want to register services that other parts of your bot can inject, run some setup work when the bot boots, react to the gateway coming online, or clean up after yourself on shutdown. And, naturally, plugins are how you share reusable functionality across multiple bots, or publish it to npm for the rest of the community to use.
The ServiceProvider base class
Section titled “The ServiceProvider base class”At its heart, a plugin is simply a class that extends ServiceProvider, declares a typed config shape on three static fields, and implements whichever lifecycle hooks it happens to need. Here’s a minimal provider that registers a webhook service and tears it down on shutdown:
import {ServiceProvider} from '@warden/core';
export interface WebhookConfig { url: string; timeout: number;}
export default class WebhookServiceProvider extends ServiceProvider { name = 'webhook'; static configNamespace = 'webhook'; static defaults: WebhookConfig = { url: '', timeout: 5000, };
register() { const cfg = this.config<WebhookConfig>(); const service = new WebhookService(cfg.url, cfg.timeout); this.container.registerInstance(WebhookService, service); }
async shutdown() { const service = this.container.resolve(WebhookService); await service.close(); }}That’s the entire contract. The name instance field identifies your provider in logs, in dependency cycles, and in other providers’ requires declarations. The three statics — configNamespace, defaults, and (as we’ll see shortly) publishes — declare how your plugin participates in the config system. And the framework hands you a few conveniences on this: this.config() returns the merged, typed config; this.container is the DI scope you’ll register services into; this.logger gives you a pre-configured Pino logger; and this.bot is the owning Bot instance should you ever need it.
Consumers instantiate your provider with no arguments and let the config system handle the rest:
new Bot() .plugins([WebhookServiceProvider]) .start();And in the consumer’s config/webhook.ts:
import {Config, env} from '@warden/core';import {WebhookServiceProvider} from 'your-webhook-package';
export default Config.define(WebhookServiceProvider, { url: env.required('WEBHOOK_URL'),});What’s available on this
Section titled “What’s available on this”| Field | Type | Description |
|---|---|---|
this.config() | T (your defaults type) | Typed, merged config for this provider |
this.container | DependencyContainer | DI container. Use for registerSingleton / registerInstance / resolve |
this.bot | Bot | The owning Bot instance. Rarely needed directly |
this.logger | Logger | Pino-backed logger, preconfigured |
The lifecycle
Section titled “The lifecycle”Plugins don’t run all their work at the same moment. Warden gives you four distinct hooks, and placing work in the right one is the difference between a clean boot and a tangle of race conditions. Here’s what each hook is for, and when it’s safe to do what:
| Hook | When | Safe to do |
|---|---|---|
register() | Before any other plugin’s boot(). Plugins run in requires-sorted order. | Bind things into the DI container — services, instances, tokens. Read your config via this.config(). Nothing that resolves another plugin’s service, since those plugins may not have registered yet. |
boot() | After every plugin’s register() has returned. May be async. | Resolve cross-plugin services, connect to databases, spin up HTTP clients — anything that doesn’t depend on the Discord gateway being live. |
onReady() | After client.login() resolves and the gateway ready event fires. May be async. | Anything that needs the real Discord client: post a startup ping, subscribe to voice events, fetch guild metadata. |
shutdown() | On SIGTERM / SIGINT, in reverse registration order. | Close connections, flush buffers, stop timers. |
To put that in a sequence, a typical boot looks like this:
1. All plugins: register() — bind services into the DI container2. All plugins: boot() — initialize connections, resolve deps3. Bot connects to Discord — gateway login, 'ready' event fires4. All plugins: onReady() — plugins that need the live client run now5. Handlers start processing — commands, events, buttons... ...6. Shutdown signal received — SIGINT/SIGTERM7. All plugins: shutdown() — cleanup (reverse order)When hooks throw
Section titled “When hooks throw”Errors aren’t all treated equally. register and boot are critical wiring — if they fail, the bot is in an undefined state and has no business staying up, so the error propagates all the way to whoever called bot.start(). onReady, on the other hand, is application-level work: one provider’s startup ping failing shouldn’t bring the whole bot down, so the error is logged and the remaining providers still get to run. shutdown follows the same philosophy as onReady — every cleanup needs the chance to run, regardless of what the others do.
| Hook | On throw |
|---|---|
register() | The entire bot start is aborted. The error propagates up to whoever called bot.start(). |
boot() | Same as register() — aborts start. |
onReady() | Logged at error level; remaining plugins’ onReady hooks still run. The bot stays up. |
shutdown() | Logged at error level; remaining plugins’ shutdown hooks still run. Shutdown always completes. |
A complete example
Section titled “A complete example”Let’s walk through a provider from start to finish. We’ll build a plugin that posts a webhook alert when the bot comes online, and another when it shuts down. It’s a small example, but it exercises typed config, onReady, shutdown, and a resolvable service — most of what you’ll actually use day-to-day.
1. Declare the config shape and the service
Section titled “1. Declare the config shape and the service”First, we’ll define what the consumer can configure. Keep this interface tight — only what the plugin truly needs — and lean on sensible defaults wherever you can:
export interface WebhookConfig { /** Endpoint that receives the startup/shutdown pings. */ url: string; /** Timeout in ms for each POST. */ timeout: number;}Next, the service itself. This is a plain, injectable class — nothing plugin-specific about it. That’s the point: your services are ordinary classes, and the provider is simply the thing that wires them into the container:
import {singleton} from 'tsyringe';
@singleton()export default class WebhookService { constructor( private url: string, private timeout: number, ) {}
async send(message: string): Promise<void> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { await fetch(this.url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({message}), signal: controller.signal, }); } finally { clearTimeout(timeoutId); } }}2. Write the provider
Section titled “2. Write the provider”Now for the fun part. The provider reads the merged config via this.config(), builds the service, and hooks into the lifecycle:
import {ServiceProvider} from '@warden/core';import WebhookService from './WebhookService';import type {WebhookConfig} from './WebhookConfig';
export default class WebhookServiceProvider extends ServiceProvider { name = 'webhook'; static configNamespace = 'webhook'; static defaults: WebhookConfig = { url: '', timeout: 5000, }; static publishes = [ { from: new URL('../stubs/webhook.stub.ts', import.meta.url), to: 'config/webhook.ts', tag: 'config', }, ] as const;
register() { const cfg = this.config<WebhookConfig>(); const service = new WebhookService(cfg.url, cfg.timeout); this.container.registerInstance(WebhookService, service); }
async onReady() { const service = this.container.resolve(WebhookService); await service.send('Bot online'); }
async shutdown() { const service = this.container.resolve(WebhookService); await service.send('Bot shutting down'); }}3. Ship a stub
Section titled “3. Ship a stub”The stub is the starting point consumers publish into their project. It lives outside src/ (so the plugin’s own build doesn’t typecheck it as internal code) and imports everything as a consumer would:
import {Config, env} from '@warden/core';import {WebhookServiceProvider} from './path-to-your-plugin';
export default Config.define(WebhookServiceProvider, { url: env.required('WEBHOOK_URL'),});4. Use it
Section titled “4. Use it”Drop the provider into .plugins() — no arguments. The consumer writes their own config/webhook.ts (or publishes your stub via warden publish), and the framework wires the rest:
import {Bot, env} from '@warden/core';import WebhookServiceProvider from './plugins/webhook/WebhookServiceProvider';
new Bot() .discover(d => d .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts')) .intents(i => i.defaults()) .plugins([WebhookServiceProvider]) .start();That’s the whole pattern, and it’s the same pattern you’ll see in every first-party Warden plugin. Config travels through config/*.ts files into this.config(), services resolve from this.container, and the onReady / shutdown hooks stitch the whole thing into the bot’s lifecycle.
Ordering plugins with requires
Section titled “Ordering plugins with requires”Once you start building a handful of plugins, you’ll inevitably hit a case where one plugin depends on another having already registered its services. Rather than relying on array order (which is brittle and silent when it breaks), you should declare the dependency explicitly:
export default class AuditServiceProvider extends ServiceProvider { name = 'audit'; static configNamespace = 'audit'; static defaults = {/* ... */}; requires = ['discord-client'] as const;
async onReady() { // 'discord-client' provider has already run — its services are ready }}At bot.start(), Warden topologically sorts your providers so that every one runs after its declared dependencies. If a name in requires isn’t present in the list, start fails fast with a helpful error; if you accidentally introduce a cycle, you’ll hear about that too. Providers without any requires keep whatever order you passed them in — so the declaration is a promise, not a magic reordering.
Swappable adapters via config
Section titled “Swappable adapters via config”Some plugins — caching, job queues, audit logs — need to work with different storage backends depending on who’s running them. In development you might want an in-memory adapter; in production, Redis. Declare the adapter as a field on your defaults, holding a default instance:
import {ServiceProvider} from '@warden/core';
export interface CacheConfig { adapter: CacheAdapter;}
export default class CacheServiceProvider extends ServiceProvider { name = 'cache'; static configNamespace = 'cache'; static defaults: CacheConfig = { adapter: new RedisAdapter(), };
register() { const {adapter} = this.config<CacheConfig>(); const service = this.container.resolve(CacheService); service.setAdapter(adapter); this.container.registerInstance(CacheService, service); }}Consumers then pick the adapter in their config file, which keeps the choice explicit and out of any environment-coupled runtime:
import {Config} from '@warden/core';import {CacheServiceProvider, RedisAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new RedisAdapter(),});import {Config} from '@warden/core';import {CacheServiceProvider, MemoryAdapter} from '@warden/cache';
export default Config.define(CacheServiceProvider, { adapter: new MemoryAdapter(),});Class instances in config are replaced wholesale — a new RedisAdapter() in the consumer’s override fully supersedes the default new MemoryAdapter(); they’re never merged.
Multiple adapters at once
Section titled “Multiple adapters at once”Some plugins need to fan out — an audit log, for instance, may want to write to a database and post to a Discord channel in one go. For those, swap the singular adapter field for a plural adapters array:
export interface AuditConfig { adapters: AuditAdapter[];}
export default class AuditServiceProvider extends ServiceProvider { name = 'audit'; static configNamespace = 'audit'; static defaults: AuditConfig = { adapters: [new FileAdapter()], };
register() { const {adapters} = this.config<AuditConfig>(); const log = this.container.resolve(AuditLog); log.setAdapters(adapters); }}import {Config} from '@warden/core';import {AuditServiceProvider, DatabaseAdapter, ChannelAdapter} from '@warden/audit';
export default Config.define(AuditServiceProvider, { adapters: [new DatabaseAdapter(), new ChannelAdapter(channelId)],});Arrays replace entirely too — your adapters: [new DatabaseAdapter(), new ChannelAdapter()] fully overrides the default [new FileAdapter()].
Shipping a config stub
Section titled “Shipping a config stub”Consumers rarely want to write a config file from scratch. Warden ships a warden publish command that copies plugin-authored stubs into their project, ready to be tweaked. Declare what your plugin publishes via the publishes static:
export default class WebhookServiceProvider extends ServiceProvider { // ... static publishes = [ { from: new URL('../stubs/webhook.stub.ts', import.meta.url), to: 'config/webhook.ts', tag: 'config', }, ] as const;}Each entry has three fields:
from— aURLor absolute path to the stub in your plugin’s package. Usenew URL('../stubs/...', import.meta.url)so paths resolve correctly after the build.to— where the stub lands in the consumer’s project, relative to their project root.tag— a free string for grouping. Warden documents three conventional tags:config,translations, and anything custom you want.
Consumers then run warden publish <your-plugin> --package=<your-package> and get a starting config they can edit.
Testing your plugin
Section titled “Testing your plugin”Warden ships a small test harness specifically for providers. runServiceProvider attaches a fresh Bot scope, wires the provider’s defaults (or a custom config you supply), runs register() and boot(), and hands back the bot so your test can resolve services straight from its container:
import {describe, it, expect, beforeEach} from 'vitest';import {container} from 'tsyringe';import {runServiceProvider} from '@warden/core/testing';import WebhookServiceProvider from '../src/plugins/webhook/WebhookServiceProvider';import WebhookService from '../src/plugins/webhook/WebhookService';
describe('WebhookServiceProvider', () => { beforeEach(() => container.clearInstances());
it('registers WebhookService with the configured URL', async () => { const {bot} = await runServiceProvider(WebhookServiceProvider, { config: {url: 'https://example.com/hook', timeout: 1000}, }); expect(bot.container.resolve(WebhookService)).toBeInstanceOf(WebhookService); });});Pass {config: {...}} to replace the provider’s defaults wholesale for that run — tests get total control over what the provider sees from this.config(). Omit it to exercise the defaults. Pass {boot: false} if you only want to hit register. onReady isn’t invoked by the harness — that’s a live-gateway concern, and it’s territory for integration tests, not unit tests.
Packaging a plugin for npm
Section titled “Packaging a plugin for npm”Once your plugin has earned its keep in your own bot, you may want to share it. The shape is refreshingly conventional:
File layout
Section titled “File layout”Directorypackages/warden-plugin-webhook/
Directorysrc/
- WebhookServiceProvider.ts
- WebhookService.ts
- index.ts
Directorystubs/
- webhook.stub.ts
Directorytests/
- WebhookServiceProvider.test.ts
- package.json
- tsconfig.json
- README.md
The stubs/ folder lives outside src/ deliberately. Stubs are consumer-facing — they import from @warden/core and from your plugin’s public name, not from relative internal paths. Keeping them outside src/ also prevents your plugin’s own build from typechecking them as first-class modules.
Package naming
Section titled “Package naming”A few conventions that help the community find your plugin:
warden-plugin-<name>— unscoped and discoverable, e.g.warden-plugin-webhook@<org>/warden-<name>— scoped to your org, e.g.@acme/warden-notify@<org>/<name>— scoped and brand-neutral, e.g.@acme/webhook
package.json
Section titled “package.json”Declare @warden/core as a peer dependency — you never want consumers ending up with two copies of the framework in their bundle. And remember to include stubs in files so npm actually ships them:
{ "name": "warden-plugin-webhook", "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist", "stubs"], "peerDependencies": { "@warden/core": "^1.0.0" }}Exports
Section titled “Exports”Export the provider class, the service, and the config interface so consumers can import whichever parts they need:
export {default as WebhookServiceProvider} from './WebhookServiceProvider';export {default as WebhookService} from './WebhookService';export type {WebhookConfig} from './WebhookConfig';And that’s all there is to it. Your plugin participates in the same lifecycle, the same DI container, and the same config system as every built-in plugin. There’s no distinction between first-party and third-party in Warden — just the ServiceProvider base class and whatever you decide to build on top of it.