Claude Code with Agenda: Node.js Scheduling Without the Drift
Why Agenda without CLAUDE.md ships schedulers that quietly drift
Agenda is the MongoDB-backed Node.js job scheduler that handles cron-style recurrence, one-off jobs, and intervals through a single API. The pitch is straightforward: define a job once, call agenda.every('5 minutes', 'job-name') or agenda.schedule('tomorrow at 8am', 'job-name'), and the scheduler persists the schedule in MongoDB and runs the job at the right time. The reality is that the same simplicity hides a small set of decisions that decide whether the deployment survives. Agenda's defaults are tuned for development: it polls every five seconds, locks jobs for ten minutes, allows unlimited concurrency, and stores jobs in the agendaJobs collection with no special indexing. Every one of those defaults is the wrong choice for production.
A Claude Code session that drafts Agenda code without project rules generates a setup that works for the first week and develops symptoms by the second. The job definition does not handle the case where the previous run is still in flight when the next schedule fires, so two copies run concurrently and the database gets two emails sent for one schedule. The lock duration is the default ten minutes, but the job takes twelve minutes on a bad day, so Agenda's stale-lock detector re-runs the job halfway through the first run. The same Agenda instance runs the scheduler and the worker, so a slow job blocks the scheduler from picking up the next one. The MongoDB collection has no index on nextRunAt, so every poll does a full collection scan and the scheduler stops keeping up when the queue grows past a few thousand pending jobs. The agenda.cancel() call in the deploy script wipes every job because the selector matched everything.
This guide covers the CLAUDE.md configuration that locks Claude Code into Agenda patterns that survive production: a strict separation between the scheduler that defines jobs and the workers that run them, idempotent job definitions with explicit concurrency and lock durations, MongoDB indexes that keep the poll fast, retry strategies that bound the failure spiral, and permission hooks that gate the destructive cancel and purge calls. For the Redis-backed alternative when MongoDB is not already in the stack, Claude Code with BullMQ covers the patterns for Redis-based queues. For the durable workflow orchestrator when Agenda is too low-level, Claude Code with Temporal covers the workflow engine.
The Agenda CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Agenda integration it needs to declare: the Agenda version, the MongoDB connection, the project structure, the scheduler/worker split, the job definition pattern, the concurrency and lock policy, the retry rule, and the hard rules that block the misconfigurations Claude makes most often.
# Agenda rules
## Stack
- agenda ^5.x (latest stable, NOT @hokify/agenda fork unless explicitly chosen)
- mongodb ^6.x driver (peer of agenda)
- MongoDB 6.0+ replica set in production, NEVER standalone
- AGENDA_MONGODB_URI env var, includes replicaSet and authSource
- AGENDA_JOB_COLLECTION env var, namespaced per service (no shared collections)
## Project structure
- src/agenda/agenda.ts , Agenda instance factory, ONE per process
- src/agenda/jobs/ , one file per job definition
- src/agenda/scheduler/index.ts , scheduler process entry point
- src/agenda/worker/index.ts , worker process entry point
- src/agenda/migrations/ , idempotent re-registration of recurring jobs
- src/jobs/ , pure functions called by job definitions
- tests/ , unit tests for job functions + integration tests with MongoDB
## Process separation (MANDATORY)
- One scheduler process: defines jobs, registers schedules, runs at low concurrency
- N worker processes: define jobs, process the queue, run at higher concurrency
- Scheduler and worker share the same job definitions (same code)
- Scheduler's processEvery: 5s, worker's processEvery: 1s
- Scheduler maxConcurrency: 1 (only the scheduler triggers schedules)
- Worker maxConcurrency: tuned per workload, default 10
## Job definitions (MANDATORY)
- One job definition per file, exported as a function
- Job name uses kebab-case with a service prefix: <service>-<verb>-<noun>
- Definition accepts the Agenda instance, registers via agenda.define(name, opts, handler)
- Handler is a thin wrapper that calls a pure function from src/jobs/
- Handler logs entry, exit, and error with the job ID and attempt count
## Concurrency and locks (MANDATORY per job)
- concurrency: explicit per job, default 1, scaled to MongoDB write capacity
- lockLimit: explicit per job, default matches concurrency
- lockLifetime: 2x the p99 job duration, minimum 60s, maximum 600s
- priority: explicit when multiple job types share a worker
## Retry policy (MANDATORY for every job)
- shouldSaveResult: true (failure handler needs the result)
- attempts: 3 default, 5 for idempotent, 1 for non-idempotent (manual replay)
- backoff: exponential via the handler, NOT Agenda built-in (which is constant)
- On final failure, route to a dead-letter collection or alerting
## Recurring jobs (MANDATORY)
- Registered via agenda.every() in a single registration function
- Registration function is idempotent: safe to call on every scheduler boot
- Use unique name per schedule, never let Agenda auto-generate
- Timezone: explicit UTC in production, never local time
## MongoDB indexes (MANDATORY)
- { name: 1, nextRunAt: 1 } compound index for the poll query
- { lockedAt: 1 } sparse index for stale-lock detection
- { lastFinishedAt: -1 } for cleanup queries
- Run db.agendaJobs.createIndexes() at scheduler boot
## Hard rules
- NEVER run scheduler and worker logic in the same Agenda instance
- NEVER skip an explicit concurrency value on a job definition
- NEVER use lockLifetime shorter than 2x the p99 job duration
- NEVER call agenda.cancel({}) without an explicit filter object
- NEVER let recurring job names collide across services in the shared collection
- NEVER store sensitive payload in job.attrs.data (it persists to MongoDB)
- ALWAYS handle SIGTERM with agenda.stop() to drain in-flight jobs
- ALWAYS log job ID, attempt, and duration on every completion or failure
Six rules here prevent the majority of incidents Claude generates without them.
The scheduler-worker separation rule is the most important architectural rule. A single Agenda instance that both schedules and processes can stall: a long-running job holds the worker, the poll thread does not get CPU, the next schedule fires late, and the next job after that fires later. With separate processes, the scheduler's only job is to mark nextRunAt correctly and the workers pick up due jobs from MongoDB. The scheduler can run at low concurrency on a small instance; the workers scale horizontally.
The explicit concurrency rule stops Claude from accepting Agenda's default of unlimited. A job that fans out to an external API will, without bounds, exceed the API's rate limit within seconds of catch-up after an outage. With concurrency: 5, lockLimit: 5, the worker holds at most five in-flight per job type, the upstream stays within its limits, and the queue drains predictably.
The lock-lifetime rule prevents the duplicate-execution class of bug. Agenda locks a job for lockLifetime milliseconds when it starts. If the job runs longer than the lock, another worker considers the lock stale and picks it up, so two copies run. Setting lockLifetime to twice the p99 duration with a 60-second floor means stale-lock recovery only kicks in for jobs that are genuinely hung.
The idempotent recurring registration rule prevents the deploy explosion. A naive agenda.every('5 minutes', 'cleanup') call in startup code, run on three worker pods, creates three independent recurring schedules. Each runs every five minutes. In a month the queue has thirty schedules and the cleanup job runs every ten seconds. With agenda.every('5 minutes', 'cleanup', {}, { unique: true }), the registration is idempotent and re-runs do nothing.
Install and project structure
Install Agenda and its peer driver:
pnpm add agenda mongodb
pnpm add -D @types/agenda
Set the environment:
# .env.local
AGENDA_MONGODB_URI=mongodb://localhost:27017/jobs?replicaSet=rs0&authSource=admin
AGENDA_JOB_COLLECTION=app_jobs
AGENDA_DEFAULT_CONCURRENCY=5
Create the Agenda instance factory:
// src/agenda/agenda.ts
import { Agenda } from 'agenda';
let instance: Agenda | null = null;
export function getAgenda(): Agenda {
if (instance) return instance;
if (!process.env.AGENDA_MONGODB_URI) {
throw new Error('AGENDA_MONGODB_URI must be set');
}
instance = new Agenda({
db: {
address: process.env.AGENDA_MONGODB_URI,
collection: process.env.AGENDA_JOB_COLLECTION ?? 'app_jobs',
options: {
useUnifiedTopology: true,
maxPoolSize: 10,
},
},
processEvery: process.env.AGENDA_PROCESS_EVERY ?? '5 seconds',
maxConcurrency: parseInt(process.env.AGENDA_MAX_CONCURRENCY ?? '20', 10),
defaultConcurrency: parseInt(process.env.AGENDA_DEFAULT_CONCURRENCY ?? '5', 10),
defaultLockLifetime: parseInt(process.env.AGENDA_DEFAULT_LOCK_LIFETIME ?? '300000', 10),
name: `${process.env.SERVICE_NAME}-${process.env.HOSTNAME}`,
});
return instance;
}
The factory returns a singleton: one Agenda instance per process. The name field is the unique identifier MongoDB sees when locking a job, derived from the service name and the hostname. This lets the operator see which pod has which job locked.
The processEvery setting controls how often Agenda polls MongoDB for due jobs. The default of five seconds is fine for the scheduler. For workers that need lower latency on ad-hoc jobs, set it to one second via the env var.
Defining a job
A job in Agenda has a name, options, and a handler. Each job goes in its own file, defines its options explicitly, and delegates the actual work to a pure function.
// src/agenda/jobs/send-welcome-email.ts
import type { Agenda, Job } from 'agenda';
import { sendWelcomeEmail } from '../../jobs/send-welcome-email';
type SendWelcomeEmailData = {
userId: string;
email: string;
templateVersion: string;
};
export function defineSendWelcomeEmail(agenda: Agenda) {
agenda.define<SendWelcomeEmailData>(
'app-send-welcome-email',
{
concurrency: 5,
lockLimit: 5,
lockLifetime: 120_000,
priority: 'normal',
},
async (job: Job<SendWelcomeEmailData>) => {
const { userId, email, templateVersion } = job.attrs.data;
const start = Date.now();
const attempt = (job.attrs.failCount ?? 0) + 1;
console.log('job_started', {
jobId: job.attrs._id?.toString(),
name: job.attrs.name,
attempt,
userId,
});
try {
const result = await sendWelcomeEmail({ userId, email, templateVersion });
job.attrs.result = result;
console.log('job_completed', {
jobId: job.attrs._id?.toString(),
name: job.attrs.name,
attempt,
durationMs: Date.now() - start,
providerId: result.providerId,
});
} catch (err) {
console.error('job_failed', {
jobId: job.attrs._id?.toString(),
name: job.attrs.name,
attempt,
durationMs: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
});
throw err;
}
},
);
}
The job name app-send-welcome-email includes the service prefix and a verb-noun structure. The options block sets concurrency, lockLimit, and lockLifetime explicitly. The handler is intentionally thin: it logs the start, calls a pure function from src/jobs/, stores the result, and logs completion or failure. The pure function is unit-testable without Agenda machinery and the handler stays focused on lifecycle concerns.
The failCount on the job is how Agenda tracks retries. Reading it as (job.attrs.failCount ?? 0) + 1 gives the current attempt number for logging.
The lockLifetime: 120_000 is two minutes, matched to a job whose p99 duration is one minute. The concurrency: 5 and lockLimit: 5 mean the worker holds at most five copies of this job in flight at once. If the email provider has a 100 requests-per-second rate limit and the send latency is 500 ms, five concurrent sends use 10% of the rate budget, leaving headroom for other senders.
The scheduler process
The scheduler process is responsible for registering recurring jobs and triggering schedules. It runs as its own deployment, separate from workers.
// src/agenda/scheduler/index.ts
import { getAgenda } from '../agenda';
import { defineSendWelcomeEmail } from '../jobs/send-welcome-email';
import { defineDailyRollup } from '../jobs/daily-rollup';
import { defineHourlyCleanup } from '../jobs/hourly-cleanup';
async function main() {
const agenda = getAgenda();
defineSendWelcomeEmail(agenda);
defineDailyRollup(agenda);
defineHourlyCleanup(agenda);
await agenda.start();
await registerRecurringJobs(agenda);
console.log('scheduler_started', { processEvery: '5 seconds' });
}
async function registerRecurringJobs(agenda: ReturnType<typeof getAgenda>) {
await agenda.every('0 3 * * *', 'app-daily-rollup', {}, {
timezone: 'UTC',
});
await agenda.every('0 * * * *', 'app-hourly-cleanup', {}, {
timezone: 'UTC',
});
}
async function shutdown(signal: string) {
console.log('scheduler_shutdown_start', { signal });
const agenda = getAgenda();
await agenda.stop();
console.log('scheduler_shutdown_complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
main().catch((err) => {
console.error('scheduler_fatal', { message: err.message });
process.exit(1);
});
The defineSendWelcomeEmail, defineDailyRollup, and defineHourlyCleanup calls register the job definitions with Agenda. The scheduler does not actually run the jobs (its concurrency is low), but it has to know about them because Agenda's same-process model expects all definitions to be present everywhere the queue is polled.
The registerRecurringJobs function is idempotent: agenda.every() with a stable job name overwrites any existing schedule rather than creating a new one. Re-running the scheduler does not duplicate schedules. The timezone: 'UTC' is explicit, so schedules do not shift when the host's local timezone changes.
The shutdown handler calls agenda.stop(), which stops new job pickup and waits for in-flight jobs to complete. Match the SIGTERM grace period in your orchestrator (Kubernetes terminationGracePeriodSeconds, ECS stopTimeout) to the longest job duration plus a buffer.
The worker process
The worker process runs the same job definitions but with worker-tuned settings. Multiple worker instances scale horizontally; the MongoDB row-level locking ensures each job runs exactly once.
// src/agenda/worker/index.ts
import { Agenda } from 'agenda';
import { defineSendWelcomeEmail } from '../jobs/send-welcome-email';
import { defineDailyRollup } from '../jobs/daily-rollup';
import { defineHourlyCleanup } from '../jobs/hourly-cleanup';
async function main() {
const agenda = new Agenda({
db: {
address: process.env.AGENDA_MONGODB_URI!,
collection: process.env.AGENDA_JOB_COLLECTION ?? 'app_jobs',
},
processEvery: '1 second',
maxConcurrency: 20,
defaultConcurrency: 5,
defaultLockLifetime: 300_000,
name: `worker-${process.env.HOSTNAME}`,
});
defineSendWelcomeEmail(agenda);
defineDailyRollup(agenda);
defineHourlyCleanup(agenda);
agenda.on('start', (job) => {
console.log('job_started', { jobId: job.attrs._id?.toString(), name: job.attrs.name });
});
agenda.on('complete', (job) => {
console.log('job_completed', { jobId: job.attrs._id?.toString(), name: job.attrs.name });
});
agenda.on('fail', (err, job) => {
console.error('job_failed', {
jobId: job.attrs._id?.toString(),
name: job.attrs.name,
message: err.message,
});
});
await agenda.start();
console.log('worker_started', { name: agenda.attrs?.name });
async function shutdown(signal: string) {
console.log('worker_shutdown_start', { signal });
await agenda.stop();
console.log('worker_shutdown_complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
main().catch((err) => {
console.error('worker_fatal', { message: err.message });
process.exit(1);
});
The worker's processEvery: '1 second' polls more aggressively than the scheduler, which keeps ad-hoc job latency low. The maxConcurrency: 20 caps the total in-flight jobs across all definitions on this worker; combined with per-job concurrency, a worker can hold five welcome emails plus five other jobs plus the recurring cleanup, up to the 20-job global cap.
The event handlers do structured logging at the Agenda lifecycle level. This is the second layer of logging: the per-job handler logs the business detail, the worker's event handlers log every transition for the queue-wide view. Both go to stdout in JSON format for log aggregation.
MongoDB indexes for the poll path
Agenda's poll query looks for jobs where nextRunAt is in the past and lockedAt is null or stale. Without indexes, this query does a full collection scan on every poll, and the scheduler stops keeping up once the collection has more than a few thousand documents.
Create the indexes at scheduler boot:
// src/agenda/migrations/create-indexes.ts
import { MongoClient } from 'mongodb';
export async function createAgendaIndexes() {
const client = await MongoClient.connect(process.env.AGENDA_MONGODB_URI!);
const db = client.db();
const collection = db.collection(process.env.AGENDA_JOB_COLLECTION ?? 'app_jobs');
await collection.createIndex(
{ name: 1, nextRunAt: 1 },
{ name: 'agenda_name_nextRunAt' },
);
await collection.createIndex(
{ lockedAt: 1 },
{ name: 'agenda_lockedAt', sparse: true },
);
await collection.createIndex(
{ lastFinishedAt: -1 },
{ name: 'agenda_lastFinishedAt' },
);
await collection.createIndex(
{ nextRunAt: 1, priority: -1 },
{ name: 'agenda_nextRunAt_priority' },
);
await client.close();
}
Call createAgendaIndexes() from the scheduler's main() function before agenda.start(). The indexes are idempotent: subsequent runs are no-ops if the indexes already exist.
The { name: 1, nextRunAt: 1 } compound matches the predicate Agenda's poll uses. The { lockedAt: 1 } sparse index speeds up stale-lock detection. The { lastFinishedAt: -1 } supports cleanup queries that delete completed jobs older than N days. The { nextRunAt: 1, priority: -1 } accelerates priority-based pickup when the queue has a mix of high and low priority jobs.
Idempotent recurring registration
The CLAUDE.md rule that recurring job registration must be idempotent is enforced by the unique option. Without it, every scheduler restart creates a new schedule.
await agenda.every(
'0 3 * * *',
'app-daily-rollup',
{},
{
timezone: 'UTC',
},
);
The current agenda API treats every() calls as upserts based on the job name and the data payload: calling it twice with the same name and the same data overwrites the existing schedule. For the older @hokify/agenda fork, add { skipImmediate: true } to avoid the immediate first run on restart.
To make the deploy story clean, write a registration module that lists every recurring job in one place:
// src/agenda/scheduler/recurring.ts
export const RECURRING_JOBS = [
{ schedule: '0 3 * * *', name: 'app-daily-rollup' },
{ schedule: '0 * * * *', name: 'app-hourly-cleanup' },
{ schedule: '*/15 * * * *', name: 'app-cache-warm' },
] as const;
export async function registerAll(agenda: Agenda) {
for (const job of RECURRING_JOBS) {
await agenda.every(job.schedule, job.name, {}, { timezone: 'UTC' });
}
}
Removing a job from this list does not automatically delete the schedule from MongoDB. Add a cleanup step that runs once after a deploy:
export async function cleanupRemovedJobs(agenda: Agenda) {
const knownNames = RECURRING_JOBS.map((j) => j.name);
const result = await agenda.cancel({
name: { $nin: knownNames },
type: 'single',
repeatInterval: { $ne: null },
});
console.log('removed_recurring_jobs', { count: result });
}
The selector { name: { $nin: knownNames }, type: 'single', repeatInterval: { $ne: null } } matches only recurring jobs whose names are not in the current list. This is the right shape for the explicit filter the hard rule demands. Combined with Claude Code with MongoDB for the underlying database patterns, the cleanup is idempotent and safe to run on every deploy.
Permission hooks for Agenda operations
Agenda's cancel and purge methods can wipe the entire job collection if called with an empty selector. Permission hooks in .claude/settings.local.json gate the destructive operations.
{
"permissions": {
"allow": [
"Bash(node -e *agenda.jobs*)",
"Bash(mongo*db.app_jobs.find*)",
"Bash(mongo*db.app_jobs.countDocuments*)"
],
"deny": [
"Bash(mongo*db.app_jobs.drop*)",
"Bash(mongo*db.app_jobs.deleteMany*)",
"Bash(node -e *agenda.cancel*)",
"Bash(node -e *agenda.purge*)"
]
}
}
The allow list permits read-only queries and job inspection. The deny list catches the operations that drop the collection or delete jobs in bulk. The node -e *agenda.cancel* pattern catches the inline-script form where Claude might be tempted to write node -e "require('./agenda').then(a => a.cancel({}))". Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can investigate the queue without being able to empty it.
Common Claude Code mistakes with Agenda
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Scheduler and worker in one process
Claude generates: a single index.ts that calls both defineRecurringJobs() and processes the queue.
Correct pattern: separate scheduler and worker entry points, scheduler has low concurrency and defines recurrences, worker has high concurrency and processes.
2. No explicit concurrency or lockLifetime
Claude generates: agenda.define('name', async (job) => { ... }).
Correct pattern: agenda.define('name', { concurrency: 5, lockLimit: 5, lockLifetime: 120_000 }, handler).
3. Non-idempotent recurring registration
Claude generates: agenda.every('5 minutes', 'cleanup') called on every worker boot.
Correct pattern: registered only from the scheduler process, with the same job name so subsequent calls upsert.
4. agenda.cancel() with empty selector
Claude generates: agenda.cancel({}) "to clean up" before redeploy.
Correct pattern: selector with { name: { $nin: knownNames }, ... } or { name: 'specific-job' }.
5. Missing MongoDB indexes
Claude generates: a deployment that runs against agendaJobs collection with no indexes.
Correct pattern: createAgendaIndexes() called at scheduler boot, with the compound index on { name, nextRunAt }.
6. lockLifetime shorter than job duration
Claude generates: lockLifetime: 60_000 for a job that takes three minutes.
Correct pattern: lockLifetime set to 2x the p99 duration, minimum 60 seconds.
Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is Agenda code Claude generates correctly the first time.
Retry strategy and dead letters
Agenda's built-in retry is constant: failed jobs retry on a fixed interval. For most workloads the right strategy is exponential backoff implemented inside the handler.
agenda.define('app-send-email', { concurrency: 5, lockLifetime: 60_000 }, async (job) => {
const attempt = (job.attrs.failCount ?? 0) + 1;
const maxAttempts = 5;
try {
return await sendEmail(job.attrs.data);
} catch (err) {
if (attempt < maxAttempts) {
const backoffMs = Math.min(60_000, 1000 * Math.pow(2, attempt));
job.attrs.nextRunAt = new Date(Date.now() + backoffMs);
console.warn('job_retry_scheduled', { attempt, backoffMs });
} else {
await deadLetter(job.attrs);
}
throw err;
}
});
The handler computes the next retry time before re-throwing. Setting job.attrs.nextRunAt before the throw tells Agenda to retry at that time rather than immediately. The exponential backoff starts at one second, doubles each attempt, and caps at one minute. After five attempts the job is marked for the dead-letter handler instead of retried.
The dead-letter pattern writes the job's data, error, and history to a separate MongoDB collection where an operator can inspect it and replay if appropriate. For external alerting on dead letters, pair with Claude Code with Sentry for the error tracking integration.
Observability and metrics
Every Agenda deployment needs three sets of metrics: queue depth (jobs by status), worker throughput (jobs/sec, p99 duration), and MongoDB health (lock contention, write latency).
For queue depth, run a periodic query against the collection:
const counts = await collection.aggregate([
{
$group: {
_id: {
name: '$name',
status: {
$cond: [
{ $eq: ['$lockedAt', null] },
{
$cond: [
{ $lt: ['$nextRunAt', new Date()] },
'queued',
'scheduled',
],
},
'running',
],
},
},
count: { $sum: 1 },
},
},
]).toArray();
Export the result to Prometheus or your metrics backend on a 30-second interval. The alerts that pay back the setup: queued count above 1000 for any job (consumer too slow), running count stuck at concurrency limit for over five minutes (worker hung), recurring job last finished more than 2x the schedule interval ago (schedule drift).
For worker throughput, instrument the handler with start/end timestamps and emit a histogram. The p99 duration tells you whether the lockLifetime you configured is tight enough. The jobs/sec rate against the queued count tells you whether you need more worker processes.
For MongoDB health, pair Agenda with Claude Code with Datadog or another APM. The metrics that matter at the database layer: poll query latency (should be under 50 ms with the indexes), lock contention on the agendaJobs collection (high contention means the workers are competing for the same jobs), write throughput on the collection (each job lifecycle is several writes).
Testing Agenda jobs
The job handler delegates to a pure function. Test the pure function directly:
// tests/jobs/send-welcome-email.test.ts
import { sendWelcomeEmail } from '../../src/jobs/send-welcome-email';
test('sends the welcome email with the right template version', async () => {
const result = await sendWelcomeEmail({
userId: 'u_test',
email: 'test@example.com',
templateVersion: 'v2',
});
expect(result.providerId).toBeDefined();
});
For integration tests against a real Agenda instance and MongoDB, use testcontainers for the database:
import { Agenda } from 'agenda';
import { GenericContainer } from 'testcontainers';
import { defineSendWelcomeEmail } from '../../src/agenda/jobs/send-welcome-email';
let mongo: any;
let agenda: Agenda;
beforeAll(async () => {
mongo = await new GenericContainer('mongo:6').withExposedPorts(27017).start();
agenda = new Agenda({
db: {
address: `mongodb://${mongo.getHost()}:${mongo.getMappedPort(27017)}/jobs`,
collection: 'test_jobs',
},
processEvery: '100 milliseconds',
});
defineSendWelcomeEmail(agenda);
await agenda.start();
});
afterAll(async () => {
await agenda.stop();
await mongo.stop();
});
test('a one-off job runs to completion', async () => {
const job = await agenda.now('app-send-welcome-email', {
userId: 'u_test',
email: 'test@example.com',
templateVersion: 'v2',
});
await new Promise((resolve) => agenda.on('complete', () => resolve(undefined)));
const finished = await agenda.jobs({ _id: job.attrs._id });
expect(finished[0].attrs.lastFinishedAt).toBeDefined();
});
The integration test spins up a real MongoDB, registers the job definition, schedules a one-off run, waits for completion, and asserts on the finished state. It catches the entire class of "the job runs locally but fails in CI" bugs that pure-function unit tests miss.
Building Agenda that does not drift
The Agenda CLAUDE.md in this guide produces a deployment where the scheduler and workers are separate processes, every job has explicit concurrency and lock lifetime, recurring registration is idempotent, MongoDB has the right indexes, retry is exponential, dead letters route to a place a human can act on, and destructive operations are gated behind permission hooks. The result is a scheduler that ships on time and a worker that completes jobs without duplicates.
The underlying principle is the same as any background-job infrastructure with Claude Code. Agenda without a CLAUDE.md produces a working setup on a single developer machine that drifts under any real load: schedules duplicate, locks expire mid-job, the MongoDB poll falls behind, and the deploy script wipes the queue. The CLAUDE.md is what makes the production-safe defaults the default for Claude.
For the Redis-backed alternative when MongoDB is not already in the stack, Claude Code with BullMQ covers the patterns. For the durable workflow orchestrator when Agenda's cron-shape is too rigid, Claude Code with Temporal covers the workflow engine. For event-driven background jobs triggered by application events rather than schedules, Claude Code with Inngest covers the alternative model.
Get Claudify. Ship an Agenda scheduler that survives the second month in production.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify