Claude Code for Background Jobs and Queues: CLAUDE.md Rules
TL;DR
- Claude Code writes background-job code that runs, but by default it skips the parts that make it safe under retries, crashes and deploys: stable idempotency keys, bounded retries, a defined home for poison messages, deterministic workflow code and a graceful shutdown.
- One CLAUDE.md fixes that for any tool. Put the shared rules (job contract, idempotency, retry policy, dead letters, shutdown) in one skeleton, then add a short block for the tool you run.
- The tools split into three families: job queues you host (BullMQ, Agenda), durable execution engines (Temporal, Restate, Inngest, Trigger.dev) and message brokers (RabbitMQ, NATS JetStream, MQTT). Each family fails differently, so each block below targets a different class of bug.
- Several of these tools shipped major versions in 2025 and 2026 that change the code Claude should write: BullMQ 6 removed legacy repeatable jobs, Inngest 4 moved triggers into the function options, Trigger.dev 4 moved the SDK import path, and the
natsnpm package now points to the modular@nats-io/*packages. - Every tool fact in this guide was checked against that tool's documentation or the npm registry on 21 October 2026.
Claude Code can build a reliable background-processing layer in an afternoon, as long as your CLAUDE.md tells it which rules are non-negotiable. This guide gives you that file. It starts with the skeleton every job queue, workflow engine and message broker shares, compares nine tools side by side, and then lists only the rules specific to each one, so you copy one shared block plus one tool block instead of nine overlapping templates.
If you are new to project configuration, our guide to CLAUDE.md explains how Claude Code loads the file at the start of every session, and CLAUDE.md examples shows how teams structure larger rule sets.
Why Claude Code needs rules for background work
Background work is where "it ran once on my laptop" and "it is correct in production" are furthest apart. A job that succeeds in development can still run twice after a retry, retry forever against a broken upstream, vanish when a pod is replaced mid-run, or replay differently after a deploy. Claude Code cannot see any of that from type definitions, so it writes the shortest code the SDK accepts.
Across all nine tools, the same six failure patterns show up when there is no CLAUDE.md:
- No idempotency. Claude enqueues or publishes without a stable key. A double-clicked button, a re-delivered webhook or a client retry creates a second job, and the customer gets two emails or two charges.
- Unbounded or missing retries. Claude either relies on a default it has not checked (which is "never retry" for some tools and "retry without limit" for others) or picks a large number with no backoff, so a broken upstream gets hammered.
- No home for poison messages. A message that can never succeed is retried, fails, and is retried again. Without a dead-letter path or a terminal acknowledgement it blocks the queue or burns capacity forever.
- Side effects in replayed code. In durable execution engines, Claude puts network calls directly in the workflow or function body, outside the step boundary that records results, so a replay repeats the side effect.
- No graceful shutdown. Claude starts a worker and never handles
SIGTERM. Every deploy kills in-flight jobs halfway through. - Unbounded concurrency. Claude leaves concurrency at the default or sets it high, and the first backlog after an outage exceeds the rate limit of whatever the jobs call.
None of these are exotic. They are what the SDKs allow by default. The fix is to make the correct pattern the only pattern Claude sees.
Which background job tool should you use with Claude Code?
All nine work well with Claude Code; the right one depends on whether you need a queue, a durable workflow or a message bus. The table compares what changes how you write the integration. Version numbers are the current releases on npm as of 21 October 2026.
| Family | Runs on | Duplicate protection | Retry default | Choose it when | |
|---|---|---|---|---|---|
BullMQ (bullmq 6.x) |
Job queue | Your Redis | A job with an existing jobId is ignored |
No retry unless attempts is above 1 |
You already run Redis and want queues, schedulers and rate limits in Node |
Agenda (agenda 6.x) |
Job scheduler | Your MongoDB by default; PostgreSQL and Redis backends available | unique() and idempotent every() |
Opt-in: a backoff strategy per job definition | You want cron-style recurring jobs on a database you already operate |
Temporal (@temporalio/* 1.x) |
Durable execution | Temporal Service (self-hosted or Temporal Cloud) plus your workers | Workflow IDs; activities must be idempotent | Activities retry without limit; workflows do not retry | Long-running, multi-step business processes that must survive crashes and deploys |
Restate (@restatedev/restate-sdk 1.x) |
Durable execution | Restate server plus your service endpoint | Idempotency-Key header, responses kept 24 hours |
ctx.run steps accept a retry policy |
Durable handlers and per-key state with a lightweight runtime |
Inngest (inngest 4.x) |
Durable functions | Inngest platform calling your serve() endpoint |
Event id (24 hours) and function idempotency key (24 hours) |
4 retries after the first attempt, per step | Event-driven steps in a serverless or Next.js app without running infrastructure |
Trigger.dev (@trigger.dev/sdk 4.x) |
Durable tasks | Trigger.dev cloud or self-hosted workers | idempotencyKey, stored 30 days by default |
Configurable per task and in trigger.config |
Long-running TypeScript tasks with no timeouts on managed compute |
RabbitMQ (amqplib 2.x client) |
Message broker | Your RabbitMQ 4.x cluster | Make consumers idempotent | Quorum queues dead-letter or drop after 20 deliveries | Routed messaging between services with exchanges and per-queue dead-lettering |
NATS JetStream (@nats-io/jetstream 3.x) |
Message broker | Your NATS server with JetStream | Nats-Msg-Id within a 2-minute default window |
Redelivery without limit unless you set MaxDeliver |
Fast service messaging plus persistence, key-value and request-reply in one system |
MQTT (mqtt 5.x client) |
Pub/sub protocol | A broker such as Mosquitto or EMQX | QoS 2 for exactly-once delivery | Not applicable (QoS levels instead) | IoT and device telemetry over constrained networks |
Sources: BullMQ job IDs and retrying failing jobs, Agenda README, Temporal retry policies, Restate HTTP invocation, Inngest idempotency and retries, Trigger.dev idempotency and tasks, RabbitMQ quorum queues, NATS streams and acknowledgements, MQTT 5.0 specification. Package versions from the npm registry. All read on 21 October 2026.
A quick way to choose:
- A queue (BullMQ, Agenda) if the work is "do this task soon, maybe on a schedule" and you are happy to own the retry and state logic yourself.
- A durable execution engine (Temporal, Restate, Inngest, Trigger.dev) if the work has several steps, waits for hours or days, or must resume exactly where it stopped after a crash.
- A broker (RabbitMQ, NATS, MQTT) if several services or devices exchange messages and none of them owns the whole process.
- Kafka if you need a replayable event log with retention measured in days; that is a different design, covered in our Claude Code with Kafka guide.
The shared CLAUDE.md skeleton for background work
This block is tool-neutral. It goes in your project's CLAUDE.md as-is, then you append the block for your tool from the sections further down. Keep every SDK import behind one module per role so Claude never scatters queue clients across route handlers.
# Background work rules
## Architecture
- src/jobs/client.ts , the ONLY file that creates the queue, broker or engine client
- src/jobs/enqueue.ts , the only functions the app calls to start background work
- src/jobs/handlers/ , one handler per job type; thin wrappers around pure functions
- src/jobs/worker.ts , worker entry point (separate process from the web server)
- src/jobs/dead-letter.ts , where permanently failed work is recorded and alerted
## Job contract (MANDATORY on every enqueue, trigger or publish)
- A job type name from a fixed list, never a free-form string
- A payload with IDs only (orderId, userId), never full records or secrets
- An idempotency key derived from the business event, e.g. receipt-${orderId}
- Log the job ID and idempotency key when the work is created
## Retries
- Retries are ALWAYS explicit: a max attempt count and a backoff
- Retry on network errors, timeouts, 429 and 5xx from upstreams
- NEVER retry validation errors or "not found"; fail permanently and dead-letter
- Every tool's default retry behaviour is checked, never assumed
## Handlers
- Handlers are idempotent: running twice has the same effect as running once
- External calls pass an idempotency key to the upstream API when it supports one
- Concurrency is explicit per job type and sized to the slowest upstream it calls
## Dead letters
- Every job type has a defined final-failure path: dead-letter queue, table or alert
- Dead-lettered work keeps its payload, error and attempt count for replay
## Shutdown
- Workers handle SIGTERM and SIGINT: stop taking new work, finish or release in-flight work, then exit
- The drain timeout matches the platform's termination grace period
## Hard rules
- NEVER run long work inside a web request handler; enqueue it
- NEVER generate a random UUID as an idempotency key at enqueue time
- NEVER put secrets or personal data in job payloads that are persisted
- NEVER change a running workflow's logic without the tool's versioning mechanism
Each section of that block exists because of one of the six failure patterns above. The rest of this section explains the pieces that need more than a one-line rule.
Idempotency keys come from the business event
Duplicate work comes from three places: a client retrying a request, a worker re-running a job after a crash, and a broker re-delivering a message. The fix is the same in all three: derive a stable key from the business event (receipt-${orderId}), pass it to the tool's deduplication mechanism, and make the handler itself safe to run twice.
Tell Claude explicitly that the key must not be a fresh UUID generated at enqueue time. A new UUID on every attempt makes every retry look new, which defeats the purpose. Also tell it which characters your tool accepts in keys: BullMQ, for example, rejects custom job IDs that contain a colon or consist only of digits (BullMQ job IDs), so receipt-123 works where receipt:123 does not.
Tool-level deduplication only protects the enqueue. The handler still needs its own guard, because every tool here can run a handler more than once (a crash after the side effect but before the acknowledgement is enough). A unique constraint on a processed-events table is the simplest guard:
// inside any handler
const firstTime = await db.processedJobs.insertIfAbsent({ key: job.idempotencyKey });
if (!firstTime) return { skipped: 'duplicate' };
Retries need a ceiling and a classification
The retry defaults across these tools disagree completely. BullMQ does not retry unless attempts is above 1. Temporal activities retry without limit by default. Inngest retries four times after the first attempt. NATS JetStream redelivers without limit unless MaxDeliver is set. If Claude does not know which default applies, it will either leave a job that silently never retries or one that retries forever.
The shared rule is to make retries explicit everywhere and to classify errors: transient failures (network, timeout, 429, 5xx) retry with exponential backoff; permanent failures (bad input, missing record, rejected authentication) stop immediately and go to the dead-letter path. Most tools give you a way to mark the second kind, covered in each block below.
Durable execution changes where side effects live
Temporal, Restate, Inngest and Trigger.dev all record progress so a run can resume after a crash. The shared consequence is that some of your code may execute again on replay, and only the work inside the tool's step boundary (an activity, a ctx.run, a step.run, a task) is recorded and skipped. A network call outside that boundary runs again on every replay. Put this rule at the top of any durable-execution CLAUDE.md, then add the tool-specific boundary from its block.
Graceful shutdown is part of every deploy
Every deploy and every autoscaling event stops workers. A worker that exits immediately leaves jobs half-done; depending on the tool, they are retried after a lock or acknowledgement timeout, which delays them, or they are stuck until someone intervenes. The shared rule: on SIGTERM, stop accepting new work, let in-flight work finish within the grace period, release anything that cannot finish, then exit. Each tool exposes a different call for this (worker.close(), drain(), consumer cancellation), listed in its block.
BullMQ rules for Claude Code
BullMQ is the Redis-backed queue most Node teams reach for, and version 6, released on 30 July 2026, changed several APIs Claude may still generate from older examples (BullMQ changelog).
- Use Job Schedulers, not legacy repeatable jobs. BullMQ 6 removed legacy repeatable jobs and their APIs. Recurring work is created with
queue.upsertJobScheduler(id, repeatOptions, template); calling it again with the same ID updates the scheduler instead of creating a duplicate, which makes it safe to run on every boot (Job Schedulers). - Install ioredis yourself if you use it. In version 6, ioredis is no longer a direct dependency; it is an optional peer dependency.
- Set
maxRetriesPerRequest: nullon worker connections. BullMQ's production guide says workers using ioredis must set this option tonull, otherwise Redis exceptions on certain commands can break the worker (Going to production). - Run Redis with
maxmemory-policy noeviction. The same guide calls it the only setting that guarantees correct queue behaviour. Tell Claude never to share an eviction-enabled cache instance with BullMQ. - Retries are off by default. Set
attemptsabove 1 and a backoff. Withbackoff: { type: 'exponential', delay: 1000 }, waits grow as2 ^ (attempts - 1) * delay(retrying failing jobs). - Use the business key as
jobId. A job added with an existingjobIdis ignored. Custom IDs must not contain:and must not be digits only. - The rate limiter is global per queue.
limiter: { max: 10, duration: 1000 }caps the whole queue at 10 jobs per second regardless of how many workers you add (rate limiting). Size it to the upstream, not to the worker count. - Configure job removal. By default completed and failed jobs are kept forever. Set
removeOnCompleteandremoveOnFaillimits so Redis memory stays bounded. - Close workers on both signals. The production guide recommends listening for
SIGINTandSIGTERMand callingawait worker.close()to minimise stalled jobs.
## BullMQ
- bullmq 6.x; ioredis installed explicitly (optional peer in v6)
- ONE connection module; worker connections set maxRetriesPerRequest: null
- Redis maxmemory-policy noeviction; never share an evicting cache instance
- Recurring work: queue.upsertJobScheduler(id, ...) only; no legacy repeat APIs
- jobId = business key with hyphens or underscores, never ':' and never digits only
- defaultJobOptions: attempts >= 3, backoff { type: 'exponential', delay: 1000 }
- removeOnComplete and removeOnFail always set with a count or age
- limiter { max, duration } sized to the upstream's limit (global per queue)
- SIGINT + SIGTERM: await worker.close() before exit
Agenda rules for Claude Code
Agenda is a scheduler for Node.js. Version 6 is ESM-only, requires Node.js 18 or later, and is no longer tied to MongoDB: it has a pluggable backend system with MongoDB as the default and official @agendajs packages for PostgreSQL and Redis (Agenda README).
- Know the defaults before you change them. Per the README,
defaultConcurrencyis 5 per job type,defaultLockLifetimeis 10 minutes,defaultLockLimitis 0 (no maximum),processEveryis 5 seconds, andmaxConcurrencyacross all jobs is 20. Write the values you rely on into CLAUDE.md so Claude does not guess. - Size the lock to the job. The lock exists for jobs that crash or time out, and the README advises a longer
lockLifetimefor long-running jobs. Set it per job above that job's slowest expected run, and listen for theexpiredevent, which Agenda emits when a job lock times out. - Retries are opt-in. A failed job is only rescheduled automatically when its definition sets a backoff strategy; the backoff options default
maxRetriesto 3. Readjob.attrs.failCountto see how many times a job has failed, and note that failed jobs stay in the database even whenremoveOnCompleteis enabled. - Recurring registration is idempotent.
every(interval, name, data, options)creates only one job even when called repeatedly, so it is safe to call on every startup. Useunique()when you need one job per set of properties. - Choose the right shutdown call.
stop()unlocks running jobs immediately so other workers can pick them up;drain()waits for running jobs to finish and stops accepting new ones, with a timeout or anAbortSignal. For deploys,drain()with a timeout below your grace period is usually what you want. - Use explicit time zones.
every()accepts atimezoneoption. Without it, schedules depend on the server's clock settings. - Keep payloads small.
job.attrs.datais persisted in your backend, so store IDs, never secrets or full records.
## Agenda
- agenda 6.x (ESM only, Node 18+); backend: MongoDB (default) | @agendajs PostgreSQL | @agendajs Redis
- Defaults we rely on: concurrency 5/job, lockLifetime 10 min, processEvery 5s, maxConcurrency 20
- Every define() sets concurrency and lockLifetime explicitly; lockLifetime > slowest run
- Every define() that should retry sets a backoff strategy (maxRetries explicit)
- Recurring jobs: agenda.every(interval, name, data, { timezone: 'UTC' }) from one registration function
- One-per-entity jobs: unique({ 'data.orderId': id })
- SIGTERM: await agenda.drain(timeout below the grace period)
- job.attrs.data holds IDs only, never secrets
Temporal rules for Claude Code
Temporal runs workflows as durable code: every step is recorded in an event history, and a worker that restarts replays the workflow from that history. The TypeScript SDK enforces much of this for you, which changes what Claude needs to be told.
- Know what the sandbox already handles. Workflow code runs in a deterministic sandbox where
Math.random(),DateandsetTimeout()are replaced with deterministic versions;Date.now()returns the time of the last workflow task completion (Temporal workflow basics). The real rule is about I/O: side effects and access to external state must go through activities. - Import activity types, never activity code. Workflow code cannot import activity definitions directly; it imports their types and calls them through
proxyActivities. Packages that reference Node.js or DOM APIs do not belong in workflow files. - Use
uuid4()from@temporalio/workflow. It draws from the sandbox's deterministic random source.crypto.randomUUID()is not available in the sandbox. - Every activity needs a timeout. An activity execution must have either a Start-To-Close or a Schedule-To-Close timeout; the docs recommend
startToCloseTimeout(activity timeouts). - The default activity retry policy never gives up. Defaults are a 1 second initial interval, a backoff coefficient of 2.0, a maximum interval of 100 times the initial interval and unlimited attempts. Workflows themselves do not retry by default (retry policies). Set
maximumAttemptsandnonRetryableErrorTypesdeliberately, and make every activity idempotent. - Version every logic change. Wrap new code in
if (patched('change-id'))with the old path in theelse; once no open workflows use the old path, switch todeprecatePatch('change-id'), and remove both after those histories leave retention. Worker Versioning is the alternative: tag workers and roll them out as deployment versions so old executions stay on old code (Temporal versioning).
## Temporal
- @temporalio/client, worker, workflow, activity 1.x
- src/workflows/ imports only @temporalio/workflow and activity TYPES; no Node or DOM APIs
- All I/O (HTTP, DB, files, email) lives in src/activities/
- IDs in workflows: uuid4() from @temporalio/workflow; never crypto.randomUUID()
- Delays: sleep() from @temporalio/workflow
- proxyActivities: startToCloseTimeout always; retry.maximumAttempts + nonRetryableErrorTypes always
- Activities are idempotent; pass idempotency keys to upstream APIs
- Logic changes to deployed workflows: patched('id') / deprecatePatch('id'), or Worker Versioning
Restate rules for Claude Code
Restate records each step of a handler in a journal and replays the journal after a failure, so the rules look similar to Temporal's but with a different API surface (Restate TypeScript SDK).
- Wrap side effects in named
ctx.runsteps. Non-deterministic operations must be wrapped so replay can reuse their results (durable steps). Give every step a stable, descriptive name. Insidectx.runyou cannot use the Restate context itself: noctx.get, noctx.sleep, no nestedctx.run. - Use the replay-safe helpers.
ctx.rand.uuidv4()for stable UUIDs (not for cryptographic use),ctx.rand.random()in place ofMath.random(), andawait ctx.date.now()for time that stays consistent across retries. - Sleep durably.
await ctx.sleep({ seconds: 10 })is tracked by Restate and survives restarts. Sleeping inside an exclusive Virtual Object handler queues other calls to that object, so for long waits the docs recommend splitting work across handlers with delayed messages (durable timers). - Pick the right service type. A Service groups stateless handlers. A Virtual Object is keyed state where at most one handler with write access runs at a time per key, while shared handlers can run concurrently for reads (Restate services). A Workflow's
runhandler executes once per key. - Bound every wait. Context actions return promises that accept
.orTimeout(...), which throws aTimeoutError. Tell Claude to rethrow any other error. - Send idempotency keys on invocations. Requests to Restate accept an
Idempotency-Keyheader; the response is kept for 24 hours by default and returned to duplicate requests without re-running the handler (HTTP invocation).
## Restate
- @restatedev/restate-sdk 1.x; services exported and served via restate.serve({ services })
- Every side effect inside ctx.run('descriptive-name', ...); no ctx calls inside ctx.run
- Random/IDs/time: ctx.rand.uuidv4(), ctx.rand.random(), await ctx.date.now()
- Delays: ctx.sleep({ ... }); avoid long sleeps inside exclusive object handlers
- Per-entity state: Virtual Object keyed by the business ID (orderId, accountId)
- Every awaited call or awakeable gets .orTimeout(...); catch TimeoutError only
- Callers send an Idempotency-Key header on invocations
Inngest rules for Claude Code
Inngest runs durable functions by calling an endpoint in your app. The TypeScript SDK is now on version 4, and its migration guide lists changes that matter for generated code (Inngest v3 to v4 migration).
- Triggers live in the function options. In v4,
createFunctiontakestriggersinside its first argument instead of as a separate second argument:inngest.createFunction({ id: 'import-images', triggers: { event: 'shop/product.imported' } }, handler)(createFunction). Function IDs should not change between deploys. - Client options moved.
signingKey,baseUrlandfetchnow belong on theInngestclient, not onserve(). The default mode is nowcloud, which requires a signing key (INNGEST_SIGNING_KEY); for local development setisDev: trueorINNGEST_DEV=1. - Event schemas changed. The
EventSchemasclass is removed; event types are declared witheventType().event.useris gone, so put that data inevent.data. - Every side effect goes in
step.run. Retries apply independently to eachstep.run(): completed steps are reused and only the failing step retries. The default is four retries after the initial attempt, configurable from 0 to 20 (Inngest retries). - Classify failures. Throw
NonRetriableErrorwhen retrying cannot help, andRetryAfterErrorto tell Inngest when a rate-limited API can be tried again. - Deduplicate at both ends. An event
idprevents duplicate handling of the same event for 24 hours; a function'sidempotencyoption is a CEL expression over the event (for exampleevent.data.cartId) that allows one run per key per 24 hours (Inngest idempotency). - Mind parallelism and checkpointing. In v4,
Promise.racewaits for all promises to settle; wrap a race ingroup.parallel()to keep early resolution. Checkpointing is on by default, and on serverless platformscheckpointing.maxRuntimeshould be set below your platform's maximum duration.
## Inngest
- inngest 4.x; ONE client in src/jobs/client.ts with signingKey from INNGEST_SIGNING_KEY
- Local dev: INNGEST_DEV=1 (never isDev: true in production code)
- createFunction({ id, triggers: { event: 'domain/action' } }, handler); ids never renamed
- Every new function is added to the serve() functions list
- ALL side effects inside step.run('name', ...); delays via step.sleep / step.waitForEvent
- Events typed with eventType(); no event.user
- Webhook-sourced events carry id = provider event ID (24h dedupe)
- Functions that must run once per entity set idempotency: 'event.data.<key>'
- Permanent failures: throw NonRetriableError; rate limits: RetryAfterError
Trigger.dev rules for Claude Code
Trigger.dev runs long TypeScript tasks on managed or self-hosted compute. Version 4 is current, and the v3 runtime no longer runs on Trigger.dev Cloud, so v3-era code will not deploy there (migrating from v3).
- Import from
@trigger.dev/sdk. The@trigger.dev/sdk/v3path still works but is deprecated and will be removed. Make the root import the only allowed path. - Define queues ahead of time. Queues are declared with the
queuefunction and referenced by name or set on the task; they can no longer be created on demand through trigger options. - Use the v4 lifecycle hook shapes. Hooks take a single object parameter,
onStartis deprecated in favour ofonStartAttempt, andhandleErroris nowcatchError. - Understand idempotency key scope and lifetime. A repeated
idempotencyKeyreturns the original run instead of starting a new one. Keys are stored for 30 days by default, adjustable withidempotencyKeyTTL. The default scope isrun, tied to the parent run, and since v4.3.1 raw strings also default torunscope; requestscope: "global"explicitly when the key must be unique across the project. If the original run fails, its key is cleared so a retry can create a new run (Trigger.dev idempotency). - Cap long tasks deliberately. Tasks have no timeout by default;
maxDuration(in seconds) stops runs that exceed it (tasks overview). - Wait with
wait.for.await wait.for({ seconds: 10 })pauses a run without a busy timer; never use asetTimeoutpromise for long delays. batchTriggerno longer returns runs directly. Retrieve them withbatch.retrieve()using the batch ID.
## Trigger.dev
- @trigger.dev/sdk 4.x; import ONLY from '@trigger.dev/sdk' (never '/v3')
- Tasks: export const x = task({ id: 'kebab-case', retry: { maxAttempts, ... }, maxDuration, run })
- Queues declared with queue() and referenced by name
- Hooks: onStartAttempt, catchError (single object parameter)
- Triggers from webhooks pass idempotencyKey; scope 'global' when it must be project-wide
- Delays: wait.for({ ... }) / wait.until(...)
- TRIGGER_SECRET_KEY from env only
RabbitMQ rules for Claude Code
RabbitMQ is the classic routed message broker, and the 4.x series changed the defaults that matter for reliability. On the Node side, amqplib is at version 2.x.
- Use quorum queues for anything that must survive a node failure. RabbitMQ's documentation calls quorum queues the default choice for a replicated, highly available queue, and classic queue mirroring was removed in RabbitMQ 4.0 (quorum queues). Declare with
arguments: { 'x-queue-type': 'quorum' }. - Plan for the delivery limit. Since 4.0, quorum queues default to a delivery limit of 20. A message that exceeds it is dropped, or dead-lettered if the queue has a dead-letter exchange. Configure
x-dead-letter-exchangeon every quorum queue so poison messages land somewhere you can inspect. - Set prefetch before consuming. In amqplib, a falsy
prefetchcount means no limit on unacknowledged messages, and since RabbitMQ 3.3.0 the limit applies per consumer unless theglobalflag is set (amqplib channel API). Always callchannel.prefetch(n)with a real number. nackrequeues by default.channel.nack(msg, false, requeue)defaultsrequeuetotrue. To dead-letter a poison message, passfalseexplicitly; otherwise it goes straight back on the queue.- Confirm publishes. Use
createConfirmChannel()andwaitForConfirms(), which rejects if any message is nacked or the channel closes first. Publish withpersistent: trueto a durable queue so messages survive a broker restart. - Know the amqplib 2.0 heartbeat change.
heartbeat: 0now disables heartbeats; omit the option or passnullto accept the server's suggestion. - Acknowledgements are manual unless you opt out.
noAckdefaults tofalse; keep it that way for any work that matters.
## RabbitMQ
- amqplib 2.x; ONE connection per process, channels per concern
- Topology declared once in src/jobs/topology.ts, re-run on reconnect
- Work queues: x-queue-type quorum, durable, x-dead-letter-exchange always set
- Delivery limit: rely on the quorum default (20) or set x-delivery-limit explicitly; never -1
- Publishers: createConfirmChannel(), persistent: true, await waitForConfirms()
- Consumers: channel.prefetch(10) (or measured value) BEFORE consume; noAck false
- Poison messages: channel.nack(msg, false, false) to dead-letter; ack only after success
- Never pass heartbeat: 0 unless heartbeats should be disabled
NATS JetStream rules for Claude Code
NATS has two layers that Claude conflates: core NATS (fire-and-forget pub/sub and request-reply) and JetStream (persistent streams with acknowledgements). The JavaScript client also moved: the nats npm package is marked "Package moved. Use @nats-io/transport-node", and JetStream lives in @nats-io/jetstream.
- Import the modular packages. Create the connection with the transport module, then call
jetstream(nc)for publishing and consuming andawait jetstreamManager(nc)to manage streams and consumers. Consume withconsumer.consume()and acknowledge each message when done (JetStream client README). - Use JetStream for work that must not be lost. Core NATS subjects are plain pub/sub; persistence, acknowledgements and redelivery come from JetStream streams and consumers. Anything that must be processed later belongs in a stream.
- Deduplicate with message IDs. Set a message ID when publishing (the
Nats-Msg-Idheader). The server rejects a repeat with the same ID within the stream's duplicate window, which defaults to 2 minutes (NATS streams). - Do not accept stream defaults blindly. Defaults are limits retention, file storage and 1 replica, which means the stream goes down with its one server. Set replicas, max age and max bytes explicitly for production streams, and choose
WorkQueueorInterestretention when messages should be removed once consumed. - Cap redelivery.
AckWaitdefaults to 30 seconds andMaxDeliverdefaults to-1, no limit (NATS acknowledgements). SetMaxDeliverand send an in-progress signal from long handlers so they are not redelivered mid-run. - Use the right acknowledgement.
ackfor success,nak(optionally delayed) for a retryable failure,termwhen no future attempt can succeed, such as a malformed payload. Usetermonly when the code can tell retrying is pointless.
## NATS JetStream
- @nats-io/transport-node + @nats-io/jetstream 3.x; never the legacy 'nats' package
- ONE connection per process; jetstream(nc) for data, jetstreamManager(nc) for setup
- Durable work goes through JetStream streams, never core NATS subjects alone
- Streams defined in code: replicas 3 in production, explicit max_age and max_bytes
- Publishes carry a message ID (Nats-Msg-Id) from the business key
- Consumers: durable, explicit ack, MaxDeliver set (never -1), AckWait above handler p99
- Long handlers send in-progress; poison messages term(); retryable failures nak(delay)
MQTT rules for Claude Code
MQTT is a publish/subscribe protocol for devices and constrained networks rather than a job queue, but it ends up in the same codebase whenever devices feed background processing. The rules are about QoS, sessions and the broker's security defaults.
- Choose QoS per message class. QoS 0 is at most once, QoS 1 at least once (duplicates possible), QoS 2 exactly once (MQTT 5.0 specification). High-volume telemetry is usually QoS 0; commands that must arrive are QoS 1 with an idempotency key in the payload.
- Never publish to a wildcard. The specification requires that the topic name in a PUBLISH packet must not contain wildcard characters;
+and#belong only in subscriptions. - Clear retained state deliberately. Publishing a zero-length payload with the retain flag removes the retained message for that topic. Retain only state topics, never commands or telemetry.
- Set MQTT 5 and stable client IDs explicitly in MQTT.js. The
mqttpackage defaultsprotocolVersionto 4 (MQTT 3.1.1), generates a randommqttjs_client ID, and defaultscleantotrue(MQTT.js README). For persistent sessions, setprotocolVersion: 5, a stable client ID from the device identity,clean: falseand a session expiry interval; in MQTT 5, an absent or zero session expiry ends the session when the connection closes. - Configure a Last Will. The
willoption makes the broker publish a message when the client disconnects ungracefully, which is how other services learn a device went offline. - Respect Mosquitto 2's security defaults. Since Mosquitto 2.0,
allow_anonymousdefaults to false, and a broker with no configured listeners binds only to the loopback interface (Mosquitto 2.0 migration). Tell Claude never to fix a connection error by settingallow_anonymous true.
## MQTT
- mqtt (MQTT.js) 5.x client; protocolVersion: 5 set explicitly
- Topic names: <tenant>/<device-class>/<device-id>/<channel>; no wildcards in publish
- QoS 0 telemetry, QoS 1 commands with an idempotency key, QoS 2 only where duplicates are unacceptable
- Retain only on state topics; clear with a zero-length retained publish
- Persistent sessions: stable clientId from device identity, clean: false, session expiry set
- Every device sets a will (LWT) message
- Broker: TLS listeners, authentication on, ACL default-deny; never allow_anonymous true
Common Claude Code background-job mistakes, and the rule that prevents each
Claude learns fastest from before-and-after pairs, so a table like this is worth pasting into CLAUDE.md under the tool block you use.
| What Claude writes by default | Why it hurts | Rule to add |
|---|---|---|
queue.add('send', data) with no jobId |
Retries and duplicate webhooks create duplicate jobs | Business-key jobId (or the tool's equivalent) on every enqueue |
jobId: \send:${id}`` in BullMQ |
Custom IDs with a colon are not valid | Hyphens or underscores in keys |
| Legacy BullMQ repeatable-job calls | Removed in BullMQ 6 | upsertJobScheduler(id, ...) |
fetch() in a Temporal workflow or outside step.run / ctx.run |
Side effect repeats on replay or breaks the sandbox | Side effects only inside the tool's step boundary |
Temporal activities with no maximumAttempts |
Default policy retries without limit | Explicit retry policy with non-retryable error types |
Inngest v3 createFunction(config, trigger, handler) |
v4 expects triggers inside the options object | createFunction({ id, triggers }, handler) |
import { task } from '@trigger.dev/sdk/v3' |
Deprecated path in v4 | Import from @trigger.dev/sdk |
channel.nack(msg) on a poison message |
Requeues by default, so it loops | channel.nack(msg, false, false) with a dead-letter exchange |
A RabbitMQ consumer with no prefetch |
No limit on unacknowledged messages | channel.prefetch(n) before consume |
A JetStream consumer with default MaxDeliver |
Redelivers without limit | Explicit MaxDeliver, term() for poison messages |
import { connect } from 'nats' |
Legacy package, moved | @nats-io/transport-node and @nats-io/jetstream |
allow_anonymous true to fix a Mosquitto connection |
Opens the broker to anyone | Authentication and ACLs, never anonymous |
A worker with no SIGTERM handler |
Deploys kill in-flight jobs | The tool's close or drain call on shutdown |
Permission hooks for queue and broker scripts
A background-processing setup collects scripts: queue inspectors, replay tools, purge commands, broker admin CLIs. The read-only ones are safe to automate; the ones that delete or replay work should always stop and ask. Claude Code's permission settings make that split enforceable:
{
"permissions": {
"allow": [
"Bash(node scripts/queue-stats.js*)",
"Bash(nats stream info*)",
"Bash(rabbitmqctl list_queues*)"
],
"deny": [
"Bash(node scripts/replay-dead-letters.js*)",
"Bash(node scripts/purge-queue.js*)",
"Bash(nats stream purge*)",
"Bash(rabbitmqctl purge_queue*)"
]
}
}
Put this in .claude/settings.local.json. The deny list means Claude has to surface those commands to you instead of running them as part of an automated task. Our guide to Claude Code permissions explains how allow and deny rules work in full.
Frequently asked questions
Which background job tool works best with Claude Code?
All nine covered here work well once your CLAUDE.md pins the rules. BullMQ suits Node teams that already run Redis; Agenda suits scheduled jobs on an existing database; Temporal and Restate suit long, multi-step processes that must survive crashes; Inngest and Trigger.dev suit teams that want durable steps without running workers or infrastructure themselves; RabbitMQ and NATS suit messaging between services; MQTT suits devices.
How do I stop Claude Code from creating duplicate jobs?
Derive an idempotency key from the business event, pass it to the tool's deduplication mechanism (a BullMQ jobId, an Inngest event id, a Trigger.dev idempotencyKey, a Restate Idempotency-Key header or a NATS message ID), and make the handler safe to run twice with a unique constraint on processed keys. Put both rules in CLAUDE.md so Claude applies them to every new job.
Can Claude Code write Temporal workflows correctly?
Yes, when CLAUDE.md states the boundary: workflows import only @temporalio/workflow and activity types, all I/O lives in activities, every activity has a timeout and an explicit retry policy, and changes to deployed workflows go through patched() or Worker Versioning. The TypeScript sandbox already makes Date, Math.random() and setTimeout() deterministic, so the rules that matter most are about side effects and versioning.
Should I use a queue or a durable workflow engine?
Use a queue when each job is a single task that can be retried as a whole. Use a durable execution engine when a process has several steps, waits for minutes or days, or must resume at the exact step where it stopped. Many teams run both: a queue for short tasks and an engine for long business processes.
What happened to the separate BullMQ, Temporal, Inngest and RabbitMQ guides?
They are merged into this page, together with the Agenda, Trigger.dev, Restate, NATS and MQTT guides. One shared skeleton plus a short block per tool is easier to copy and easier to keep current, and every tool fact here was checked against current documentation on 21 October 2026.
Related guides
- Claude Code with Kafka: event streams, partitions and consumer groups, kept as a separate deep dive.
- Claude Code for transactional email: the email rules most background jobs end up calling.
- Claude Code for mobile and desktop apps: the same one-skeleton approach for Expo, React Native, Flutter, Capacitor, Swift, Electron and Tauri.
- Claude Code with Docker and deploying with Claude Code: running workers as separate processes in production.
Build background work that survives production
The pattern here is the same one that makes Claude Code reliable anywhere: decide the non-negotiable rules once, write them where Claude reads them every session, and keep the tool-specific code behind one module. For background work, that means business-derived idempotency keys, explicit retries with a dead-letter path, side effects inside the step boundary, and workers that shut down cleanly.
Get Claudify. A complete Claude Code configuration layer with 1,738 skills, 13 specialist agents and persistent memory, ready to adapt to your stack.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify