A news and market-sentiment bot for a private Telegram group, built on self-hosted n8n. Two behaviors from one shared sentiment engine:
- Proactive — a scheduled morning digest posted to the group, covering configured focus topics plus a sentiment read on tracked tickers.
- Reactive — a group-chat agent for on-demand questions ("latest on X", "sentiment on NVDA"), with conversation memory and live web search.
All LLM outputs are validated by deterministic QA checks, logged to a central QA sheet, and reviewed weekly by an AI analyst that diagnoses failures against a knowledge base of known signatures.
┌───────────────────────────────────┐
│ sentiment_lookup (sub-workflow) │
│ Input normalize → Alpha Vantage │
│ → (throttled?) Marketaux │
│ → Qwen dimensional scoring │
│ → merge + net read → QA → log │
└───────────────┬───────────────────┘
called by │ called by
┌─────────────────────────┴──────────────────────────┐
│ │
┌─────────▼───────────┐ ┌─────────────▼──────────────┐
│ News Bot A │ │ News Bot B │
│ Proactive Daily Push│ │ Reactive On-Demand │
│ cron 07:00 │ │ Telegram Trigger │
│ Tavily (per topic) │ │ → Has text (event filter) │
│ → sentiment_lookup │ │ → Auth gate (chat IDs) │
│ → Qwen compose │ │ → AI Agent (Claude) │
│ → QA → log │ │ tools: tavily_search, │
│ → sanitize → send │ │ sentiment_lookup │
│ (to group) │ │ → sanitize → reply │
└─────────┬───────────┘ └──────────────┬─────────────┘
│ │
└────────────────► llm_qa_logger ◄────────────────────┘
(shared: normalize → Google Sheet append)
│
┌─────────▼───────────┐
│ QA Analyst (weekly) │
│ read sheet → stats │
│ → Claude diagnosis │
│ → Telegram DM report│
└─────────────────────┘
| Workflow | Trigger | Purpose |
|---|---|---|
sentiment_lookup (sub-workflow) |
Called by A & B | Ticker news → dimensional sentiment scoring → per-ticker net read |
News Bot A - Proactive Daily Push |
Cron 0 7 * * * |
Tavily news per focus topic + sentiment block → HTML digest → Telegram group |
News Bot B - Reactive On-Demand |
Telegram webhook | Agent (Claude Sonnet) with search + sentiment tools, windowed memory, non-text event filter, chat allow-list |
llm_qa_logger (shared sub-workflow) |
Called by A & sub-workflow | Appends standardized QA records to a Google Sheet |
QA Analyst - Weekly Pipeline Health |
Cron 0 8 * * 1 |
Reads QA sheet → deterministic stats → Claude diagnosis against failure-signature KB → plain-text DM report |
| Role | Model / API | Notes |
|---|---|---|
| Dimensional scoring | qwen3.5:9b via Ollama (/api/chat, format: json, think: false) |
Local, free, high-volume |
| Digest compose | qwen3.5:9b via Ollama |
Prose HTML, think: false |
| Reactive agent | claude-sonnet-5 (Anthropic) |
Tool routing + synthesis |
| QA analyst | claude-sonnet-5 (Anthropic) |
Interprets pre-computed stats only |
| News discovery | Tavily (topic=news/finance, time_range) |
Free tier 1,000 credits/mo |
| Ticker sentiment | Alpha Vantage NEWS_SENTIMENT |
Free tier 25 req/day — one batched call per run |
| Sentiment fallback | Marketaux | Kicks in on AV throttle (detected via Note/Information) |
Each article is scored by the local model on three anchored dimensions, then composited:
score = polarity (−1..1) × materiality (0..1) × confidence (0..1)
Rumors and trivial mentions decay toward neutral structurally (low confidence
or materiality) rather than by rule. Per-ticker net pools provider scores
and model scores; labels at ±0.15. The QA validator independently recomputes
the arithmetic, checks ranges and label bands, and measures model-vs-provider
divergence per ticker (divergence is surfaced, not smoothed — it's signal).
Note: sentiment coverage for ETFs and thinly-covered symbols is often sparse;
the digest flags thin reads (n of 1–2) rather than hiding them.
Three layers:
1. Inline validators (Code nodes) run deterministically, never block, and
attach a qa record to the data:
- Validator A (
sentiment_lookup): JSON parse/coverage, range checks,polarity×materiality×confidence == scorerecompute, label-band check, silent-failure (all-nulls) detector, per-ticker divergence. - Validator B (
News Bot A): length bounds, banned strings (preamble, advice, markdown leakage), hallucinated-link detector (every href must exist in the payload), unknown-ticker detector, HTML balance.
The Telegram chunk nodes in both bots additionally sanitize outbound
HTML (escape stray angle brackets, balance tags per chunk) so malformed
markup degrades to visible text instead of a Telegram 400.
2. Central log. Validators feed llm_qa_logger (fire-and-forget, Wait
for Sub-Workflow OFF; Sheets node mapping mode must be Map Automatically),
which appends to a Google Sheet:
ts | project | workflow | node | model | status | flags | metrics
3. Weekly AI analyst. QA Analyst reads the sheet, computes stats
deterministically in a Code node (fail rates, flag frequencies, early-vs-late
trend, mean divergence per ticker), and has Claude diagnose against the
failure-signature knowledge base — with instructions to hedge on thin samples
and never invent numbers. Report arrives as a plain-text Telegram DM (no
parse mode, so the diagnostician can't fail on formatting itself).
Known blind spot: the analyst sees only rows that reached the Sheet. A failure that prevents the log write (e.g. a broken Sheets node) is invisible to it — the global n8n error workflow is the net for that layer. Planned fix: give the analyst the n8n API as a tool to cross-reference failed executions.
- Self-hosted n8n (built on 2.29.x) reachable via public HTTPS for Telegram
webhooks (e.g. Tailscale Funnel),
WEBHOOK_URLset. - Ollama on the LAN with the scoring model pulled; must bind
0.0.0.0if n8n runs in Docker (OLLAMA_HOST=0.0.0.0). Endpoint used:http://<host>:11434/api/chat. - A Telegram bot (via @BotFather) added to the target group (see Telegram group setup below).
- Telegram API (trigger + send nodes across bots and analyst)
- Anthropic API (agent + analyst chat models)
- Tavily (predefined credential type)
- Alpha Vantage + Marketaux (Query Auth credentials — never inline keys)
- Google Sheets OAuth (QA logger + analyst)
- Create the group, add the bot.
- Group Privacy: @BotFather →
/mybots→ Bot Settings → Group Privacy → Disable so the bot receives plain group messages (with privacy ON, bots only reliably receive /commands, @mentions, and replies). After changing this, the bot must be removed from the group and re-added for the setting to take effect. - Get the group chat ID (a negative number): post in the group, then
read
message.chat.idfrom the Telegram Trigger input in n8n's Executions list.getUpdatesreturns 409 while a webhook is registered, so use the execution log instead. - Put the group ID in News Bot A → CONFIG →
chat_idand in News Bot B → Auth gate (OR'd with any personal chat IDs that should also be allowed, e.g. for DM access). - Supergroup caveat: Telegram silently upgrades groups to supergroups
when certain settings change or members are added — the chat ID changes
to a new
-100…value and the old one goes dead. If the bot goes silent in the group, re-grab the ID from a fresh execution and update both spots.
workflows/sentiment_lookup.json— save, note its workflow IDworkflows/llm_qa_logger.json— save, note its ID; create the QA Sheet with the header row above; set the Sheets node to Map Automaticallyworkflows/news_bot_a_proactive.json— point itssentiment_lookupand logger Execute Workflow nodes at the IDs from steps 1–2workflows/news_bot_b_reactive.json— same re-pointing for thesentiment_lookuptool; set the Auth gate chat IDs; activate to register the webhookworkflows/qa_analyst.json— point Read QA log at the same Sheet; runs weekly, or execute manually anytime for an on-demand health report
All reader-facing config lives in News Bot A → CONFIG node:
focus_topics (drives Tavily searches), tracked_tickers, chat_id
(the group). The agent's focus areas are also stated in News Bot B →
AI Agent system message — keep the two aligned. Model tags are set inside
the two Ollama HTTP nodes and the QA validators' model field.
Access control (Bot B): the Has text IF node drops non-text group
events (joins, photos, pins) before they reach the agent; the Auth gate
allow-lists chat IDs. Gating is per-chat, not per-person — anyone in an
allowed group can use the bot and spend API credits. For person-level
control, gate on message.from.id instead.
See docs/test-plan.md. Short version: prove sentiment_lookup in isolation
(including forcing the Marketaux fallback once), then Bot A manually, then
Bot B live. Known failure signatures:
- All
model_scorenull → scoring call broken (model tag, Ollama unreachable, or reasoning leaked into JSON). The merge fails silently by design; Validator A flags it. - Telegram 400 "can't parse entities" → broken HTML from the model; the chunk-node sanitizer now absorbs this (degrades to visible text); Validator B logs the underlying flag either way.
- Tool schema rejection from Anthropic (
input_schema.propertiespattern) → a tool is exposing a malformed schema; thesentiment_lookuptool uses an explicit manual schema (tickers: string) for this reason. - "No ticker received" → the agent called sentiment without a symbol; the error text instructs it to resolve the ticker via search or fall back to news-only (private companies).
- Bot silent in the group, no executions → Group Privacy still ON, or the bot wasn't removed/re-added after changing it, or the group upgraded to a supergroup and the chat ID changed (see Telegram group setup).
- Executions stop at
Has textorAuth gate→ non-text event filtered (by design), or the incomingchat.iddoesn't match the allow-list — read the actual ID from the execution input. - QA rows missing despite incidents → logger itself failed (check
llm_qa_loggerexecutions; historically: Sheets mapping mode left on Define Below).
- API keys live in n8n credentials only. Workflow exports must contain
credential IDs, never raw keys — check before committing:
grep -rE "tvly-|apikey|api_token" workflows/should return nothing suspicious. - Bot B is gated to allow-listed Telegram chats (Auth gate node) so strangers can't spend API credits; every member of an allowed group has access.
- If a key ever lands in a chat, log, or commit: rotate it.
- n8n API as a QA Analyst tool — cross-reference failed executions against the Sheet (closes the logger blind spot).
- 👍/👎 inline-keyboard feedback on digests/answers → logged next to QA rows → few-shot calibration of the scoring prompt.
- Relevance/recency-weighted net aggregation (fields already flow; merge-node change only).
- Sentiment for private/unlisted companies via Tavily-fed scoring branch.
- Golden-set eval harness for model upgrades (9B vs 14B/30B comparison).