The biggest improvement I ever made to an AI agent had nothing to do with prompts.
It was this: I stopped treating agents like magical reasoning machines and started treating them like boring background jobs.
That one mindset shift fixed more production issues than any model upgrade.
Before that, I had the usual architecture fantasy:
- clean prompt
- neat cron schedule
- GPT-5 or Claude doing the hard part
- a little retry logic
- done
That setup works right up until:
- the same trigger fires twice
- the same customer gets emailed twice
- a long-running job overlaps the next run
- cron silently misses a run on Sunday
- your agent dies quietly and nobody notices until Monday
So this is not a prompt guide.
This is the setup guide I’d actually use for unattended AI agents in production.
If your agent can survive 8 attempts, a missed cron, duplicate triggers, and a flaky model provider, it can usually survive real traffic.
The architecture in one sentence
Cron should create work, not do work.
The basic pattern:
- Cron, GitHub Actions, a webhook, or n8n timer enqueues a job
- A queue stores that job durably
- Workers execute the agent steps
- Monitoring verifies the trigger actually happened
- Failed jobs either retry or go to a dead-letter queue
That’s it.
Not sexy. Very effective.
Rule 1: cron should trigger work, not perform work
If I’m building an unattended agent today, I do not let cron directly run the full agent logic.
I let cron do one thing:
enqueue a job
Why?
Because cron is a starter pistol, not a control plane.
If you’ve ever had one job still running when the next one starts, or watched a machine reboot at exactly the wrong time, you already know this.
A schedule is a request. Not a guarantee.
Bad pattern
# cron directly runs the whole agent
*/15 * * * * node run-agent.js
Better pattern
# cron only enqueues work
*/15 * * * * node enqueue-agent-job.js
Then enqueue-agent-job.js writes to BullMQ, SQS, Cloudflare Queues, or whatever queue you trust.
Example: enqueue with BullMQ
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL);
const queue = new Queue('agent-jobs', { connection });
const customerId = '123';
const date = '2026-10-17';
await queue.add(
'daily-summary',
{ customerId, date },
{
jobId: `customer-${customerId}-daily-summary-${date}`,
attempts: 8,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
removeOnFail: 5000,
}
);
If you use n8n, queue mode already pushes you in this direction.
The main process handles timers and webhooks. Workers execute jobs separately. That’s much closer to reality than the single-process demo workflows people love to post screenshots of.
Rule 2: duplicate triggers are an idempotency problem, not an LLM problem
When the same job gets triggered twice, a lot of people reach for AI logic.
Don’t.
Duplicate suppression is not a GPT-5 problem.
It’s an idempotency problem.
The cleanest fix is usually a business-derived key.
Examples:
customer-123-daily-summary-2026-10-17account-42-invoice-followup-2026-10-17ticket-9981-first-response
That key should represent the business event you only want to happen once.
BullMQ example
await queue.add('agent-task', payload, {
jobId: `customer-${customerId}-daily-summary-${date}`,
attempts: 8,
backoff: { type: 'exponential', delay: 1000 },
});
If that same jobId is added again while the old job still exists, BullMQ ignores the duplicate.
That’s great, but there’s a catch.
The BullMQ gotcha
Duplicate suppression only works while the old job still exists.
If you aggressively clean up completed or failed jobs, the old ID disappears, and your dedupe window disappears with it.
| Option | What you gain | What you lose |
|---|---|---|
| Keep more completed/failed BullMQ jobs | Longer duplicate detection window | More Redis/storage pressure |
| Aggressive auto-removal | Lower storage cost | Weaker idempotency if you rely on queue history |
| Separate idempotency store | Strong duplicate protection | More moving parts |
My opinion: if the side effect matters, use a separate idempotency record.
Especially for things like:
- sending email
- charging a card
- creating a Zendesk ticket
- posting to Discord
- writing to a CRM
Queue history is useful. It should not be your only memory.
Example: separate idempotency table
create table agent_idempotency (
idempotency_key text primary key,
status text not null,
created_at timestamptz not null default now()
);
Then in your worker:
async function ensureNotProcessed(db, key) {
const result = await db.query(
`insert into agent_idempotency (idempotency_key, status)
values ($1, 'started')
on conflict do nothing`,
[key]
);
return result.rowCount === 1;
}
If ensureNotProcessed() returns false, you skip the side effect.
That one check can save you from a lot of embarrassment.
Rule 3: retry model calls in worker steps, not in orchestration
Retries are good.
Retrying the wrong layer is how you create chaos.
The pattern I like is simple:
- orchestration decides what should happen next
- worker steps do the nondeterministic work
- LLM calls and API calls happen inside retriable execution steps
- side effects happen only after idempotency checks
That means GPT-5, Claude Opus 4.6, Grok 4.20, OpenAI-compatible endpoints, CRM APIs, and webhooks all live in the worker layer.
Not inside some magical “agent brain.”
Example worker with retries
import { Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL);
const worker = new Worker(
'agent-jobs',
async job => {
const { customerId, date } = job.data;
// 1. check idempotency
// 2. call model
// 3. validate schema
// 4. perform side effect
return { ok: true, customerId, date };
},
{ connection }
);
worker.on('completed', job => {
console.log(`completed job ${job.id}`);
});
worker.on('failed', (job, err) => {
console.error(`failed job ${job?.id}:`, err.message);
});
Exponential backoff matters more than people think
BullMQ exponential backoff uses:
2 ^ (attempts - 1) * delay
If your base delay is 3000 ms, the 7th attempt waits 192000 ms.
That’s about 3.2 minutes after the previous attempt.
That’s good.
When OpenAI, Anthropic, or your CRM is having a bad hour, you want retries to stretch into real time instead of hammering the provider every few seconds.
A practical retry policy
await queue.add('agent-task', payload, {
attempts: 8,
backoff: {
type: 'exponential',
delay: 3000,
},
});
This is also where predictable compute matters.
If your agents retry, fan out, summarize, classify, and recover all day long, per-token billing turns reliability work into a budgeting problem. That’s exactly why flat-rate OpenAI-compatible infrastructure like Standard Compute is useful for agent-heavy systems: you can build for robustness without staring at token costs every time a workflow needs more attempts or fallback calls.
Rule 4: pick the boring queue you’ll actually operate
People love asking which queue or orchestration tool is best.
Usually the real question is: which one will your team actually run correctly at 2 a.m.?
Here’s the short version.
| Tool | Where I’d use it |
|---|---|
| BullMQ | Node.js workers that need custom job IDs, retries, and Redis-backed durability |
| Temporal | Multi-step agents where deterministic orchestration and activity-level retries really matter |
| n8n queue mode | Automations that start in n8n but need real worker separation for 24/7 execution |
| AWS SQS | Managed queueing when you want AWS-native infrastructure |
| Cloudflare Queues | Managed queueing with useful batch handling and edge-friendly architecture |
My take:
- BullMQ is great when you want control and you’re already in Node.js
- Temporal is excellent, but it can be overkill for simple agents
- n8n queue mode is the right move if your team already lives in n8n
- SQS and Cloudflare Queues are solid if you want managed infra instead of running Redis
If your agent is simple, BullMQ + Redis + Healthchecks.io is often enough.
If your agent has long waits, branching logic, approvals, and several external systems, Temporal starts looking less like overengineering and more like basic self-respect.
Rule 5: dead-letter queues are not optional
If your agent touches real traffic, you need somewhere for poison jobs to go.
Not later.
Now.
Because some failures are transient:
- rate limits
- 502s
- model timeouts
- temporary network failures
And some failures are permanent:
- malformed payloads
- deleted accounts
- impossible state
- missing required fields
Those should not live in the same retry loop forever.
What I want in a dead-letter payload
- original input payload
- error class
- stack trace or failure reason
- attempt count
- timestamps
- source queue name
Example dead-letter object
{
"jobId": "customer-123-daily-summary-2026-10-17",
"attempts": 8,
"errorClass": "SchemaValidationError",
"message": "Missing customer email",
"payload": {
"customerId": "123",
"date": "2026-10-17"
}
}
My dead-letter rules
- retry network and model failures
- dead-letter malformed input quickly
- make dead-letter queues visible in dashboards
- manually reprocess first, automatically reprocess second
If your dead-letter queue becomes a graveyard nobody checks, you didn’t build reliability.
You built denial.
Rule 6: heartbeat monitoring catches the failures logs won’t
This is the part teams skip all the time.
They’ll spend days comparing GPT-5 and Claude, then trust unattended jobs to vibes.
You need heartbeat monitoring.
Healthchecks.io is one of the easiest ways to do this.
Example cron with heartbeat
8 6 * * * /home/me/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://hc-ping.com/your-uuid-here
That catches more than “did cron start.”
It catches:
- machine outages
- cron daemon issues
- non-zero exits
- jobs that run too long
- missing successful completions
For AI agents, this is the difference between observability and wishful thinking.
Minimum viable monitoring for unattended agents
- a heartbeat for every scheduled trigger
- queue depth alert
- dead-letter queue alert
- worker error-rate alert
- “no successful runs in X hours” alert
That last one is the killer.
A lot of agents don’t fail loudly.
They just stop doing useful work.
Quick queue health check with Redis
If you’re using BullMQ, even a simple debug script helps:
const counts = await queue.getJobCounts();
console.log(counts);
Or from the terminal, if you’re checking Redis health:
redis-cli ping
Not sophisticated. Still useful.
Rule 7: model failover should be explicit policy, not prompt magic
I do not want a worker deciding philosophically which model “feels right.”
I want rules.
Example:
- if GPT-5 times out twice, route to Claude Opus 4.6
- if Claude is rate-limited, route to Grok 4.20 or Qwen
- if the fallback model changes output shape, validate schema before any side effect
That’s it.
Put failover logic in the worker policy layer.
That’s where retries, timeouts, and schema validation already belong.
Example failover policy
async function callModelWithFailover(input) {
const providers = [
{ name: 'gpt-5', timeoutMs: 15000 },
{ name: 'claude-opus-4.6', timeoutMs: 15000 },
{ name: 'grok-4.20', timeoutMs: 15000 },
];
for (const provider of providers) {
try {
const result = await callModel(provider.name, input, provider.timeoutMs);
validateResponseSchema(result);
return result;
} catch (err) {
console.warn(`model ${provider.name} failed: ${err.message}`);
}
}
throw new Error('All model providers failed');
}
If you’re using OpenAI-compatible tooling, this gets easier.
You can swap providers without rewriting your whole app. That’s one reason Standard Compute is interesting for agent workloads: it gives you an OpenAI-compatible endpoint with routing across models like GPT-5.4, Claude Opus 4.6, and Grok 4.20, which is exactly the kind of boring failover layer unattended systems need.
The key point is not “use magic routing.”
The key point is: make model selection operational policy, not hidden behavior.
Rule 8: the stack I’d actually ship this week
If I had to launch a 24/7 unattended agent by Friday, this is what I’d trust:
- Trigger layer: cron, GitHub Actions, webhook, or n8n timer
- Queue layer: BullMQ on Redis, or AWS SQS / Cloudflare Queues for managed infra
-
Idempotency: business-derived key like
account-42-invoice-followup-2026-10-17 - Workers: stateless Node.js workers, or n8n workers in queue mode
- Retries: exponential backoff for model/API calls, not orchestration state
- Dead letters: isolate poison jobs after retry threshold
- Health checks: Healthchecks.io heartbeat plus queue/dead-letter alerts
- Model failover: explicit timeout and fallback rules across GPT-5, Claude, Grok, or Qwen
- Compute layer: OpenAI-compatible endpoint that won’t punish you for retries and background agent traffic
Example environment setup
export REDIS_URL=redis://localhost:6379
export EXECUTIONS_MODE=queue
export HEALTHCHECKS_URL=https://hc-ping.com/your-uuid-here
Example project shape
agent-system/
enqueue/
daily-summary.js
workers/
summary-worker.js
lib/
idempotency.js
model-router.js
schema.js
monitoring/
heartbeat.sh
That stack is not glamorous.
That’s exactly why I trust it.
Final take
Most agent content wants to talk about memory, planning, autonomy, and reasoning.
Sure. Those things matter.
But a mediocre agent with good queueing, idempotency, retries, and monitoring will beat a brilliant agent that:
- double-runs
- silently dies
- retries side effects forever
- disappears over a weekend
If you want one practical takeaway, it’s this:
Make your agent boring before you make it smart.
Smart is optional.
Boring is what survives production.
If you’re building agents on n8n, Make, Zapier, OpenClaw, or custom Node.js workers, I’d optimize for reliability first and model cleverness second. And if your agents are running all day, the pricing model matters too. Flat-rate, OpenAI-compatible compute is a lot easier to build on than per-token infrastructure when retries, failover, and background automation are part of the design instead of an accident.
That tradeoff becomes obvious the first time your “simple cron agent” meets real traffic.
Top comments (0)