· 5 min read
Grounding AI-written marketing posts in real weather data, without letting the model make things up
A daily pipeline that turns National Weather Service forecasts into social posts for contractors. The hard parts were expiry, idempotent runs, a stuck stream, and one rule in the prompt.

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 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.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
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);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)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
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.