Building an Opportunity Tracker
March 2026
Staying on top of a high-volume professional pipeline is genuinely hard. Information lives across Gmail threads, LinkedIn messages, and mental notes. Follow-ups get missed. Conversations go stale. The overhead of tracking everything starts to compete with the actual work of moving things forward.
So I built a tool to manage it. In one afternoon with Claude Code, I shipped a full-stack opportunity tracker — a password-protected Kanban board with Gmail integration, AI-powered contact extraction from screenshots and pasted text, a morning email digest, and reply-to-update command parsing. It lives at jobs.saachigupta.net.
What it does
The dashboard has four columns: Active, Waiting on Them, Follow Up on Me, and Closed. Each contact card shows name, organization, role, days since last contact, and a stale flag when a thread has gone quiet past a configurable threshold.
The features I use most:
- Free-text paste: Copy any email, LinkedIn message, or raw notes into the Add Contact form, hit Parse, and Claude extracts name, organization, role, status, and next action. Review and save.
- Gmail scanning: Connects to Gmail via OAuth and scans for relevant emails. Matches by sender email or organization name in the subject — so automated emails from ATS platforms get matched to the right contact even when the sender address isn't the person you spoke with.
- Morning digest: Every day at 8am PT, a digest arrives listing all active contacts grouped by status, with stale flags and next actions. No dashboard login required.
- Reply-to-update: Reply to the digest email with commands like
close 3,waiting 2, ornote 4 great call, following up Friday— the app polls every 20 minutes and applies updates automatically.
Architecture
The stack is FastAPI + PostgreSQL deployed on Railway, with a single-page vanilla JS frontend served as static files from the same process. DNS runs through Netlify via a CNAME pointing the subdomain at Railway.
┌─────────────────────────────────────────────┐
│ jobs.saachigupta.net │
│ Netlify DNS → CNAME → Railway │
└─────────────────┬───────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI (Railway) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth │ │ Contacts │ │ Gmail OAuth │ │
│ │ cookie │ │ CRUD API │ │ scan / digest │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Vision API │ │ APScheduler │ │ Static Files │ │
│ │ text+image │ │ 3 jobs │ │ HTML / JS / CSS │ │
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
└──────────┬──────────────┬───────────────┬──────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌──────────────────┐
│ PostgreSQL │ │ Gmail API │ │ Anthropic API │
│ Railway │ │ Google │ │ claude-sonnet │
└──────────────┘ └─────────────┘ └──────────────────┘
APScheduler runs 3 background jobs inside the FastAPI process:
• 8:00am PT daily → build + send email digest
• every 20 min → poll digest thread for reply commands
• every 60 min → scan Gmail for new inbound emails
The interesting problems
ATS email matching. Automated platform emails often come from a generic no-reply address — not the person you actually spoke with. My initial scan matched contacts by sender email only, so it missed these entirely. The fix: a secondary match that checks whether the organization name appears in the email subject. Same logic catches emails from Greenhouse, Lever, Ashby, Workday, and similar platforms.
Too much automation is a bug. The first version auto-applied everything: new contacts, status updates, closures — all without asking. It immediately closed a contact based on a false positive ("unfortunately" appeared in a confirmation email) and flooded the Active column with newsletter signups. The fix was a review modal — the scanner now returns proposed changes, I approve or dismiss each one, and only selected changes get applied.
Railway internal networking. Railway's PostgreSQL plugin provides a .railway.internal hostname that only resolves inside their network. Hardcoding it as a plain environment variable broke startup — Railway needs the variable set as a reference (${{Postgres.DATABASE_URL}}) to wire up the internal network dependency. Several healthcheck failures before this clicked.
PostgreSQL sequence collision. The seed script inserted rows with explicit IDs but didn't update the Postgres autoincrement sequence. The first new insert tried ID 1, which already existed, and crashed. Fix: SELECT setval('contacts_id_seq', (SELECT MAX(id) FROM contacts)) at the end of the seed script.
What I'd do differently
Start with the review step. The temptation with AI-powered automation is to skip confirmation entirely — but for anything that writes to a real database, a human-in-the-loop step is almost always worth the extra click. Approving a proposed change takes two seconds. Debugging a wrongly closed contact takes much longer.
I'd also define the email matching rules more precisely upfront. "Relevant email" is fuzzier than it sounds — it includes platform confirmations, rejection emails from no-reply addresses, notification digests, and direct human outreach. A keyword filter catches all of them indiscriminately. A tighter definition from the start would have saved several iterations.
Tools used
- FastAPI — Python web framework; serves both the API and static frontend files
- PostgreSQL — stores contacts, digest threads, and Gmail OAuth tokens
- SQLAlchemy (async) — ORM with asyncpg driver
- APScheduler — in-process scheduler for digest, scan, and reply polling
- Google Gmail API — OAuth2 for reading inbox, sending digest, threading
- Anthropic API (claude-sonnet-4-6) — vision and text extraction for contact parsing
- itsdangerous — signed cookie auth with a single shared password
- Railway — deployment with managed PostgreSQL
- Netlify DNS — CNAME routing for the subdomain
- Claude Code — AI pair programmer that built most of this
Honest reflection
I couldn't have shipped this solo in a day. The architecture — async SQLAlchemy, APScheduler inside FastAPI's lifespan context, Gmail OAuth token refresh, Railway's internal networking model — each piece would have taken hours to figure out independently. With Claude Code, the hard parts became: describe what you want, review what it built, debug together.
What that unlocked wasn't just speed. It was scope. I would never have started a project that touched Gmail OAuth, a background scheduler, a vision API, and a reply-parsing email bot all at once. The AI-assisted workflow made the whole thing feel tractable from the first message.
The thing I keep coming back to: the best use of AI tools isn't automating the interesting parts — it's removing the activation energy for building at all. Once something exists, iterating on it is easy. Getting to "it exists" used to be the hard part.