Scheduled jobs
Introduction
Section titled “Introduction”Your bot doesn’t just respond to interactions — it also has work that needs to happen on a schedule. Cleaning up expired mutes, generating daily activity reports, purging old audit logs, checking for inactive users. These are background tasks that run on a timer, not in response to a user action.
Warden’s core includes a simple, in-memory job scheduler powered by node-cron. You define a class with a @job() decorator, set a schedule, and the framework runs it automatically. There’s no persistence, no queue, no worker processes — just a cron timer that calls your execute() method on schedule.
For most bots, this is all you need. If you later outgrow it — you need job persistence across restarts, payloads, retries, or queue-based processing — there’s a clear upgrade path to the @warden/jobs plugin.
Defining a job
Section titled “Defining a job”A job is a class with a @job() decorator and an execute() method:
import {job, Job} from '@warden/core';
@job({name: 'DailyModReport', schedule: every.day.at('09:00')})export default class DailyModReportJob implements Job { constructor(private caseService: CaseService, private modLog: ModLogService) {}
public async execute(): Promise<void> { const stats = await this.caseService.getDailyStats();
await this.modLog.send( Embed.info('Daily Moderation Report') .addFields( {name: 'Warnings', value: String(stats.warnings), inline: true}, {name: 'Mutes', value: String(stats.mutes), inline: true}, {name: 'Bans', value: String(stats.bans), inline: true}, {name: 'Reports', value: String(stats.reports), inline: true}, {name: 'Auto-mod actions', value: String(stats.automod), inline: true}, ) .setTimestamp(), ); }}The @job() decorator accepts the following parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique identifier for the job |
schedule | string | Yes | Cron expression or result of a schedule helper |
The class must implement the Job interface, which requires an execute(): Promise<void> method. Jobs don’t receive any arguments — they’re self-contained units of work that fetch whatever data they need through injected services.
Schedule helpers
Section titled “Schedule helpers”Writing cron expressions from memory is error-prone. Warden provides a fluent every helper that reads like English and produces the correct cron string:
every.minute() // '* * * * *' — every minuteevery.minutes(5) // '*/5 * * * *' — every 5 minutesevery.minutes(15) // '*/15 * * * *' — every 15 minutesevery.hour() // '0 * * * *' — every hour, on the hourevery.hours(6) // '0 */6 * * *' — every 6 hoursevery.day.at('09:00') // '0 9 * * *' — daily at 9:00 AMevery.day.at('23:30') // '30 23 * * *' — daily at 11:30 PMevery.week.on('monday').at('10:00') // '0 10 * * 1' — every Monday at 10:00 AMevery.week.on('friday').at('17:00') // '0 17 * * 5' — every Friday at 5:00 PMSome real-world examples:
// Check for expired mutes every minute@job({name: 'ExpiredMuteCheck', schedule: every.minute()})
// Clean up old auto-mod logs every 6 hours@job({name: 'AutoModLogCleanup', schedule: every.hours(6)})
// Post daily mod report at 9 AM@job({name: 'DailyModReport', schedule: every.day.at('09:00')})
// Weekly inactive user report every Monday morning@job({name: 'InactiveUserReport', schedule: every.week.on('monday').at('10:00')})Raw cron expressions
Section titled “Raw cron expressions”If the every helper doesn’t cover your use case, you can pass a raw cron string directly:
@job({name: 'CustomSchedule', schedule: '*/5 * * * *'}) // every 5 minutes@job({name: 'Midnight', schedule: '0 0 * * *'}) // midnight daily@job({name: 'Quarterly', schedule: '0 9 1 */3 *'}) // 9 AM on the 1st of every 3rd monthWarden uses node-cron under the hood, so any valid node-cron expression works. The standard five-field format is: minute hour day-of-month month day-of-week.
Dependency injection
Section titled “Dependency injection”Jobs have full constructor injection support, just like commands and events. The @job() decorator registers the class for DI automatically — no @injectable() needed:
@job({name: 'ExpiredMuteCheck', schedule: every.minute()})export default class ExpiredMuteCheckJob implements Job { constructor( private muteService: MuteService, private modLog: ModLogService, private logger: Logger, private client: Client, ) {}
public async execute(): Promise<void> { const expired = await this.muteService.getExpired();
for (const mute of expired) { try { const guild = this.client.guilds.cache.get(mute.guildId); const member = await guild?.members.fetch(mute.userId);
if (member) { await member.timeout(null, 'Mute expired'); await this.modLog.send( Embed.success(`Unmuted ${member.user.username} — mute expired`) .addFields({name: 'Original reason', value: mute.reason}) .setTimestamp(), ); }
await this.muteService.markExpired(mute.id); } catch (error) { this.logger.error(`Failed to unmute ${mute.userId}`, error); } }
if (expired.length > 0) { this.logger.info(`Processed ${expired.length} expired mutes`); } }}You may inject the discord.js Client to interact with guilds, channels, and members — even though there’s no interaction triggering the job.
Error handling
Section titled “Error handling”If a job’s execute() method throws, the error flows through the same error handler system as commands and events. You may define a job-specific error handler:
@errorHandler({type: 'job'})export default class JobErrorHandler extends ErrorHandler { constructor(private logger: Logger, private modLog: ModLogService) {}
public async handle(error: Error, context: ErrorContext): Promise<void> { this.logger.error(`Job "${context.name}" failed`, error);
await this.modLog.send( Embed.error(`Scheduled job "${context.name}" failed`) .addFields({name: 'Error', value: error.message}) .setTimestamp(), ); }}A failed job does not stop the scheduler — the next execution will fire on schedule as normal. This is important for critical tasks like expired mute checks: a single failure (maybe the API was briefly unavailable) shouldn’t prevent the next check from running.
A complete example
Section titled “A complete example”Here’s a pair of jobs you might run — a daily inactive user report and an hourly auto-mod statistics cleanup:
Daily inactive user report
Section titled “Daily inactive user report”import {job, Job, Embed} from '@warden/core';
@job({name: 'InactiveUserReport', schedule: every.day.at('09:00')})export default class InactiveUserReportJob implements Job { constructor( private userService: UserService, private modLog: ModLogService, private client: Client, private config: Config, ) {}
public async execute(): Promise<void> { const guildId = this.config.get('guild.id') as string; const guild = this.client.guilds.cache.get(guildId); if (!guild) return;
const thresholdDays = this.config.get('moderation.inactiveThresholdDays', 30) as number; const inactiveUsers = await this.userService.getInactive(guildId, thresholdDays);
if (inactiveUsers.length === 0) return;
const list = inactiveUsers .slice(0, 20) .map(u => `<@${u.id}> — last active ${u.daysSinceLastActivity} days ago`) .join('\n');
await this.modLog.send( Embed.warning(`${inactiveUsers.length} users inactive for ${thresholdDays}+ days`) .setDescription(list) .setFooter({text: inactiveUsers.length > 20 ? `Showing 20 of ${inactiveUsers.length}` : ''}) .setTimestamp(), ); }}Auto-mod statistics cleanup (every 6 hours)
Section titled “Auto-mod statistics cleanup (every 6 hours)”import {job, Job} from '@warden/core';
@job({name: 'AutoModStatsCleanup', schedule: every.hours(6)})export default class AutoModStatsCleanupJob implements Job { constructor( private statsService: StatsService, private logger: Logger, ) {}
public async execute(): Promise<void> { const retentionDays = 90; const deleted = await this.statsService.deleteOlderThan(retentionDays);
if (deleted > 0) { this.logger.info(`Cleaned up ${deleted} auto-mod stat records older than ${retentionDays} days`); } }}Upgrading to @warden/jobs
Section titled “Upgrading to @warden/jobs”The built-in job scheduler covers the common case: periodic tasks that run in-process. But as your bot scales, you may need more:
- Persistence — jobs that survive bot restarts (what if the bot restarts at 8:59 and misses the 9:00 report?)
- Payloads — scheduling a job to run once at a specific time with specific data (e.g., “unmute user X in 30 minutes”)
- Retries — automatically retrying failed jobs with exponential backoff
- Queues — processing heavy tasks (like bulk audit log exports) without blocking the main thread
The @warden/jobs plugin provides all of this, backed by a persistent store (Redis, database, or custom adapter). The migration is intentionally smooth:
// Before: @warden/core built-in@job({name: 'DailyModReport', schedule: every.day.at('09:00')})export default class DailyModReportJob implements Job { ... }
// After: @warden/jobs plugin — same decorator, same interface@job({name: 'DailyModReport', schedule: every.day.at('09:00')})export default class DailyModReportJob implements Job { ... }Your existing @job() classes work without changes. The plugin adds new capabilities (one-off dispatch, payloads, queue configuration) but doesn’t change the API for scheduled cron jobs. You simply install the plugin, register it, and your existing jobs gain persistence for free.