Enactus Lead Agent
Finds, qualifies and tracks sponsor leads for a student club's outreach team.
Next.js 16 (App Router)React 19TypeScriptTailwind v4Neon PostgresVercelDeepSeek R1 + V3.2 via OpenRouterExaApolloFirecrawlGmail APISlack webhooks
Problem
Enactus SFU's External Relations team turns over completely every year. The sponsor prospect list, the notes on who already said no, and the reason a company was worth approaching lived in one outgoing VP's head and a spreadsheet rebuilt at each handover. Prospecting itself was volunteers searching the web by hand with no shared pipeline, so the same businesses got approached twice and warm leads went cold between executives.
What I built
A Next.js app where a volunteer describes the sponsors they want in plain English and watches the run: Exa searches the open web, DeepSeek R1 argues each candidate against the club's real projects with its reasoning streamed to the page, a fast structuring model turns that prose into rows, Apollo drops defunct, wrong-province and pay-to-join membership bodies, and survivors land in Neon Postgres and on a Kanban board mid-run. A sortable list view, a team scoreboard and a settings page sit on the same rows. Every lead opens a composer that drafts an email or a LinkedIn DM; email sends from the shared club mailbox behind a confirm that names the recipient, and nothing is ever sent automatically. Contact lookup is on demand via Firecrawl and restricted to the company's own domain; a Slack webhook announces in-kind wins, which have no invoice to make them visible. The whole run is one 60-second Vercel function driven by a shared deadline, with streamed and salvaged partial output so an overrun costs a few leads rather than all of them.
Highlights
- Code guards instead of prompt rules: grounded() nulls any contact name, role or email not present verbatim in fetched evidence and resets connection claims the evidence does not support; membership-org and name-vs-domain checks run in code even with no enrichment key; an email lint warns, never rewrites, on numbers or program names absent from the facts, with each false-positive fix pinned by a test.
- Built for latency variance: one 52-second deadline drives both LLM stages, the structuring call streams so a truncated body can be scanned for complete JSON objects, and candidates are structured in disjoint chunks concurrently, which is what made 'give me 10 leads' return 10 inside one 60-second function.
- The requested lead count is parsed in code with a noun-anchored, unit-excluding regex and drives query count, candidate funnel and final slice; the 25-per-run cap is reported against what the user actually typed instead of silently rewriting the ask.
- No-LinkedIn-scraping enforced structurally: contact lookup only reads URLs on the company's own registrable domain, so social profiles are unreachable rather than discouraged; addresses are regex-extracted from fetched text, never model-generated, and ranked by mailbox purpose so a sponsorship desk beats a complaints desk.
- Operational guardrails: a hard $20 CAD monthly API cap with no bypass flag, loud failures where three silent catch blocks used to hide outages, a 518-assertion selfcheck that runs with no database or test framework in under a second, and lead-score weights hand-set from a backtest of the club's own outreach history with post-outcome fields excluded.
Numbers
- 518 assertions in the dependency-free selfcheck, all passing in 0.2 seconds
- One agent run is a single 60-second serverless function capped at 25 leads
- Hard $20 CAD monthly cap on API spend, overridable only via environment and never removable
- Chunked concurrent structuring delivered 10 of 10 requested leads in one 40.3-second production run