A deep dive into the YC Launch Monitor — a Python bot that tracks every new YC and a16z Speedrun company, and — the fun part — catches founders announcing their acceptance on X/LinkedIn before the official listing goes live.
If you work in GTM (go-to-market), you know the game: the person who reaches a freshly-accepted YC founder first wins the meeting. The problem is that YC publishes its new batch all at once, and by then every sales rep on the internet has the same list.
The real signal lives earlier — in the founder's own "big news: I got into Y Combinator" tweet, posted days before YC updates its directory.
So I built a bot that watches for both. It's called YC Launch Monitor, it's open source, and this post walks through exactly how it works under the hood.
The core idea: two kinds of signal
The bot emits two alert types:
- ⚡ EARLY — a founder announced their acceptance on X/LinkedIn, but the company is not yet in the official YC/Speedrun directory. This is the "get ahead of everyone" signal.
- ✅ CONFIRMED — the company is now officially listed in the YC directory or the a16z Speedrun program.
Both land in a Slack channel as rich Block Kit cards, deduplicated, on a schedule.
Architecture
The design is deliberately pluggable — four sources feed one pipeline, and adding a fifth is a single module:
┌─────────────────────────────────────────────────────────┐
│ main.py (orchestrator) │
│ --once / --loop → run_once() → deliver() │
└───────┬──────────────────────────────┬──────────────────┘
│ │
OFFICIAL sources│ │SOCIAL sources (optional)
▼ ▼
┌──────────────────────────┐ ┌───────────────────────────────────┐
│ yc_directory.py │ │ x_twitter.py / linkedin.py │
│ yc-oss GitHub Pages API │ │ X API v2 / pluggable endpoint │
│ → changes feed + diff │ │ → raw Post objects │
└───────────┬──────────────┘ └───────────────┬───────────────────┘
│ ✅ CONFIRMED │
│ ▼
┌───────────▼──────────────┐ ┌───────────────────────────────────┐
│ speedrun.py │ │ detector.classify_post(post, idx) │
│ speedrun-api.a16z.com │ │ match vs CompanyIndex │
│ → slug-list diff │ │ → ⚡ EARLY or ✅ CONFIRMED │
└───────────┬──────────────┘ └───────────────┬───────────────────┘
│ ✅ CONFIRMED │
└───────────────┬─────────────────────────┘
▼
┌────────────────────────────┐
│ state.py (SQLite state.db)│ dedup via seen_events
│ mark_seen / is_seen │ incremental via snapshots
└────────────┬───────────────┘
▼
┌────────────────────────────┐
│ alerts.py (SlackClient) │ Block Kit payload
│ chat.postMessage / webhook│
└────────────┬───────────────┘
▼
┌─────────────┐
│ Slack DM / │ ⚡/✅ company · founder · batch ·
│ channel │ source · details · links
└─────────────┘
Stack: Python 3.9+ · requests · SQLite (stdlib) · PyYAML + python-dotenv · Slack Block Kit · launchd/cron. No database server, no message broker, no framework — just three pip dependencies.
Choosing the data sources (the part that took the longest)
The obvious source — ycombinator.com/companies — turned out to be a dead end: it's an Inertia.js single-page app with no clean public endpoint, and YC's old v0 API is gone. Scraping it is fragile.
The working solution is the yc-oss GitHub Pages mirror of the YC directory's Algolia index:
https://yc-oss.github.io/api/
It's free, needs no key, updates daily, and — crucially — exposes a changes feed (changes/latest.json) with an added array. That's a clean, incremental "what's new" signal, exactly what a monitor wants:
BASE = "https://yc-oss.github.io/api"
def fetch_changes() -> dict:
return get_json(f"{BASE}/changes/latest.json")
For a16z Speedrun (a separate accelerator from YC, worth monitoring on its own), there's a proper public REST API:
https://speedrun-api.a16z.com/api/companies/companies/
Paginated, ~251 records, and each record includes founder names, X/LinkedIn/website URLs, cohort, and industries — everything a rich outreach alert needs. We diff the full slug list against our stored snapshot to detect new companies.
For X and LinkedIn, the situation is different:
-
X needs an API v2 Bearer token (Basic tier ~$100/mo) for
search/recent. When present, we scan for announcement phrasings. -
LinkedIn has no free public API, so the bot uses a pluggable adapter — point it at any service that accepts
POST {"query":..., "freshness_minutes":N}and returns{"results":[{author,text,url,published_at}]}.
Crucially, YC Directory + Speedrun work with zero API keys. The bot is fully functional out of the box; X/LinkedIn are skipped gracefully until configured.
State management & duplicate detection (the boring part that matters)
A monitor that spams you with the same company every 8 hours is useless. So all state lives in a local SQLite database (state.db) with two tables:
-
seen_events— one row per dedup key that's already been alerted.INSERT OR IGNOREmakes dedup atomic and race-free. -
snapshots— the SHA-256 hash of each source's last-seen payload, so the bot only acts on changes.
def mark_seen(self, dedup_key, source, company):
now = datetime.now(timezone.utc).isoformat()
self.conn.execute(
"INSERT OR IGNORE INTO seen_events (dedup_key, source, company, alerted_at) "
"VALUES (?,?,?,?)", (dedup_key, source, company, now))
self.conn.commit()
def snapshot_unchanged(self, source, payload) -> bool:
h = hashlib.sha256(str(payload).encode("utf-8")).hexdigest()
row = self.conn.execute("SELECT payload_hash FROM snapshots WHERE source = ?",
(source,)).fetchone()
return bool(row and row["payload_hash"] == h)
The first run of each source establishes a baseline — it stores the snapshot but alerts on nothing. From then on, only new companies trigger alerts. No duplicate spam, even across restarts.
The early-detection classifier (the fun part)
The detector turns a raw social post into a classified alert. The whole challenge is avoiding false positives — a founder's bio mentioning "Amazon" is not an Amazon launch, and a post saying "solo founder" must not match a company literally named "Solo".
The matching strategy, in order of confidence:
-
Exact X-handle match — the author's
@handlematches a company's handle in the official index. Strongest signal. - Word-boundary company name near an announcement keyword — the company name appears (case-sensitively, since founders capitalize it) within ~40 characters of a phrase like "got into YC", "accepted", "batch", "speedrun".
def classify_post(post: Post, idx) -> Alert:
matched = _match_company(post, idx) # handle match → name near keyword
if matched:
status = STATUS_CONFIRMED # already officially listed
else:
company = _extract_company_hint(post.text) or "Unknown company"
status = STATUS_EARLY # announced before official listing
return Alert(company=company, founder=post.author, source=post.source,
status=status, details=post.text, link=post.url,
dedup_key=make_dedup_key(post.source.lower(), post.post_id or post.url),
extra={...})
If the post matches an officially-listed company → ✅ CONFIRMED. If it doesn't (or the company can't be matched at all) → ⚡ EARLY, because the founder is clearly announcing before the official listing. That's the whole point.
The alert model & Slack delivery
Every detection becomes an Alert dataclass that knows how to render itself as a Slack Block Kit payload — header, status/source badges, batch/cohort context, description, and action buttons:
@dataclass
class Alert:
company: str
source: str # "YC Directory" | "Speedrun" | "X (Twitter)" | "LinkedIn"
status: str = STATUS_EARLY # early | confirmed
founder: str = ""
details: str = ""
link: str = ""
dedup_key: str = ""
detected_at: str = field(default_factory=utcnow_iso)
extra: dict = field(default_factory=dict)
def to_slack_payload(self, cfg: dict) -> dict:
# ... builds header / status / batch / details / buttons blocks ...
Delivery supports both an OAuth bot token (chat.postMessage — posts to a channel or a DM) and a legacy Incoming Webhook as fallback:
def send_payload(self, payload: dict) -> bool:
if self.bot_token:
r = requests.post(API, json=payload,
headers={"Authorization": f"Bearer {self.bot_token}"}, timeout=20)
data = r.json()
return r.status_code == 200 and data.get("ok") is True
if self.webhook_url:
r = requests.post(self.webhook_url, json=payload, timeout=20)
return r.status_code == 200
return False
Persistence & ops
Three ways to run it persistently, from simplest to most robust:
./run.sh --loop # foreground, polls every interval_minutes
# or cron:
0 */8 * * * cd /path/yc-launch-monitor && ./run.sh --once >> /tmp/yclm.log 2>&1
# or macOS launchd (survives reboots):
cp deploy/com.yclaunchmonitor.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.yclaunchmonitor.plist
Default cadence is every 8 hours (configurable via YC_SCHEDULE_INTERVAL_MINUTES). Each run also writes a machine-readable pond_report.json (sources enabled, state, alerts this run) for verification, and can expose a /health HTTP endpoint for monitoring.
Testing
The test suite uses only stdlib unittest — no extra framework. It covers the two things that break silently: dedup (no duplicate alerts across runs) and the classifier (the false-positive traps above).
.venv/bin/python -m unittest discover -s tests
There's also a --test-alert flag that posts real sample alerts (a live Speedrun company + a real founder post) so you can verify Slack delivery before going live.
What's next / how to extend
The source layer is the extension point. To add Reddit, Bluesky, or Hacker News, copy x_twitter.py as a template — the classifier, dedup, and Slack delivery all work unchanged. Other ideas in the repo's README: Slack interactive buttons, CRM export (Google Sheets / Airtable / Notion), alert scoring by ICP fit, and a --report daily-digest flag.
Code & more: https://www.dailybuild.xyz/project/239-yc-launch-monitor
Top comments (0)