GitHub - Lakshya77089/locus-signal: Weekly customer risk & opportunity brief for a CRO — self-healing news query vocabulary · GitHub
Skip to content

Repository files navigation

Locus Signal

A weekly brief for the Locus CRO: what happened to our customers that he should care about, split into risk and opportunity, short enough to read in four minutes and forward two items from.

It is a web app, not a script. The account list, the recipients, the schedule and the pause switch are all edited in the UI by whoever owns the brief. Nobody needs a terminal to change how it works.


Live instance

https://locus.lakshyasharma.me

The password is in the submission email rather than in this file, because this repository is public and the instance holds real recipient addresses and a "Run now" button that spends real model tokens. There is no username — one shared password, entered once, remembered for 30 days.

Start at Is it working? for the funnel and the per-run audit, or go straight to the most recent brief to see the output. Every number on the dashboard is clickable down to the individual articles and the reason each one was cut.

Running on a single Azure VM behind nginx: two containers (app + Postgres) bound to loopback, TLS from Let's Encrypt, and a systemd timer that fires the hourly tick. See Deploy it.


What it does

Every Monday at 07:00 IST (changeable in the UI, no redeploy):

  1. Collects. For each active account it pulls the last 14 days of news from a tiered set of sources (see Sources and fallbacks). Several queries per account, biased toward corporate events rather than product news, and repeated in the account's local language for non-English markets.
  2. Dedupes. Canonical URLs plus title-similarity matching, because the same wire story appears under nine mastheads.
  3. Triages. A model judges every surviving article against that specific account: is this even the right company, is it a revenue-relevant event, is it risk or opportunity, how severe, and why does it matter to Locus.
  4. Edits. A second, stronger model takes every candidate that cleared triage and picks at most four. This is the pass that does the actual job — turning four hundred things that happened into the four that matter. It is instructed to return nothing rather than pad a thin week.
  5. Sends. A plain HTML email, one card per item, each with a why-it-matters line, a concrete next step, and one-click "was this useful" links.
  6. Records. Every article it considered and the reason each one was cut is stored, so the filter can be inspected rather than trusted.

Run it locally

You need Node 20+ and a Postgres database.

npm install
cp .env.example .env.local     # fill in DATABASE_URL and OPENROUTER_API_KEY
npm run db:setup               # creates tables, seeds the account list
npm test                       # scheduling logic self-tests
npm run dev                    # http://localhost:3000

Then in the app: open Recipients, add an email address, and press Run now (preview) on the Briefs page. The first run takes a few minutes because it is scanning every account.

To do the same from the command line:

npm run run:brief              # build a brief, print it, do not email
npm run run:brief -- --send    # build it and email it

Getting a database in two minutes

Any Postgres works. The quickest free option is Neon — create a project and paste the connection string into DATABASE_URL. Supabase and Vercel Postgres work identically. For a local one:

docker run -d --name locus-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres

Deploy it

Built for Vercel, but it is a stock Next.js app and will run anywhere.

  1. Push to GitHub, import the repo in Vercel.
  2. Add the environment variables from .env.example in Project Settings. At minimum: DATABASE_URL, OPENROUTER_API_KEY, RESEND_API_KEY, ADMIN_PASSWORD, CRON_SECRET, APP_URL.
  3. Deploy. vercel.json registers an hourly cron.

The hourly cron is deliberate. It does not mean hourly emails. The cron wakes the app every hour and asks the database whether a brief is due right now — matching the cadence, day, hour, timezone and pause window that are set in the UI. That indirection is the whole reason a non-technical person can move the send from Monday to Thursday, or pause it for two weeks, without anyone touching a cron expression or redeploying.

Long runs are chunked. A single scan of 35 accounts will not fit in one serverless invocation, so a run is a resumable state machine; the cron handler processes what it can and re-invokes itself to continue. Nothing is lost if an invocation is cut short.

Email

Email goes through Resend. Resend's shared sandbox sender (onboarding@resend.dev, the default here) only ever delivers to the address that owns the Resend account, and no amount of DNS setup changes that — the from address itself has to move onto a verified domain. Verify the domain in Resend, then set the Sent from field on the Schedule page; the live instance sends as Locus Signal <hello@lakshyasharma.me>.

It is a setting rather than an environment variable on purpose: a commercial owner can change the sender without a redeploy.

Without RESEND_API_KEY everything still works; briefs are built and readable in the app, they just are not emailed.


The screens

Screen What it is for
Briefs Every brief ever produced. Run one on demand.
Accounts Add a customer when one signs, mute one when they churn, add a prospect. Aliases and a free-text "what should we know about them" feed directly into how signals are judged.
Recipients Who receives it. Pause someone on leave instead of deleting them.
Schedule Cadence, day, time, timezone, pause. Also how selective the brief is — max items, minimum severity, lookback window.
Is it working? Answers the month-two question. Run reliability, delivery, reader ratings, cost per brief, the full funnel, blind-spot accounts, and a per-run audit of everything considered and why it was cut.

How "is it working?" is answered

Three questions that are usually collapsed into one:

  • Does it run? Run success rate, last run, send failures, cost per brief.
  • Does it land? Briefs emailed, delivery failures, per-recipient activity.
  • Is the judgement any good? Every item in the email carries Useful / Not useful / Knew it links. No login, one click, attributable to the reader by a signed link. "Already knew it" is tracked separately from "not useful" on purpose: the first means the brief was too slow, the second means it was wrong. Those need different fixes.

Plus two things that are easy to miss:

  • Blind spots — active accounts that have never produced a single signal. Usually means the aliases are wrong, not that the customer is quiet.
  • The audit trail — for any run, every article the model saw and the exact reason it was dropped. If the CRO ever says "why didn't I hear about X", that page answers it.

Configuration

Everything in .env.example. Only two are truly required: DATABASE_URL and at least one OPENROUTER_API_KEY_n.

Variable Required Purpose
DATABASE_URL yes Postgres connection string
OPENROUTER_API_KEY_1_5 yes (at least one provider) Provider 1. Pool fails over between keys
OPENROUTER_ENABLED no false skips provider 1 entirely. Defaults to on
AZURE_OPENAI_ENDPOINT for Azure Resource endpoint
AZURE_OPENAI_API_KEY_1_5 for Azure Provider 2 key pool
AZURE_TRIAGE_DEPLOYMENT / AZURE_EDITOR_DEPLOYMENT for Azure Deployment names, not model ids
AZURE_ENABLED no false skips provider 2 entirely. Defaults to on
OPENROUTER_TRIAGE_MODEL no Defaults to google/gemini-2.5-flash
OPENROUTER_EDITOR_MODEL no Defaults to anthropic/claude-sonnet-5
RESEND_API_KEY for email Sending the brief
APP_URL for email Links inside the email
ADMIN_PASSWORD for deploy Password-gates the UI. Unset = open, fine locally
CRON_SECRET for deploy Stops anyone triggering a send
NEWSAPI_AI_KEY_1_5 no NewsAPI.ai — first choice paid source. Pool fails over between keys
NEWSAPI_ORG_KEY no NewsAPI.org — fallback paid source
ALERT_EMAIL no Where breakage is reported. Falls back to brief recipients
FEEDBACK_SECRET no Signs feedback links; falls back to ADMIN_PASSWORD

Layout

src/lib/
  sources.ts     news collection, entity resolution, dedupe, source health
  llm.ts         OpenRouter client, strict JSON-schema calls, cost accounting
  analyze.ts     the two model passes and their prompts
  pipeline.ts    resumable run state machine, ranking and selection
  email.ts       HTML rendering, per-recipient delivery and retry
  alerts.ts      push alerting so silent failure is impossible
  settings.ts    DB-backed schedule, including the is-it-due logic
  seed.ts        the starting account list
  schema.sql     tables; idempotent, applied on boot
src/components/
  ui.tsx         design-system primitives
  Sidebar.tsx    app navigation
src/app/
  briefs/        the brief itself, and the archive
  accounts/      account management
  recipients/    distribution list
  schedule/      cadence and selectivity
  health/        is it working
  api/cron/tick  the scheduled entry point
scripts/         setup and CLI run

Cost

Two model tiers, on purpose. Triage runs ~35 times a week over a large, boring payload, so it goes to a cheap fast model; the editorial cut runs once and is the hardest judgement in the product, so it goes to a strong one. Roughly $0.30–$0.60 per weekly run over ~35 accounts.

Cost is not estimated from a hardcoded price table — OpenRouter reports the actual spend per call and it is stored on the run, so "Is it working?" shows real money.

Sources and fallbacks

Four sources in two tiers. The split matters: a keyed vendor is a single point of silent failure, because quota exhaustion returns an empty result set that looks exactly like a customer having a quiet fortnight.

Tier Source Needs a key Role
Paid, 1st NewsAPI.ai NEWSAPI_AI_KEY Full article bodies, own syndication de-dup, per-language querying
Paid, 2nd NewsAPI.org NEWSAPI_ORG_KEY Fallback. Weaker, but a different vendor with a different failure surface
Baseline Google News RSS no Always runs. Multiple queries per account, per locale
Gap-filler GDELT no Only when everything else came back thin. Throttled, so used sparingly

How the chain behaves:

  • The paid chain stops at the first success. No point paying two vendors for the same fortnight.
  • Transient failures retry (network, 429, 5xx) with backoff. Failures that retrying cannot fix — rejected key, exhausted plan — fail immediately.
  • A circuit breaker trips after three consecutive failures, or instantly on a fatal one, so a dead vendor costs one attempt rather than 35 timeouts per run.
  • The keyless baseline always runs. With no keys at all, or with every paid vendor down, the brief is thinner rather than absent.
  • Falling back is reported, not absorbed. Any degradation raises an alert and is written to source health, because a quietly thinner brief with no explanation is the failure mode worth engineering against.

With no keys configured the tool works fully on the keyless tier — that is the default, so a fresh clone runs without signing up for anything.

Providers, key pools and failover

Two model providers, tried in priority order, each independently switchable and each carrying a pool of up to five keys:

OPENROUTER_ENABLED=true     # provider 1
AZURE_ENABLED=true          # provider 2, fallback

The failure ladder, widest first:

OpenRouter key 1..5  ->  Azure key 1..5  ->  loud error

That split exists because the two kinds of failure need different answers. A key-level failure — rate limit, empty balance — is fixed by another key. A provider-level failure — regional outage, deprecated deployment, suspended billing — is only fixed by another provider. Neither is allowed to degrade into an empty result that looks like a quiet week.

Azure specifics worth knowing:

  • The "model" is your deployment name, not a model id, so AZURE_TRIAGE_DEPLOYMENT / AZURE_EDITOR_DEPLOYMENT must match what exists in your resource. A wrong name returns 404, which is treated as fatal — retrying cannot fix a typo.
  • Azure does not report per-call cost, so token counts stay real but the dollar figure is marked unmeasured rather than guessed.
  • Both auth styles are sent (Authorization: Bearer and api-key), so either endpoint surface works.

Both key pools also read NEWSAPI_AI_KEY_1_5. Fill as many as you have; one is enough to run.

This exists because the two most common ways an API call dies — rate limit and exhausted credit — are both fixed by using a different key. Quota is per key, so on limited tiers a second key is the difference between covering all 35 accounts and covering the first twelve.

The pool distinguishes two failure classes, because they need opposite responses:

Failure Classified as Response
429, 5xx, network transient — the key is fine, just busy 20s cooldown, move to the next key
401, 403, 402 / out of credit fatal — this key is finished 6h cooldown, raise an alert

Marking a rate limit as fatal would burn a healthy key; marking an empty balance as transient would retry into the same wall on all 35 accounts. The split is the thing that makes failover work.

Two behaviours worth knowing:

  • With one key, there is nothing to fail over to, so the pool waits briefly (up to ~25s) for a rate limit to lift rather than failing the account over something that clears in seconds. With several keys it fails over instantly.
  • A dead key raises an alert exactly once, not once per call — a key running out of credit is the single most likely reason this system quietly stops working, and it is invisible from the outside.

Check the pools any time:

npm run check:keys      # pool logic tests, then a live ping of both models
npm run check:sources   # the news chain, including a forced-failure test

Model routing

Both models are environment variables, so switching is a config change:

OPENROUTER_TRIAGE_MODEL=google/gemini-2.5-flash
OPENROUTER_EDITOR_MODEL=anthropic/claude-sonnet-5

Any OpenRouter model that supports structured outputs works. The code sends a strict JSON schema and validates the response against it, so a model that ignores the schema fails loudly rather than returning a plausible-looking empty brief.

About

Weekly customer risk & opportunity brief for a CRO — self-healing news query vocabulary

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages