Claude Code with Trigger.dev: Background Jobs Done Right
Why Trigger.dev without CLAUDE.md generates v2 code
Trigger.dev is the developer-first background job platform that ships durable execution, scheduled tasks, retries, and a hosted dashboard with a few decorator-style task definitions in TypeScript. The v3 release in 2024 was a major rewrite: a new task definition format, a new SDK shape, a new dashboard, and a different deployment model. Claude Code's training data contains a substantial amount of v2 code from blog posts, tutorials, and community examples written before the v3 cutover, and without explicit CLAUDE.md guidance, Claude regenerates v2 patterns that no longer work.
The most common Claude defaults that break Trigger.dev v3 integrations: importing from @trigger.dev/sdk (v2) instead of @trigger.dev/sdk/v3 (v3), defining tasks with client.defineJob() (v2) instead of task() from v3, using the v2 io object inside the task body, missing the id: field on task() definitions (which is required and used as the deduplication key for idempotencyKey), skipping retry config and assuming retries are automatic on every error, not specifying machine size on long-running or memory-heavy tasks, and missing the trigger.config.ts file that v3 requires for project configuration.
This guide covers the CLAUDE.md configuration that locks Claude Code into Trigger.dev v3's correct model: the v3 SDK imports, the task/schedule shapes, retry tuning, idempotency keys, machine sizing, and the project structure v3 expects. If you are building a Next.js application that triggers tasks from API routes, Claude Code with Next.js covers the request handling layer. For the heavier durable execution alternative when you need versioned workflows, Claude Code with Temporal covers that path.
The Trigger.dev v3 CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Trigger.dev v3 integration it needs to declare: the v3 SDK, the project structure, the task definition shape, retry config, idempotency rules, and the hard rules that block v2-pattern regression.
# Trigger.dev v3 integration rules
## Stack (V3 ONLY)
- @trigger.dev/sdk/v3 (NOT @trigger.dev/sdk, which is v2)
- @trigger.dev/build for build-time config
- TypeScript 5.x strict
- Node.js 20.x (Trigger.dev runs your tasks on their infrastructure)
- TRIGGER_SECRET_KEY in .env.local
- TRIGGER_PROJECT_REF in trigger.config.ts (committed)
## Project structure (V3 REQUIRED)
- trigger.config.ts , project-wide config (REQUIRED at root)
- src/trigger/ , task definitions
- src/trigger/jobs/ , one file per task (optional sub-grouping)
- src/lib/trigger-client.ts , runtime client for triggering tasks from app code
## Task definition (V3 SHAPE)
import { task } from '@trigger.dev/sdk/v3';
export const myTask = task({
id: 'kebab-case-id', // REQUIRED, unique per project
retry: {
maxAttempts: 5,
factor: 2,
minTimeoutInMs: 1000,
maxTimeoutInMs: 60000,
randomize: true,
},
machine: { preset: 'small-1x' }, // optional: small-1x | small-2x | medium-1x | medium-2x | large-1x | large-2x
maxDuration: 300, // optional, seconds
run: async (payload: MyTaskPayload, { ctx }) => {
// task body here
return { result: '...' };
},
});
## Trigger from app code
import { tasks } from '@trigger.dev/sdk/v3';
import type { myTask } from '@/trigger/my-task';
await tasks.trigger<typeof myTask>('kebab-case-id', payload);
// or for batch:
await tasks.batchTrigger<typeof myTask>('kebab-case-id', [{ payload }, { payload }]);
## Hard rules (V2 BLOCKERS)
- NEVER import from '@trigger.dev/sdk' (root). ALWAYS '@trigger.dev/sdk/v3'
- NEVER use client.defineJob(). ALWAYS task() from v3
- NEVER use the v2 io object inside run(). It does not exist in v3
- NEVER skip the id: field on task() definitions, it is required
- NEVER assume retries happen automatically, configure retry: explicitly
- NEVER hardcode TRIGGER_SECRET_KEY, read from process.env.TRIGGER_SECRET_KEY
- ALWAYS set maxAttempts in retry, the default may not match your tolerance
- ALWAYS use kebab-case for task IDs, dashboard sorts and filters by ID
## Idempotency
For triggers that may fire twice (webhooks, retry loops):
await tasks.trigger<typeof myTask>('id', payload, {
idempotencyKey: 'user-123-order-456',
});
The same idempotencyKey within 30 days returns the existing run, not a new one.
Four rules here matter most.
The v3 import rule is the single biggest source of broken code Claude generates without CLAUDE.md. Trigger.dev's v2 SDK is still installable, still on npm, and the API documentation Claude was trained on includes a substantial amount of v2 examples. When asked to set up Trigger.dev, Claude defaults to v2 patterns about half the time. Forcing @trigger.dev/sdk/v3 in CLAUDE.md eliminates this drift.
The task ID rule is what makes Trigger.dev's idempotency and observability work. The id: field on a task definition is the unique identifier in the dashboard, the parameter to tasks.trigger(), and the key Trigger.dev uses to deduplicate when idempotencyKey is set. Claude often omits it (the v2 SDK derived it from the function name), which fails type checking in v3.
The retry config rule prevents silent stalls. Trigger.dev v3 retries automatically by default, but the default policy may not match your failure modes. A task that calls an external API with 30-second timeouts needs different retry timing than a task that hashes data locally. Forcing explicit retry config in CLAUDE.md makes Claude make the right decision per task.
The idempotency key rule is what turns at-least-once delivery into effectively-once execution. Webhooks fire multiple times. Retry loops re-trigger tasks. Without an idempotency key, your application processes the same event twice (double-charging, double-sending, double-recording). With an idempotency key, Trigger.dev returns the existing run handle on the second trigger and you stay correct.
Install and project setup
Install the v3 SDK:
npm i @trigger.dev/sdk@latest
npm i -D @trigger.dev/build
npx trigger.dev@latest init
The init command scaffolds trigger.config.ts at the repo root and creates the src/trigger/ directory. The config file:
// trigger.config.ts
import { defineConfig } from '@trigger.dev/sdk/v3';
export default defineConfig({
project: 'proj_xxxxxxxxxxxxxxxxxxxx',
runtime: 'node',
logLevel: 'log',
maxDuration: 300,
retries: {
enabledInDev: false,
default: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10000,
factor: 2,
randomize: true,
},
},
dirs: ['./src/trigger'],
});
Environment file:
# .env.local
TRIGGER_SECRET_KEY=tr_dev_or_prd_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The runtime client that triggers tasks from app code:
// src/lib/trigger-client.ts
import { configure } from '@trigger.dev/sdk/v3';
if (!process.env.TRIGGER_SECRET_KEY) {
throw new Error('TRIGGER_SECRET_KEY is not set');
}
configure({
secretKey: process.env.TRIGGER_SECRET_KEY,
});
Import this once at app startup (e.g., in instrumentation.ts for Next.js) so the SDK is configured before any tasks.trigger() call.
The task pattern
A simple task that sends an email:
// src/trigger/send-welcome-email.ts
import { task } from '@trigger.dev/sdk/v3';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export interface SendWelcomeEmailPayload {
toEmail: string;
userName: string;
loginUrl: string;
}
export const sendWelcomeEmail = task({
id: 'send-welcome-email',
retry: {
maxAttempts: 5,
factor: 2,
minTimeoutInMs: 2000,
maxTimeoutInMs: 60000,
randomize: true,
},
maxDuration: 30,
run: async (payload: SendWelcomeEmailPayload, { ctx }) => {
ctx.logger.info('Sending welcome email', { to: payload.toEmail });
const { data, error } = await resend.emails.send({
from: 'Claudify <hello@claudify.tech>',
to: payload.toEmail,
subject: `Welcome, ${payload.userName}`,
html: `<p>Welcome, ${payload.userName}. <a href="${payload.loginUrl}">Log in</a></p>`,
text: `Welcome, ${payload.userName}. ${payload.loginUrl}`,
});
if (error) {
throw new Error(`Email send failed: ${error.message}`);
}
return { emailId: data?.id };
},
});
Triggering this from a Next.js route handler:
// src/app/api/signup/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { tasks } from '@trigger.dev/sdk/v3';
import type { sendWelcomeEmail } from '@/trigger/send-welcome-email';
export async function POST(req: NextRequest) {
const { email, name, userId } = await req.json();
const handle = await tasks.trigger<typeof sendWelcomeEmail>('send-welcome-email', {
toEmail: email,
userName: name,
loginUrl: 'https://claudify.tech/login',
}, {
idempotencyKey: `signup-${userId}`,
});
return NextResponse.json({ ok: true, runId: handle.id });
}
Two patterns this code follows that Claude omits without CLAUDE.md. First, the trigger call uses a typed generic (<typeof sendWelcomeEmail>) which gives TypeScript the payload shape and the return type. Without the generic, the payload is any and Claude has no signal that the shape matters. Second, the idempotency key is derived from userId, so duplicate signup form submissions do not send two welcome emails.
Scheduled tasks
Trigger.dev v3 supports cron-style scheduled tasks defined with schedules.task():
// src/trigger/daily-report.ts
import { schedules } from '@trigger.dev/sdk/v3';
export const dailyReport = schedules.task({
id: 'daily-report',
cron: '0 9 * * *', // 09:00 every day, UTC
maxDuration: 600,
retry: {
maxAttempts: 3,
factor: 2,
minTimeoutInMs: 5000,
maxTimeoutInMs: 60000,
},
run: async (payload, { ctx }) => {
ctx.logger.info('Running daily report', { timestamp: payload.timestamp });
// generate and email the report
},
});
For multiple environments, attach schedules to specific environments using the dashboard or pass them as runtime config. The cron field is in standard 5-field format, in UTC by default. To use a timezone, pass an object:
export const dailyReport = schedules.task({
id: 'daily-report',
cron: {
pattern: '0 9 * * *',
timezone: 'Europe/London',
},
// ...
});
Add a schedules section to CLAUDE.md:
## Scheduled tasks
- Use schedules.task() (not task()) for cron-driven tasks
- cron: standard 5-field format in UTC by default
- For timezone: cron: { pattern: '...', timezone: 'Europe/London' }
- The payload is auto-generated: { timestamp, lastTimestamp, externalId, scheduleId, ... }
- ALWAYS set maxDuration on scheduled tasks (default may not match daily workload)
- Use schedules.create() at runtime to attach schedules to specific instances dynamically
Machine sizing
Trigger.dev v3 lets you specify the machine size each task runs on. The default is small-1x (0.25 vCPU, 0.5 GB RAM). Tasks that process large payloads, do heavy computation, or hold significant memory should specify a larger machine.
| Preset | vCPU | RAM | Use case |
|---|---|---|---|
small-1x |
0.25 | 0.5 GB | Default for I/O-bound tasks |
small-2x |
0.5 | 1 GB | Light parsing, small transforms |
medium-1x |
1 | 2 GB | Image processing, PDF generation |
medium-2x |
2 | 4 GB | Larger ML inference, big payloads |
large-1x |
4 | 8 GB | Video processing, heavy compute |
large-2x |
8 | 16 GB | Specialized workloads |
export const transcodeVideo = task({
id: 'transcode-video',
machine: { preset: 'large-1x' },
maxDuration: 1800, // 30 minutes
run: async (payload: { videoUrl: string }, { ctx }) => {
// ffmpeg processing
},
});
Get Claudify. The bundled Trigger.dev CLAUDE.md ships with the v3 SDK enforcement, machine sizing presets, and the idempotency key patterns pre-configured.
Lifecycle hooks
Trigger.dev v3 tasks support optional lifecycle hooks: onStart, onSuccess, onFailure, onComplete, and onWait. These run alongside the task and let you record analytics, send notifications, or trigger follow-up tasks.
import { task } from '@trigger.dev/sdk/v3';
export const processPayment = task({
id: 'process-payment',
run: async (payload: { customerId: string; amount: number }) => {
// charge the customer
return { paymentId: 'pi_xxx' };
},
onStart: async ({ payload, ctx }) => {
ctx.logger.info('Payment started', { customer: payload.customerId });
},
onSuccess: async ({ payload, output, ctx }) => {
ctx.logger.info('Payment succeeded', { paymentId: output.paymentId });
},
onFailure: async ({ payload, error, ctx }) => {
ctx.logger.error('Payment failed', { customer: payload.customerId, error: String(error) });
// alert the operations team
},
onComplete: async ({ payload, ctx }) => {
ctx.logger.info('Payment task complete', { customer: payload.customerId });
},
});
Add a lifecycle section to CLAUDE.md:
## Lifecycle hooks
- onStart: fires once when task begins (use for setup, logging)
- onSuccess: fires after run() returns successfully (use for analytics, success notifications)
- onFailure: fires after exhausting retries (use for ops alerts, dead-letter queue)
- onComplete: fires regardless of success or failure (use for cleanup)
- onWait: fires when the task pauses (e.g., wait.for(), wait.until())
- Hooks run with the same ctx as run(), but errors in hooks do NOT trigger task retry
- NEVER throw from onSuccess or onFailure, log the error and continue
Batching and triggering large lists
For high-volume work (sending 10,000 personalized emails, processing a list of records), use tasks.batchTrigger() instead of looping tasks.trigger(). Batch triggers create one API call per batch instead of one per item.
import { tasks } from '@trigger.dev/sdk/v3';
import type { sendWelcomeEmail } from '@/trigger/send-welcome-email';
interface Recipient {
email: string;
name: string;
userId: string;
}
async function sendBatchWelcomeEmails(recipients: Recipient[]) {
const items = recipients.map(r => ({
payload: {
toEmail: r.email,
userName: r.name,
loginUrl: 'https://claudify.tech/login',
},
options: {
idempotencyKey: `welcome-${r.userId}`,
},
}));
// batchTrigger handles up to 500 items per call
const handle = await tasks.batchTrigger<typeof sendWelcomeEmail>(
'send-welcome-email',
items,
);
return handle.batchId;
}
For lists over 500, chunk them:
async function sendBatchInChunks(recipients: Recipient[]) {
const CHUNK_SIZE = 500;
const batchIds: string[] = [];
for (let i = 0; i < recipients.length; i += CHUNK_SIZE) {
const chunk = recipients.slice(i, i + CHUNK_SIZE);
const batchId = await sendBatchWelcomeEmails(chunk);
batchIds.push(batchId);
}
return batchIds;
}
Wait, retry, and abort
Inside a task's run() function, you can wait for time, wait for events, or abort the run with a controlled error.
import { task, wait } from '@trigger.dev/sdk/v3';
export const reminderTask = task({
id: 'reminder',
run: async (payload: { userId: string }, { ctx }) => {
// Send the initial notification
await sendNotification(payload.userId, 'Welcome!');
// Wait 24 hours, then send a follow-up
await wait.for({ hours: 24 });
await sendNotification(payload.userId, 'Need help getting started?');
// Wait 7 days, then send a re-engagement
await wait.for({ days: 7 });
await sendNotification(payload.userId, 'We miss you!');
},
});
wait.for() and wait.until() are durable: the task is paused and persisted, then resumed at the wait expiry without keeping a Node process alive. This is the major v3 advancement over v2's io.wait().
To abort a task with a specific error type, throw a typed error:
import { AbortTaskRunError } from '@trigger.dev/sdk/v3';
export const validateInput = task({
id: 'validate-input',
run: async (payload) => {
if (!payload.requiredField) {
throw new AbortTaskRunError('Required field missing');
}
// ... rest of task
},
});
AbortTaskRunError does not trigger retries (the task is marked failed immediately) and surfaces a clean message in the dashboard.
Common Claude Code mistakes with Trigger.dev
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. v2 import path
Claude generates: import { task } from '@trigger.dev/sdk';.
Correct pattern: import { task } from '@trigger.dev/sdk/v3';.
2. client.defineJob() (v2)
Claude generates: client.defineJob({ id: 'job', name: 'Job', version: '1.0.0', trigger: ..., run: ... }).
Correct pattern: export const myTask = task({ id: 'task-id', run: async (payload, { ctx }) => { ... } });.
3. Missing id: field
Claude generates: task({ run: async (payload) => { ... } }).
Correct pattern: task({ id: 'kebab-case-id', run: async (payload, { ctx }) => { ... } }).
4. No retry config
Claude generates: a task with no retry: block.
Correct pattern: explicit retry: { maxAttempts, factor, minTimeoutInMs, maxTimeoutInMs }.
5. No idempotency key on triggers
Claude generates: await tasks.trigger('task-id', payload).
Correct pattern: await tasks.trigger('task-id', payload, { idempotencyKey: 'derived-from-event' }) for webhooks or retry loops.
6. setTimeout instead of wait.for()
Claude generates: await new Promise(resolve => setTimeout(resolve, 24 * 60 * 60 * 1000));.
Correct pattern: await wait.for({ hours: 24 }); (durable, does not keep the process alive).
Add these as before/after pairs in CLAUDE.md.
Permission hooks for Trigger.dev scripts
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(npx trigger.dev@latest dev*)",
"Bash(npx trigger.dev@latest deploy*)",
"Bash(npx trigger.dev@latest list*)"
],
"deny": [
"Bash(npx trigger.dev@latest delete*)",
"Bash(node scripts/cancel-all-runs.js*)",
"Bash(node scripts/purge-schedules.js*)"
]
}
}
Local dev and deploy are routine. Bulk cancellations or schedule deletions are destructive. For broader hook patterns, Claude Code hooks covers the full configuration model.
Building Trigger.dev integrations on v3
The Trigger.dev v3 CLAUDE.md in this guide produces background job code where the v3 SDK is the only import path, every task has an explicit ID and retry policy, idempotency keys are derived from event identifiers, lifecycle hooks attach to the right phase, and waits are durable instead of process-bound.
The underlying principle is that v3 is a different product from v2, and Claude's training data does not strongly separate them. Forcing the v3 patterns in CLAUDE.md eliminates the regression risk. Once Claude is locked into v3, the SDK is one of the more ergonomic background job platforms available: typed task IDs, durable waits, batch triggers, and per-task machine sizing all work the way the documentation describes.
For the heavier durable execution alternative when you need versioned workflows or compensating transactions, Claude Code with Temporal covers that path. For one-off cron jobs at the platform level, Claude Code with Vercel cron scheduling pairs with Trigger.dev for hybrid setups. Get Claudify. The bundled Trigger.dev template ships with v3 enforcement, retry presets, and the idempotency key patterns pre-configured.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify