Job Search Assistant
A human-in-the-loop AI job search assistant: ranks postings and drafts tailored CVs/cover letters. You apply manually.
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.
- 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.
- codeNormalize, dedupe, storePlain Python and SQLite. All three sources share one storage path, so a posting seen twice is recognised however it arrived.
- modelRank against my profileScores relevance and writes a short rationale, grounded only in profile/.
- modelDraft materialsCV bullet suggestions, a cover letter, answers to form questions — each constrained by a schema built from my real CV.
- outputI review and applyEvery output is a draft. Nothing is submitted, and no email is ever sent.
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 onlyllm.py,config.py,requirements.txtand the test double — the pipelines, CLI and prompt templates were untouched. A same-day follow-up (bbd5684) hot-fixed a 404 aftergemini-2.5-flashwas 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, thegoogle-genaiSDK makes exactly one attempt per call and raises immediately on a 429 or a transient 503. The fix was configuringHttpRetryOptionsexplicitly 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 incanned_answers.yamland overrides toneeds_inputif the field is actually blank, with a test covering the case where the model hallucinates an answer for a blank field. Inpipeline/tailor.py, CV bullet suggestions are constrained toLiteral[...]types forentry_id/section, built dynamically fromcv_canonical_structure.yamlat 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 sharedstore_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.
2132425fixed invalid YAML incanned_answers.yamlthat 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.