Competitive intelligence your agents can query. Signal watches a market, turns public noise into scored and attributed signals, and exposes them as structured data an AI agent can actually consume — not a dashboard a human has to read.
→ See it: signal.gledach.de — a real snapshot of a
running deployment. Every view, real data, nothing to install. It is a static export
(npm run demo:html), so it is read-only and dated rather than live.
Live Feed. A convergence is the unit that matters: a pattern several independent publishers corroborate, scored from its own evidence rather than a fixed floor, with the rule that fired it stated inline.
More views — Compare and Battle
Compare. The anchor company against up to three rivals, section by section. Claims carry
[unverified] when the signal set does not support them — the tool is built to say so.
Battle. Call prep for one competitor: kill shots and the objections you should expect back, ranked against deal context (codebase size, team size) rather than dumped as a list.
It ships tracking thirteen AI coding agents and prompt-to-app builders across two segments:
claudecode, cursor, codex, windsurf (pro-dev) and lovable, bolt, v0,
replit (vibe-coding). Point it at your own market by editing one gitignored file.
git clone https://github.com/gledach/signals.git && cd signals
npm install
npm run db:migrate # local database + demo data. No account. No API key.
npm run doctor # confirm it worked, and see what each missing key unlocks
npm run view # a populated dashboard at 127.0.0.1:5180That works offline on a fresh clone. There is no build step, no framework, and three runtime dependencies. Two more are optional and lazy-loaded, so a server or CI runner never pays for them (plus Playwright, dev-only, for screenshots).
| Consumer | Surface |
|---|---|
| Agents | An MCP server (npm run mcp) — tools, signal:// resources and a coverage block on every result; skills under .claude/skills/; the dashboard's JSON endpoints; npm run companies -- --json |
| Humans | A localhost dashboard, markdown battlecards, analyst briefs |
| Cron | npm run start (ops/cron-entry.mjs) — one scheduled pass over every watcher |
The dashboard is one client, not the product. See docs/plans/14-agent-native-refactor.md for where that is going.
cp config/companies.default.mjs config/companies.local.mjs # the roster — the only file most deployments edit
npm run companies # confirm what is live
npm test # gate: smoke + fixture suites (incl. email), all offlinecompanies.local.mjs is gitignored and overrides the shipped roster, so you can pull
upstream forever without a merge conflict in the one file you customised. Feeds, GitHub
repo mappings, HN queries and classifier collision-warnings all derive from it
automatically — no brand name or market vocabulary is hardcoded anywhere outside
config/ — npm test fails if a brand leaks into a watcher, prompt or classifier, or
if the dashboard's category labels and deal-context axes drift from the roster.
Five more layers follow the same .local.mjs overrides .default.mjs pattern when you
need them: deal-context (Battle's filter axes), subdomain-signals (cert/sitemap
scoring), agent-policy (what an MCP agent may do), aeo-prompts, and feeds.
Index: docs/README.md
| Doc | When to read |
|---|---|
| docs/start.md | Start here if you're new. Install guide assuming no prior git/Node knowledge, ~15 min |
| docs/howto.md | Task-oriented "how do I…". The manual |
| docs/why.md | Why this architecture — zero build step, 3 runtime deps (+2 optional) |
| docs/cost.md | LLM spend, the model ladder, budget guardrails |
| docs/mcp.md | The MCP server — tools, signal:// resources, coverage block, agent policy |
| docs/gmail.md | Google Alerts via Gmail (Path A′ zones) — setup, env, hard rules |
| docs/blindspots.md | What Signal cannot see. Honest audit + quarterly review |
| docs/decisions/ | Accepted one-way doors, with the rejected option recorded too |
| docs/roadmap.md | Master roadmap and what has shipped |
| docs/plans/ | Plans against this codebase, plus 00-backlog for unbuilt ideas |
| docs/nextsteps.md | Architectural direction — the knowledge-layer thesis |
| analyst/persona.md | The analyst prompt behind npm run analyst — modes, output contract, banned words |
| chrome-extension/README.md | Chrome side-panel extension — experimental, needs the dashboard running, not covered by npm test |
| Directory | README |
|---|---|
ingest/ |
Zone 1 collectors (Gmail) — not ordinary watchers |
ingest/gmail/ |
Gmail token process modules |
ingest/gmail/parsers/ |
Alert parsers + how to add one |
pipeline/ |
Classify / promote / notify |
watchers/ |
Public-source collectors + cron note |
ops/ |
Cron, migrate, gmail:oauth |
core/ |
Store chokepoint + scoring |
config/ |
Roster / policy resolution |
test/ |
Offline gates (npm test) |
test/fixtures/email/ |
Gmail parser fixtures |
Phase 1 code is in-tree. Does not run on Railway cron by default.
Full guide: docs/gmail.md — setup, env, hard rules,
automation and
troubleshooting.
# Offline proof
npm run watch:gmail:dry -- --fixture=test/fixtures/email/google-alert-sample.html
# After GCP Desktop OAuth client in .env:
# npm run gmail:oauth && npm run watch:gmail && npm run email:promote:nollm
npm run doctor # reports Gmail modules, token age, hit statuses, MCP/cron invariantsTwo zones, and the second one is easy to forget. watch:gmail only fills the local
data/email/inbox.db; nothing appears in the dashboard until email:promote runs.
If Live Feed shows no email-google-alert signals, check npm run email:requeue:dry
for a large pending count — that is the usual cause, not a parser fault.
Signals are gathered from public sources. Generated analysis — battlecards, briefs, convergences — is model output, not verified fact, and must be reviewed by a human before being used externally or shown to a customer. The shipped demo dataset is a dated snapshot of public headlines, not live data.
The quick start above needs no accounts. These unlock the rest:
npm run help # full cheat-sheet, grouped by job
npm run fetch # collect fresh signals (needs no key; --no-llm for keyword-only)
# Optional local Gmail alerts (never put the refresh token on Railway in v1):
# npm run gmail:oauth && npm run watch:gmail && npm run email:promote:nollm
# → docs/gmail.md
npm run correlate # build convergences from what you have
npm run bootstrap -- --company=<id> # generate a battlecard (needs OPENROUTER_API_KEY)
npm run analyst -- --mode=brief # analyst brief (needs OPENROUTER_API_KEY)
npm run mcp # expose it to an agent over MCP| You want | You need |
|---|---|
| Collect and browse signals | nothing — local file database |
| LLM classification, battlecards, briefs | OPENROUTER_API_KEY |
| Broader discovery search | TAVILY_API_KEY |
| Multi-machine or scheduled cron | a hosted libSQL URL + token in TURSO_DATABASE_URL |
Everything optional degrades rather than failing: with no LLM key the keyword classifier runs instead, and with no hosted database the local file is used.
- Ingests ~60 RSS feeds (Google News, Reddit, vendor blogs, GitHub releases) across the tracked roster + category-wide feeds, plus HN via Algolia, Tavily search, GitHub releases/issues, YouTube transcripts, cert-transparency, sitemap/robots diffs, Google Trends, and answer-engine visibility checks.
- Classifies each signal via Claude Haiku 4.5 (over OpenRouter):
product_launch,pricing_change,funding,customer_win,review_complaint, etc. - Scores business impact on a 0–100 scale (re-weighted for CI — product launches > generic press).
- Stores in libSQL — a local file by default, a hosted Turso database when
TURSO_DATABASE_URLpoints at one — keyed by content hash (hashId PRIMARY KEY) for idempotent dedup. - Correlates signals into convergences — cross-axis patterns (e.g. a product launch + a hiring push + a cert change all pointing at healthcare) with a structured
evidencecitation graph. - Generates battlecards — Claude Sonnet 5 synthesizes a v0 battlecard per competitor, grounded against the anchor company's card (
MAIN_COMPANY_ID), with a human-editable section that survives refreshes. Voice comes fromcore/home-brand.mjs: partisan only when a company is markedisUs, third-person otherwise. Claude Opus 5 vianpm run researchfor the deepest, fact-checked populations. - Analyst CLI —
npm run analyst -- --mode=<scan|deep|gap|outside|brief>produces Obsidian-ready markdown briefs from the persona inanalyst/persona.md. - Serves a viewer at
http://localhost:5180— the sidebar has two labelled axes: VIEWS across Feed / Battle / Compare / Market / Intel / Report / Briefs / Inbox, and COMPANIES, where a click opens that company's page (Overview · Signals · Infrastructure · Battlecard) from anywhere. Linear-style dense UI, number keys1-8plus agleader for mode switching. Compare is N-way: an anchor plus up to three rivals, shareable as#vs=a,b,c. - Chrome extension — side-panel UI for browsing signals, Intel Check (compare any webpage against your intel using on-device Gemini Nano), clip signals to Turso, and desktop notifications. See
chrome-extension/README.md.
npm run mcp starts mcp-server.mjs over stdio. It is the surface this repo is built
for; the dashboard is a second client. Full detail in docs/mcp.md.
Tools: list_companies, search_signals, get_convergences, get_battlecard,
list_briefs, get_brief, run_analyst, market_summary.
Resources: signal://battlecard/{companyId} and signal://brief/{briefId}.
Every signal-reporting tool returns a coverage block — a collection-recency status
(fresh / slowing / stale / never), a trustEmptyResult flag and any warnings —
so an agent can tell "nothing happened" from "we stopped looking". Without it, a watcher
that quietly stopped running reads to an agent as a calm market.
Read-only by default, not read-only. run_analyst spends money, so it ships disabled:
config/agent-policy.default.mjs has an empty allowActions. Opt in per deployment by
creating config/agent-policy.local.mjs (gitignored, or point SIGNALS_AGENT_POLICY at
your own module) with allowActions: ['run_analyst']. Spend is bounded by a rolling 24h
USD ceiling read from the shared llm_cost table, so cron, the CLI and any agent draw
down the same ledger.
See npm run help for the full cheat-sheet. Most common:
npm run fetch # every 30 min via Task Scheduler, or manually
npm run correlate # build convergences
npm run refresh # daily on cron — refreshes all battlecards
npm run view # keep this tab pinned
npm run brief # 200-word morning brief (Opus analyst persona)Every script the repo ships with, grouped by the job you're trying to do.
Rule of thumb: anything under npm run <x> here is safe to run as-is.
| Command | What it does |
|---|---|
npm install |
Install Node deps into node_modules/ (~72 MB) |
npm run db:migrate |
Apply every file in sql/*.sql in lexical order — to the local file by default, to Turso when TURSO_DATABASE_URL is hosted. Idempotent, and seeds the demo set on a first run |
npm run doctor |
Reports rather than repairs: roster + anchor mode, hosted-vs-local database, collection freshness, which keys are set and what each missing one blocks, and the agent policy (plus remaining budget once a paid action is enabled). Also launches the MCP server from an unrelated directory to confirm it resolves the same store — the failure where an agent reads an empty database while the CLI sees a full one |
npm test |
The gate: 15 smoke sections + 5 fixture suites. Fully offline |
npm run smoke |
The 15 smoke sections alone, without the fixture suites |
npm run db:test |
Round-trip smoke test — append / alreadySeen / load / update / delete |
npm run check:models |
Sanity-check OpenRouter — lists default classifier / synthesis / deep models and probes one request each |
npm run companies |
Print the live roster and which config file it came from. -- --json for the machine-readable form |
npm run demo:seed |
Load demo/seed-signals.jsonl into whatever database is configured. Refuses when a local roster is active unless you pass --force — demo rows carry the demo roster's company ids. db:migrate already does this on a first run; set SIGNALS_NO_DEMO_SEED to suppress it |
npm run demo:export |
Snapshot the current database back into demo/seed-signals.jsonl |
npm run demo:clear |
Delete exactly the hashIds in the seed file. Nothing you collected yourself |
npm run demo:html |
Build a single self-contained HTML snapshot of the dashboard into demo/signal-demo.html — this is what signal.gledach.de serves. It starts the real viewer, asks it the same questions the browser asks, and inlines the answers behind a fetch shim, so the snapshot cannot drift from the live dashboard. Degraded rows are excluded unless you pass --include-degraded. Override the published URL with SIGNAL_DEMO_URL |
| Command | What it does | Typical cadence |
|---|---|---|
npm run fetch |
Walk ~60 RSS feeds (Google News, Reddit, vendor blogs, GitHub releases) across the tracked roster, classify with Haiku, write signals to the store | every 30 min – 2 h |
npm run fetch:nollm |
Same, keyword-only classifier. Free, lower precision | — |
npm run fetch -- --company=lovable |
Only one competitor this run | — |
npm run watch:sites |
Fetch sitemap.xml + robots.txt per domain, diff against last snapshot under data/snapshots/, emit signals for new / removed paths and rule changes |
every 6 h |
npm run watch:sites:dry |
Preview diffs, write nothing | — |
npm run watch:certs |
Pull cert-transparency (crt.sh) entries per competitor domain, emit signals for new subdomains (leading indicator for vertical launches) | every 6 h |
npm run watch:certs:dry |
Preview, write nothing | — |
npm run watch:youtube |
Fetch each tracked channel's /videos page, get captions via youtube-transcript, classify transcripts, write signals + archive transcript excerpts to the store (mirrored at data/transcripts/) |
daily |
npm run watch:youtube -- --company=claudecode |
One competitor only | — |
npm run watch:youtube -- --limit=3 |
Only 3 most-recent per channel (cheap test) | — |
npm run watch:youtube -- --force-reclassify |
Re-classify videos even if hashId already seen | — |
npm run watch:hn |
Query Algolia HN Search API per competitor + category, filter by points/recency, classify + store signals. Structured fields (points, comments, author, timestamps) rather than parsed RSS | daily |
npm run watch:hn:dry |
Preview hits, classify, write nothing | — |
npm run watch:hn -- --company=replit |
One competitor only | — |
npm run watch:hn -- --no-llm |
Skip LLM classifier, use keyword only (free) | — |
npm run watch:github |
Walk a hardcoded companyId → owner/repo map for releases, breaking-change tags and issue complaints. 60 req/hr unauthenticated, 5,000/hr with GITHUB_TOKEN |
daily |
npm run watch:github:dry |
Preview, write nothing | — |
npm run watch:aeo |
Answer-engine visibility — ask the prompts in config/aeo-prompts.*.mjs and record which brands each engine names when a buyer forms a shortlist. Detection is a deterministic word-boundary match, not a model judging model output. One LLM call per prompt per engine, which is why ops/cron-entry.mjs only runs it on the weekly pass |
weekly |
npm run watch:aeo:dry |
Preview, write nothing | — |
npm run watch:tavily |
Tavily Search API queries for each competitor — mention discovery beyond RSS. Budget-guarded (~72% of free 1000/mo tier at the tracked roster daily, 2 queries each) | daily |
npm run watch:tavily:dry |
Preview, no API spend, no DB writes | — |
npm run watch:trends |
Google Trends spike detection — brand + " alternative" queries. Batched: hits only the 8 stalest queries per run (rotates through all ~32 over 4 runs). Google's unofficial API rate-limits hard at 32-queries-in-a-row | weekly, scheduled 4× |
npm run watch:routing |
Model routing — OpenRouter's public Data API (CC BY 4.0): which models the market routes tokens to, and which apps process them. Emits only movement above a threshold, so a flat week is silent. Needs OPENROUTER_API_KEY; skips cleanly without one |
daily |
npm run watch:trends:dry |
Preview spikes, write nothing | — |
npm run watch:trends:full |
Override the batch — hit all ~32 queries in one run. Only when you know Google's rate-limiter is calm | manual |
npm run watch:trends -- --geo=GB |
Regional scoping (default US) | — |
npm run watch:trends -- --batch-size=16 |
Override batch size (default 8) | — |
npm run all |
Convenience wrapper: fetch + hn/sites/certs/youtube/tavily/trends + correlate + refresh, in sequence. Use for end-of-day catch-up runs. Note it does NOT run watch:github or watch:aeo — ops/cron-entry.mjs covers both, this wrapper does not |
manual |
| Command | What it does |
|---|---|
npm run correlate |
Load recent signals from Turso, apply THEME + COUNT rules from correlation-rules.mjs, emit convergence signals when patterns fire. Dedup by iso-week |
npm run correlate:dry |
Print what would fire, write nothing |
npm run clean:convergences |
Delete every signalType='convergence' row in Turso. Use before a correlate re-run if you've tuned the rules |
| Command | What it does |
|---|---|
npm run self-bootstrap |
Generate / refresh battlecards/<your-id>.md — the home vendor's own positioning, features matrix, USPs. Only meaningful when a company is marked isUs; in market-watch mode there is no self-card |
npm run bootstrap -- --company=<id> |
Generate one competitor battlecard from the signal set, grounded against the anchor's card (MAIN_COMPANY_ID). Sonnet 5, ~8-12k tokens, ~$0.09 |
npm run refresh |
Re-generate the AUTO section of all 13 battlecards in sequence (plus the self-card, if a company is marked isUs). Runs on the cron's daily tier; weekly is the cheaper choice if the cards rarely move. Operator-written HUMAN text is never overwritten — an all-placeholder scaffold is rewritten to match the anchor mode, and npm run research appends its own block inside HUMAN |
npm run research -- --company=<id> |
Deep research via Opus 5 — populates the HUMAN section with fact-checked overview, verified facts, objections to expect, deep weaknesses, recent moves, rumor watch, operator todos. 180-day signal window. ~$0.50 per run |
npm run research:dry -- --company=<id> |
Preview Opus input without calling the model. --company= is required — without it the script exits 2 with a usage error |
Persona lives in analyst/persona.md. Five modes from it, each produces
Obsidian-ready markdown in briefs/YYYY-MM-DD-<mode>.md (gitignored).
| Command | What it does |
|---|---|
npm run report:weekly |
Weekly Report snapshot — deterministic compute (no LLM), captures convergences + top-10 signals + per-competitor synopsis for the ISO week. Persists to Turso + briefs/weekly-<week>.md. Idempotent per week. Schedule Mondays 07:00 |
npm run report:weekly -- --week=2026-W15 |
Regenerate a specific week (useful for backfill) |
npm run report:weekly:dry |
Compute the snapshot and print a preview of it, write nothing |
npm run brief |
/brief mode — 5-minute morning brief. Max 200 words. Last 24h critical signals. ~$0.03 |
npm run scan |
/scan mode — routine sweep over last 14d high-impact + convergences, capped at 80 signals. Top 5 ranked. ~$0.08 |
npm run analyst -- --mode=deep --company=lovable |
/deep — 90-day deep dive on one competitor. Cites convergences by hashId via the structured evidence graph |
npm run deep:all |
/deep across every tracked competitor in one pass. ~$0.20 and ~30s each, so cost scales with the roster — ops/cron-entry.mjs only fires it on the Monday pass, and the MCP surface never exposes it |
npm run deep:all:dry |
Same sweep, prints the assembled input, sends nothing |
npm run analyst -- --mode=outside --topic=<string> |
/outside — analogies from adjacent markets, second-order effects, one defended contrarian take |
npm run analyst -- --mode=gap |
/gap — red-teams Signal itself: feeds list, correlation rules, feature registry, tracked-company bias. Different target from the other modes. Monthly cadence |
npm run analyst -- --mode=scan --dry-run |
Print digest + persona size without calling the model. Confirms the Turso query + digest assembly path works |
| Command | What it does |
|---|---|
npm run transcripts |
List all archived transcripts (store rows merged with the data/transcripts/ mirror) |
npm run transcripts -- --stats |
Word counts per competitor |
npm run transcripts -- "pricing" |
Case-insensitive substring search, highlighted, with ≤3 snippets per hit |
npm run transcripts -- "SOC 2" --company=claudecode |
Scope search to one competitor |
npm run transcripts -- --id=<videoId> |
Print one full transcript |
npm run transcripts -- "enterprise" --context=100 |
Wider snippet window |
npm run backfill:transcripts |
Walk every sourceKind='youtube' signal in Turso; fetch + save any missing transcripts. Safe to re-run. This is where local Whisper runs (if CI_WHISPER_ENABLED=true). Whisper additionally requires yt-dlp and ffmpeg on PATH (pipx install yt-dlp) — audio is downloaded with yt-dlp, not an npm package |
npm run backfill:transcripts -- --dry-run |
Preview what would be fetched |
npm run transcripts:sync |
Promote any disk-only transcript into the store. One-off, for an archive collected before the store was canonical |
npm run transcripts:sync:dry |
Preview what would be promoted |
YouTube pipeline in one breath: watch:youtube fetches captions via HTTP (no audio download, no transcription — free for ~80% of videos), archives a bounded excerpt to the artifacts table (mirrored at data/transcripts/<co>/<id>.json), classifies the first 6000 chars via Haiku, writes one signal row to Turso. Whisper is opt-in, only fires for caption-less videos when you explicitly enable it — otherwise the video still gets a noise signal noting that captions weren't available.
| Command | What it does |
|---|---|
npm run reclassify |
Re-run the classifier against signals already in Turso. Updates signalType + impactScore in-place when the new call differs. Defaults to LLM-classified / keyword / keyword-fallback signals only, excludes heuristic + convergence |
npm run reclassify:dry |
Preview which signals would change, no writes |
npm run reclassify -- --company=<id> |
Scope to one competitor |
npm run reclassify -- --limit=50 |
Cap the batch |
npm run reclassify -- --all |
Include all classifyMethods + signalTypes (use with care) |
| Command | What it does |
|---|---|
npm run view |
Start dashboard/serve.mjs on http://localhost:5180. Linear-style sidebar nav across Feed / Battle / Compare / Market / Intel / Report / Briefs / Inbox. Keyboard: 1-8 or g + f/b/c/m/i/r/s/x for mode, j/k for list nav, Cmd+K for the command palette |
Ctrl+C |
Stop the viewer |
Every OpenRouter call appends one JSONL row to data/llm-cost.jsonl with
timestamp, script, model, tokens, USD cost (passthrough + upstream),
duration, and caller-supplied tags (company, mode). A per-process footer
prints after any run that crosses $0.001 of spend.
Every call also mirrors into the Turso llm_cost table (fire-and-forget) so history
survives a redeploy — the JSONL is wiped on every Railway deploy — and so the MCP agent
budget in core/agent-budget.mjs can draw down the same ledger as cron and the CLI.
npm run cost reports from the local JSONL, so the two can diverge.
| Command | What it does |
|---|---|
npm run cost |
Last 30 days, grouped by day + script + model |
npm run cost:today |
Just today |
npm run cost:7d |
Last 7 days |
npm run cost -- --by=script |
Group by script name |
npm run cost -- --by=model |
Group by model slug |
npm run cost -- --by=company |
Group by meta.company tag (only rows with one) |
npm run cost -- --raw |
Dump every line as JSON for piping into jq / spreadsheet |
npm run cost -- --all |
All time, no window |
Screenshots for design review + regression spotting. Browser used is the
system-installed Microsoft Edge (channel: 'msedge') — we skip Playwright's
Chromium download to avoid corporate-proxy TLS issues.
| Command | What it does |
|---|---|
npm run shot |
Screenshot each mode tools/shot.mjs knows about, in the dark theme — --mode=all is the default. Output lands in screenshots/ (gitignored) |
npm run shot -- --mode=battle --competitor=lovable |
Battle mode for one competitor |
npm run shot -- --mode=intel |
Intel mode (convergence list) |
npm run shot -- --mode=market |
Market mode |
npm run shot:all |
The same sweep, stated explicitly — one file per mode, in the dark theme. Add -- --theme=light for the light set |
| Command | What it does |
|---|---|
npm run notify:test |
Fire a test Windows toast to confirm node-notifier + Focus Assist are playing nicely. Rate-limited to 5/run by default |
| Command | What it does |
|---|---|
npm run chrome-data |
Generate chrome-extension/data/companies.json — pre-baked signal digest (last 7 days) for instant Intel Check |
npm run chrome-data -- --days=14 (or npm run chrome-data:14d) |
Same, but last 14 days |
The extension itself loads as an unpacked Chrome extension — see chrome-extension/README.md for install instructions.
Model and notification defaults live in .env.example; the config-layer overrides are listed here. Nothing in this table is needed for the quickstart:
| Var | Required | Purpose |
|---|---|---|
OPENROUTER_API_KEY |
for LLM features | Classification, battlecards, briefs. Without it the keyword classifier runs instead |
TURSO_DATABASE_URL |
hosted only | libsql://… URL for a hosted DB. Defaults to the local file data/signals.db |
TURSO_AUTH_TOKEN |
hosted only | Long-lived JWT for a hosted DB |
TAVILY_API_KEY |
optional | Enables watch:tavily (free tier: 1000 cr/mo) |
GITHUB_TOKEN |
optional | Raises the GitHub API limit for watch:github from 60/hr to 5,000/hr. GH_TOKEN is accepted too |
CI_CLASSIFIER_MODEL |
optional | Per-signal triage (high volume). Default anthropic/claude-haiku-4.5. Qwen / Kimi alternatives in .env.example. Never Opus — a single fetch is 200+ calls. |
CI_SYNTHESIS_MODEL |
optional | Battlecard synthesis (medium volume). Default anthropic/claude-sonnet-5 |
CI_DEEP_MODEL |
optional | Analyst /deep, /gap, /outside, research (low volume, max reasoning). Default anthropic/claude-opus-5 |
CI_WHISPER_ENABLED |
optional | Set true to enable local Whisper fallback for caption-less YouTube videos. Disabled by default. Also requires yt-dlp and ffmpeg on PATH (pipx install yt-dlp) — audio is downloaded with yt-dlp, not an npm package |
CI_WHISPER_MODEL |
optional | Whisper model name, default base.en |
CI_TOAST_THRESHOLD |
optional | Min impact score for Windows toasts (0-100, 101 disables). Default 80 |
CI_TOAST_MAX_PER_RUN |
optional | Hard cap on toasts per process run. Default 5 |
CI_VIEWER_URL |
optional | Dashboard URL clicked toasts open. Default http://localhost:5180 |
CI_TAVILY_MONTHLY_BUDGET |
optional | Tavily credit cap. Default 800 |
CI_TAVILY_MIN_HOURS_BETWEEN_RUNS |
optional | Tavily cooldown. Default 12 |
SIGNALS_COMPANIES |
optional | Path to a roster module, overriding config/companies.local.mjs / .default.mjs |
SIGNALS_FEEDS |
optional | Path to a feeds module. Feeds derive from the roster, so most deployments never set this |
SIGNALS_DEAL_CONTEXT |
optional | Path to a deal-context module — the Battle filter axes. See config/deal-context.default.mjs |
SIGNALS_SUBDOMAIN_SIGNALS |
optional | Path to a subdomain/sitemap scoring module. See config/subdomain-signals.default.mjs |
SIGNALS_AGENT_POLICY |
optional | Path to an agent-policy module — what an MCP agent may trigger and its spend ceiling. Ships read-only |
SIGNALS_AEO_PROMPTS |
optional | Path to an answer-engine prompt module. See config/aeo-prompts.default.mjs |
NODE_TLS_REJECT_UNAUTHORIZED |
⚠ | Set to 0 ONLY for corporate-proxy unblock; prefer NODE_EXTRA_CA_CERTS |
NODE_EXTRA_CA_CERTS |
optional | Path to corporate root CA PEM — proper TLS fix |
Three independently-tunable knobs, each matched to a workload. All override via env vars; full alternatives live commented in .env.example.
| Knob | Workload | Volume | Default | Why this default |
|---|---|---|---|---|
CI_CLASSIFIER_MODEL |
Per-signal triage (fetch / watch:hn / watch:tavily / reclassify) | Thousands of calls per run | anthropic/claude-haiku-4.5 |
Reliable JSON output via OpenRouter response_format; fast; cheap enough that a 500-signal fetch is under $0.10 |
CI_SYNTHESIS_MODEL |
Battlecards + self-card synthesis | Dozens per week | anthropic/claude-sonnet-5 |
Best balance of structured-output instruction-following + cost. ~$0.08–0.12 per battlecard |
CI_DEEP_MODEL |
Analyst modes + npm run research |
1-10 per day | anthropic/claude-opus-5 |
Max reasoning depth; Opus handles the "red-team my CI pipeline" (/gap) and "predict roadmap" kind of thinking that cheaper models get shallow on. ~$0.50 per research run, ~$0.08 per /scan |
Cheap swap for the classifier: qwen/qwen3-coder or qwen/qwen3.6-plus-04-02 or moonshotai/kimi-k2.5-0127 — all strong JSON output at a fraction of Haiku's price. Verify with npm run cost -- --by=model after a week.
Never set the classifier to Opus. A single npm run fetch can fire 200+ classifier calls; at Opus rates that's ~$3 per fetch, burns the OpenRouter monthly key budget in days. The short-circuit added in classify.mjs will fall back to keyword if you hit a 403, but Opus-as-classifier is fundamentally the wrong trade.
BYOK (bring-your-own-key): at https://openrouter.ai/settings/integrations you can link your Anthropic / OpenAI / xAI / DeepSeek API keys directly, so OpenRouter routes requests through your own keys and you pay those providers' direct rates (cheaper than OpenRouter's margin on top). The local cost logger tracks upstreamCost separately from passthroughCost so reporting works either way.
This applies to a hosted deployment. The default is a local libSQL file, which has no
outage to survive — but once TURSO_DATABASE_URL points at Turso, a Turso outage means
you can't read or write signals. The system is designed around "retry when it's back,"
but here's the fallback ladder if you ever hit sustained downtime:
- Transient outage (<1 hour) — Do nothing.
INSERT OR IGNOREon every watcher makes re-runs idempotent; the next scheduled cron catches up. - Sustained outage (hours) — Swap
TURSO_DATABASE_URLin.envto a local libSQL file temporarily:file:./data/signals-local.db. The@libsql/clientlibrary supports both seamlessly. Runnpm run db:migrateagainst the local DB once, then carry on with fetch/watch/correlate. When Turso is back, export your local DB and merge:sqlite3 data/signals-local.db ".dump signals" > local-signals.sql turso db shell signals < local-signals.sql
- Account loss / credential leak — Provision a fresh database, point
TURSO_DATABASE_URL/TURSO_AUTH_TOKENat it and runnpm run db:migrate. There is no JSONL archive to restore from: signals regenerate by re-running the watchers, and battlecards / briefs / talk-tracks re-hydrate from their on-disk mirrors (core/artifacts.mjsreads database-first, disk-fallback).
What's NEVER lost:
- Battlecards (
battlecards/*.md) — in git - Analyst persona (
analyst/persona.md) — in git - Schema (
sql/*.sql) — in git - Cost log (
data/llm-cost.jsonl) — local disk, gitignored but survives turso outages - YouTube transcripts (
data/transcripts/) — disk mirror of theartifactsrows - Saved talk-tracks (
data/talk-tracks/) — disk mirror of theartifactsrows
Only things in Turso can be lost in a Turso-account incident: signals + watcher baselines. Both are regenerable (signals by re-running fetch, baselines by letting the first post-restore run capture a new baseline).
Two operators sharing the same Turso DB is supported — nothing corrupts
if you both run commands simultaneously. Every write is either
INSERT OR IGNORE (signals dedup on hashId) or UPSERT (state rows
last-write-wins). But concurrent runs have different cost profiles:
| Command | Concurrent behavior |
|---|---|
view, cost, transcripts |
✅ Fully safe. Read-only or local-file-only |
analyst, brief, scan |
⚠ Safe — each writes a brief row to Turso keyed per mode+date, plus llm_cost rows. Two concurrent runs of the same mode last-write-wins on the brief; you pay 2× LLM cost |
bootstrap, research, refresh |
✅ Safe — each writes its own local battlecards/*.md file. If both commit, normal git merge conflict |
fetch, watch:hn, watch:youtube |
⚠ Safe but wasteful — both run the full external-API sweep + LLM classifier. You pay 2× API cost, dedup catches the duplicates on write |
correlate |
⚠ Safe — convergence dedup by hashId; wall time is the only cost |
watch:sites, watch:certs, watch:trends |
⚠ Last-write-wins on baselines. Small data loss if one run's diff gets overwritten by the other's concurrent write. Next run re-baselines correctly |
watch:tavily |
🚨 Budget race. The singleton credit counter can under-count: both read credits=20, both spend 5, both write 25 — but the second write wins so you've spent 30 credits and only recorded 25. 12h internal cooldown prevents single-operator double-spend but not two operators |
Practical rule:
- Scheduled crons (Task Scheduler / Coolify / GitHub Actions): don't coordinate. Accept a small amount of waste.
- Manual ad-hoc runs: a quick "running fetch now" in chat saves API dollars.
- The only thing worth actively coordinating:
watch:tavily— don't both fire it within the same hour. Tavily credits are real money on paid tiers.
Grouped by role, not by feature — config/ holds what a deployment retargets, core/
the domain logic, and the entry points a human or a cron actually invokes live in cli/,
watchers/ and ops/.
Signal/
├── .env OpenRouter + Turso credentials + Tavily
├── package.json Deps: @libsql/client, google-trends-api, node-notifier,
│ nodejs-whisper, youtube-transcript (+ playwright, dev)
├── mcp-server.mjs The agent surface — tools, signal:// resources, coverage
│
├── config/ Everything a deployment retargets. Each layer resolves
│ $SIGNALS_* → *.local.mjs → *.default.mjs:
│ companies, feeds, deal-context, subdomain-signals,
│ agent-policy, aeo-prompts. Plus correlation-rules.mjs
│
├── core/ Domain logic, no I/O entry points:
│ store (libSQL client), artifacts (the one safe way to
│ read/write a generated document), features, scoring,
│ signal-taxonomy, home-brand (anchor mode + voice),
│ coverage, agent-budget, registry, events, robots, feed-urls
│
├── runtime/ env.mjs (.env loader) + paths.mjs (the one path resolver)
│
├── pipeline/ classify, openrouter, correlate, transcript, notify
│
├── watchers/ fetch-signals, hn, sites, certs, youtube, github, tavily,
│ └── adapters/ trends, aeo — and the rss / reddit / tavily / youtube-channel
│ source adapters they share
│
├── cli/ Every `npm run` entry a human types: analyst, help, doctor,
│ bootstrap-*, refresh-battlecards, cost-report, reclassify,
│ weekly-report, transcripts, chrome-data, demo, list-companies
│
├── ops/ cron-entry.mjs, db-migrate.mjs, notify-test.mjs
│
├── dashboard/
│ ├── serve.mjs Zero-dep localhost server + JSON endpoints
│ └── viewer/ index.html + viewer.js + viewer.css (static, no build)
│
├── sql/ Numbered migrations, applied in order by `npm run db:migrate`
├── test/ smoke.mjs (the gate) + store-roundtrip + fixtures/
├── tools/ Playwright captures, skill-frontmatter validation
│
├── analyst/persona.md Senior CI analyst persona (loaded at runtime)
├── battlecards/ MD files, HUMAN + AUTO sections (gitignored; `_template.md` ships)
├── briefs/ Analyst CLI output (gitignored)
├── demo/ seed-signals.jsonl — the shipped demo dataset
├── chrome-extension/ Side-panel extension (unpacked)
├── docs/ start / howto / why / cost / mcp / blindspots / roadmap / plans
└── data/ gitignored runtime cache
- libSQL — plain SQL over HTTP, no schema-deploy step, and no reactive runtime this workload has any use for. Works behind a corporate proxy, and runs identically as a local file or a hosted database, which is what lets a fresh clone work with no account.
- Schema source of truth —
sql/*.sqlmigration files. Applied vianpm run db:migrate. Never talk to the DB directly from caller code — usecore/store.mjs. - Zero-dep philosophy — only libSQL + a couple of adjacent tools added; everything else uses Node built-ins.
- Generated documents are canonical in the store, mirrored to Markdown on disk — the mirror is what makes a battlecard editable in any editor; the store is what makes it survive a redeploy. The HUMAN section is never overwritten by a regeneration, which is the reason every writer read-modify-writes rather than replacing the file.
- Agent surface is read-only by default —
run_analystis the only paid tool and it refuses until a deployment opts in, behind a spend ceiling shared with the cron and the CLI.
| Service | Monthly |
|---|---|
| OpenRouter, at the shipped models and cadence | ~$15–25 |
| Turso free tier (9 GB storage, 1B reads, 25M writes) | $0 |
| Tavily free tier (1000 credits) | $0 |
Cadence moves this number far more than model choice. A job making one LLM call per tracked company costs four times as much on the 6-hourly tier as on the daily one, and the output is no fresher — that mistake cost this deployment ~$209/month on its own before it was caught. A reasoning model on the classifier is the other trap: it bills its deliberation as output tokens, which are the expensive side of every rate card.
Do not trust the table. npm run cost:estimate prices your next run from your own
ledger, and npm run cost reports what actually happened. Full detail, including the
model ladder and the budget ceiling, in docs/cost.md.
Signal needs none of the following — it is a plain Node project and npm test is the whole
gate. But the two tools below shaped how it was built, and they are the reason a lot of the
comments in here explain why rather than what:
- apsolut-seshat — a notes vault that sits
beside a repo rather than inside it: inbox → ideas → plan → decisions → knowledge. What
keeps working notes, half-formed ideas and operational detail out of the codebase, so
what remains in
docs/is documentation rather than someone's scratchpad. - apsolut-agents — a shared workspace for several AI agents on one repo: an append-only log, task cards, human-gated one-way doors, and one agent able to dispatch another and stay accountable for the result. Several decisions here came out of adversarial review between models — see docs/decisions/, which records the rejected option as well as the accepted one.
Neither is a dependency and neither ships in this repo. If you work with AI agents on a codebase you own, both are worth a look.


