SLOPSHOPPER

job-hunter-guard

Job Hunter project guard: protects .env, data/jobs.db and cv/cv_master.yaml, and runs ruff after every Python edit

newguardstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · job-hunter-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by job-hunter-guard: job-hunter-guard: .env holds API keys and must not be read or copied. Use .env ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Job Hunter Agent

tests

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">

What it does

  1. Collects jobs in parallel from:
  2. LinkedIn and Indeed (through JobSpy)
  3. About 240 company career boards on Greenhouse, Lever, Ashby, SmartRecruiters, Workday and Comeet
  4. The Israeli Tech Map and VC portfolio job boards (Getro)
  5. Remote boards: Remotive, RemoteOK, Himalayas, We Work Remotely, Arbeitnow
  6. Google Jobs through SerpAPI (optional)
  7. Dedupes the same job across boards and skips jobs it has already seen.
  8. Filters by location (Israeli cities, including Hebrew names, or remote jobs open to Israel), role, seniority, required years and posting age.
  9. Pre-scores every job for free with TF-IDF and skill overlap against the CV.
  10. LLM-scores only the top N jobs and returns a structured JobMatch: a score, the reasoning, missing skills and a CV tip.
  11. Saves to SQLite. Re-runs never overwrite your own status and notes.
  12. Shows everything in a Hebrew (RTL) Streamlit dashboard with a jobs table, inline status editing, an application pipeline, skill-gap insights and source health.
  13. Learns from your choices. Jobs you mark interested/applied or not relevant are shown to the LLM as hints when it scores the next ones.
  14. Hands the writing to Claude on your subscription. For a job you pick, it prepares a brief (posting, match analysis, CV, rules) and opens Claude Code on it, or you attach it to a claude.ai chat. Claude writes a tailored CV, a cover letter, interview prep and a list of changes and gaps, and adds nothing that is not in your CV. It runs on your Claude plan, not the API.
  15. Works for more than one person. Anyone on the same PC can add themselves by uploading a CV. They get their own jobs, statuses and settings, and their search terms come from their CV.
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]

Two ways to run the search

  • Fixed pipeline (default). A LangGraph StateGraph with one conditional edge (LLM or no LLM). It is repeatable, cheap and easy to test.
  • Agent loop (--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.

Quick start

Requires Python 3.11+. Developed on Windows.

pip install -r requirements.txt          # requirements-dev.txt adds pytest and the design tools
  1. Open the dashboard with 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.
  2. Optional, to use an LLM: copy .env.example to .env and add your key (for example ANTHROPIC_API_KEY=...). Set the provider and model under llm: in config.yaml.
  3. Click "בואו נמצא את המשרה הבאה" in the sidebar, or run 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.

Usage

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)

Preparing an application with Claude (subscription, not API)

In a job's card, open "הכנת מועמדות עם Claude" and click "הכנת תיק למשרה". This writes data/tailored/<job>/brief.md. From there:

  • "פתיחה ב-Claude Code" (Windows and macOS) opens Claude Code in a new window on the brief. The button removes 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).
  • "הורדת התיק ל-claude.ai" downloads the brief to attach to a claude.ai chat.
  • Inside Claude Code you can also type /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.

Several people on one PC

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.

Daily run on Windows

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/.

Configuration

All settings live in config.yaml:

SectionWhat it controls
profileManual corrections to the skills and seniority extracted from the CV (profile.overrides)
filtersLocation, roles, seniority, max_required_years (0 = junior only; null = no limit)
searchQueries and results_per_query
sourcesWhich collectors run, and their options
matchingllm_top_n (how many jobs go to the LLM), fetch_descriptions, feedback_examples (how many of your marked jobs the LLM sees)
agentAgent-loop goal, budgets, role groups and an optional separate model
notifyDesktop notification for new jobs scoring at or above min_score
llmProvider and model (provider: none turns the LLM off)
users/<id>/config.yamlPer-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.

Project layout

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.

Design choices

  • One broken site never stops a run. Every collector catches its own errors and reports them in a source-health record, which the dashboard shows.
  • The LLM sees only pre-scored jobs. Only the top N go to the model, which keeps runs cheap and fast.
  • Your data is protected. Collecting or re-scoring never touches status or notes.
  • Offline tests. The test suite runs on recorded fixtures and a deterministic fake LLM, so it makes no network or API calls. GitHub Actions runs it on every push and pull request (Ubuntu with Python 3.11 and 3.13, Windows with Python 3.13).

Troubleshooting

  • 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.
  • LinkedIn returns 429: LinkedIn rate-limits fast requests. The run continues with the other sources. Lower search.results_per_query.
  • "another run is in progress": another run holds the lock. If it is stuck, delete data/run.lock (users/<id>/data/run.lock for another person).
  • Claude Code bills the API instead of the subscription: start it from the dashboard button, or run claude from a terminal where ANTHROPIC_API_KEY is not set. /status inside Claude Code shows which login it uses.
  • Theme changes do not show: restart Streamlit.

Roadmap

  • CV Tailoring Agent (Phase 8, planned): a second LangGraph agent that tailors the CV through the API, with a mechanical fact-check loop and a Word template. Until then, the Claude handoff above does this job on your subscription.
  • Direct collectors for AllJobs and Drushim. Today their jobs arrive only when Google Jobs lists them.
  • More ideas, ranked, at the end of docs/IMPLEMENTATION_PLAN.md.
Source 2 files
hooks/register.ts 57 lines
1import type { Register } from 'claude-code'
2
3import { checkBash, checkFile, isPython } from './rules'
4
5export const register: Register = on => {
6  let hasRuff: boolean | undefined // unknown until the first Python edit
7
8  // 1. File guard: .env, the jobs DB, the master CV
9  on('tool.call', { tool: ['Read', 'Edit', 'Write'] }, async ($, e, next) => {
10    const exists = await $.fs.exists(e.file_path).catch(() => true)
11    const verdict = checkFile(e.tool, e.file_path, exists)
12    if (verdict.isBlocked) {
13      return { deny: `${$.plugin.name}: ${verdict.reason}` }
14    }
15
16    const ran = await next(e)
17
18    // 2. After a successful Python edit: ruff check (read-only, never rewrites the file)
19    const isEdit = e.tool === 'Edit' || e.tool === 'Write'
20    if (!isEdit || !isPython(e.file_path) || ran.deny !== undefined || ran.isError || hasRuff === false) {
21      return ran
22    }
23    const lint = await $.process
24      .run(['ruff', 'check', '--output-format', 'concise', '--quiet', e.file_path], { timeoutMs: 20_000 })
25      .catch(() => undefined)
26    if (lint === undefined) {
27      hasRuff = false
28      $.ui.status('ruff not found — pip install ruff')
29      return ran
30    }
31    hasRuff = true
32    const name = e.file_path.split(/[\\/]/).pop()
33    const issues = lint.stdout.trim()
34    if (lint.exitCode === 0 || issues === '') {
35      $.ui.status(undefined)
36      return ran
37    }
38    const count = issues.split('\n').length
39    $.ui.status(`ruff: ${count} issue${count === 1 ? '' : 's'} in ${name}`)
40    return { ...ran, context: [...(ran.context ?? []), `ruff found issues in ${e.file_path} — fix them:\n${issues.slice(0, 4000)}`] }
41  }).catch(($, e, next) =>
42    // hook skipped (failure or re-entry): pure check only, treating the file as existing (fail closed)
43    next.called ? next(e) : checkFile(e.tool, e.file_path, true).isBlocked ? { deny: 'job-hunter-guard: protected file' } : next(e),
44  )
45
46  // 3. Bash guard
47  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
48    const verdict = checkBash(e.command)
49    return verdict.isBlocked ? { deny: `${$.plugin.name}: ${verdict.reason}` } : next(e)
50  }).catch(($, e, next) => {
51    // the hook was skipped (failure or re-entry): judge again with the pure rule, no $ calls
52    if (next.called) return next(e)
53    const verdict = checkBash(e.command)
54    return verdict.isBlocked ? { deny: `job-hunter-guard: ${verdict.reason}` } : next(e)
55  })
56}
57
hooks/rules.ts 65 lines
1// Pure rules, kept apart from the hooks so tests can check them directly.
2
3// .env and .env.local etc. — but not .env.example (meant to be read and edited)
4export const ENV_FILE = /(^|[\\/])\.env(\.(?!example$)[^\\/]+)?$/i
5// the SQLite DB and its WAL/journal siblings
6export const JOBS_DB = /(^|[\\/])data[\\/]jobs\.db(-wal|-shm|-journal)?$/i
7// the tailoring agent's source of truth
8export const MASTER_CV = /(^|[\\/])cv[\\/]cv_master\.ya?ml$/i
9
10const ENV_IN_CMD = /(^|[\s\\/'"=])\.env(?!\.example)(\.[\w-]+)?(?=$|[\s'";|&)])/i
11
12/** Bash commands that would destroy the application history or leak secrets. */
13const BASH_RULES: readonly { pattern: RegExp; reason: string }[] = [
14  {
15    pattern: /\b(rm|del|erase|rd|rmdir|remove-item|unlink|mv|move|move-item)\b[^|;&\n]*jobs\.db/i,
16    reason: 'data/jobs.db holds the application history (statuses, notes). Do not delete or move it.',
17  },
18  {
19    pattern: /(>|\bout-file\b|\bset-content\b)\s*[^|;&\n]*data[\\/]jobs\.db/i,
20    reason: 'Overwriting data/jobs.db would erase the application history.',
21  },
22  {
23    pattern: /\bdrop\s+table\b/i,
24    reason: 'DROP TABLE on the jobs DB is blocked. For schema changes add a migration (see the job-storage skill).',
25  },
26  {
27    pattern: /\bdelete\s+from\s+["'`]?(jobs|runs|tailored_cvs|profile)\b(?![^;]*\bwhere\b)/i,
28    reason: 'DELETE without WHERE would wipe a whole table. Add a WHERE clause or ask the owner.',
29  },
30]
31
32export type Verdict = { isBlocked: false } | { isBlocked: true; reason: string }
33
34export const checkBash = (command: string): Verdict => {
35  for (const rule of BASH_RULES) {
36    if (rule.pattern.test(command)) {
37      return { isBlocked: true, reason: rule.reason }
38    }
39  }
40  const readsEnv = /\b(cat|type|get-content|gc|less|more|head|tail|bat|grep|findstr|sed|awk|cp|copy)\b/i.test(command)
41  if (readsEnv && ENV_IN_CMD.test(command)) {
42    return { isBlocked: true, reason: '.env holds API keys and must not be read or copied. Use .env.example for the variable names.' }
43  }
44  return { isBlocked: false }
45}
46
47export const checkFile = (tool: 'Read' | 'Edit' | 'Write', path: string, exists: boolean): Verdict => {
48  if (ENV_FILE.test(path)) {
49    return { isBlocked: true, reason: '.env holds API keys; it is off-limits to Claude. Edit it yourself, or read .env.example for the variable names.' }
50  }
51  if (tool !== 'Read' && JOBS_DB.test(path)) {
52    return { isBlocked: true, reason: 'data/jobs.db is written only by the app (jobhunter/db.py), never edited directly.' }
53  }
54  // Creating the first draft is allowed (Phase 8 step 1); changing an existing one is the owner's call.
55  if (tool !== 'Read' && MASTER_CV.test(path) && exists) {
56    return {
57      isBlocked: true,
58      reason: 'cv/cv_master.yaml is the owner\'s source of truth for tailoring. Show the proposed change and ask the owner to apply it.',
59    }
60  }
61  return { isBlocked: false }
62}
63
64export const isPython = (path: string): boolean => /\.pyi?$/i.test(path)
65