A founder asked me to build an autonomous AI agent that monitors their reviews around the clock.
I nodded, asked what the agent should actually do, and listened.
Every hour, someone needs to read the new Google reviews. The negative ones must reach the founder fast. That is the whole job.
Here is what I shipped.
A cron job. One LLM call per review. A Slack webhook.
That is it. No agent framework. No elaborate reasoning loop. No vector database. No memory store.
Every hour, the job pulls reviews posted since the last run. Each review goes to the model exactly once with one instruction: summarize this and flag it if it is negative. The negative ones land in the founder's Slack channel. Everything else gets logged and ignored.
It runs perfectly, costs almost nothing, and the founder loves it.
The whole system
One cron line:
0 * * * * /usr/bin/node /home/app/review-watch.js
One small script:
// review-watch.js: read new reviews, flag the bad ones, ping Slack
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function main() {
const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const reviews = await fetchNewReviews(since); // your reviews API here
for (const review of reviews) {
const verdict = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: "Classify this review as positive, neutral or negative. Reply with one word, then a one line summary.",
},
{ role: "user", content: review.text },
],
});
const [label, summary] = verdict.choices[0].message.content.split("\n");
if (label.trim().toLowerCase() === "negative") {
await postToSlack(`Negative review from ${review.author}: ${summary}`);
}
}
}
async function postToSlack(text) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Two moving parts. You can read the entire system in one sitting.
Why this beat the agent
An agent would have added a planning loop, tool definitions, retry orchestration and state management. For what? The input is fixed: new reviews. The schedule is fixed: every hour. The output is fixed: a Slack message for the bad ones. There is no branching decision to make, no tool to choose, no memory to carry between runs.
Every layer an agent adds on top of that is a layer you get to debug at 3am. A script fails in obvious ways: the cron log tells you exactly which line broke. An agent fails in interesting ways: a tool call went sideways three steps deep inside a reasoning chain and nobody can reproduce it.
Selling an agent when a script would do is how projects get slow and expensive.
The honest framework
Build the agent when the job has real decision making:
- The system must choose between tools based on what it reads
- The path branches on intermediate results, not on fixed rules
- It needs memory across steps to finish a single task
- The input is ambiguous enough that one fixed prompt cannot handle it
Ship the script when the job looks like this:
- It runs on a schedule or on a trigger
- The inputs are known and the outputs are known
- The model does one well defined thing, like classify or summarize
- You can read the whole system in one file
Most of the agent requests I get land in the second list. That is not a failure of ambition. It is good engineering: the simplest system that does the job.
One note on cost
This runs one small model call per review, once an hour. There is no framework overhead, no repeated reasoning passes, no state store to maintain. The bill is a rounding error and the founder has never once asked what it costs. Compare that to an agent looping through a large model for the same hourly job and the difference pays for itself.
What is the most overbuilt "AI agent" you have seen? Or built? Drop it in the comments.
I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. Tech Lead at LoopxCorp. I write about backend engineering, AI integration and practical system design.
Top comments (20)
The honest part of this post is the fixed-input, fixed-schedule, fixed-output framing — that's the actual test for whether you need an agent at all. Most 'agent' pitches collapse into exactly this shape once you ask what decisions the system genuinely makes. The question I'd add: what's the tripwire that tells you this cron job has outgrown itself — is it the first conditional branch in the workflow, or state that has to persist between runs?
For me the tripwire is state, not the first branch. A branch is still an if statement and I have shipped scripts with plenty of those. The day the script needs to remember what happened in a previous run, that is when it stops being a script in my head. When did persistence force a redesign in your work?
State is the cleaner tripwire — a branch is still just logic, but the moment a run has to remember the last one, you've got durability, concurrency, and recovery to design for, not just a script. The line I'd draw inside that: does the state just need to survive a restart, or be shared and consistent across runs? That second one is where a file stops being enough and it becomes a real store.
That distinction is the right one. Surviving a restart is just writing a file and reading it back, I have done that plenty of times without anything fancy. Shared and consistent across runs is where it changes, because now two runs can disagree and you need one source of truth, and that is the point where I stop calling it a script even if the code is still small. Where do you usually draw it, a single store or something with real transactions?
I'd draw it at the disagreement itself: a single store with atomic writes is usually enough, and it stays a script-plus-store in my head. Transactions earn their place when a partial write is worse than no write at all — say, marking a job done without recording its output. Is that the case you've hit in practice, or has the single-store version held up so far?
The single store version has held up for me so far, mostly because my jobs are small enough that a write either lands or the next run just redoes it. The partial write case you describe is exactly where I would reach for transactions though, a job marked done without its output is worse than a duplicate, because the duplicate at least gets caught and the missing output just vanishes. Have you had a partial write bite you in production, or is it more a design rule for you?
More a design rule than a war story — the asymmetry you name is the reason: a duplicate gets caught, a missing output just vanishes, so the cost of the partial write is silent loss, not a visible retry. That asymmetry is also why I'd keep the single store until the silent-loss case shows up in practice, not just in theory — has a job of yours ever silently dropped output and only surfaced weeks later, or does the redo-on-next-run cover that too?
Redo on next run has covered it for me so far, mainly because the jobs are small and the next run redoes exactly what the last one missed. The silent drop case I actually watch for is the one where a run marks something done and then fails before writing the output, because then there is nothing left to redo. That is the one case where I would stop trusting the single store. Has that marking done too early pattern shown up in your work, or do you guard it some other way?
That's the sharper version of the silent-loss case — redo covers work never started, but a premature done-mark leaves nothing to redo, so the loss is invisible by design. The guard I'd use is ordering, not transactions: write the output first, mark done second, and make the redo idempotent, so a crash between the two just redoes work instead of losing it. Have you hit that ordering in a job where output and flag couldn't be written together, or has the single write held so far?
That ordering is exactly the guard I reach for too, because it turns a crash into a repeat instead of a loss. In practice I keep the output write and the done mark in the same store so the two move together, and when they truly cannot share one write I fall back to a dedupe key on the output itself so a repeated run is harmless. Have you found a case where even the dedupe key could not save the run, or has that combination held for you so far?
That combination is the strongest version of the guard — same-store atomicity where you can get it, dedupe key where you can't. The case where even the dedupe key wouldn't save the run is when the side effect already left the store: a repeated run can skip the write, but it can't unsend the email or uncharge the payment the first run already emitted. Has that outside-the-store side effect been the limit in your jobs, or has the dedupe key held because the outputs stay inside the store?
That outside the store case is the real limit, and it is the one I design around first now. A dedupe key protects the write, but it cannot unsend an email or uncharge a payment, so for anything that leaves the store I put the guard before the side effect, not after it: check the key, then emit, and record the emit in the same step where I can. When the emit itself cannot be made idempotent, the fallback is a reconciliation pass that catches what went out twice. Has that before the emit ordering held for you, or do you handle the reconciliation side differently?
Guard-before-emit is the right ordering — and it's held for me precisely because the check has to be a claim, not just a read: two runs that both check before either emits will still double-send. That's where reconciliation earns its place, as detection after the fact rather than prevention, which is fine for an email but thin comfort for a charge. Do you make that claim atomic in your own store, or lean on the provider's idempotency key for the emit itself?
I make the claim atomic in my own store first, because that is the part I control. The claim row goes in with a unique key in the same write as the job record, so the second run sees it taken and stops before it ever reaches the emit. The provider key is the second layer, not the first: I pass the same key through when the emit is a charge, so even if my claim somehow slips, the provider still returns the first result, which matters most for a charge where detection after the fact is thin comfort. Do you treat the claim and the provider key as two layers too, or does one of them carry the whole job for you?
Two layers, definitely — the claim is the gate that stops the second run from ever emitting, and the provider key is the backstop for the case the claim slips or the process dies between claim and emit. Neither carries the whole job alone: the claim can't protect you once the emit has left your store, and the key can't stop two runs that both think they're first. For a charge I'd want both, plus reconciliation as the audit trail — do you reconcile against the provider's records too, or trust the key's replay as the final word?
The test I'd steal is the fixed-input, fixed-schedule, fixed-output one — that's the sentence that decides agent vs script before any code gets written, and I now run it on every automation request first. The one failure mode I'd still design for in your script: overlapping cron runs. If an hourly run ever takes longer than an hour, two instances pull the same 'since' window and every review gets classified twice — benign here, but the fix is cheap, a lock file or a lease row in SQLite before the fetch. Have you hit a job yet where the script turned out to need the branching after all — and did you rewrite, or layer the agent on top?
The overlap point is fair, and honestly it is the one I would add first. A lock before the fetch is cheap and it kills the double classify problem. On branching, I have always rewritten the script first and only layered an agent on top when the branches needed memory across runs. Which side did you land on when you hit that?
lock before the fetch. clean fix for the overlap.
The fixed input, fixed schedule, fixed output test is a good filter, and I would use it in the first client call rather than after the proposal. Once a request is written as one job with a known input and a known output, the word agent usually drops out of the conversation on its own.
The comments add the part that decides whether the cron version survives. State is the real tripwire, as Agentic Affairs said, more than the first branch in the logic. If the job has to remember what it already processed, you need a single store and a clear rule for what counts as done. Odinaka's overlapping runs point is the one we see most in production: a lock taken before the fetch, not after, saves a lot of duplicate Slack messages.
Did the founder in your example ask for changes once the simpler version was live, or did it stay at one call?
In that example the simpler version did the job, one call per new review and the Slack alert, and that was the whole point, the negative reviews arriving on time. The first call filter is the part I agree with most. Writing the request as one job with a known input and a known output before the proposal is where the word agent usually drops out on its own. Do you write that filter down as a step in the call, or is it more a gut check for you?