Eight AI agents that run a technical blog with nobody watching.
Find what buyers search for. Write about it credibly. Refuse to publish it if it isn't good enough. Ship it, syndicate it, then measure what happened — and feed that back in. Every week. No CMS logins, no editors, no calendar reminders.
Overview
A content supply chain, rebuilt as a chain of agents
Each one has a single job, a written contract, and a quality gate behind it. None of them can call each other.
Getting an LLM to produce a plausible blog post takes an afternoon. Getting it to produce one that is long enough, structurally complete, keyword-correct, stylistically human, internally linked, correctly illustrated and verifiably published takes everything else. The generation call is the small part.The interesting engineering is routing each task to the right model, giving every agent a narrow contract, scoring output against something the generator doesn't control — and actually rejecting work that fails.
Why this had to exist
Research was the bottleneck
Finding which long-tail variants deserve an article means hours in keyword tools per topic — and paid SEO suites bill per seat, per month, forever.
Cadence collapsed under client work
An agency's writers are its engineers. One client deadline and publishing stops — but SEO only compounds if you never stop.
Naive AI content is worse than none
Generic LLM output is legible to readers and to search engines. Publishing it under the banner of an AI agency is actively self-harming.
Nothing closed the loop
Content shipped and was forgotten. Nobody went back for the page sitting at position 11 that needed one revision to reach page one.
Architecture
Seven agents, one feedback loop
Click any agent to inspect its contract, its use of AI, and how it fails.
Keyword Research
Agent 1 · Discovery · gpt-4oExpands seed keywords through Google Autocomplete — direct suggestions plus a 26-letter alphabet expansion per seed — then hands the deduplicated pool to GPT-4o, which returns structured JSON scoring each keyword on relevance, commercial intent and feasibility, assigning a topical cluster, search intent and difficulty.
Only keywords clearing a composite score of 6.0 are stored.
The orchestrator is deliberately thin
pipeline_runner.py acquires an exclusive flock so a manual trigger can never race the cron job, fetches the sitemap once and injects it into shared config (three downstream agents need internal-link candidates; one HTTP call serves all of them), assigns a run ID, then invokes each agent inside an error-isolating wrapper. One quiet behaviour matters more than it looks: before running the writer it checks queue depth, and if fewer than five keywords remain it forces a research pass first. The pipeline refills its own inputs.
# The chain — and the loop that makes it more than a conveyor belt
KeywordResearch → TopicGeneration → ContentWriting
→ SEOOptimization ⇄ ContentWriting # max 2 revision cycles
→ InternalLinking → Publishing → ContentDistribution
# Any subset runs standalone — the status column makes it resumable
$ python -m src.pipeline_runner writer seo linker publisherThe post lifecycle
State lives in a column, not in memory. A run that dies halfway through resumes exactly where it stopped — the next invocation simply finds rows in the appropriate status.
| Table | Role | Written by |
|---|---|---|
keywords | Scored queue with cluster, search intent, difficulty | Agent 1 |
blog_topics | Editorial briefs — outline JSON, LSI terms, meta, CTA | Agent 2 |
posts | Content plus quality, SEO, density and revision metrics | Agents 3–6 |
pipeline_runs | Per-agent execution log: status, duration, tokens, cost, errors | BaseAgent |
cost_tracking | Daily per-model token and spend ledger | LLM wrapper |
distribution_logs | Per-platform syndication attempts and outcomes | Agent 7 |
post_performance | GSC impressions, clicks, CTR, position, top queries | Agent 8 |
striking_distance | Queries ranking 5–20 — the optimization backlog | Agent 8 |
content_decay | Pages losing impressions — the refresh backlog | Agent 8 |
schema_version | Migration tracking | Init |
A single-writer, cron-driven pipeline handling one post per run has no need for a database server. SQLite gives transactional handoffs, zero operational surface, and — critically — a state file small enough to persist between stateless GitHub Actions runs through the cache API. A status column bought resumability, inspectability, selective re-runs and crash recovery for a fraction of the complexity of a workflow engine.
AI Engineering
Where the difficulty actually lives
Model routing, prompt architecture, multi-pass generation chains — and convincing an LLM to reliably reject its own kind of work.
3.1 Every call declares a task. The task picks the model.
All eight agents share one OpenAI wrapper, but no call names a model directly — it names a task, and the task resolves to a model, a token ceiling and a temperature. Creative work goes to GPT-4o at high temperature. Evaluative and formatting work goes to GPT-4o-mini at low temperature and roughly a sixteenth of the price.
| Task | Model | Max tokens | Temp | Rationale |
|---|---|---|---|---|
| Keyword research | gpt-4o | 4,096 | 0.5 | Nuanced commercial-intent judgment across ~100 keywords in one call |
| Topic generation | gpt-4o | 4,096 | 0.5 | Structured editorial planning, JSON mode enforced |
| Blog drafting | gpt-4o | 12,000 | 0.8 | Long-form generation needs headroom and creative latitude |
| Draft expansion | gpt-4o | 12,000 | 0.85 | Higher temp so it doesn't just regurgitate the short draft |
| Quality review | gpt-4o | 12,000 | 0.4 | Editorial correction wants precision, not invention |
| Humanize rewrite | gpt-4o | 12,000 | 0.9 | Highest temperature in the system — variance is the goal |
| SEO scoring | gpt-4o-mini | 4,096 | 0.5 | Rubric-following against a fixed schema; cheap enough to run on every draft |
| Distribution copy | gpt-4o-mini | 2,048 | 0.5 | Short-form reformatting of content that already exists |
When one model both writes and grades, it reliably approves its own tics. Splitting them is the single highest-leverage decision in the system.
3.2 The four-step writing chain
No single call produces a 2,500-word article that is simultaneously long enough, structurally complete, editorially sharp and stylistically human. Each property gets a dedicated pass with its own prompt, persona and temperature — and a verification step behind it.
Word count is measured, not assumed. Keyword presence in the title, the first 100 words and the meta description is verified and repaired in code if the model drifted. Every pass that can shorten an article is followed by a re-measurement, and expansion re-fires if needed. An LLM will confidently misreport anything countable.
3.3 Prompts are source code
Not one prompt is embedded in Python. All eight live in config/prompts/*.txt — 495 lines of specification that diff, review and roll back like any other source file. Runtime context (keyword, outline, word targets, sitemap URLs, prior rejection reasons) is injected as the user message; the file is always the system message. Most quality improvements in this project were prompt commits, not code commits.
# config/prompts/blog_writer_draft.txt — excerpt
STRICT WRITING RULES — FOLLOW THESE EXACTLY:
1. BANNED WORDS AND PHRASES — Never use any of these:
"delve", "leverage", "utilize", "facilitate", "paradigm shift"
"in today's rapidly evolving", "in the ever-changing landscape"
"game-changer", "revolutionary", "cutting-edge"
"unlock the power", "harness the potential", "supercharge"
...
MANDATORY SECTIONS: Introduction, Problem, Architecture, Implementation,
Code Example, Tech Stack, Performance, Common Mistakes, Use Cases,
FAQ (3-5 Q&A), Conclusion, CTA.Live Demo
The Humanizer Lab
This runs the real thing. All 121 production regex rules and the actual burstiness formula, extracted from src/utils/text_humanizer.py and executed in your browser. Type anything, or load a sample.
Anti-AI-detection pipeline
121 rulesliveStruck-through red = pattern detected. Green = the deterministic replacement.
✗ 27 patterns rewritten deterministically
✓ Burstiness 0.60 — sentence rhythm is within human range
quality_score = 10 − (27 × 0.5) − 0.00 = 0.0
Why burstiness?
Burstiness is the coefficient of variation of sentence lengths. Human prose swings — a nine-word sentence followed by a forty-word one. LLM prose settles into a metronome around 15–25 words and stays there. That uniformity is one of the most reliable machine signatures there is, and unlike vocabulary it survives paraphrasing.
# src/utils/text_humanizer.py — measured, not asserted
cv = std_dev(sentence_word_counts) / mean(sentence_word_counts)
score = min(1.0, cv / 0.6) # 0.0 = metronome, 1.0 = human
if burstiness < 0.3:
logger.warning("Low burstiness (%.2f) — may sound AI-generated", burstiness)
quality_score = 10 - (ai_patterns_remaining * 0.5) - burstiness_penaltyThree defenses stack, in increasing order of trustworthiness: instruction (the banned-words prompt), LLM rewriting (the temperature-0.9 humanize pass, which is told to add at least five sentences under eight words and two over forty), and deterministic enforcement(what you just ran). Only the last one can't be ignored by a model having an off day.
Live Demo
The Quality Gate
How a machine decides that machine-written content isn't good enough to publish. This simulator uses the exact scoring logic from seo_optimization_agent.py — toggle failures and watch the verdict flip.
SEO Optimization Agent — verdict simulator
40% programmatic60% LLMToggle defects the deterministic checks would find, and set what the LLM judge scored.
programmatic = 100 (no deductions)
programmatic = 100
overall = int(85 × 0.6 + 100 × 0.4) = 91
Missing mandatory sections are an automatic rejection, whatever the blended score says — you cannot buy your way past a missing FAQ with good prose. And after two failed revision cycles the post ships with a logged warning, because an unbounded quality loop is a pipeline that never publishes. Bounded autonomy over perfect autonomy.
| Check | Enforced by | Threshold |
|---|---|---|
| Word count | Code — at 3 points in the chain | 2,000–3,000 target · 1,500 hard floor |
| Keyword density | Code (BeautifulSoup text extraction) | 0.5% – 2.5% |
| Keyword in title / intro / meta | Code — auto-repaired if missing | Mandatory |
| Mandatory sections | Code (marker matching) + LLM | 11 sections, all required |
| Heading count | Code (regex over H2–H6) | ≥ 5 |
| HTML validity | Code | No malformed structure |
| AI pattern count | Code (121 compiled patterns) | Scored into quality metric |
| Burstiness | Code (sentence-length CV) | Warn below 0.30 |
| Overall SEO score | Hybrid 60 / 40 | ≥ 80 to publish |
| Internal links | Code — pillar-first, cluster-aware | Up to 3 |
Live Demo
The economics of autonomy
Autonomous LLM systems fail expensively before they fail loudly. Every call is costed from live token usage, attributed to a model, written to a daily ledger — and limits are checked before the next call is made.
Cost model
estimateReal published pricing, transparent token assumptions. Adjust and see where the money goes.
Assumes 3 long-form gpt-4o passes per post (draft · review · humanize),
2 short gpt-4o calls (research · topic), 2 gpt-4o-mini calls (judge · distribution).
Each revision cycle adds 2 long-form 4o passes + 1 mini re-judge.
Estimate — the ledger in cost_tracking holds the real figures.
gpt-4o $2.50 in / $10.00 out per 1M
gpt-4o-mini $0.15 in / $0.60 out per 1M
Pre-call limit checks
Exceeding $2 in a run or $10 in a day raises CostLimitExceeded and halts generation — checked before the request goes out, not after the bill arrives.
Bounded retries
Rate limits and 5xx back off exponentially (2s → 4s → 8s, capped at 60s) for at most three attempts. Client errors are never retried — a malformed request retried three times is three times the bill for the same failure.
Attributed ledger
Every call writes model, task, input/output token split and estimated cost to cost_tracking, keyed by date and model. Spend is queryable per agent, per day.
Free where it counts
Keyword discovery runs on Google Autocomplete plus LLM scoring rather than a subscription SEO suite. The data source costs nothing; the intelligence layer costs cents.
Reliability
An unattended system is defined by how it fails
Nobody is watching at 06:00 UTC on a Monday. Every failure mode has to have a decided-in-advance answer.
🔒 Exclusive run lock
fcntl.flock on a lock file. A second concurrent run exits cleanly rather than corrupting shared state — the manual-trigger-during-cron scenario.
🛡 Per-agent isolation
Every agent runs inside a wrapper that catches everything, records crashed with the exception to pipeline_runs, and lets the rest of the chain continue.
📡 Pre-flight checks
If WordPress is unreachable the publisher refuses to start, leaving posts in ready. Nothing is lost; the next run picks them up.
⚡ Transient vs. terminal
Connection errors and timeouts revert a post to ready for automatic retry. Genuine failures mark it failed so it stops consuming attempts.
✓ Publication verification
After creating a post the client re-fetches it by ID. A 201 that didn't actually persist is treated as a failure, not a success.
↓ Graceful degradation
No image? Publish without it. Link injection failed? Mark ready anyway. Platform down? Log per-platform and continue. Nothing optional blocks the critical path.
↩ Rollback on partial work
If topic generation fails mid-flight the keyword reverts from in_progress to new. Failed work never silently consumes inputs.
👁 Full run observability
Every execution records start, finish, status, records processed, tokens, cost and error text — queryable history, not scrollback in a log file.
Deployment
The pipeline runs on GitHub Actions — Mondays at 06:00 UTC, capped at 15 minutes, with a workflow_dispatch trigger exposing each agent and useful combinations (writer,seo,linker,publisher) as dropdown options. The SQLite file is restored from the Actions cache before the run and saved afterwards with if: always(), so state survives even when the pipeline fails. Logs upload as artifacts with 30-day retention. Secrets are injected as environment variables and resolved through *_env key indirection in YAML, so no credential ever touches the repository. A Dockerfile, Procfile, Railway config and APScheduler-based in-process scheduler exist for running the same pipeline as a long-lived service instead.
War Stories
Nine problems that only appear at 3am
Every one of these was found in production, by a run nobody was watching.
01 Posts kept coming back short
Asking for 2,000–3,000 words reliably produced 1,200. Worse: the humanization pass shrank posts further while “tightening” them — the fix for one problem was quietly causing another.
02 Content read as AI-written
Uniform sentence rhythm, identical paragraph openers and a recognizable stock vocabulary survived every prompt-level instruction not to use them. Telling a model to avoid its own defaults only partly works.
03 The keyword kept falling out
Each rewrite pass had a chance of dropping the primary keyword from the title, the intro or the meta description — the three places it matters most. Four passes meant four chances to lose it.
_enforce_keyword_placement() verifies all three after generation and repairs them in code: injecting into the title, prepending a natural mention to the first paragraph, appending to the meta description inside the 160-character limit.04 WordPress was intermittently unreachable
Posts that had cost real money to generate were being marked failed because of transient network conditions on an ephemeral CI runner.
ready rather than failed. Post-creation verification by re-fetch, so a phantom success is caught.05 Google rate-limited the scraper
Alphabet expansion issues up to 27 requests per seed keyword. With nineteen seeds, aggressive crawling drew 429s fast.
06 Quality loops could run forever
A post that keeps failing review keeps getting rewritten. The article may simply be at a local maximum — and the loop will happily burn the daily budget discovering that.
07 Cost could run away silently
A retry storm or an unusually long generation chain multiplies spend without anything visibly breaking. The pipeline looks healthy right up until the invoice.
08 Internal links looked machine-placed
Early versions appended link lists at the end of posts — an obvious footprint that adds no topical value and reads exactly like what it is.
09 Concurrent runs corrupted state
A manual trigger fired during a scheduled run left two processes writing the same SQLite file.
flock at process start. The second run exits cleanly with a message instead of racing.Evolution
Prototype to production in four weeks
Three agents became eight. The pivotal change wasn't adding capability — it was granting one agent the authority to say no.
Week 1 proved the chain. Week 2 made the output publishable. Week 3 made quality measurable. Week 4 made it a system. Roughly half the elapsed time went into rejecting and rewriting rather than generating — which is the same ratio the finished pipeline runs at, and not a coincidence.
Lessons
What building this changed
Generation is the easy 20%
A plausible blog post takes an afternoon. Long enough, structurally complete, keyword-correct, human-sounding, linked, illustrated and verifiably published takes everything else.
Never let the generator grade itself
Separate agent, different model, explicit rubric it didn't author. Self-review in the same context approves nearly everything.
Use code for anything countable
Word counts, density, heading counts, keyword placement, HTML validity — an LLM will confidently misreport all of them. Ask models for judgment, not arithmetic.
Autonomy needs hard stops
Two revision cycles. Fifty requests. Three retries. $2 per run. Every loop has a bound, because the failure mode of an unattended agent is never stopping.
Status columns beat orchestrators
Resumability, inspectability, selective re-runs and crash recovery — for a fraction of the complexity of a workflow engine, and it made every agent runnable from the CLI.
Prompts are source code
495 lines in versioned text files, not Python literals. Every change to editorial standards became a reviewable diff.
Cadence is a quality decision
Cutting from three posts a week to one was an improvement. More output from the same pipeline dilutes link equity and topical focus.
Measure the thing you're worried about
“Does this sound like AI?” is unanswerable. “Is the sentence-length coefficient of variation below 0.3?” is a check you can put in a pipeline.
What ships next
Close the optimization loop
striking_distance already identifies pages ranking 5–20. The next agent takes that queue as input and generates targeted revisions — optimizing what exists rather than only adding to it.
Automated content refresh
Same pattern for content_decay: declining pages get regenerated sections, updated examples and a republication.
Performance-weighted scoring
Feed real ranking outcomes back into the keyword agent's scoring prompt, so topic selection learns which clusters actually performed.
Multi-model consensus review
Route the quality gate through more than one model family and require agreement, reducing single-model bias in what gets approved.
Native LinkedIn publishing
LinkedIn currently routes through Slack for a human tap — the last manual touchpoint anywhere in the system.
Prompt regression tests
A held-out topic set scored on every prompt change, so editorial standards get tuned with evidence instead of vibes.
Every figure here describes architecture, configuration or enforced guardrails — all verifiable in the codebase. The cost model is an explicit estimate with its assumptions exposed. Traffic and ranking outcomes are tracked continuously in post_performance, striking_distance and content_decay via the daily Search Console sync, and should be read from live data rather than quoted from a document.
Idea in your head? Let’s
bring it to life.
Got a project? A wild idea? Or just want to say hey?
We're here for all of it — reach out anytime.