# I Built a ₹0 AI Job Hunter That Runs on My Own Laptop
### Free public job feeds, free-tier LLMs, a resume “truth guard”, and a browser assistant that never clicks Submit without me
— -
Job hunting as an engineer has a strange shape. The interesting part is short: reading a role, deciding if it fits, talking to people. The rest is a long, repetitive loop. You open twenty career pages, skim fifty descriptions that turn out to need eight years of Kubernetes, rewrite the same resume bullets again, and type your notice period into yet another form.
I build LLM applications for a living, so I did the obvious thing: I automated the loop. But I gave myself three constraints that shaped every decision:
1. **It must cost nothing.** No paid APIs, no cloud hosting, no SaaS subscriptions.
2. **It must run on my own PC.** My resume, my applications and my inbox stay on my machine.
3. **It must never lie or cheat.** No invented experience on a resume, no CAPTCHA solving, no bot evasion, no scraping behind logins.
The result is **AI Job Hunter**, an open-source system that finds jobs, scores them, writes tailored resumes it can’t exaggerate, and helps me apply. This post walks through how it works and what I learned building it.
> 🔗 Code: https://github.com/pushkar-jat/AI-Job-Hunter
*The Today page: new jobs, strong matches, application funnel, and job mail waiting for confirmation. (Screenshots use a demo database with fictional companies.)*
— -
## The architecture in one picture
The stack is deliberately boring:
- **NestJS + TypeScript** for the API and a CLI
- **React + Vite + TanStack Query + Tailwind** for the dashboard
- **SQLite** (`better-sqlite3`, WAL mode) for everything stored
- **Python + python-docx** for parsing and rendering resumes, and **LibreOffice** headless for PDFs
- **Playwright** for the application assistant
- **A pluggable LLM layer** that talks to free tiers (Groq, Gemini, OpenRouter `:free`, Cerebras, or local Ollama) with plain `fetch` and no vendor SDKs
Everything binds to `127.0.0.1`. There is no login screen, because there is no server anyone else can reach.
— -
## Step 1: Finding jobs without scraping anyone
The first rule I set was **public feeds only**. It turns out a large share of tech companies host their careers page on an applicant tracking system (ATS), and most ATSes expose a public, documented JSON feed:
| ATS | Public endpoint |
| — -| — -|
| Greenhouse | `boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true` |
| Lever | `api.lever.co/v0/postings/{slug}?mode=json` |
| Ashby | `api.ashbyhq.com/posting-api/job-board/{slug}` |
| SmartRecruiters | `api.smartrecruiters.com/v1/companies/{slug}/postings` |
| Workable, Recruitee | similar public APIs |
On top of those I added a few key-free remote-job aggregators (Remotive, Jobicy). LinkedIn, Indeed and Naukri are **not** scraped. Their terms don’t allow it, and I didn’t want a tool that only works until it gets blocked.
To find a company’s board, a small CLI reads its public careers page and looks for embedded ATS links:
```bash
npm run companies:detect — https://company.com/careers
# → { “company”: “Company”, “ats”: “greenhouse”, “slug”: “company”, “enabled”: true }
```
**Being a polite client** was a design requirement, not an afterthought:
- at most 1 request per second per host
- exponential backoff on 429 and 5xx errors, honouring `Retry-After`
- a descriptive `User-Agent`
- for SmartRecruiters and Workable, detail pages are fetched **only when a listing is new or changed**
Every fetched job goes through the same normalizer. It strips HTML, maps `Bengaluru` to `Bangalore`, parses salary (`₹25–30 LPA`, `$140k`) into annual numbers, and extracts experience ranges (`5+ years`, `3–6 years`). It also maps skills through an alias file, so `node`, `nodejs` and `node.js` all become `Node.js`. Duplicates are caught twice: by `(source, external_id)`, and by a hash of company, title and application URL, because the same job often appears on two boards.
— -
## Step 2: Scoring jobs, with AI only where it helps
Sending every job to an LLM would burn through free quotas in an afternoon, and most jobs don’t deserve it. So scoring runs in three layers.
**[Image: docs/images/scoring.png]**
**Layer 1: hard filters, no AI.** Does the job need far more experience than I have? Is the known salary below my minimum? Is it remote-but-US-only? If so, it scores 0 and keeps the reason. An unknown salary is never a rejection.
**Layer 2: deterministic component scores.** Skills coverage, experience fit, role-title similarity, location, salary, and **semantic similarity** from embeddings. The embeddings come from `Xenova/all-MiniLM-L6-v2`, run in-process through `@huggingface/transformers`. No GPU, no API, about 300 MB of RAM. The weights live in a JSON config:
```
overall = 0.30·skill + 0.20·experience + 0.15·semantic + 0.15·role + 0.10·location + 0.10·salary
```
**Layer 3: LLM analysis, only for promising jobs.** If the preliminary score is at least 60, the job goes to an LLM, highest scores first, capped by a per-run budget. The model returns strict JSON: a score, matched and missing skills, experience fit, a one-line reason and risks. The final score blends the two: `0.8 × deterministic + 0.2 × LLM`.
**[Image: docs/images/job-detail.png]**
*Every score is explainable: component bars, matched and missing skills, and the AI’s reason.*
### Making free-tier LLMs reliable
Free tiers are generous but fragile. You hit requests-per-minute limits, tokens-per-minute limits, daily caps, and the occasional model that ignores JSON mode. The provider layer handles all of that in one place:
- **Every AI call goes through one function:** `LlmService.complete(task, messages, schema)`. Features never talk to a provider directly.
- **Per-task chains from `.env`:** for example `LLM_ROUTE_MATCH=groq,groq_fast,gemini_lite`. Adding any OpenAI-compatible provider takes a few `.env` lines and no code.
- **Client-side rate limiting** (sliding-window RPM/TPM) plus **daily quotas stored in SQLite**, so a restart doesn’t forget them.
- **Failover:** a 429 puts that provider into a cooldown based on `Retry-After`, and the next provider takes over.
Get Pushkar Jat’s stories in your inbox
Join Medium for free to get updates from this writer.
- **Validation:** output is checked with zod. Invalid JSON gets one repair retry with the error appended, then fails over.
- **Graceful degradation:** if every provider is exhausted, the job keeps its deterministic score and goes to the front of the queue for the next run. With no keys at all, the whole system still works deterministically.
- **PII redaction:** my email, phone and profile URLs become placeholders before anything is sent to a cloud model.
That last point matters more than it looks. A tool that sends your resume to five different AI providers should at least not send your phone number.
— -
## Step 3: Tailored resumes that cannot lie
This is the part I care about most. An LLM asked to “tailor this resume for this job” will happily add Kubernetes to your skills and turn “improved latency” into “improved latency by 47%”. That’s fabrication. A recruiter will find it, and it’s wrong.
So the design makes lying structurally impossible:
1. A Python script parses my master `.docx` into JSON with **stable IDs** for every bullet (`exp1.b3`, `proj2.b1`).
2. The LLM may only **select, reorder and rephrase** existing items, referenced by ID. It cannot create new ones.
3. A **truth guard written in code, not in a prompt** checks the output:
- any ID that doesn’t exist in the master resume is dropped
- company names, titles, dates, education and certifications always come from the master resume
- a rephrased bullet that mentions a technology absent from my resume and profile reverts to the original text
- a rephrased bullet with a number that isn’t in its source bullet reverts to the original text
4. Every intervention is logged and shown in the UI.
The unit tests feed it deliberately fabricated output (fake employers, invented metrics, skills I don’t have) and assert that all of it is stripped. Prompts are suggestions; code is a guarantee.
Rendering is a clean single-column, ATS-friendly DOCX from python-docx, converted to PDF with headless LibreOffice. The cover letter goes through the same guard.
— -
## Step 4: An apply assistant that knows its limits
Auto-apply bots are everywhere, and most of them are the reason career sites have CAPTCHAs. I wanted the opposite: an assistant that saves typing but leaves every decision to me.
The apply step opens a **visible** Chromium window with a persistent profile, so I sign in myself once and the session persists. No passwords are stored. Then it sorts each form field into one of three groups:
- **Auto-fill:** name, email, phone, LinkedIn and GitHub URLs, current location, resume and cover-letter uploads.
- **Draft for approval:** expected salary, notice period, relocation, visa questions, consent checkboxes, and any free-text answer. Drafts come from my profile, my saved standard answers, or an LLM, and the source of each one is shown.
- **Stop and hand over:** CAPTCHA (reCAPTCHA, hCaptcha, Turnstile, Cloudflare checks), OTP or MFA, assessments, video, anything unrecognized. The run pauses, a banner appears in the dashboard, and the browser stays open for me.
`AUTO_SUBMIT=false` is the default, and the final **Submit** click always needs my approval. After I submit, the exact field-to-value payload (no secrets) and a confirmation screenshot are stored, and the application moves to SUBMITTED in a small state machine that logs every transition.
**[Image: docs/images/tracker.png]**
*The tracker: a kanban board where invalid moves are refused and every change is logged.*
— -
## Step 5: Reading my job mail, read-only
Two gaps remained. Most job suggestions arrive by email, and I apply to plenty of jobs outside the tool. The fix was to connect my own Gmail, **strictly read-only**:
- IMAP with a Google **app password**. The mailbox is opened with `EXAMINE` and bodies are fetched with `BODY.PEEK`, so nothing is ever marked read, moved, deleted or sent.
- Only envelopes are read for every message. Bodies are downloaded only for mail that looks job-related.
- OTP, verification and password-reset mail is skipped **by subject, before its body is downloaded**, and never stored. Auto-login links and URLs inside snippets are never stored either.
Rule-based classifiers, plus a small LLM fallback for unknown senders, sort the mail:
- **Job alerts** (LinkedIn, Indeed, Naukri…) are parsed into individual jobs with clean, canonical links. They’re scored like any other job, but they’re **manual-apply only**: I open the link and apply myself.
- **”Thank you for applying”** mail creates or advances an application to SUBMITTED, marked “via email”.
- **Interview, assessment, offer and rejection** mail becomes a *suggestion*. Nothing in my tracker changes until I click to confirm.
**[Image: docs/images/inbox.png]**
*The Inbox: job mail by category, with one-click confirmations grouped per application.*
— -
## What I learned
**1. Deterministic first, AI second.** Hard filters and simple weighted scores handle most of the decision cheaply and explainably. The LLM adds nuance where it’s worth the quota. Because the system works with no AI at all, an outage is an inconvenience, not a failure.
**2. Put guarantees in code.** “Never invent metrics” in a prompt is a hope. A function that compares numbers between the source and the rephrased bullet is a guarantee. The same goes for “never click Submit” and “never store OTPs”.
**3. Free tiers are a resource to manage.** Rate limiters, persisted quotas, cooldowns, failover chains and per-run budgets turned flaky free APIs into something I can run twice a day unattended.
**4. Being polite costs nothing.** One request per second, backoff, `Retry-After`, and fetching details only for changed listings. A twice-daily sync over dozens of companies still finishes in minutes.
**5. Human-in-the-loop is a feature.** The best automation here removes the typing and the triage, and leaves judgement to me: which roles to pursue, what to say, when to submit.
— -
## Try it
Everything runs locally. You need Node.js 20+ and Python 3.11+. A free Groq key and LibreOffice are optional but recommended.
```bash
git clone https://github.com/pushkar-jat/AI-Job-Hunter.git
cd AI-Job-Hunter
npm install && npm run setup
python -m pip install -r py/requirements.txt
npx playwright install chromium
npm run doctor # checks your setup and prints exact fixes
npm run dev # dashboard at http://localhost:5173
```
Fill in your profile, upload your master resume, add a few companies, and press **Sync now**.
If you build something on top of it, or have ideas for making it better, I’d love to hear about it in the comments or on GitHub.
*Happy hunting. 🎯*
— -
**Tags:** Artificial Intelligence · LLM · Job Search · TypeScript · Open Source





