Ask a language model for a social post for a roofing company and you get something fluent and generic: "Is your roof ready for winter? Our expert team offers a 50-year warranty and can finish most jobs in 4 days!" The client never offered a 50-year warranty. Nobody said 4 days. The post sounds great and is a liability.
For a client building marketing tools for home service contractors, I built a pipeline that writes posts from things that are actually happening: a freeze warning tomorrow night in three of the contractor's towns, a high wind advisory, poor air quality. The model writes the words. It does not get to invent the facts. Here is how it is put together.
The app home, where signals turn into post ideas waiting for review.
The rule that does most of the work
The planning prompt gives the model a list of dated, located facts and the client's real services, and then states one rule:
Only state facts given above: no invented numbers, prices, offers, warranties, durations, years or phone numbers. Today is {date}. Write advice before a coming event, never as if it already happened. Only the overlap is publishable.
Both lines came from real outputs. Without the first, the model added warranties and timelines that sounded plausible. Without the second, a post written on Tuesday about Thursday's storm said "after this week's storm", which is wrong on the day it would be published.
The rule alone is not a guarantee, which is why nothing is ever posted automatically. A person approves every post. The point of the pipeline is to make that review fast because the facts are already correct.
The pipeline
From a forecast to an approved post.
Runs that cannot double up
A cron job calls the signals endpoint every hour, and the app also runs a startup bootstrap with retries at 1, 2, 4, 8 and 16 seconds after each deploy. That means the same work is requested more than once, on purpose, so each run has to claim its slot:
INSERT OR IGNORE INTO signal_runs (schedule_key, status, started_at) VALUES ('nws-alerts@area-42:2026-10-25', 'running', CURRENT_TIMESTAMP); Only one run gets the slot.
The key is provider@area:day. If the insert did nothing, someone else owns the run. A failed run, or one stuck in running for over 20 minutes, can be claimed again. A successful one cannot. Weather alerts are fetched hourly, while the daily set (forecast, seasonal items, local news) runs once after 6 AM in the client's own timezone.
Weather data that expires
The National Weather Service API is free and good, with two things to know. It wants a User-Agent that includes contact details. And you resolve a location in two steps: /points/{lat},{lng} gives you the forecast and grid URLs for that point, which you then fetch. Those point lookups rarely change, so they are cached for 31 days.
Every forecast period has a validity window, written as an ISO 8601 interval like 2026-10-25T18:00:00-07:00/PT3H. A signal is only useful inside that window. The code parses it and drops anything already over before it is saved:
function windowOf(validTime: string) { const [start, duration] = validTime.split('/') const from = new Date(start) return { from, to: new Date(from.getTime() + isoDurationMs(duration)) } } const live = signals.filter((s) => windowOf(s.validTime).to > now) A forecast period has a window, and the window can close.
Without that check, an expired warning can reach the planner next to a current one, and the model will happily write about both. An expired signal is now never written to the database at all, and old signals for the same source are retired in the same transaction that saves the new ones.
Requests to NWS get two attempts with a 15 second timeout, and retry on 429 or 5xx while respecting Retry-After.
Small details that changed the output
- Keyword order matters. A regex that checked "wind" before "wind chill" filed cold-weather alerts as wind events. Match the longer, more specific phrase first.
- Dedupe the forecast cards. Several periods can carry the same headline. Each signal gets a fingerprint of the provider plus a SHA-256 of its external ID, and duplicates are dropped.
- Plan per industry. A regex once labelled a safety consultancy as a remodeler. Each client now has an explicit industry plan rather than a guess from their website.
Local news, carefully
Local news is a second source. The scraper reads robots.txt, only fetches from an allowlist, caps pages at 2 MB with a 15 second navigation timeout, and asks a model whether each article is relevant to the client's trade, caching that answer so the same article is never judged twice. In one test with 24 local sites, 6 blocked plain fetches.
From plan to image
The planner sees signals in a compact line format, roughly [Category: kind, severity] (when) [towns] title: body (why), writes the copy, and a layout step turns it into HTML. Playwright renders that to an image, either on a client photo or an AI background. Costs are tracked per post, stored as integer micro-dollars so sums never drift: about $0.09 to $0.12 on a client photo, and about $0.16 with a generated background.
The bug that froze every run
Why every run froze.
At one point every signal run froze. Nothing had crashed. Runs started and never finished.
The cause was a helper that read a response body with a size limit. When the body got too large it cancelled the reader and threw. But the response had been cloned earlier (one copy for logging, one for parsing), and a cloned body is a tee. Cancelling one half of a tee does not settle until the other half is cancelled or read too. So await reader.cancel() waited forever, and so did the run.
// before: hangs when the response was cloned await reader.cancel() throw new Error('Response too large') // after: cancel in the background and fail now void reader.cancel().catch(() => {}) throw new Error('Response too large') The regression test races an endless cloned stream against a 5 second timer and fails if the helper is still waiting. It is a small test that protects a very quiet failure: a hung run looks exactly like a slow day with no news.
What I would tell someone building this
- Give the model facts, not topics
"Freeze warning, Thursday 9 PM to Friday 9 AM, in these three towns" produces a better post than "write about winter prep", and it is checkable.
- Forbid what it tends to invent
Name the categories outright: numbers, prices, offers, warranties, durations, years, phone numbers.
- Tell it what day it is
And whether the event is coming or past. Models write in a vague present tense otherwise.
- Make runs claimable
A unique key per provider, area and day turns retries and overlapping crons into no-ops.
- Expire data at the source
Drop anything outside its validity window before it is saved, not when it is shown.
- Keep a human in the loop
Grounding makes review fast. It does not make review optional.
Frequently asked questions
How do you stop an LLM from inventing facts in marketing posts?
Give the model a list of dated, located facts and tell it to state only those facts. My planning prompt names the categories the model tends to invent: numbers, prices, offers, warranties, durations, years and phone numbers. A person still approves every post, because the rule reduces made-up claims but does not guarantee none slip through.
Why does an LLM need to be told today's date when writing posts?
Without the date, a model writes in a vague present tense and gets timing wrong. In my pipeline, a post written on Tuesday about Thursday's storm said "after this week's storm", which is false on the day it would be published. The prompt now states today's date and says to write advice before a coming event, never as if it already happened.
How do you handle weather alerts that have already expired?
Parse each forecast period's validity window and drop anything already over before it is saved. National Weather Service periods carry an ISO 8601 interval such as 2026-10-25T18:00:00-07:00/PT3H. Without the check, an expired warning can reach the planner next to a current one, and the model will write about both.
How do you stop an hourly cron job and a startup retry from running the same work twice?
Make each run claim its slot with a unique key before it starts. I use INSERT OR IGNORE on a key shaped provider@area:day, and only the run whose insert lands proceeds. A failed run, or one stuck in running for over 20 minutes, can be claimed again, but a successful one cannot.
Why can reading a cloned HTTP response body hang forever?
A cloned response body is a tee, and cancelling one half does not settle until the other half is cancelled or read too. In my pipeline, await reader.cancel() on an oversized body waited forever and froze every signal run. The fix was to cancel in the background with void reader.cancel().catch(() => {}) and throw immediately.
Originally published on my site. More posts and projects at mahmadmanzoor.com.






