Job Hunter project band above the prompt: current build phase, last pytest result and last agent run; /phase to set the phase

A local AI agent that collects software, data and AI job postings (in Israel, plus remote jobs open to Israel), scores each one against your CV, and shows the results in a Streamlit dashboard where you track your applications.
Built with LangGraph, LangChain, Pydantic, SQLite and Streamlit. The LLM is optional: without an API key, everything still runs on a free heuristic score.
<img alt="Job Hunter dashboard: KPI cards, the scored jobs table and the run sidebar (Hebrew, RTL)" src="docs/images/dashboard-light.png">
JobMatch: a score, the reasoning, missing skills and a CV tip.status and notes.flowchart LR
CV[CV file] --> P[load_profile]
P --> C[collect<br/>parallel, fault-tolerant]
C --> N[dedupe]
N --> F[rule filter]
F --> PS[pre-score<br/>TF-IDF + skills]
PS -->|top N, LLM on| LS[LLM score]
PS -->|no LLM| DB
LS --> DB[(SQLite)]
DB --> D[Streamlit dashboard]
StateGraph with one conditional edge (LLM or no LLM). It is repeatable, cheap and easy to test.--agent). A ReAct-style loop in which the model writes its own searches with the tools search_jobs, read_posting and save_match, and stops once it has saved N good matches. The guardrails live in code, not in the prompt: step and search budgets, a job must be read before it can be saved, filtered jobs are never shown to the model, and searches must cover every role group.Requires Python 3.11+. Developed on Windows.
pip install -r requirements.txt # requirements-dev.txt adds pytest and the design tools
streamlit run dashboard.py and upload your CV (PDF, DOCX or TXT, in Hebrew or English) in the Profile tab, or put it in cv/ yourself. The newest file is used..env.example to .env and add your key (for example ANTHROPIC_API_KEY=...). Set the provider and model under llm: in config.yaml.python run_agent.py --no-llm for a free first run.A step-by-step guide in Hebrew, for anyone who just wants to use the tool, is in docs/USER_GUIDE.he.md.
python run_agent.py # full run: collect, filter, score, save
python run_agent.py --no-llm # heuristic scores only (free)
python run_agent.py --sources ats,remotive --limit 50 # partial run
python run_agent.py --dry-run # no DB writes; prints the top 15
python run_agent.py --rescore # re-score stored jobs against the current CV
python run_agent.py --check-companies # validate companies.yaml
python run_agent.py --agent --goal 5 # tool-using agent loop
python run_agent.py --user dana # run for a person added in the dashboard
python -m jobhunter.handoff --list # best jobs and their ids
python -m jobhunter.handoff <job_id> --launch # brief for Claude, open Claude Code on it
streamlit run dashboard.py # dashboard
pytest -q # tests (offline, recorded fixtures)
In a job's card, open "הכנת מועמדות עם Claude" and click "הכנת תיק למשרה". This writes data/tailored/<job>/brief.md. From there:
ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN from that window's environment: the dashboard has .env loaded, and when Claude Code finds an API key in its environment it asks to use it instead of your subscription (once approved, it bills the API)./apply <job_id>.Claude saves cv_tailored.md (plus a .docx copy), cover_letter.md, interview_prep.md and changes.md next to the brief, and the dashboard lists them for download. The brief forbids adding any skill, employer, date or number that is not in the CV.
Profile tab → "הוספת אדם נוסף": a name, a CV and, optionally, job titles. This creates users/<id>/ (gitignored) with that person's CV, their own jobs.db and run files, and a config.yaml that holds only what differs from the main one. That file turns on auto_from_cv: the search titles, the role filter and the experience limit come from their CV (jobhunter/personalize.py), because the main config.yaml is tuned for the owner's junior CS search. Without an LLM, the titles typed in the form are used. Pick who is searching at the top of the sidebar. Schedule a daily run for them with scripts\schedule_windows.ps1 -User <id>. The tool-using agent loop stays the owner's.
A full run takes a few minutes. Most of that time is JobSpy, which waits between queries so LinkedIn does not rate-limit it.
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 # every day at 08:30
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 -At 7:15am
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 -RunNow
powershell -ExecutionPolicy Bypass -File scripts\schedule_windows.ps1 -Remove
This registers a Task Scheduler task named JobHunterAgent. If the PC was off at the scheduled time, the run starts when it turns on. Logs go to data/logs/.
All settings live in config.yaml:
| Section | What it controls |
|---|---|
profile | Manual corrections to the skills and seniority extracted from the CV (profile.overrides) |
filters | Location, roles, seniority, max_required_years (0 = junior only; null = no limit) |
search | Queries and results_per_query |
sources | Which collectors run, and their options |
matching | llm_top_n (how many jobs go to the LLM), fetch_descriptions, feedback_examples (how many of your marked jobs the LLM sees) |
agent | Agent-loop goal, budgets, role groups and an optional separate model |
notify | Desktop notification for new jobs scoring at or above min_score |
llm | Provider and model (provider: none turns the LLM off) |
users/<id>/config.yaml | Per-person overrides on top of config.yaml (display_name, auto_from_cv, search.queries) |
To add companies, put their slugs in companies.yaml and run --check-companies. scripts/sync_companies.py pulls new boards from the Israeli Tech Map and VC job boards.
run_agent.py CLI entry point
dashboard.py Streamlit app
config.yaml settings
companies.yaml company career boards
jobhunter/
agent.py LangGraph pipeline
agent_loop/ tool-using agent loop
collectors/ one module per source; each returns list[Job] and never raises
filters.py location / role / seniority / age rules
dedupe.py same posting / same role on several boards
matcher.py pre-score + parallel LLM scoring
prompts.py every LLM prompt in one place (and the Claude brief)
cv.py CV parsing and profile extraction (cached by file hash)
db.py SQLite schema, upserts, run history
handoff.py one job -> brief for Claude Code / claude.ai, Markdown -> Word
users.py people added from the dashboard (users/<id>/)
personalize.py search settings derived from a CV (auto_from_cv)
runlock.py stop.py one run at a time, graceful stop
ui/ dashboard design system (tokens, CSS, components)
scripts/ board checks, company sync, Windows scheduling
tests/ pytest suite with recorded API responses and a fake LLM
docs/ architecture, implementation plan, Hebrew user guide
See docs/ARCHITECTURE.md for the data model, the scoring formula and the design decisions.
status or notes.401 invalid x-api-key: the key is wrong or was revoked. Create a new one.not scoped to a workspace: set llm.anthropic_workspace_id in config.yaml, or create the key inside a workspace.search.results_per_query.data/run.lock (users/<id>/data/run.lock for another person).claude from a terminal where ANTHROPIC_API_KEY is not set. /status inside Claude Code shows which login it uses.docs/IMPLEMENTATION_PLAN.md.hooks/register.tsx 113 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Progress } from '../types'
5import {
6 LAST_RUN_PY, PHASE_FILE, PLAN, isAgentRun, isPytest, parsePlan, parsePytest,
7 readLastRun, readPhaseNumber, shortTime, toPhase,
8} from './parse'
9
10const EMPTY: Progress = { phase: null, tests: null, lastRun: null }
11const progress = atom({ plugin: 'job-hunter-progress', key: 'progress' } as const, EMPTY)
12const isHidden = atom({ plugin: 'job-hunter-progress', key: 'isHidden' } as const, false)
13
14const readText = ($: EngineInterface, path: string) => $.fs.read(path).then(t => (typeof t === 'string' ? t : '')).catch(() => '')
15
16const loadPhase = async ($: EngineInterface) => {
17 const phases = parsePlan(await readText($, PLAN))
18 const number = readPhaseNumber(await readText($, PHASE_FILE)) ?? 0
19 return toPhase(phases, number)
20}
21
22// `python` on Windows, `python3` on Linux/macOS; first one that answers wins.
23const loadLastRun = async ($: EngineInterface) => {
24 for (const python of ['python', 'python3']) {
25 const out = await $.process.run([python, '-c', LAST_RUN_PY], { timeoutMs: 15_000 }).catch(() => undefined)
26 if (out !== undefined && out.exitCode === 0) return readLastRun(out.stdout)
27 }
28 return null
29}
30
31export const register: Register = on => {
32 on('session.start', async ($, e, next) => {
33 await $.command.register({
34 name: 'phase',
35 description: 'Job Hunter: show or set the current build phase (/phase 4)',
36 })
37 const [phase, lastRun] = await Promise.all([loadPhase($), loadLastRun($)])
38 await update($, progress, p => ({ ...p, phase, lastRun }))
39 return next(e)
40 })
41
42 on('command.run', { command: 'phase' }, async ($, e) => {
43 const arg = e.args.trim()
44 if (arg === 'hide' || arg === 'show') {
45 await update($, isHidden, () => arg === 'hide')
46 return { text: arg === 'hide' ? 'Progress band hidden.' : 'Progress band shown.' }
47 }
48 if (arg !== '') {
49 const n = Number(arg)
50 if (!Number.isInteger(n) || n < 0) return { text: 'Usage: /phase <number> | /phase hide | /phase show' }
51 await $.fs.write(PHASE_FILE, JSON.stringify({ phase: n }) + '\n')
52 }
53 const phase = await loadPhase($)
54 await update($, progress, p => ({ ...p, phase }))
55 if (phase === null) return { text: `No phases found in ${PLAN}.` }
56 return { text: `Phase ${phase.number}/${phase.total}${phase.title ? ` — ${phase.title}` : ''}` }
57 })
58
59 // Watch pytest and agent runs that Claude starts.
60 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
61 const ran = await next(e)
62 if (ran.deny !== undefined) return ran
63
64 if (isPytest(e.command)) {
65 const tests = parsePytest(ran.text ?? '')
66 if (tests !== null) {
67 await update($, progress, p => ({ ...p, tests }))
68 if (tests.failed + tests.errors > 0) $.ui.toast(`pytest: ${tests.failed + tests.errors} failing`)
69 }
70 }
71 if (isAgentRun(e.command) && !/--dry-run/.test(e.command)) {
72 const lastRun = await loadLastRun($)
73 await update($, progress, p => ({ ...p, lastRun }))
74 if (lastRun !== null && lastRun.strong > 0) $.ui.toast(`Agent run: ${lastRun.newJobs} new jobs, ${lastRun.strong} strong`)
75 }
76 return ran
77 }).catch(($, e, next) => next(e)) // never get in the way of a shell command
78
79 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80 const p = await read($, progress)
81 const hidden = await read($, isHidden)
82 if (e.props.hasSurvey || hidden || (p.phase === null && p.tests === null && p.lastRun === null)) {
83 return next(e)
84 }
85 const { Box, Text, Button } = $.ui.resolve(e)
86 const t = p.tests
87 const isRed = t !== null && t.failed + t.errors > 0
88
89 return (
90 <Box flexDirection="row" gap={1}>
91 {p.phase !== null && (
92 <Text bold>
93 Phase {p.phase.number}/{p.phase.total}
94 {p.phase.title ? ` · ${p.phase.title}` : ''}
95 </Text>
96 )}
97 {t !== null && (
98 <Text color={isRed ? 'red' : 'green'}>
99 {isRed ? `· tests ${t.failed + t.errors} failing, ${t.passed} passed` : `· tests ${t.passed} passed`}
100 </Text>
101 )}
102 {p.lastRun !== null && (
103 <Text dimColor>
104 · last run {shortTime(p.lastRun.finished)}: {p.lastRun.newJobs} new, {p.lastRun.strong} strong
105 {p.lastRun.failedSources > 0 ? `, ${p.lastRun.failedSources} sources failed` : ''}
106 </Text>
107 )}
108 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
109 </Box>
110 )
111 })
112}
113hooks/parse.ts 85 lines1// Pure parsing, kept apart from the hooks so tests can check it directly.
2import type { LastRun, Phase, Tests } from '../types'
3
4export const PLAN = 'docs/IMPLEMENTATION_PLAN.md'
5export const PHASE_FILE = '.claude/phase.json'
6
7/** "## Phase 4 — Filtering and matching" → { 4: "Filtering and matching" } */
8export const parsePlan = (markdown: string): Map<number, string> => {
9 const phases = new Map<number, string>()
10 for (const m of markdown.matchAll(/^##\s+Phase\s+(\d+)\s*[—–-]\s*(.+?)\s*$/gm)) {
11 phases.set(Number(m[1]), m[2] ?? '')
12 }
13 return phases
14}
15
16export const toPhase = (phases: Map<number, string>, number: number): Phase | null => {
17 if (phases.size === 0) return null
18 const total = Math.max(...phases.keys())
19 return { number, total, title: phases.get(number) ?? '' }
20}
21
22export const readPhaseNumber = (json: string): number | null => {
23 try {
24 const n = Number((JSON.parse(json) as { phase?: unknown }).phase)
25 return Number.isInteger(n) && n >= 0 ? n : null
26 } catch {
27 return null
28 }
29}
30
31/** Reads pytest's summary line: "23 passed, 1 failed, 2 errors in 0.41s" (any order). */
32export const parsePytest = (output: string): Tests | null => {
33 const lines = output.split('\n').filter(l => /\b(passed|failed|error|errors|no tests ran)\b/.test(l))
34 const summary = lines.at(-1)
35 if (summary === undefined) return null
36 const n = (word: RegExp) => Number(summary.match(word)?.[1] ?? 0)
37 return { passed: n(/(\d+) passed/), failed: n(/(\d+) failed/), errors: n(/(\d+) errors?\b/) }
38}
39
40export const isPytest = (command: string) => /\bpytest\b/.test(command)
41export const isAgentRun = (command: string) => /\brun_agent\.py\b/.test(command)
42
43/** Python run on the host to read the last finished run from data/jobs.db; prints JSON or "null". */
44export const LAST_RUN_PY = `
45import json, os, sqlite3
46p = os.path.join("data", "jobs.db")
47if not os.path.exists(p):
48 print("null"); raise SystemExit
49c = sqlite3.connect(p)
50try:
51 r = c.execute("SELECT id, started, finished, stats FROM runs WHERE finished IS NOT NULL ORDER BY id DESC LIMIT 1").fetchone()
52 if r is None:
53 print("null"); raise SystemExit
54 stats = json.loads(r[3] or "{}")
55 strong = c.execute("SELECT COUNT(*) FROM jobs WHERE score >= 75 AND first_seen >= ?", (r[1],)).fetchone()[0]
56 failed = c.execute("SELECT COUNT(*) FROM source_health WHERE run_id = ? AND error IS NOT NULL AND error != ''", (r[0],)).fetchone()[0]
57 print(json.dumps({"finished": r[2], "newJobs": int(stats.get("new", 0)), "strong": strong, "failedSources": failed}))
58except sqlite3.Error:
59 print("null")
60`
61
62export const readLastRun = (stdout: string): LastRun | null => {
63 try {
64 const v = JSON.parse(stdout.trim()) as LastRun | null
65 return v && typeof v.newJobs === 'number' ? v : null
66 } catch {
67 return null
68 }
69}
70
71/** "2026-10-13T06:31:02+00:00" → "13/10 09:31" in Israel time */
72export const shortTime = (iso: string): string => {
73 const d = new Date(iso)
74 if (Number.isNaN(d.getTime())) return iso
75 try {
76 const parts = new Intl.DateTimeFormat('en-GB', {
77 timeZone: 'Asia/Jerusalem', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
78 }).formatToParts(d)
79 const get = (t: string) => parts.find(p => p.type === t)?.value ?? ''
80 return `${get('day')}/${get('month')} ${get('hour')}:${get('minute')}`
81 } catch {
82 return iso.slice(5, 16).replace('T', ' ') // no Intl/time zone data: "10-13 06:31" UTC
83 }
84}
85types/index.d.ts 16 lines1export type Phase = { number: number; total: number; title: string }
2export type Tests = { passed: number; failed: number; errors: number }
3export type LastRun = { finished: string; newJobs: number; strong: number; failedSources: number }
4
5export type Progress = {
6 phase: Phase | null
7 tests: Tests | null
8 lastRun: LastRun | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'job-hunter-progress': { progress: Progress; isHidden: boolean }
14 }
15}
16