I applied to 40 jobs. I tracked exactly 3 of them. The other 37 lived rent-free in a spreadsheet I stopped opening after week two.
So I did what any reasonable engineer would do: spent a weekend building a robot to do it for me.
The result: an open-source Flask app that reads your Gmail, uses Claude AI to figure out what happened in each email, and builds you a full job search dashboard. Zero manual entry. Here's how it works.
The problem with every existing tool
Huntr, Teal — beautiful Kanban boards. You still move the cards yourself. That's just a prettier spreadsheet.
Simplify — great Chrome extension for autofilling applications. Does nothing after you hit submit. The moment you need it most: silence.
LazyApply, LoopCV — auto-applies to hundreds of jobs for you. Congratulations, you've been rejected at scale.
G-Track — actually syncs Gmail! But uses keyword/domain heuristics. It doesn't understand your emails, it pattern-matches them.
None of them read "We'd love to move forward with your application" and know that means in_process. That's where an LLM earns its keep.
The whole system in one diagram
Gmail API → raw emails → Claude (tool use) → structured data → SQLite → dashboard
That's it. The AI part is one function call.
The core trick: give Claude one tool and let it decide
Instead of asking Claude to "return JSON," you give it a function to call — and tell it to only call it for job-related emails. Claude classifies, extracts, and structures all in one pass. Non-job emails? It just... doesn't call the tool. Beautiful.
_SAVE_APPLICATION_TOOL = {
'name': 'save_job_application',
'description': 'Save a job application. Only call this for actual job emails, not newsletters.',
'input_schema': {
'type': 'object',
'properties': {
'company': {'type': 'string'},
'role': {'type': 'string'},
'status': {
'type': 'string',
'enum': ['applied', 'in_process', 'interview_scheduled', 'rejected', 'offer'],
},
'applied_date': {'type': 'string', 'description': 'YYYY-MM-DD'},
'interview_date': {'type': 'string', 'description': 'YYYY-MM-DD if scheduled'},
'skills': {
'type': 'array',
'items': {'type': 'string'},
'description': 'Tech skills mentioned. Max 8.',
},
},
'required': ['gmail_message_id', 'company', 'status'],
},
}
The status enum is load-bearing — Claude cannot invent a value outside those five. Schema as guardrails.
Now batch up to 50 emails and fire:
message = client.messages.create(
model='claude-haiku-4-5-20251001',
max_tokens=4096,
tools=[_SAVE_APPLICATION_TOOL],
messages=[{'role': 'user', 'content': prompt}],
)
# Each tool_use block = one detected job application
results = [b.input for b in message.content if b.type == 'tool_use']
One API call. 50 emails in, structured records out.
The one data model rule that saves everything
Status only moves forward. Always.
STATUS_RANK = {'applied': 1, 'in_process': 2, 'interview_scheduled': 3, 'offer': 4, 'rejected': 5}
TERMINAL_STATUSES = {'rejected', 'offer'} # once here, you're done (sadly or happily)
if existing.status not in TERMINAL_STATUSES:
if STATUS_RANK.get(new_status, 0) > STATUS_RANK.get(existing.status, 0):
existing.status = new_status
Without this, your daily scan would see an old "application received" email and cheerfully overwrite the interview you scheduled last Tuesday. Don't let it.
A UniqueConstraint on (user_id, gmail_message_id) means the same email can never create two records. Idempotent scans, no drama.
The bonus features (the fun stuff)
Auto follow-ups — application sitting in applied for 14+ days? Claude writes you a polite "just checking in" email. You decide whether to send it.
Interview prep — status flips to interview_scheduled? Claude immediately generates 5 likely questions, 4 things to research, 3 talking points, and 1 tip. Stored on the record, shown on the dashboard.
Daily scheduler — three background jobs, staggered 5 minutes apart so each one has fresh data:
CronTrigger(hour=8, minute=0) # scan Gmail
CronTrigger(hour=8, minute=5) # generate follow-ups
CronTrigger(hour=8, minute=10) # generate interview prep
Wake up at 8am. Everything's already done.
Running it
git clone https://github.com/your-repo/job-tracker
cd job-tracker
pip install -r requirements.txt
cp .env.example .env # add your Anthropic + Google keys
python app.py
No Google keys? Set GOOGLE_CLIENT_ID= to blank. The app loads 8 demo applications so you can click around immediately.
Cost: Claude Haiku at ~$0.25/million tokens. Scanning 50 emails costs less than 0.01 cents. You spend more on the tab you accidentally left open.
Why not just ask Claude for JSON?
Three reasons tool use wins:
- One bad email doesn't kill the batch. Tool use is per-item. A JSON array fails all-or-nothing.
- "Skip non-job emails" is natural. Claude just... doesn't call the tool. No null entries, no filtering logic.
-
The enum is enforced at the API level. Claude cannot return
"ghosted"as a status, no matter how accurate that would be.
That's it
Under 300 lines of Python for the core pipeline. Runs on your laptop. Your data stays local. Claude Haiku keeps costs laughably low.
Full write-up with architecture deep-dive → Medium article
Repo → https://github.com/bangadpurva/job-tracker
Go forth and be rejected in an organized fashion. 🚀
Top comments (0)