DEV Community

Cover image for Build an AI Job Tracker With Gmail + Claude published: Auto-extracts applications, statuses, and interview dates from your inbox.
Purva Bangad
Purva Bangad

Posted on • Originally published at Medium

Build an AI Job Tracker With Gmail + Claude published: Auto-extracts applications, statuses, and interview dates from your inbox.

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
Enter fullscreen mode Exit fullscreen mode

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'],
    },
}
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. One bad email doesn't kill the batch. Tool use is per-item. A JSON array fails all-or-nothing.
  2. "Skip non-job emails" is natural. Claude just... doesn't call the tool. No null entries, no filtering logic.
  3. 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)