Agents on call: how six cron jobs and a prompt run my side project
A government-contracts search engine I stopped actively working on is now operated by scheduled AI agent sessions — health checks that remember last week, a dependency sweeper that hands me a branch, a rescue bot that drafts but never sends — plus a new event-driven tier where the LLM is the last resort, not the first responder. The patterns that made it safe are the interesting part.
1. The project nobody is working on
First, the thirty-second version of what the app is, because none of this makes sense without it.
The United States federal government buys almost everything it uses — laptop carts, janitorial services, satellite parts, lab equipment — through a public bidding process. Every opportunity to sell to the government is posted on a website called SAM.gov, the government’s official procurement portal. It’s an extraordinary public dataset: 500–2,000 new contract opportunities a day, every one of them public information. It is also, to put it gently, not a pleasant website to search.
GovTrove is my attempt at a better front door: a search engine over that data with real full-text search, filters that make sense, and email alerts. A Go API, a React frontend, a Postgres database, and a data pipeline that ingests SAM.gov’s official bulk data and public API every day (their terms prohibit scraping, so everything goes through the sanctioned programmatic paths, with rate limits respected with margin — that constraint shows up again later).
Here’s the twist that makes this article exist: earlier this year I put the project on the back burner. No paying users, other things to do. Normally that’s where side projects go to die quietly — the dependency rot sets in, the upstream feed breaks one Tuesday, nobody notices for a month, and eventually the domain lapses.
Instead, GovTrove is currently operated — monitored, tuned, its users personally emailed, its blog written — by six scheduled AI agent sessions and, as of this week, one event-driven one. I am still in the loop for every consequential decision, but I’m in the loop on my phone, over Telegram, for about ten minutes a week.
This post is a tour of how that works, and — more usefully — the handful of design patterns that stopped it from being terrifying.
2. The shape of the thing
The mechanics are almost embarrassingly simple, and I’ve come to believe the simplicity is load-bearing.
The prompt is the program. Each automation is a “skill”: a markdown file
in the repo (.claude/skills/<name>/SKILL.md) that describes, in prose, what
the agent should do — what signals to gather, how to judge them, what it may
and may not touch, and how to talk to me. Next to it sit a handful of shell
and SQL scripts. The scripts are deliberately dumb: read-only I/O helpers
that fetch metrics, run queries, and print text. All judgement lives in the
prose; all mechanism lives in the scripts. The line between them is drawn
exactly where determinism is cheap and judgement is expensive.
A scheduler types into real sessions. A small local cron manager wakes up on a schedule — Monday 8:00 for costs, Tuesday for dependencies, Thursday for health, and so on, deliberately staggered so two agents never compete for my attention — and starts an ordinary interactive Claude Code session in the repo, typing a one-line prompt like:
Run the GovTrove health check: invoke the health-check skill and follow its SKILL.md exactly. Begin now.
That’s the whole trigger. The prompt file is version-controlled next to the skill it triggers, so changing what the cron says is a git commit, not a click in a scheduler UI. The skill file is the program; the cron is a one-line bootloader.
Telegram is the approval channel. Every skill reports to me through a
68-line bash script with three verbs: send, wait, and reset. It
long-polls the Telegram API and persists a message offset to disk, which
sounds like a boring detail until you realize what it prevents: without it,
last week’s “yes” could approve this week’s deploy. Every run flushes the
queue before listening. Those four lines of bash are the single
highest-leverage safety feature in the system.
There’s a second constraint hiding in there that I’ve grown fond of. The agent’s shell tool has a hard timeout of about ten minutes, so a skill that wants to wait three hours for my reply can’t just sleep. Instead it composes the wait out of dozens of five-minute polls that it chooses to keep issuing. The harness’s limits become the concurrency model: the agent can always reconsider, time out gracefully, or send a gentle reminder at the 90-minute mark, because control returns to it every five minutes.
3. A tour of the six loops
The health check that remembers last week
Thursday mornings, an agent pulls about nine families of signals: database freshness and search volumes, Google Search Console trends, product analytics, uptime probes, error logs across every service, certificate expiry, pipeline execution history, dead-letter queues. Then it does the thing most monitoring never does: it compares against its own history.
Every run appends one JSON line to a plain file — no database, no service,
just a JSONL file that is the system’s entire cross-run memory. The trick
that makes a stochastic diagnostician trackable is a fixed vocabulary of
issue keys: ingest-stalled, search-slow, snapshot-bloat,
cert-expiring, about fourteen in all. The model isn’t allowed to name
problems freely; it must classify them into the enum. That stable identity
is what lets it diff against previous runs and tell me ”🆕 NEW”, ”⏳
ONGOING since Aug 1 (3 runs)”, or — my favorite, because almost no alerting
system says it — ”✅ RESOLVED: cleared on its own since last week.”
The digest arrives numbered, and the numbering is the protocol: I reply
do 2,3 or fix search or ignore it entirely. There is no reply parser —
the model is the parser. Fixes are classified into two tiers: safe ones
(read-only queries, ANALYZE, re-running an idempotent backfill) it applies
immediately; risky ones (anything involving a deploy, a schema change, a
lock-taking VACUUM FULL) require a second explicit “go” with the exact
plan and the before measurement in hand. And every applied fix must report
proof, not just “done”: before 65 seconds → after 120 milliseconds is a
real line from a past digest.
One more rule earns its keep. GovTrove’s pipeline polls for new upstream
data many times a day and no-ops when there’s nothing new — which means a
completely dead upstream feed produces a beautiful row of green,
one-second, “successful” runs. Last month SAM.gov’s bulk export silently
broke for eight days and every dashboard stayed green. That failure is now
encoded in the skill as a cross-signal rule: fast successes plus stale data
still equals ingest-stalled. Prompts turn out to be a decent format for
institutional memory — the guardrails read like postmortems, with the
incident dates still on them.
The cost watch that can’t touch anything
Monday’s agent pulls AWS spend per service and database storage, projects the month-to-date bill to a full month before comparing (raw month-over-month is always a false alarm on the 3rd), applies a noise floor (a service going from $0.00 to $0.01 is a 999% increase and means nothing), and sends one digest. It is the only agent with no approval loop, for the best possible reason: it has no ability to change anything. If it spots a fix worth making, it recommends; it never acts. Autonomy is graduated across the fleet, and the bottom rung is “look, but don’t touch.”
The dependency sweeper that hands me a branch
Tuesday’s agent runs in two phases with an approval gate between them, not at the top. Phase one is always safe so it always runs: list outdated Go modules and npm packages, then — the part worth stealing — run reachability-based vulnerability scanning, which only flags vulnerabilities in code paths the app actually calls. A CVE in a function you never invoke is context, not an alarm; the digest reflects that hierarchy, and cosmetic patch-lag on thirty packages is summarized as a count rather than listed.
Phase two happens only if I reply with approval: bump the agreed packages on a fresh branch, rebuild, run the tests, re-run the vulnerability scan to confirm the finding actually cleared, and stop. It never merges. The handoff artifact is a reviewable branch and a diffstat, with the closing line “I did not merge or deploy.” Major version bumps are excluded from automation by name in the skill file — React Router 6→7, TypeScript major bumps — so the model can’t talk itself into them.
The SEO editor and the blog writer, with a written border
Two agents share one editorial surface, which is exactly the setup where automations usually trample each other, so their skills contain an explicit ownership boundary: one writes new posts, the other improves existing pages, and each is instructed to hand topics over the fence rather than cross it. The editor mines Search Console for pages sitting at positions 4–15 with high impressions and dismal click-through, proposes sharper titles and metas with the evidence inline (“position 6.9, 489 impressions, 0.4% CTR”), and edits only after I approve the batch. It also knows which pages in the site are generated by a Lambda rather than checked in as files, and flags those instead of editing them — teaching an agent the difference between source and build output prevents a whole class of silently overwritten work.
The writer, meanwhile, proposes a topic and outline before drafting (cheaper to redirect at the outline stage), checks for duplicates by actually reading the existing posts rather than maintaining an index, and follows a rule I’d recommend to any SEO-adjacent automation: ignore queries whose intent you structurally can’t win. Thousands of people search for the government’s official API documentation; they will never click a third party, and an agent that chases the biggest impression numbers will burn every run on them.
The user rescue that cannot send email
This is the one I’d defend in front of a jury. Daily, an agent finds recently signed-up users who had a bad first experience — searched, got zero results, left — diagnoses why from their actual search history (an over-quoted phrase, a filter that excluded everything), and drafts a short personal email from me with working search links. Two design decisions make it trustworthy:
It verifies every claim the way the user will experience it. Any search link that goes in an email must be re-executed against the live API first — and against the frontend’s semantics, defaults included. An early version verified against the raw API while the app silently applied its own default filters, and quoted counts that were wrong by 3–37× (one email claimed 810 results for a search that showed 22 in the app). The lesson generalizes: verifying against your API is not the same as verifying against your product.
It cannot send. The first version could send email and was guarded by an environment-variable tripwire. The current version creates drafts in my own mailbox; a human presses send. That’s not a guardrail — it’s the removal of an entire risk class. The worst possible bug used to be “emailed a user twice”; now it’s “created a draft I delete.” When you can redesign an irreversible action out of the loop instead of gating it, do that.
The skill also contains my favorite sentence in the whole repo, aimed squarely at an LLM’s eagerness to please: “An empty cohort, a stale catalog, or ‘I found a bug instead’ are all good outcomes. Don’t manufacture emails to have something to show.” Every autonomous system needs its version of that sentence.
4. The patterns, extracted
Pulling the transferable ideas out of the tour:
- Prompt as program, scripts as I/O. Judgement in versioned prose, mechanism in dumb deterministic helpers. Review the prompt like code, because it is.
- Graduated autonomy. Four rungs observed in one small system: can’t-touch-anything (cost watch), gate-before-the-irreversible-transport (rescue drafts), gate-before-edit-and-deploy (content, SEO), and per-item risk-tiered gates (health fixes, dependency bumps). Pick the rung per task, not per system.
- Stable keys for a stochastic worker. Give the model an enum for naming problems and every downstream feature — persistence diffs, trend lines, analytics — gets identity for free.
- Verified or absent. No claim reaches a user (or me) unless a deterministic check confirmed it in that same run, measured the way the user would see it.
- Anti-goal-seeking, in writing. A run that produces nothing must be a documented success state, or the agent will invent work.
- Bounded waits, clean exits. Every wait has a deadline, every idle path ends the run without side effects. No agent in the system can hang.
5. The new tier: event-driven, with the LLM as last resort
Everything above wakes on a clock. The newest piece wakes on an event — and it inverts the usual architecture of “AI features” in a way I think is underused.
The event: a user’s search returns zero results. That’s the single worst moment in the product — the daily rescue emails exist precisely because people hit it and leave. The obvious 2026 solution is “call an LLM and have it suggest a better search.” We built almost exactly not that.
Instead, the zero-result event enters a cost-tiered cascade, implemented as a plain Go package:
Tier 1 — inspection, zero queries. Pure code looks at the query and filters for near-certain causes: a date range that’s reversed; a deadline filter entirely in the past (guaranteed zero, since the app only shows opportunities you can still bid on); a query that’s a quoted phrase (the number-one cause of empty results — government notices never match natural phrasing); something that looks like a solicitation number being strangled by filters; a typo the database’s trigram index already has a fix for.
Tier 2 — bisect, then probe. Here’s the part I like. Candidate fixes can be generated by rules too — unquote the phrase, drop the narrowing term, widen the exact industry code to its 4-digit family, remove one filter — and each candidate is verified by a count query against the real search index, which costs milliseconds. Before spending that budget, two probes bisect the blame: run the query without the filters, and the filters without the query. Whichever side comes back zero is where the remaining probes go. The output is a diagnosis plus up to three suggestions, each carrying a count that is true by construction: “Quotes require the exact phrase — matching all the words instead finds 41.” Total cost: a handful of indexed counts inside a fixed budget of eight. No tokens.
Tier 3 — the LLM, for the long tail only. Only if every rule fails does the event reach a language model (a small, fast one, via an API gateway, behind a feature flag and a daily cap). And its comparative advantage is exactly the thing rules can’t do: vocabulary. The user typed “cleaning lady”; the government says custodial services. The user searched “safety gear”; procurement says PPE. The model gets one tool — the same count probe the rules use, drawing from the same budget — and a transcript of every probe already tried so it doesn’t repeat dead ends.
Then the part that makes it trustworthy: the server doesn’t believe the model. A suggestion survives only if its exact parameters were probed in that conversation with a non-zero count, and the count displayed is the probed one, never the model’s claim. The enforcement is a dozen lines of Go sitting after the model’s final answer. The prompt asks nicely; the code insists. And “the catalog genuinely has nothing for this — set an alert” is a first-class answer, because the anti-goal-seeking rule followed the architecture down from the cron tier: an irrelevant “success” is worse than an honest zero.
There’s a feedback loop hiding here that I suspect is the actual long-term value. Every rescue is logged with which tier resolved it and why. Any pattern the LLM tier resolves repeatedly is, by definition, a deterministic rule nobody has written yet — so the periodic job that reviews the logs can promote it into Tier 2. The LLM tier’s job is to shrink itself.
Is the whole thing “just an API call to an LLM when search returns zero”? The transport is, sure. The differences are that the model is the last tier instead of the first, that its output is verified rather than trusted, that the prompt is a versioned artifact shared with the rest of the agent fleet’s conventions, and that on most days the model is never called at all. If you’re adding an LLM feature to a product, I’d argue this shape — rules first, verified tools, model for the residue — is worth copying far more than any prompt.
6. What it costs, and what I’d tell you to steal
The numbers are almost anticlimactic. The whole platform — the app, the
pipeline, the database, and every agent described here — runs on
single-digit-dollars-a-month infrastructure plus LLM usage that rounds to
pocket change, because the expensive model work happens a handful of times
a week and the event-driven tier resolves most events without a model at
all. The scarce resource was never compute; it’s my attention, and the
system is essentially an attention compiler: it turns “operate a SaaS” into
“read three Telegram digests and reply do 2.”
If you take one thing: the interesting engineering in agent automation is not in the prompts. It’s in the containment structure around them — the offset file that expires stale approvals, the enum that gives fuzzy output stable identity, the draft-only mailbox that deletes a risk class, the count probe that makes hallucinated claims structurally impossible, the sentence that tells the agent an empty result is a success. Every one of those is boring, deterministic, and small. That’s the point. The model supplies judgement; the boring parts make the judgement safe to act on.
GovTrove’s data comes from SAM.gov, the U.S. government’s official procurement portal, via its public APIs and bulk data services — attribution and links back to the source on every record.