Dispatch
How I Built an AI-Powered Local News Network in a Weekend
I’m a former managing editor of two rural newspapers in Montana. I also spent the last 8 weeks running an automated daily news digest for a single Missouri lake town that nobody read.
Last weekend, I reconfigured the whole operation into a multi-state network covering 11 small towns across Missouri, Montana, New Mexico, and Colorado. It publishes daily, in my voice, with editorial commentary. The whole thing runs on a $20/month server and costs less than $1.50 a day in AI calls.
This is the post about how it actually got built, what worked, what didn’t work, what I’m still figuring out, and why I’m publishing the architecture even though I could keep it to myself.
My starting slate was 1 town, 0 readers, and $35/month going out the door.
In February 2026, I built the first version of Rural News Wire to cover Lake of the Ozarks, Missouri. It was a single Python script that hit a few RSS feeds, ran the articles through Claude Sonnet for summaries, generated a “Ty’s Take” editorial paragraph, and posted the result to a Ghost CMS site I’d set up on a $20/month DigitalOcean droplet.
It worked. Every morning, a new digest published. The articles were summarized very cleanly. The editorial commentary was actually pretty decent.
There was just one problem: nobody was reading it.
After 8 weeks of daily publishing, I had 11 indexed pages on Google, ten clicks, and zero subscribers. My total cost was about $35/month for the droplet plus a few bucks for Claude API calls. My total revenue was zero.
This is the spot where many projects can die. You’ve proven the tech works, but you have not proven anyone actually wants the product. My moves were to either to kill this project or scale it. I chose to scale, but the path I took was both surprising and interesting.
In order to monetize this project, I needed sponsors. Sponsors would pay for sponsor slots in each daily digest. I had a list of 530 local businesses I scraped from the Lake of the Ozarks Chamber of Commerce. I had a 25-script call sheet generated by Claude with personalized pitches for each business. The plan was to cold call my way to first revenue.
Then I admitted the harder truth: I hadn’t called any of them. The call sheet had been sitting on my desktop for almost a week. I’m a writer, not a phone salesman. Asking strangers for money is something I’ve avoided in every previous business I’ve built. I knew this about myself.
Even worse, when I imagined actually pitching the product, the pitch felt too thin. “Hi, I’d like you to pay $50 a week to be in my daily news digest.” The next question was always going to be “how many subscribers do you have?” The honest answer was “not many.”
The problem was that I needed audience before sponsors could possibly make sense. Audience = scope. One town with daily content and zero readers is a project. Eleven towns with daily content and an SEO footprint compounding daily is a publication network.
So, I changed the plan. Instead of cold-calling sponsors for one town, I refactored the entire system to cover multiple towns at once, then let the SEO do the audience-building work over the next three to six months.
The original code had Lake of the Ozarks hardcoded everywhere (sources lived in a global dict, weather config pointed at a specific Missouri zone, tags, slugs, and location names all baked into Python files that I’d have to edit every time I wanted a new town).
That was fine for one location, but it would be a nightmare for 50. My fix was to extract every location-specific thing into its own JSON file. Each town became a single config file that now lives in /opt/ruralnewswire/locations/. The code never changes when I add a new town; it just adds new data.
Here’s what a location config looks like:
{ “id”: “pagosa-springs”, “enabled”: true, “display_name”: “Pagosa Springs”, “state”: “CO”, “ghost_tags”: [ {“name”: “Daily Digest”}, {“name”: “Pagosa Springs”}, {“name”: “Archuleta County”}, {“name”: “Colorado”} ], “ty_take_context”: “Pagosa Springs, Colorado, the seat of Archuleta County. Population around 1,800 in town. Famous for the deepest geothermal hot springs in the world. Tourism economy plus ranching and timber. The voice should reflect the resort-town tension between locals and second-home owners, water rights, wildfire risk…”, “weather”: { “alerts_url”: “https://api.weather.gov/alerts/active?zone=COZ021”, “zone_name”: “Archuleta County, CO” }, “sources”: { “pagosasun”: { “name”: “Pagosa Springs SUN”, “rss_urls”: [“https://pagosasun.com/feed/”], “fallback_url”: “https://pagosasun.com/”, “type”: “wordpress” }, “pagosadailypost”: { “name”: “Pagosa Daily Post”, “rss_urls”: [“https://pagosadailypost.com/feed/”], “fallback_url”: “https://pagosadailypost.com/”, “type”: “wordpress” } } }
That ty_take_context field is the secret sauce. It tells Claude who I am for THIS town, aka what I know about the place, what tensions exist there, and what type of voice would feel “local”. Pagosa is a hot springs with second-home owners. Big Timber is ranchers and snowpack in the Crazy Mountains. Las Cruces is a bilingual culture in the Organ Mountains with border politics. The same code generates radically different editorial voices because each location feeds Claude radically different context.
A new location loader scans the directory and returns every enabled config. The runner loops through all of them. The whole multi-location system added maybe 80 lines of Python and removed dozens of hardcoded values.
By the time the refactor was done, I had a system where adding a new town meant creating one JSON file. No code changes ever.
The first time I tried to publish all 11 locations, half of them came out broken. Claude kept refusing to write summaries, saying “I don’t have enough article content to work with.”
I’d assumed RSS feeds gave me article bodies. They mostly don’t. RSS feeds give you titles, URLs, and sometimes a short excerpt. To get the actual article content, I needed to visit each URL and scrape the body myself.
I wrote a body fetcher. The function visits each article URL, strips out navigation and footer junk, and tries a series of common article body selectors in order of likelihood:
def fetch_article_body(url): """Visit an article URL and extract the main body text.""" try: resp = requests.get(url, headers=HEADERS, timeout=15) resp.raise_for_status() soup = BeautifulSoup(resp.text, “html.parser”)
for tag in soup(["script", "style", "nav", "aside",
"footer", "header", "form", "iframe"]):
tag.decompose()
selectors = [
"article",
"[itemprop='articleBody']",
".article-body",
".story-body",
".entry-content",
".post-content",
".tnt-asset-type-article",
"main",
"#main-content",
]
body_el = None
for selector in selectors:
body_el = soup.select_one(selector)
if body_el and len(body_el.get_text(strip=True)) > 200:
break
if not body_el:
paragraphs = soup.find_all("p")
text = " ".join(p.get_text(" ", strip=True) for p in paragraphs
if len(p.get_text(strip=True)) > 30)
else:
paragraphs = body_el.find_all("p")
text = " ".join(p.get_text(" ", strip=True) for p in paragraphs
if len(p.get_text(strip=True)) > 30)
text = " ".join(text.split())
return text[:2500]
except Exception:
return None
Three things about this function are worth explaining:
The selector list is ordered. ChamberMaster sites use different markup than WordPress sites which use different markup than custom newsroom CMSes. The list goes from most-specific to most-generic, so we get the cleanest extraction possible.
The 200-character threshold is intentional. Some sites have container divs that match the selector but are mostly navigation. We require the matched element to contain at least 200 characters of actual text before accepting it.
The 2500-character cap matters. Without it, very long articles blow up Claude’s context window when we batch 30 articles together. This is a hidden issue; your scraper works fine on small articles and silently fails when you hit a long one. Had to fix early.
After adding body fetching, every digest started getting real summaries. The improvement in quality was night and day. The cost is about 1.5 seconds of fetch time per article, which adds maybe 60-90 seconds to a full multi-location run so it’s worth it.
The Ty’s Take problem that almost killed the launch
Here’s where I almost shipped something embarrassing.
The first time I ran the full multi-location publish, several digests came out with editorial commentary that broke character in the worst possible way. The Las Cruces digest opened with: “I appreciate the setup, but I’ve got to be straight with you — these headlines aren’t from Lake of the Ozarks, which isn’t my beat.”
The Alamogordo digest broke the fourth wall completely as well, refusing to engage with the headlines and explaining what stories it would prefer to have been given.
The Chama Valley digest somehow referenced “Lake of the Ozarks needing better broadband,” applying my Missouri editorial frame to a New Mexico story.
The problem was a stale prompt. My original generate_tys_take function had been written for a single-location system. The system prompt told Claude that he was the editor of “a daily news digest for Lake of the Ozarks, Missouri.” When I started feeding that prompt headlines from completely different states, Claude noticed the mismatch.
Switching to Claude Haiku 4.5 (much cheaper than Sonnet for this workload) made the problem worse, not better. Haiku is more honest about uncertainty, which means when given thin content or a confusing setup it’s more likely to break character to acknowledge the issue. Sonnet would just confidently invent something, but Haiku raises its hand and asks for clarification.
My fix was to rewrite the prompt with explicit anti-meta-commentary rules:
prompt = """You are Ty McDuffey, writing the daily editorial paragraph “Ty’s Take” for Rural News Wire. You’ve been a working editor at small-town papers for years. You write the way real columnists write, not the way an AI explains itself.
LOCATION YOU’RE WRITING FOR TODAY: """ + location_context + """
ABSOLUTE RULES (do not break these):
- Never address an “assignment”, “prompt”, “request”, or “setup”. You are not being assigned anything. You are a writer doing your job.
- Never say “I appreciate” or “let me be straight with you” or “I need to flag” or anything that suggests you’re a chatbot.
- Never break the fourth wall by talking about what stories you wish were here. The headlines provided ARE the news today. Find the angle.
- Never reference Lake of the Ozarks unless the headlines are actually from there.
- If it’s a slow news day, find a HUMAN angle in what’s actually there. The death notices ARE community news. The school sports calendar IS a window into the town. Write like an editor who knows that.
VOICE: Direct. Plainspoken. Caring about the place. Like a guy who’s lived in this country and seen things change.
GOOD EXAMPLES of starts:
- “The snowpack numbers out of the Upper Yellowstone tell a story…”
- “Anytime a sheriff and his deputies end up facing charges…”
BAD EXAMPLES (never write like this):
- “I appreciate the assignment but…”
- “Let me be straight with you—today’s news cycle…” """
After adding the rules and the explicit good/bad examples, every location’s editorial voice locked into character. The Chama Valley digest now opens with “Slow week up north, which means you’re seeing the real texture of things — a man charged in a domestic, another claiming the devil’s got his friend’s ear, copper wire disappearing off a business…” The Big Timber digest speaks in plural (“we’ve seen dry years before”) like an actual rancher.
The lesson I leared was when an LLM output keeps breaking in the same way, the fix isn’t usually a more elaborate prompt. It’s an explicit list of failure modes to avoid, paired with concrete examples of what success looks like. Show, don’t just tell.
What Rural News Wire actually costs to run
Here are the economics.
Infrastructure: $20/month. A single DigitalOcean droplet running Ubuntu, Ghost CMS, and the Python pipeline. Could be smaller. I overprovisioned because I knew I’d add things.
AI calls: about $0.15-0.20 per location per day with Haiku. For 11 locations, that’s roughly $2 a day or $60/month. If I’d stayed on Sonnet 4 it would have been closer to $300/month. Switching to Haiku 4.5 for the digest workload was the difference between a viable economic model and a money pit.
Domain and email: about $20/year for the domain, free email forwarding through Namecheap.
Total monthly cost at 11 locations: roughly $80/month.
If I scale to 50 locations: roughly $250/month. At 100: roughly $500/month. The economics get better per location as I add more, because the fixed costs (droplet, domain) stay flat.
For comparison: Patch.com, the AOL hyperlocal news experiment, reportedly burned through about $300 million trying to do this with human reporters. They had to spend roughly $50,000 per location per year. I’m doing it for about $1,000 per location per year, all-in, with infrastructure that scales.
The reason I can do this and they couldn’t isn’t that I’m smarter than them. It’s that the technology to summarize and synthesize at near-zero marginal cost finally exists. Patch was trying to solve a 2010 problem with 2010 tools. Rural News Wire is solving the same problem with 2026 tools.
The Future of Rural News Wire
Email delivery isn’t set up yet. Ghost has the newsletter feature enabled but Mailgun isn’t connected. People can subscribe, but no email goes out. This needs to get fixed this week or none of the audience-building matters.
My sponsor slot is empty. The infrastructure for paid sponsorships is fully built. There’s a primary slot at the top of every digest, mid-slot between source sections, JSON-driven so I can switch sponsors with a config change. Until I land a first sponsor, every digest shows a house ad linking to the Sponsor page. That’s fine for now and will be reworked within three months.
The Big Horn County News in Montana now publishes from the same office as the Carbon County News in Red Lodge, which means I was effectively scraping the same paper twice. I disabled Big Horn until I find dedicated Hardin sources. I might add a Hardin radio station feed instead.
The dedup logic is global, not per-location. Right now, when an article gets scraped for one location it can’t be re-scraped for another location that shares the same source. This caused Chama Valley to grab all the Rio Grande SUN articles before Abiquiú could get them. I merged them into one location to fix the symptom. The fix is per-location dedup, which I’ll get to.
I’m charging $100/week or $350/month for the primary sponsor slot in any single town. Founding sponsor pricing locks that in as the network grows. I genuinely don’t know if that’s too high, too low, or right yet. I’ll know when the first three sponsors either close or don’t.
Why I’m publishing the RNW architecture instead of keeping it secret
More rural local news existing is genuinely good. If someone reads this post and builds their own version covering 10 towns I’d never have gotten to — say, eastern Oregon, the Mississippi Delta, or the Texas Hill Country, that’s technically a win for the people who live there. The market for “daily local news in a town with no newspaper” is infinite. There are thousands of small towns in the U.S. that lost their daily paper a decade ago and never got it back. I can’t cover them all. Nobody can.
If you want to build something like this, the architecture is here. The hard part starts after the code works.
What’s next for RNW
This week: Mailgun setup so the newsletter actually sends. Founding sponsor outreach (small batch, warm leads first). Submit the Sponsor page for Google indexing.
This month: Subscriber growth experiments — Facebook groups, X, the Lake of the Ozarks subreddit. 5 more locations: I have a list of candidate towns where I have personal ties. A weekly post here about what’s working and what’s breaking.
The next 90 days will tell me whether this is a side project or a real business. I think it’s a real business. The infrastructure is built, the unit economics work, and the underlying need — daily local news for places that don’t have a paper — isn’t going anywhere.
If you want to follow along: the daily digests live at ruralnewswire.com. Subscribe and pick the towns you care about — Lake of the Ozarks, Las Cruces, Pagosa Springs, Big Timber, and seven others, with more coming.
I’m posting weekly updates on what’s working and what’s breaking on X at @tymcduffey.
If you’re building something similar — or want to — I’d love to compare notes. If you want to hire me to build something like this for your own market or a different vertical, I’m open to that too. Email me at ty@ruralnewswire.com.
-30-