A Telegram bot that uses the wisdom of the crowd to decide what goes into the public PL R&D Radar β the monthly "catch up in a minute" digest of the strongest signals across Protocol Labs R&D's focus areas.
Curators opt in with a single message, answer two quick questions, then get a few pairwise match-ups a day: two candidate cards, tap the stronger one. The winner stays on the throne and faces a fresh challenger (the "king-of-the-hill" mechanic). Aggregated across dozens of curators, these taps produce a robust ranking of what deserves to be on the Radar β plus a read on which segments care about what.
New here (agent or human)? Read
AGENTS.mdfirst β it's the cold-start briefing: stack, hosts, how to run locally, and how to ship a PR with a live preview. You should be able to start a fresh session, say "we're working on the PL Radar thing, repo islksbrssr/pl-radar", and file a PR without a long ramp-up.
Status: Live in production on Fly.io (Telegram bot + web app + API). The web app (Radar carousel, month + composite-lens selectors, Data view) is in review. In-browser voting and AI-assisted card/source submission (paste a URL, an LLM drafts a reviewable card; dedup runs on add) are live. Next: internal-source ingestion.
The Radar was previously curated by recency + a hand-picked list. That doesn't scale and misses the collective taste of the org. This project replaces the guesswork with a low-friction voting mechanism:
- Low friction: it lives entirely in Telegram. No app, no login, no website to visit. Onboarding is under a minute.
- Statistically sound: pairwise "A beats B" votes feed an Elo rating (the same family as chess ratings and the Bradley-Terry preference model), which turns noisy individual taps into a stable, confident ordering.
- Segment-aware: curators are tagged by role and focus area at signup, so we can ask "who values what?" β e.g. do investors rank governance signals higher than researchers do? This is the lightweight conjoint/part-worth analysis the product brief asked for.
Telegram (curators) Consumers
β taps β²
βΌ β read-only JSON
ββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββ
β grammY bot (polling) β β plrd.org Radar PL app-store β
β onboarding + voting β β (picks winners) dashboard β
βββββββββββββ¬βββββββββββ ββββββββββββββββββββ²ββββββββββββββββ
β writes votes β
βΌ β
ββββββββββββββββββββββββ Elo + segments βββββββββ΄ββββββββββββ
β SQLite (single file) β ββββββββββββββββββΆ β Express HTTP API β
β votes = source of β β /api/*.json β
β truth; Elo is a cache β βββββββββββββββββββββ
ββββββββββββββββββββββββ
One backend, three read-only faces. The bot + database + ranking live in one small always-on process. It exposes a read-only JSON API that both the public Radar and a PL app-store dashboard can consume. Secrets (bot token, DB) never leave this process and never enter a repo or the PL sandbox.
The PL app-store sandbox injects no secrets and no persistent storage, so a stateful bot can't live there. Instead the bot runs on a tiny always-on host (Fly.io), and the outputs are what the app store / plrd.org read.
src/
config.ts Env parsing (BOT_TOKEN, DB path, admins, round size)
types.ts Focus areas, roles, Card/Curator types
db/
schema.sql SQLite schema (curators, cards, votes, rounds, sessions)
index.ts Connection + idempotent schema bootstrap
repo.ts All SQL β the single place persistence is defined
ranking/
elo.ts Elo rating + full recompute from vote history
segments.ts Per-role leaderboards + attribute win-rates (conjoint)
bot/
index.ts Commands + callback routing
onboarding.ts 3-step signup wizard (role β focus β cadence)
voting.ts King-of-the-hill match-up flow
keyboards.ts Inline keyboards
copy.ts All user-facing text + progress-bar helper
session.ts Transient per-curator flow state type
http/server.ts /health + read-only /api/*.json + /api/submit/* (AI submit)
submit/ Paste-a-URL submission: fetch.ts β llm.ts β parse.ts + dedup.ts
scheduler.ts Optional daily nudge + background re-ingest
seed/ Sample cards + `npm run seed`
dev/simulate.ts Offline harness to sanity-check the ranking maths
index.ts Entry point (starts HTTP + bot together)
Dockerfile, fly.toml Deployment (Chunk 3)
1. Create a bot with @BotFather (Telegram):
- Open a chat with @BotFather β send
/newbot. - Pick a name (e.g.
PL R&D Radar) and a username ending inbot(e.g.plrd_radar_bot). - BotFather replies with a token like
123456:ABC-β¦. Keep it private. - (Optional)
/setdescriptionand/setuserpicto make it look polished.
2. Configure & install:
cp .env.example .env # then paste your BOT_TOKEN into .env
npm install
npm run seed # load sample cards to vote onFind your own Telegram user id via @userinfobot and
put it in ADMIN_IDS in .env to unlock /stats and /leaderboard.
3. Run:
npm run devNow message your bot /start in Telegram and walk through onboarding β voting.
Verify the ranking without a bot (no token needed):
npm run seed
npx tsx src/dev/simulate.ts # fabricates curators, prints leaderboards/startβ a warm welcome explaining the 30-seconds-a-day deal.- Step 1/3 β Role (
π¬ Researcher,πΈ Capital, β¦) β segment tag. - Step 2/3 β Focus areas (multi-select, toggles with β ) β segment tags.
- Step 3/3 β Cadence (2/3/5 per day, or π² surprise me).
- Done π β one tap to start the first round.
Each step edits the same message in place and shows a progress bar
(β°β°β± Step 2 of 3), so it feels like one smooth card. Every match-up also edits
in place, so a whole round is one live message rather than a wall of texts. The
reigning champion is marked π reigning so curators feel the NYT-style
"does my pick survive the next challenger?" hook.
Commands: /vote (start a round), /settings (redo preferences),
/pause / /resume, /help. Admins also get /stats and /leaderboard.
votesis the source of truth β every raw "winner beat loser" pairwise preference, tagged with the curator (and therefore their segment).- Elo is a derived cache on each card for instant leaderboards; it can be
recomputed from scratch at any time (
ranking/elo.ts β recomputeElo). - Segment analysis (
ranking/segments.ts) slices the same votes:leaderboardForRole(role)β Elo using only that segment's votes.attributeWinRates(attr, role?)β how often cards with a given attribute (focus area / content type / internal-vs-field) win, per segment. This is the conjoint-flavoured "what pulls each segment's preference" view.
The HTTP root serves a single-page app (Inter + Newsreader, light/dark, styled like plrd.org) with a left sidebar:
- Radar β the published Radar for a chosen month as a swipeable carousel (top 5 + "you're all caught up" + Share on X), recycled from plrd.org/insights. A lens selector switches between the General Radar (all votes) and a peer segment (your role or focus area) β "what people like you found most relevant".
- Vote β participate in voting (full in-browser flow is the next PR; for now it links to the Telegram bot).
- Data β curation analytics: who-values-what, per-role preference, curators.
Content is organised by monthly edition: only the current month is open for voting; past months appear as published Radars. Old cards expire out of the pool automatically.
| Endpoint | Purpose |
|---|---|
GET /health |
Liveness probe (required by Fly/PLN). |
GET /api/editions.json |
Months available, with labels + a current flag. |
GET /api/radar.json?edition=&lens=&limit=5 |
Top cards for an edition through a lens (general / role:<key> / focus:<slug>), in RadarItem shape. |
GET /api/radar-candidates.json?edition=&limit=6 |
Current-edition winners for plrd.org to ingest. |
GET /api/overview.json |
Everything the Data view needs (stats, win-rates, curators). |
GET /api/leaderboard.json |
Global Elo ranking of the card pool. |
GET /api/segments.json |
Per-role leaderboards + attribute win-rates. |
radar-candidates.json intentionally matches the RadarItem type in
plrd.org/src/components/PLRadar.tsx, so the Radar can fetch winners with no
transformation (see Integrating with plrd.org below).
The bot must stay awake 24/7 (long polling), which rules out free tiers that sleep. Fly.io runs a tiny always-on machine (~$2/mo, often within free allowance) with a persistent volume for the SQLite file.
fly launch --no-deploy # create the app (uses fly.toml)
fly volumes create data --size 1 # 1 GB volume for the SQLite file
fly secrets set BOT_TOKEN=β¦ ADMIN_IDS=β¦ DAILY_NUDGE_HOUR=9
fly deploySecrets live only in Fly's secret store β never in the repo. The read-only API is exposed over HTTPS for plrd.org and the app-store dashboard to fetch.
The public Radar currently derives cards by recency + a hand-picked
radar-signals.ts. To let the crowd drive it, fetch the curated winners at
build time:
// plrd.org β in the Insights page data loader
const res = await fetch(
'https://plrd-radar-curator.fly.dev/api/radar-candidates.json?limit=6',
{ next: { revalidate: 3600 } },
)
const { items } = await res.json() // items already match RadarItemWinners flow straight into the existing PLRadar component. (Deferred until the
backend is deployed and has real votes.)
Full workflow lives in AGENTS.md. The short version:
- Branch off
main,npm run typecheck, open a PR (gh pr create --base main). - Visual change β screenshot in the PR. Commit the PNG under
docs/screenshots/and link it by commit SHA (branch names have slashes that break raw URLs):https://raw.githubusercontent.com/lksbrssr/pl-radar/<SHA>/docs/screenshots/foo.png - Live preview of your branch (Vercel-style, on Fly β web-only, no bot, self-seeds demo data):
Then put
fly deploy --config fly.preview.toml --app plrd-radar-curator-preview
https://plrd-radar-curator-preview.fly.dev/at the top of the PR. Anyone can open that URL (public HTTPS, no login).
Assume others are pushing in parallel:
- Rebase before merge:
git fetch origin && git rebase origin/mainthengit push --force-with-lease. Branches go stale fast. - The preview URL is shared β last deploy wins, so deploying your branch
overwrites whoever deployed before you. Treat it as transient (deploy β demo β
move on). Need two previews at once? Spin up a throwaway app:
fly apps create plrd-radar-curator-pr-N --org personalβ deploy with--app plrd-radar-curator-pr-Nβfly apps destroyit after. - Only ONE Telegram bot may poll (production). Never run
npm run dev(bot) against the prod token from a second session β usenpm run dev:servefor UI.
- Voting bot β onboarding, king-of-the-hill, SQLite, Elo.
- Ranking + segments β Elo, per-role / per-focus leaderboards, read-only API.
- Deployed to Fly.io β bot + web + API live; results dashboard.
- Web app β sidebar (Radar / Vote / Data), monthly editions, swipe carousel recycled from plrd.org, composite peer lens (role + interests).
- In-browser voting β interest onboarding + king-of-the-hill on the web, reusing the profile/lens concept (next PR).
- PL app store β package the web app for the LabOS AI-apps dashboard.
- plrd.org wiring β Radar consumes
/api/radar-candidates.jsonwinners. - Internal-source ingestion β Doro (news crawler), PL Platform,
PL Capital portfolio/sourcing, focus-area lead picks β
cardsrows.
- Minimal deps by design: grammY (bot), better-sqlite3 (storage), express (API). No ORM, no message broker β the scale (dozens of curators) doesn't warrant it, and SQLite β Postgres is a small migration if it ever does.
- Everything is recomputable. Delete the Elo cache, re-derive from
votes. - No secrets in the repo.
.envand.data/are git-ignored; the deploy path uses Fly secrets.