Job Hunter Agent: Implementation
How Job Hunter Agent was built in two days and grown from real use: edit-based tailoring, deterministic checks, the MCP integration and the debt I know about.
The first version of resume tailoring asked the model to rewrite my 28,000-character resume. It timed out at 180 seconds, and when it did finish it had quietly “improved” a few facts. The rewrite into line edits that code applies is the best summary of how this project was implemented: let the model suggest, let deterministic code decide.
Sprint or iteration planning
No sprints. Two build days, then iterations of a few hours, each started by a concrete annoyance from using the app (see Prioritization approach). Each iteration ended with the change deployed and checked in the live app, so there was never a pile of unreleased work. The phase tables in Product roadmap are the record.
Feature development
The features where the implementation choice mattered most:
| Feature | Implementation choice |
|---|---|
| Resume tailoring | The model returns ResumeEdits (original line, revised line, reason); apply_edits applies only edits whose original is a whole line or a unique substring of one. Anything else is skipped, so nothing new can be added |
| Truthfulness checks | Pure functions: numbers in the output not in the original; changes that don’t quote the original; posting terms (tools, capitalised phrases with their acronym) the output adds that the resume lacks |
| Shortlist confidence | The same ATS scorer re-scores the tailored resume; an independent check rather than the optimizer grading itself |
| Job evaluation on demand | Scores and drafts whatever the score; replaces drafts and contacts on the same application; refuses approved or applied ones |
| Per-profile schedules | One APScheduler job per enabled profile, re-synced on every profile change; a lock serializes runs |
| Company facts | Parsed from LinkedIn’s About page by label, Wikidata matched by website, Google News RSS; refreshed after a week |
1
2
3
4
5
6
target = _norm(_PREFIX.sub("", change.original))
matches = [i for i, line in enumerate(lines) if _norm(_PREFIX.sub("", line)) == target]
if len(matches) == 1:
i = matches[0]
prefix = _PREFIX.match(lines[i]).group(1) # keep the bullet, swap the text
lines[i] = prefix + _PREFIX.sub("", change.revised.strip())
API and UI implementation
The API is one router module with thin handlers: validate, call the pipeline, wrap long work in track(...) so it appears in the run history. The UI is three static files. Two patterns carried most of it:
- An
el(tag, attrs, ...children)helper that builds DOM safely, so a posting’s text can never become markup. - Render functions per section (
skillsView,confidenceView,companyView,profileRow) that take data and return elements; a reload is just calling them again.
Database and infrastructure implementation
One Repository protocol (insert, update, get, list, delete) with two implementations: an in-memory dict for development and tests, and Supabase through supabase-py wrapped in asyncio.to_thread. The same tests run against both. Infrastructure is declarative in homelab-gitops: the Deployment with its sidecar and init container, a PVC, the Ingress, a NetworkPolicy and an ExternalSecret, all described in Hosting.
Integration development
The integrations each needed one hard-won fix:
- MCP 2.x: the client yields a 2-tuple and results use
is_error/structured_content, unlike most examples. Wrapped once inLinkedInClient. - LinkedIn job pages: the model sometimes misread the hiring company, so the header is parsed by position and the model is only a fallback. Company tools need the slug from the page’s references, not the display name.
- LinkedIn in Kubernetes: the sign-in needed an explicit
--chrome-path, a home folder owned by the browser’s user on the volume, and a login run withkubectl execinside the running container (a separate login pod fought Argo CD’s sync). - Wikidata: HTTP 403 until the User-Agent identified the tool and a contact URL.
Technical debt tracking
Known debt, kept visible here rather than in a tracker:
| Debt | Cost of leaving it |
|---|---|
| Traces aren’t exported in production (no OTLP endpoint set) | Slow runs are hard to break down |
| No pacing between LinkedIn requests inside a run | A burst of page loads per profile |
accept_warnings isn’t stored on the application | Can’t tell later which approvals overrode flags |
Re-scoring adds a second job_scores row; reads take the newest | Harmless now, confusing later |
Single default user despite per-row user_id | Multi-user needs auth-to-RLS wiring |
| UI has no automated tests | Regressions caught by eye |
Code reviews
Every diff was read before it was pushed, and three things got extra care: anything in truthfulness.py, anything touching auth, and every SQL migration. The scanners in the build pipeline (Semgrep, Bandit, Gitleaks, Trivy) are the second reviewer. A review rule I’d keep: a change to the truthfulness checks needs a test built from a real draft that motivated it (the BYOK and “Mentions Own” cases are both tests now).
Developer documentation
README.md: what it does, quick start, API table, configuration, sign-in, LinkedIn setup.docs/architecture.md,docs/testing.md,docs/plan.md,docs/evaluation.md.- Module docstrings that explain contracts, for example why
apply_editscan’t add lines. - This playbook for the decisions and the reasons.
Checklist
- Every feature in the table has a test that pins its rule
- Debt table reviewed when a phase starts
- OTLP endpoint configured so traces leave the pod
- Pacing between LinkedIn requests added
accept_warningsstored with the approvalREADME and docs updated in the same commit as the code
