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.
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.
Every Monday at 07:00 IST (changeable in the UI, no redeploy):
- 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.
- Dedupes. Canonical URLs plus title-similarity matching, because the same wire story appears under nine mastheads.
- 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.
- 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.
- 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.
- Records. Every article it considered and the reason each one was cut is stored, so the filter can be inspected rather than trusted.
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:3000Then 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 itAny 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/postgresBuilt for Vercel, but it is a stock Next.js app and will run anywhere.
- Push to GitHub, import the repo in Vercel.
- Add the environment variables from
.env.examplein Project Settings. At minimum:DATABASE_URL,OPENROUTER_API_KEY,RESEND_API_KEY,ADMIN_PASSWORD,CRON_SECRET,APP_URL. - Deploy.
vercel.jsonregisters 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 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.
| 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. |
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.
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 |
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
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.
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.
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_DEPLOYMENTmust match what exists in your resource. A wrong name returns404, 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: Bearerandapi-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:
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 testBoth 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.
