Aksh Goyal

Job Search Assistant

A human-in-the-loop AI job search assistant: ranks postings and drafts tailored CVs/cover letters. You apply manually.

pythonfastapisqlitellmgemini-apiclichrome-extensionjob-search

AkshGoyal/Agent_JobApplications

Problem

Applying to jobs involves the same manual work over and over: reading a posting, deciding whether it’s worth your time, and rewriting your CV and cover letter to fit it. The project spec frames this directly as a personal tool — discover relevant job openings based on my profile and targets, rank them, and generate tailored application materials, then apply manually. It’s built for one user managing their own job search, not as a multi-tenant product.

What I built

I wrote the product spec and directed Claude Code to build it, reviewing and testing everything along the way — consistent with the working agreements recorded in CLAUDE.md (plan-mode-first, my review before code lands).

In plain terms: a tool that takes a job posting — pasted in, captured with a one-click Chrome extension, or picked up automatically from LinkedIn job-alert emails — and uses an LLM to pull out the structured details (company, title, location) and score how well it matches your background. For postings you’re interested in, it can draft CV bullet suggestions, a cover letter, and answers to application-form questions, all grounded strictly in a profile you maintain (profile/facts.yaml, narratives.md, canned_answers.yaml, your CV). A human reviews and sends everything manually — the tool never submits an application or sends an email itself.

Feature spotlight — the Opportunities digest

Separate from the job-tracking pipeline entirely, opportunities scan makes one web-search-grounded LLM call (Gemini’s own search tool plus structured output) to surface things a job tracker wouldn’t otherwise catch: newly launched or funded AI startups, notable AI developments, concrete learning gaps, and entrepreneurship angles — all filtered through the same profile, with every item required to be grounded in a real search result, never invented. It’s deduped against prior scans, runs on demand from the CLI or web UI, and also runs automatically every week via a scheduled GitHub Action (opportunity-scan.yml) that posts the digest as a GitHub Issue — a standing market-intelligence habit, not just a one-off query.

Architecture

Data flows one way, and the model is only called at the two points where judgment is actually required. Everything else is ordinary code I can test.

  1. inputA posting arrivesPasted in, captured by the Chrome extension, or read out of a LinkedIn alert email over read-only IMAP. Three sources, one entry point.
  2. codeNormalize, dedupe, storePlain Python and SQLite. All three sources share one storage path, so a posting seen twice is recognised however it arrived.
  3. modelRank against my profileScores relevance and writes a short rationale, grounded only in profile/.
  4. modelDraft materialsCV bullet suggestions, a cover letter, answers to form questions — each constrained by a schema built from my real CV.
  5. outputI review and applyEvery output is a draft. Nothing is submitted, and no email is ever sent.
The CLI and the local web UI are two interfaces over these same functions — neither has logic the other lacks.

The knowledge base in profile/ is the only source of truth the model may draw on, llm.py is the single wrapper every model call goes through, and db/ holds the schema and migrations.

Hard parts and decisions

  • LLM provider migration, contained by design (bd8ee40). The app switched from Anthropic to Google Gemini. Because every LLM call goes through one wrapper (llm.py), the change touched only llm.py, config.py, requirements.txt and the test double — the pipelines, CLI and prompt templates were untouched. A same-day follow-up (bbd5684) hot-fixed a 404 after gemini-2.5-flash was retired for new API keys, caught because the scheduled live-run GitHub Action calls the real API (tests mock the LLM client, so this class of failure can only show up there).
  • Retries weren’t free (04fd800). Unlike the Anthropic SDK, the google-genai SDK makes exactly one attempt per call and raises immediately on a 429 or a transient 503. The fix was configuring HttpRetryOptions explicitly on client construction, with a unit test that asserts the option is set (no live network call) so the behaviour can’t silently regress again.
  • Grounding enforced at the schema level, not just by prompting. In pipeline/answer.py, when the model claims a pre-approved “canned” answer applies to a question, the code doesn’t trust that claim — it independently re-looks-up the field in canned_answers.yaml and overrides to needs_input if the field is actually blank, with a test covering the case where the model hallucinates an answer for a blank field. In pipeline/tailor.py, CV bullet suggestions are constrained to Literal[...] types for entry_id/section, built dynamically from cv_canonical_structure.yaml at call time — so a fabricated CV entry is schema-invalid before it could ever be returned, rather than merely checked after the fact.
  • Explicit boundary enforcement on the Gmail source (a025a45). The Gmail-alert ingestion opens the mailbox strictly read-only over IMAP, never sends/marks/moves/deletes anything, never fetches linkedin.com itself, and stores job URLs from the emails as inert text labels rather than following them — several of the project’s stated non-goals (no LinkedIn scraping, no email sending) enforced directly in code.
  • A late refactor unified three ingestion paths (43f99e5). The normalize → dedup → store logic was pulled out of the manual-paste-specific function into a shared store_posting(), so manual paste, the Chrome extension and Gmail ingestion all go through one storage path instead of three separately-maintained ones.
  • Smaller in-flight fixes are visible too — e.g. 2132425 fixed invalid YAML in canned_answers.yaml that had been committed.

The offline test suite (101 test functions across 18 files) mocks the LLM client throughout, so it runs in CI on every push with no API key and no live model calls.

Outcome

I use this in my own job search. It isn’t a point-in-time build — the parts I reach for most are the ranking pass, which keeps me from spending an evening on a posting I’d have dropped after reading it properly, and the answer drafting, which turns the same repeated form questions into something I edit rather than write from scratch.

What I’d do next

The spec has an explicitly deferred backlog: an HR contact finder, outreach-email drafting behind a human-approved outbox, and alumni-referral triage. It also notes a planned but unbuilt feature — detecting application confirmations and rejections in email as suggested status changes only, never automatic ones.

Separately, the profile files (facts.yaml, canned_answers.yaml) still contain unfilled placeholders. Output quality for tailoring and answering depends on that profile being complete, which is a practical next step independent of any new feature.