Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

FSignal

πŸš€ LIVE DASHBOARD Β· /health Β· /ledger

Why these are open. The dashboard and /ledger carry the companies themselves, and on a public deployment that is the pipeline this bot exists to give one person an edge in. They are open anyway, deliberately, while the project is under review: a claim you cannot check is not evidence, and asking a reviewer to take the precision numbers on trust would cost more than the exposure does.

Closing them is one variable β€” DASHBOARD_TOKEN gates all three behind Authorization: Bearer ... or ?token=..., and /health reports lead_endpoints_protected so the posture is never a guess. It is tested both ways in tests/test_endpoint_access.py. /health and the Pond endpoints are never gated: the first carries counts rather than companies, the second is how Pond health-checks the agent.

A persistent Slack monitor that finds Y Combinator and a16z Speedrun founders before the official directory lists them β€” and proves the claim on every alert.

A normal directory watcher tells you what has already been published. FSignal is built around the earlier moment: the founder has announced acceptance publicly, but the directory has not caught up. The hard part is not finding posts that mention YC; it is knowing which of them are genuinely early. So every EARLY alert carries a receipt:

πŸ”Ž OFFICIAL CHECK Not found in YC Fall 2026 6,200 YC records checked Β· snapshot 21:18 UTC No match on exact name, shortened name within the batch

Every alert links straight to the directory search that proves it, so you can check it yourself in about ten seconds. Everything the bot rejects is recorded too, with a reason β€” live at /ledger, and committed as evidence/raw/ledger.json so the numbers stay checkable after the deployment stops being public.

Proof of Functionality

FSignal successfully intercepts real Ghost Signals before they are listed in the official directory.

A delivered EARLY alert Delivered to a real workspace: who, from where, in which batch, and the founder's own words. Source: X (indexed search) names the path the signal actually came from β€” an indexed result is never dressed up as a native one.

The receipt and the buttons The receipt: 6,200 YC records checked, snapshot timestamped, and the match rules that found nothing. The two buttons open the founder's post and the directory search that backs the claim.

The directory search that backs it The same search, run against ycombinator.com/companies filtered to Fall 2026: "Sorry, no matching companies found". The alert's claim, checked independently, in about ten seconds.

Earlier captures of these alerts are in docs/proof/archive/, unedited, with a note on what each one shows that has since been fixed. Deleting the before is how a fix stops being checkable.

How it works

YC Directory ─┐
Speedrun ─────┴─→ complete official snapshot (size + timestamp)
 β”‚
X / LinkedIn ──→ identity gate ──→ scoring ──→ official check ──→ EARLY / already-listed
 β”‚ β”‚ β”‚
 suppression ledger persisted receipt Slack alert
 β”‚
 company later appears officially β†’ CONFIRMED + lead time

The four sources

Each is a separate adapter on its own schedule, and each reports its own health and retrieval path at /health.

Source Implemented in How it is read Every
YC Directory app/sources/official.py Β· YCDirectorySource The public Algolia index the site's own JavaScript queries 60 min
Speedrun app/sources/official.py Β· SpeedrunSource a16z's first-party API, the one speedrun.a16z.com calls itself 120 min
X app/sources/social.py Β· XSource X recent search where a paid plan exists, otherwise publicly indexed X URLs β€” labelled either way 240 min
LinkedIn app/sources/social.py Β· LinkedInSource Publicly indexed post URLs 240 min

Official snapshot. ycombinator.com/companies is a client-side Algolia application; FSignal uses the same public search key the page hands its own JavaScript. A single Algolia query caps at 1,000 hits, so a full crawl enumerates the batch facet and pulls one slice per batch β€” 6,200 of 6,200 companies in about 8 seconds. Between full crawls, one recent-window query against the launch-date index catches new listings. Speedrun uses a16z's own first-party API, the one speedrun.a16z.com calls itself.

On "YC Speedrun". The brief names a YC Speedrun page. There is no such program: the public, distinct Speedrun company directory is a16z Speedrun, and that is what FSignal monitors β€” as a separate source, on its own schedule, with its own SPEEDRUN badge on every alert, exactly as the brief asks. It is tagged a16z rather than YC because that is who runs it, not because a source was substituted.

Targeting. Search terms are derived from whichever batches the directory reports as currently filling, not hardcoded. A batch that is fully published cannot produce an early signal, so hunting one is worse than not hunting. When Winter 2027 opens, the queries retarget with no code change.

Identity gate. A candidate must yield a defensible company identity β€” a parenthesised program tag like Orca Aerospace (YC F26), a claim like Structured AI is backed by Y Combinator, or our company <Name> β€” and survive a disqualifying-context check (application deadlines, rejection posts, demo-day recaps, alumni threads, recruiter posts, investor commentary). Anything that fails is ledgered with a reason and never alerted.

Official check. Domain first, then exact normalized name, then a batch-scoped strict token-prefix so Nodus resolves to Nodus Compute while Shepherd (YC S26) does not match the unrelated Shepherd (Winter 2021). Last, a batch-scoped handle comparison, because X names companies by handle: @speko_ai is the directory's Speko, and comparing those as names says they are different companies β€” which is how an already-listed company gets announced as an early discovery. Never fuzzy similarity, and both the prefix and handle rules are confined to the batch the post itself claims, so neither can reach across cohorts. If the snapshot is stale, the verdict is possible, never early.

Dedup. One alert per company, not per post. A second independent source for the same company replies in the original Slack thread as corroboration.

Measured behaviour

How early is "early"?

The product is judged on lead time, so it is measured rather than asserted. A backtest replays the live queries, extraction and matching against YC's own published launched_at, for the batches the monitor is currently hunting (evidence/raw/lead-time-backtest.json):

Companies resolved to a published listing time 17
Founder posted before YC listed them 10
Median lead 4.4 days
Mean lead 15.3 days
Longest lead 50 days (screenpipe)
Ahead by a week or more 5

The other 7 posted after YC listed them; they are reported, not averaged away. The search index dates posts to the day, so each figure is Β±1 day, and ranking means this is a sample of the posts that existed rather than all of them. Both caveats are recorded in the artifact itself.

Precision

On 139 real captured Serper results for public LinkedIn posts (tests/fixtures/linkedin_corpus.json, replayable offline):

Candidates evaluated 139
Suppressed, each with a reason code 106
Correctly identified as already listed 30
Alerts raised 3
Alerts naming a real company 3 / 3

tests/test_precision.py enforces precision β‰₯ 90% and a noise budget of at most one non-actionable alert per twenty, in CI, against that fixture.

Status at a glance

What is running, what is merely written, and what is running in a reduced mode. The distinction matters more than the tick marks: implemented and verified live are not the same claim, and a reader should not have to work out which one they are reading.

Status
YC Directory monitoring verified live Full facet-sliced crawl, mode: full at /health
Speedrun monitoring verified live a16z first-party API, mode: canonical
LinkedIn monitoring verified live Indexed public posts
X monitoring fallback active Native path implemented; the reference deployment runs indexed_fallback because its account has no paid X plan
Persistent stateful monitoring verified live SQLite on a mounted volume, dedup enforced by DB constraint
Early detection verified live A real EARLY alert delivered, with the directory search that backs it
Slack delivery verified live Durable outbox, bounded retry, dead-letter
Slack interactivity verified live Signed requests accepted, replays and forgeries refused, on the reference deployment. Your own install needs the one manual step in Setup
Pond Protocol V1 deployed Manifest, authenticated runs and idempotency, exercised against the live URL
Ghost β†’ Confirmed lead time backtested Measured against YC's published launch times; no live confirmation has occurred yet on this deployment
Lead-endpoint auth implemented, deliberately off DASHBOARD_TOKEN gates the dashboard, /ledger and timelines. Left unset while under review so the evidence stays checkable; verified working in both postures
Multi-tenancy not applicable Single-workspace personal bot, as the brief specifies
Real-time delivery limitation Polling, not push. Latency is bounded by the interval

Setup

New to Python or the terminal? Read docs/INSTALL.md instead. Same result, every click spelled out, Windows and macOS both covered, with a troubleshooting table keyed to the exact errors you might hit.

Requires Python 3.12+ (or Docker) and a Slack workspace where you can install an app. Two credentials are enough to run everything: a Slack bot token and a Serper key.

python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # three lines marked >>> REQUIRED <<<
python -m uvicorn app.main:app --port 8000

Or docker compose up -d, which mounts ./data so state survives restarts.

Slack (required)

  1. Create an app at api.slack.com/apps β€” you can upload slack-app-manifest.yml directly.
  2. OAuth & Permissions β†’ add the chat:write bot scope β†’ install to your workspace.
  3. Invite the bot to your channel: /invite @FSignal.
  4. Copy the channel ID (right-click the channel β†’ View channel details) and the bot token (xoxb-...) into SLACK_CHANNEL_ID and SLACK_BOT_TOKEN.

Slack interactivity (one minute, worth doing)

Every alert ends in two navigation buttons β€” the founder's post, and the directory search that backs the claim. They open their links either way, but until Slack has somewhere to acknowledge the click it stamps the message "This app is not configured to handle interactive responses", which is a warning triangle on the one message built to be trusted.

  1. Interactivity & Shortcuts β†’ toggle Interactivity on.
  2. Request URL: https://<your public base>/slack/interactions β€” for the reference deployment that is https://fsignal-production.up.railway.app/slack/interactions. It must be public HTTPS; Slack will not accept localhost.
  3. Basic Information β†’ App Credentials β†’ Signing Secret β†’ copy into SLACK_SIGNING_SECRET, then restart.

The endpoint runs no business logic. It verifies Slack's HMAC signature in constant time, refuses anything older than five minutes so a captured request cannot be replayed, and answers 200. With no secret configured it answers 503 rather than trusting an unsigned caller. /health reports slack_interactions_configured so you can see which state a deployment is in.

The manifest deliberately does not declare this: Slack requires a Request URL alongside it, and that URL is your deployment, not one that belongs in a public repo.

Search provider (required)

Neither X nor LinkedIn offers unrestricted public post search on a free plan, so both social sources read publicly indexed URLs through Serper rather than scraping authenticated pages. Create a key and set SERPER_API_KEY; the free tier is enough. Results cap at 10 per query, which is why the bot issues one query per active batch rather than one combined query.

Want it faster than the shipped 4-hour social cadence? The directories already run hourly; the founder-post half is limited by the search index, not by the polling. The three tiers that close that gap β€” and the one that is impossible β€” are set out in docs/ROADMAP.md.

X (optional β€” improves the X source, does not enable it)

X's own recent search carries metadata indexed search cannot: author bio, profile URL, and the exact post timestamp. FSignal prefers it whenever it is reachable. It is not on the free tier β€” without a paid plan the API answers 402 credits depleted.

So the X source has two paths. Native first; on a billing block or a missing token, the same vocabulary runs against indexed public X URLs using the Serper key above. Which path answered is never hidden: /health reports mode per source, the mode is persisted on every signal, and Slack renders Source: X (indexed search) when the alert came from the fallback. An indexed result is never presented as a native one.

Set X_BEARER_TOKEN if you have a paid plan. Leave it empty otherwise β€” the source works either way.

What the reference deployment is actually running: the indexed fallback, mode: "indexed_fallback" in /health, because the account behind it has no paid X plan. The native path is implemented and preferred, and it is not what you are looking at. Alerts from that deployment say Source: X (indexed search) for the same reason. The practical difference is metadata and latency, not coverage: indexed results carry a post's date to the day rather than the hour, which is why the lead-time figures above are Β±1 day.

Pond (optional)

Set POND_ACCESS_KEY and PUBLIC_BASE_URL on a public HTTPS deployment, then publish the agent with that base URL. See docs/POND.md.

Verifying it works

python scripts/preflight.py # which credentials are still missing
pytest -q # full suite, no credentials needed
python scripts/scan_once.py # one real scan against live sources
python scripts/send_test_alert.py # one real Slack message

Then open /health (source health, snapshot sizes, ledger counts), /ledger (every candidate and its verdict), and /signals/{id}/timeline (one signal's full history).

Offline replay β€” re-derive the numbers yourself

python scripts/replay_corpus.py # or --json for a machine-readable summary

One command, no credentials, no network, no API spend. It resets its own database and replays the 139 committed candidates through the unmodified production pipeline β€” the same extraction, scoring, matching, dedup and alert code the deployment runs. Nothing is hand-fed and no timestamp is invented.

It prints what the corpus produced and then runs the identical batch a second time, which must add nothing: that is the dedup claim demonstrated rather than asserted.

139 candidates evaluated β†’ 106 suppressed Β· 30 already listed Β· 3 alerted
second pass: 0 new signals, 0 new alerts

tests/test_offline_replay.py runs it in CI with the socket layer removed underneath, so "offline" is enforced rather than promised. Add python scripts/demo_server.py afterwards to browse the result at /ledger.

This is a rehearsal, not proof of live delivery; for that, see evidence/.

Adding a source

A source needs a name and an async collect(). Nothing else changes β€” extraction, scoring, matching, persistence, dedup, Slack and Pond all sit downstream of that boundary. See docs/ARCHITECTURE.md.

Documentation

docs/INSTALL.md The click-by-click setup, for someone who has never used Python
docs/ARCHITECTURE.md How the pipeline fits together, and where a new source plugs in
docs/POND.md The Pond Protocol V1 endpoints, envelope validation and idempotency
docs/ROADMAP.md What it would take to get to real time, including the tier that is impossible
docs/EVIDENCE.md What each captured artifact establishes, and how to re-derive it
evidence/ The artifacts themselves, each carrying the URL and moment it came from

What this is built for

One person, one workspace, running continuously β€” which is what the brief asks for, and what the design is honest about being. State survives restarts, alerts survive a Slack outage, and a failing source degrades to a labelled fallback rather than going dark. That is the bar it meets.

It is deliberately not a multi-tenant service. There is one database, one Slack channel, and one configuration; every caller of list_ghosts sees the same operator's signals. Serving several buyers their own private pipelines is a different architecture β€” per-tenant isolation and a scheduler that is not process-local β€” not a patch on this one. The limits below are the shape of that decision, not oversights in it.

Known limitations

  • The lead endpoints are open on this deployment, on purpose and only for now. The dashboard, /ledger and /signals/{id}/timeline carry the companies themselves, so a public deployment publishes the pipeline the operator is relying on being alone in having. That is a real cost, taken knowingly: while the project is under review, a reviewer being able to open the ledger is worth more than the exposure. Set DASHBOARD_TOKEN and all three answer 401 without Authorization: Bearer ... or ?token=... β€” the second because these are pages a person opens in a browser, and a browser cannot send a header. /health reports lead_endpoints_protected either way. Anyone running this for real should set it on day one.
  • Nothing watches the watcher. /health reports source health, retry state and snapshot age, but nothing pages anyone when the process dies. Point an uptime check at it if the alerts matter.
  • The Serper allowance runs out, and both social sources go dark when it does. At the shipped cadence the two of them issue about 90 queries a day (15 per run, six runs each), and the free allowance is a one-time grant rather than a monthly one β€” so it lasts on the order of a month, then X and LinkedIn start failing. The failure is loud: /health turns the source unhealthy and the scheduler backs off. Top the key up, or widen X_SCAN_INTERVAL_MINUTES and LINKEDIN_SCAN_INTERVAL_MINUTES, or drop ACTIVE_BATCH_COUNT to 1 β€” query volume scales with the number of batches hunted.
  • Polling, not push. Latency is bounded by the polling interval; there is no webhook.
  • LinkedIn is index-limited. Discovery depends on Google having indexed the post, so the true lead time is worse than X's would be. Serper's free tier returns 10 results per query.
  • X requires a paid plan. See above.
  • X recent search only reaches back 7 days. An outage longer than that leaves a gap the since_id watermark cannot recover.
  • SQLite, single worker. The scheduler is process-local, so run one uvicorn worker and keep the database on persistent storage. Postgres is the upgrade path, not a need here.
  • The identity gate prefers precision. Announcements that name no company ("we got into YC!") are recorded as suppressed rather than alerted. That is deliberate.

Security

.env is gitignored and excluded from the Docker build context; the image never contains it. Rotate any credential that has been in a working tree.

About

Persistent Slack monitor that finds YC and a16z Speedrun founders before the official directory lists them. Every early alert carries a timestamped receipt. Median lead time: 4.4 days.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /