signalscope
tech

One streaming route, four sources that fail on their own, and citations the server checks.

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.

Shape of a run

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.

Where the signals come from

SourceViaNote
Google NewsRSSBrand name only. Adding category terms to the query zeroed recall, so triage does the disambiguating instead.
Hacker NewsAlgolia APICarries an engagement number, which is the only real popularity signal in the set.
GDELTRESTGlobal media coverage. Queries run one at a time, because it rejects parallel requests.
TavilyoptionalGeneral web search. Runs only when a key is set. Everything else works without one.

Citations that cannot be invented

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.

Two models with different jobs

triage · fast

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

synthesis · analyst

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.

Two bugs that only testing found

both shipped broken, both looked fine in review
symptom

Adding category terms to the news query returned nothing

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.

fix

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.

symptom

GDELT dropped every request when queried in parallel

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.

fix

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.

Decisions, and what each one costs

Two models, not one

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.

Keyless public sources

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.

Schema first, prompt second

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.

Streaming NDJSON, not a spinner

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.

Stack

Next.js 16 App RouterReact 19TypeScriptTailwind v4Vercel AI SDK v6Anthropic or OpenAIZod schemasNDJSON streamingVercel

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.