RFP Tracker is an open-source tool that finds public K-12 and library procurement opportunities, reads the attached documents, and tells you which ones are worth bidding on.
It pulls FCC Form 470 filings from USAC Open Data, downloads the RFPs and attachments those filings point at, extracts what is actually being bought — equipment, services, manufacturers, quantities, deadlines — scores each opportunity against your business profile, and alerts you when a strong match is posted.
Built for vendors selling into the E-Rate programme: network equipment, structured cabling, Wi-Fi, firewalls, managed services and internet access.
Runs on plain Node — no build step, no framework, no database server.
Node 22.6+ executes the TypeScript directly and persistence is node:sqlite.
| What it does | USAC Form 470 + monitored websites → download attachments → extract requirements → score → alert |
| Built for | E-Rate vendors and resellers chasing K-12 and library bids |
| Documents it reads | PDF, DOCX, XLSX, CSV, HTML — detected by magic bytes, not file extension |
| Scoring | Deterministic and fully explainable, tuned to your own business profile |
| Works offline | Yes. Without an API key it uses rule-based extraction; --dry-run costs nothing |
| Stack | Node 22.6+, native TypeScript, node:sqlite, vanilla HTML/CSS/JS |
npm install
cp .env.example .env # then add your ANTHROPIC_API_KEY
npm run pipeline -- --limit 25
npm run serve # → http://localhost:4000npm run pipeline runs the whole chain: init → ingest → extract + analyse →
alert. Every stage is also runnable on its own.
| Command | What it does |
|---|---|
npm run check |
Self-check: Node version, SQLite, parsers, schema legality, scoring sanity, USAC reachability |
npm run test:extract |
Runs every document extractor against real files (PDF, DOCX, XLSX, CSV, TXT, HTML, stub) |
npm run ingest |
Pulls opportunities from USAC + configured websites |
npm run analyze |
Downloads attachments, extracts text, runs the model, scores |
npm run rescore |
Re-scores stored analyses after you edit the business profile — no network, no API cost |
npm run alerts |
Emits alerts for strong matches; writes data/alerts.json |
npm run pipeline |
All of the above in order |
npm run serve |
Dashboard + JSON API |
Useful flags:
npm run ingest -- --limit 200 --states TX,CA --since 2026-08-01
npm run analyze -- --limit 20 --max-docs 6
npm run analyze -- --dry-run # download + extract, no model call, no cost
npm run analyze -- --offline # rule-based extraction, no model call
npm run alerts -- --threshold 80USAC Open Data ─┐
├─→ opportunities ─→ documents ─→ extraction ─→ Claude ─→ analysis ─→ score ─→ alerts
Web sources ────┘ └─→ dashboard
Ingest. USAC's Open Competitive Bidding dataset (jt8s-3q52) is the primary
source. It is denormalised — one row per service request — so rows are grouped
back into one opportunity per Form 470, carrying every itemised request
(function, quantity, unit, min/max capacity) and links to the certified Form 470
PDF and any RFP attachment. Web sources are generic link harvesters: they fetch a
listing page and pull out links matching a pattern.
Extraction. Documents are identified by magic bytes, not by file
extension — hosts routinely serve HTML error pages from .pdf URLs, and trusting
the extension is how a stub becomes a phantom "scanned PDF". PDFs with a text
layer are parsed directly; PDFs without one are marked scanned and passed to
Claude as native PDF document blocks so it reads them visually. DOCX goes through
mammoth, spreadsheets through SheetJS (every sheet, as CSV).
Analysis. One structured-output call per opportunity. The JSON schema in
src/lib/ai/schema.ts is enforced by the API, so the response always parses.
The prompt is explicit that absent facts must come back as null — quantities
and manufacturers are never inferred.
Scoring. Deterministic and fully explainable: technology fit (35), lines carried (15), geography (10), organization type (10), services (10), billing method (8), deal size (7), timing (5), minus penalties for exclusions and low-confidence extractions. Every point is traceable to a rule, and the dashboard shows the whole breakdown.
Alerts. Anything at or above ALERT_THRESHOLD fires once per channel
(console, webhook), tracked in the alerts table so nothing repeats.
data/alerts.json is rewritten every run for polling.
Everything scoring depends on lives in src/lib/config.ts:
export const businessProfile: BusinessProfile = {
coreTechnologies: ["network switching", "wireless", "firewall", ...],
manufacturers: ["Cisco", "Meraki", "Aruba", ...],
priorityStates: ["TX", "CA", "FL", ...],
minDealSizeUsd: 10_000,
...
};The shipped profile assumes a K-12 / library network-infrastructure VAR (switching, Wi-Fi, firewall, cabling, UPS). Edit it to match your actual business, then
npm run rescore— no re-ingest, no API cost.
Add monitored sites to webSources in the same file.
| Endpoint | Purpose |
|---|---|
GET /api/opportunities |
Search + filter. q, state, type, billing, technology, minScore, openOnly, sort, limit, offset |
GET /api/opportunities/:id |
Full detail: analysis, score breakdown, documents, contact |
GET /api/facets |
Filter values with counts, plus corpus stats |
GET /api/profile |
Active business profile and threshold |
GET /api/health |
Liveness |
curl 'localhost:4000/api/opportunities?q=switch&state=TX&minScore=70'A Form 470 is the filing a US school or library must post before buying E-Rate-eligible products or services. It opens a mandatory 28-day competitive bidding window, and it is public. For a vendor it is the earliest reliable signal that a buyer is about to spend money, which is why this tool is built around it rather than around commercial bid-aggregator listings.
USAC Open Data — the Open Competitive Bidding dataset (jt8s-3q52), which is
free and public. No scraping, no login, no paid feed. A Socrata app token is
optional and only raises the rate limit. The tool can also monitor regional
education-service-centre and procurement pages for RFPs that never make it into
the dataset.
It reads the documents. A Form 470 tells you almost nothing on its own — the
requirement lives in an attached PDF or spreadsheet. The tool downloads every
attachment, identifies it by magic bytes rather than by file extension —
hosts routinely serve an HTML error page from a .pdf URL, and trusting the
extension is how a stub becomes a phantom "scanned document" — and extracts the
equipment, manufacturers, quantities and deadlines from it.
Scoring is deterministic and fully explainable: technology fit, lines carried,
geography, organisation type, services, and billing method, each contributing a
stated number of points to a 0-100 total. Every score can be traced back to the
rule that produced it. It is tuned to your business in src/lib/config.ts, and
npm run rescore re-scores everything already in the database with no network
calls and no API cost.
They are the two E-Rate reimbursement routes. BEAR means the applicant pays the vendor in full and claims the discount back afterwards; SPI means the vendor invoices USAC directly for the discounted portion. Which one a filing specifies materially changes a vendor's cash position, so the tool extracts it and it is a scoring input.
The USAC data is free. The only paid component is the AI summarisation, and it
is behind an explicit flag: --dry-run downloads and extracts without calling
any model, --offline uses rule-based extraction, and --limit caps every run.
You can operate the whole pipeline with no API key at all.
Partly, and honestly. A PDF with no text layer is detected as scanned and passed to the model as a native document block so it can be read visually. Without an API key, a scanned document yields nothing and says so rather than reporting zero values as though they were findings.
USAC's document host is currently serving placeholders. Every object on
publicdata.usac.org — including documents search engines have indexed — returns
the same 144-byte HTML meta-refresh stub instead of the file. This is an outage
on their side, not a bug here. The tracker detects these explicitly
(extraction_mode = "placeholder"), does not cache them, and analyses those
opportunities from posting metadata alone at reduced confidence. When USAC
restores the host, the next npm run analyze picks up the real files with no
code change.
The USAC API's own data remains rich and is what the current analyses are built from — itemised service requests with quantities, capacities, and the applicant's own narrative description.
Without an ANTHROPIC_API_KEY, npm run analyze falls back to a rule-based
extractor. It reads the source's structured fields and applies keyword rules, and
marks every row model = "rule-based-fallback" with low confidence so those rows
are never confused with model output. Set the key and re-run to upgrade them.
Web source monitors are generic. They harvest links from a listing page and will find nothing on sites that render listings via client-side JavaScript; those need a per-site adapter. The two shipped example URLs are unverified placeholders — replace them with the portals you actually track.
ralph/ holds an autonomous build loop
(technique): the same prompt is fed to
Claude repeatedly, one backlog item per iteration, until completion criteria are
met.
MAX_ITERATIONS=10 ./ralph/loop.shralph/PROMPT.md— the loop prompt and completion criteriaralph/fix_plan.md— the backlog; one item per iteration, ticked only with proofralph/JOURNAL.md— what each iteration did
src/
lib/
config.ts business profile + monitored sources ← edit this
db.ts store.ts schema and persistence
queries.ts dashboard search/facets
score.ts deterministic scoring
alerts.ts alert channels
http.ts fetch with retry + backoff
sources/ usac.ts, web.ts
parse/extract.ts magic-byte sniffing + all extractors
ai/ schema.ts, analyze.ts, fallback.ts
scripts/ init-db, ingest, analyze, rescore, alerts, pipeline, selfcheck, test-extract
server.ts JSON API + static host
public/index.html dashboard (zero dependencies)
data/ sqlite db, document cache, alerts feed (gitignored)