The interesting parts of this build are not the model calls. They are what happens around them: keeping a 90 second run legible, surviving a dead source mid-run, and making it impossible for the report to cite something that does not exist.
One POST to /api/monitor with a five minute ceiling. The response is a stream of newline-delimited JSON events, and the client renders each one as it lands.
{"type":"stage","stage":"fetch","status":"start"}
{"type":"source","sourceStatus":{"source":"news","status":"ok","count":31}}
{"type":"source","sourceStatus":{"source":"gdelt","status":"error","detail":"timeout"}}
{"type":"stage","stage":"fetch","status":"done","detail":"68 unique signals"}
{"type":"stage","stage":"triage","status":"start"}
{"type":"stage","stage":"triage","status":"done","detail":"kept 41, dropped 27 as noise"}
{"type":"signals","signals":[...]}
{"type":"stage","stage":"synthesize","status":"start"}
{"type":"report","report":{...}}Note the third line. GDELT timed out and the run kept going, because every source resolves independently and reports its own status. A dead source costs you its signals, not your report.
| Source | Via | Note |
|---|---|---|
| Google News | RSS | Brand name only. Adding category terms to the query zeroed recall, so triage does the disambiguating instead. |
| Hacker News | Algolia API | Carries an engagement number, which is the only real popularity signal in the set. |
| GDELT | REST | Global media coverage. Queries run one at a time, because it rejects parallel requests. |
| Tavily | optional | General web search. Runs only when a key is set. Everything else works without one. |
The model is shown a numbered list of signals and asked to cite by id. Models sometimes cite an id that was never in the list. Rather than trusting the prompt, the server intersects every cited id with the ids it actually sent, and drops the rest before the report reaches the browser.
// Citation integrity: the model may only cite ids of signals it was shown. // Strip any hallucinated ids so every chip in the UI resolves to evidence. const validIds = new Set(kept.map((s) => s.id)); const clean = (ids: number[]) => ids.filter((id) => validIds.has(id)); report.themes.forEach((t) => (t.signalIds = clean(t.signalIds))); report.competitorMoves.forEach((m) => (m.signalIds = clean(m.signalIds))); report.opportunities.forEach((o) => (o.signalIds = clean(o.signalIds))); report.risks.forEach((r) => (r.signalIds = clean(r.signalIds))); report.actions.forEach((a) => (a.signalIds = clean(a.signalIds)));
Six lines, and they are the reason every chip in the report opens a real source card. It is a guarantee the code makes, not one the prompt asks for.
Reads every raw signal, scores relevance 0 to 10, drops the noise. Claude Haiku by default, a GPT mini model on the OpenAI path.
~70 in, ~40 out
Reads the survivors once and writes the whole report against the schema. Claude Sonnet by default, GPT on the OpenAI path.
~40 in, 1 report out
Provider selection lives in one file. Set ANTHROPIC_API_KEY and it uses Anthropic; set only OPENAI_API_KEY and it falls back to OpenAI. Both model ids are overridable by environment variable, so swapping models is config rather than a code change.
The Google News query started as brand plus category, which read like the more precise version. It matched almost nothing, because the RSS endpoint treats the extra terms as hard filters. Recall went to roughly zero for most brands.
Query the brand name alone and let the triage model throw out the homonyms. Recall came back, and precision is now handled by the step that is good at it.
Sources fan out at the same time for speed, and GDELT quietly failed most of the time under that pattern. It rejects concurrent queries from the same client rather than queueing them.
Its queries run sequentially with a retry, inside the parallel fan-out of the other sources. The stage stays fast and GDELT stopped being flaky.
Neither was visible in the code. Both needed a real run against live endpoints, which is what the smoke test in scripts/smoke-test.mjs exists for.
Triage reads every raw signal, so it runs on the cheapest fast model. Synthesis reads about 40 survivors once, so it gets the expensive analyst-grade one. Using the strong model for both would cost several times more for a worse ratio of thinking to reading.
cost
Two model configs to keep straight, and a triage step that can be wrong about what to drop.
No paid scraping tier and no OAuth dance. Anyone can open the deployment and get a real run. That constraint is why X, Reddit, and LinkedIn are absent rather than half-working.
cost
The social layer is missing, and it is the layer where a lot of brand sentiment actually lives.
The report shape is a Zod schema passed to the model, not a list of headings in a prompt. Every field comes back typed, which is what makes rendering and Markdown export straightforward rather than defensive.
cost
A stricter schema means a failed generation fails loudly instead of degrading into prose.
A live run takes 60 to 90 seconds. A spinner for that long reads as broken. The route streams one JSON object per line as each stage and source resolves, so the UI shows the pipeline working.
cost
The client has to tolerate partial lines and a stream that ends without a terminal event.
Not built yet: stored runs and week-over-week diffing, alerting on a threshold, the authenticated social sources, and full article text instead of snippets.