Skip to main content

vacancy-radar

PyPI Python versions Coverage

A local-first job application tracker. A markdown vault is the source of truth; a derived LanceDB index provides hybrid BM25 + vector search; an MCP server exposes read/write tools to any LLM client.

Why

A job search scatters itself across places that don't talk to each other: the posting in a browser tab, the recruiter's reply in email, the take-home in a downloads folder, and a spreadsheet holding the one thing you thought to write down. The spreadsheet tracks status well enough. It cannot answer "what did I actually tell this company in March?", because the words never went in it.

vacancy-radar keeps the words. Every vacancy is a directory of plain markdown — postings, emails, notes, take-homes — with typed frontmatter for the things worth querying, and a search index derived from all of it. Three properties follow from that, and they are the point:

  • It's yours, and it's readable. The vault is markdown files in a directory you chose. Grep it, edit it in any editor, put it in git, back it up like anything else. The index is derived and disposable — delete it and rebuild it any time.
  • It doesn't lose things. Every write is append-only, a validated status transition, or a validated field edit that records what it changed. There is no delete command anywhere in the CLI, and no tool that discards what you already recorded.
  • An LLM can use it directly. The MCP server exposes the same operations the CLI has, so you can ask your assistant to find the thread with a company, file a recruiter's email, or move a vacancy to interview — and it reads and writes the same markdown you do.

That last one is also how you get an existing tracker in: conversational import via find_existing/create_vacancy (see the import guide), not an xlsx/CSV importer. This project ships no spreadsheet parser and no interchange format.

Quick start

1. Install

You need uv. Nothing else — no clone, no uv.lock.

macOS / Linux:

curl -LsSf https://astral.sh/uv/install.sh | sh   # if you don't have uv
uv tool install vacancy-radar

Windows (PowerShell):

winget install --id=astral-sh.uv -e              # if you don't have uv
uv tool install vacancy-radar

That puts both vacancy-radar and vacancy-radar-mcp on your PATH. To try it without installing anything permanently, uvx vacancy-radar --version works too.

On Windows, if the commands aren't found afterward, uv installed them to %USERPROFILE%\.local\bin without that being on your PATH. Run uv tool update-shell and open a new terminal.

Expect the first run to be slow. The dependency set is large (lancedb and fastembed are hundreds of megabytes resolved), and the first search downloads an embedding model on top of that. Both are cached afterward — a slow first run is the download, not a hang.

2. Create a vault

macOS / Linux:

vacancy-radar init ~/job-search
export VAULT_PATH=~/job-search

Windows (PowerShell):

vacancy-radar init $HOME\job-search
$env:VAULT_PATH = "$HOME\job-search"          # this session only
setx VAULT_PATH "$HOME\job-search"            # persist for new sessions

setx does not affect the session you run it in, so set both if you want to keep using the current terminal.

init is idempotent — running it again against the same path is a no-op, not an error — and it never touches anything already in a directory it adopts, so pointing it at an existing folder is safe.

Every command expects a vault at VAULT_PATH, or an explicit path where the command takes one.

3. Connect your MCP client

For Claude Desktop, add this to claude_desktop_config.json — on macOS ~/Library/Application Support/Claude/, on Windows %APPDATA%\Claude\:

{
  "mcpServers": {
    "vacancy-radar": {
      "command": "vacancy-radar-mcp",
      "args": ["--vault", "/absolute/path/to/job-search"]
    }
  }
}

The vault path must be absolute. On Windows, escape the backslashes — JSON treats a lone \ as an escape character, so C:\Users\you\job-search has to be written "C:\\Users\\you\\job-search" (forward slashes work too, and are harder to get wrong).

For Claude Code, register it without editing JSON by hand:

claude mcp add vacancy-radar -- \
    vacancy-radar-mcp --vault /absolute/path/to/job-search

On Windows, drop the \ line continuation and put it on one line.

Both assume uv tool install from step 1 put vacancy-radar-mcp on PATH. If you would rather not install it persistently, use uvx as the command instead — but note it needs --from, because uvx resolves a distribution named after the command it is given, and the script here is vacancy-radar-mcp while the distribution is vacancy-radar:

{
  "mcpServers": {
    "vacancy-radar": {
      "command": "uvx",
      "args": [
        "--from", "vacancy-radar",
        "vacancy-radar-mcp",
        "--vault", "/absolute/path/to/job-search"
      ]
    }
  }
}

A bare uvx vacancy-radar-mcp looks for a distribution that does not exist.

4. Start talking to it

Restart the client, and the twelve tools are available. Things worth saying first:

I applied to Acme Corp for a Backend Engineer role today, found on their careers page. Add it.

Here's the recruiter's reply (paste it) — file it under the Acme application.

What's gone quiet? Show me anything I applied to more than three weeks ago with no reply.

I have an interview with Acme on Thursday. Prep me from what's in the vault.

The model calls find_existing before creating anything, so telling it about a vacancy twice does not produce two of them. Everything it writes lands as markdown in your vault, immediately searchable and readable without any of this tooling.

Prefer the terminal? The CLI reference below covers the same operations.

How CLI and MCP fit together

Two front doors, one back end: a human runs the CLI, an LLM client calls MCP tools, and both go through the same core.py functions onto the same markdown vault and LanceDB index — neither interface has any capability, or any data, the other doesn't see.

flowchart TD
    subgraph CLI["Human — terminal (vacancy-radar CLI)"]
        direction TB
        C1["init\ncreate the vault"]
        C2["new\ncreate a vacancy"]
        C3["add / note\nattach documents & notes"]
        C4["status set\nmove it through the pipeline"]
        C5["edit\ncorrect a field"]
        C6["list / show / search\nread it back"]
        C1 --> C2 --> C3 --> C4 --> C5 --> C6
    end

    subgraph MCP["LLM client — MCP tools (vacancy-radar-mcp)"]
        direction TB
        M1["find_existing\ncheck before creating"]
        M2["create_vacancy\n(conversational import)"]
        M3["add_document / append_note"]
        M4["set_status"]
        M5["edit_vacancy"]
        M6["list_vacancies / get_vacancy / search"]
        M1 --> M2 --> M3 --> M4 --> M5 --> M6
    end

    C6 --> Vault[("Markdown vault\n+ LanceDB index")]
    M6 --> Vault

init and reindex are CLI-only (see their sections below for why); everything else on both sides is the same underlying operation, exposed twice. The CLI trusts the human typing the command; the MCP tools add the extra checks a model calling them unsupervised needs — create_vacancy never deduplicates on its own (call find_existing first), and every typed error comes back as something the model reads and can act on in the same turn, not a crash.

CLI reference

Examples below are written for an installed tool. Working from a clone, prefix each one with uv run.

Reading

list, show, search, and doctor are read-only: they never write to the vault or the index. Every command prints a Rich table for humans by default; add --json for a machine-readable, stable, documented shape suitable for piping.

# List vacancies, optionally filtered (--status, --company, --since,
# --needs-review, --stale-days), sorted by last activity.
vacancy-radar list --status applied --status interview

# Show one vacancy or company - which kind it is is auto-detected.
vacancy-radar show acme-corp

# Search the vault's index (see "Retrieval evaluation" below);
# --scores prints each hit's keyword/semantic rank and fused score.
vacancy-radar search "backend engineer" --scores

# Report vault and index health: marker, index freshness, embedding
# model availability, and every warning collected while reading.
vacancy-radar doctor

Writing

note, status set, edit, add, new, and reindex write to the vault. Every write is append-only, a validated status transition, or a validated field edit that records what it changed in the vacancy's own edit history - nothing here deletes existing content, and there is no delete command anywhere in the CLI. note, status set, edit, and add accept a vacancy by id, company slug, company name, or unambiguous fuzzy match - a company argument resolves to that company's vacancy when it has exactly one. Anything that can't be narrowed to a single vacancy - a name matching two companies, or a company holding two vacancies - prints what it matched and exits non-zero without writing anything. --json follows the same stable, documented-shape convention as the read commands above.

# A vacancy argument is an id, a company name/slug (when that company
# has exactly one vacancy), or an unambiguous fuzzy match. A company
# with several vacancies needs the vacancy id - the error names every
# candidate id, so the fix is to paste one of them.

# Append a dated note. The filename is always derived from the date -
# never something you (or an LLM) supply.
vacancy-radar note 2022-03-22-acme-corp-backend-engineer \
    "Called the recruiter, no news yet."

# Move a vacancy's status. Backward transitions need --force; the first
# move off 'found' needs --applied-date.
vacancy-radar status set 2022-03-22-acme-corp-backend-engineer \
    applied --applied-date 2026-01-15
vacancy-radar status set 2022-03-22-acme-corp-backend-engineer \
    found --force

# Correct a vacancy's stored fields - a posting URL that moved, a role
# captured as "unknown", a mistyped date. One option per editable
# field; several can change in one call. Every applied change is
# appended to the vacancy's "## Edit History" block.
vacancy-radar edit 2022-03-22-acme-corp-backend-engineer \
    --source-url https://acme.example/careers/backend \
    --role "Senior Backend Engineer"

# Remove a field with --clear (repeatable). Only the nullable fields can
# be cleared: source-url, stage-note, salary-note, recruiter,
# external-id, applied-date.
vacancy-radar edit 2022-03-22-acme-corp-backend-engineer \
    --clear recruiter --clear salary-note

# --tags replaces the whole list rather than appending to it; --tags ""
# empties it. Status is not editable here - `status set` owns it, so a
# transition is always validated against the status graph. A vacancy's
# company and id cannot be changed at all.
vacancy-radar edit 2022-03-22-acme-corp-backend-engineer --tags "remote,python"

# File a *text* document (posting, email, note, or takehome) from a
# file or stdin. --external-id makes re-filing the same source (e.g.
# the same email thread) a no-op instead of a duplicate. `--file`/
# `--stdin` read UTF-8 text - binary content (a PDF) fails with a
# clear error naming the alternative below, not a crash.
vacancy-radar add 2022-03-22-acme-corp-backend-engineer \
    --kind email --title "Recruiter reply" --date 2026-01-16 \
    --external-id gmail-thread-42 --stdin < email.txt

# Create a new vacancy (and its company directory, if new). --url is
# optional - omit it when the source has no link at all (e.g. a
# recruiter sent only a PDF attachment).
vacancy-radar new --company "Acme Corp" --role "Backend Engineer" \
    --url https://jobs.acme.example/1 --found-date 2026-01-10

# Rebuild the index from the vault's current state. Not required for
# ordinary use - every write above already reindexes its own effect
# eagerly, before returning, so it's searchable immediately.
vacancy-radar reindex

# Extract text from any PDF under documents/ into a git-tracked
# <filename>.pdf.md sidecar, so it's searchable by content, not only
# by filename. Off by default - a build never silently creates sidecar
# files; pass this explicitly whenever a PDF was added or changed.
vacancy-radar reindex --extract-sidecars

Attaching a PDF

There is no CLI command for filing a PDF the way add files text - copy it directly into the vacancy's documents/ directory, then reindex with extraction:

macOS / Linux:

cp ~/Downloads/posting.pdf \
    ~/job-search/acme-corp/2022-03-22-acme-corp-backend-engineer/documents/
vacancy-radar reindex --extract-sidecars

Windows (PowerShell):

Copy-Item $HOME\Downloads\posting.pdf `
    $HOME\job-search\acme-corp\2022-03-22-acme-corp-backend-engineer\documents\
vacancy-radar reindex --extract-sidecars

This registers the PDF as a document (kind: takehome, title set to the filename, date set to the vacancy's found_date - none independently settable this way; a posting or interview-prep PDF is filed the same way, just under that kind) and writes a <filename>.pdf.md sidecar containing its extracted text, so it's searchable by content afterward. An encrypted or unparseable PDF fails extraction without crashing the reindex - a warning naming the file prints in reindex's output (and appears under --json's warnings field too).

MCP server reference

vacancy-radar-mcp exposes the same core.py functions the CLI calls, as MCP tools/resources/prompts over stdio, for any MCP client (Claude Desktop, etc.). Quick start covers wiring it into a client.

Vault path: --vault if given, else the VAULT_PATH environment variable; neither present fails startup naming both. MCP's client- supplied-roots capability is deliberately not used here - it is deprecated as of the protocol revision the MCP Python SDK speaks by default (2026-07-28, SEP-2577), so --vault/VAULT_PATH are the only two ways to point the server at a vault, exactly like configuring any other stdio MCP server.

Twelve tools, matching core.py's functions one for one - argument marshalling only, no filtering/sorting/business logic of its own:

tool delegates to
list_vacancies core.list_vacancies
get_company core.get_company
get_vacancy core.get_vacancy
search core.search
read_document core.read_document
resolve core.resolve
find_existing core.find_existing
append_note core.append_note
set_status core.set_status
edit_vacancy core.edit_vacancy
add_document core.add_document
create_vacancy core.create_vacancy

reindex is deliberately not exposed - every write tool already refreshes the index itself, and there's no legitimate reason for a client-connected model to trigger a full rebuild. create_vacancy is exposed, for conversational import (see the import guide): it does not deduplicate, so its own tool description tells a calling model to call find_existing first and only create when that returns no match. search's description tells the model to prefer mode="keyword" for exact-name/proper-noun queries and leave mode unset (hybrid) otherwise, citing the measured numbers in "Retrieval evaluation" below - without that guidance a model defaults to hybrid for everything and the per-category tuning goes unused. A typed core.py error (an ambiguous match, an invalid status transition, a nonexistent vacancy) surfaces as a structured tool result the calling model can read and act on, never a raw crash.

Two resources: vacancy-radar://companies (every company's slug, display name, and vacancy count) and vacancy-radar://statuses (every status and the transitions permitted from it, read live from models.py).

Three prompts: prep_interview(company) (dossier, postings, prior notes, take-homes), draft_reply(vacancy_id) (prior correspondence, to match tone), and weekly_review() (stale applications, pending take-homes, vacancies tagged needs_review). An unresolved or ambiguous company/vacancy_id returns a message saying so - never a fabricated dossier.

Working on the code

From a clone:

uv sync --all-groups

Every CLI example above then needs a uv run prefix — uv run vacancy-radar list. The MCP server runs the same way:

uv run vacancy-radar-mcp --vault /path/to/vault

Tests and lint:

uv run pytest
uv run ruff check .
uv run ruff format --check .

CI runs these on Linux only. The code is written to be platform-independent — pathlib throughout, every text read and write pinned to UTF-8 rather than the platform default — but macOS and Windows are not covered by automated tests, so please report anything platform-specific you hit.

Install the git hooks once per clone, so lint/format issues are caught before you commit (the same checks CI runs):

uv run pre-commit install

Run them on demand against all files with uv run pre-commit run --all-files.

Releases are cut by pushing a vX.Y.Z tag; see docs/releasing.md for the procedure and the one-time publishing setup.

Retrieval evaluation

Measured with eval/'s harness: 30 labelled queries (eval/queries.yaml, across seven categories) run against the committed fixture vault (tests/fixtures/vault/), scored with recall_at_k/mrr over each mode's top 5 results. Reproduce it from a clone with uv run vacancy-radar eval.

Overall, by mode:

mode vector_weight fusion recall@5 MRR
keyword 0.761 0.675
semantic 0.678 0.583
hybrid 0.5 rrf 0.672 0.541
hybrid (shipped default) 0.5 linear_combination 0.794 0.687

Per category, keyword vs. semantic vs. the shipped hybrid default (recall@5):

category keyword semantic hybrid (0.5, linear_combination)
exact_name 1.000 0.800 1.000
technical 0.800 0.800 1.000
paraphrase 0.250 0.750 0.250
temporal 1.000 0.250 1.000
multi_result 0.417 0.250 0.417
filtered 0.792 0.833 0.792
negative 1.000 1.000 1.000
overall 0.761 0.678 0.794

Keyword dominates exact_name (company/recruiter proper nouns) exactly as the retrieval design predicted, and semantic strictly dominates paraphrase (queries sharing no content word with their target) — the one category keyword structurally cannot serve at all. Both single modes are weak on multi_result and one of them is weak on temporal, which top-5 recall@5 punishes when several correct documents compete for five slots. The fusion strategy default did not survive contact with measurement: RRF (the original default) underperforms plain keyword search on this corpus — 0.672 vs. 0.761 overall recall@5, identical across every vector_weight since RRF is rank-based and weight-agnostic — while Linear Combination fusion at vector_weight 0.3–0.6 ties for the best result on every metric checked (0.3–0.6 score identically; 0.7 ties on recall@5 but drops on MRR). default_vector_weight is set to 0.5, the middle of that tied band, and fusion_strategy to LINEAR_COMBINATION — both a reversal of the original guess, not a confirmation of it. See config.py's SearchConfig docstring for the tuning rationale behind the shipped values.

Limitations. These numbers characterize the retrieval design against a synthetic, generator-produced fixture vault — not a real corpus of job-search correspondence. tests/fixtures/generate.py's proper nouns, phrasing patterns, and document lengths are artifacts of the generator, not a sample of real vacancies, emails, or notes. Treat this table as justification for a default, not a claim that generalizes. See docs/evaluation.md for the procedure to run a second eval against your own real vault after import, and for which numbers to trust when retuning SEARCH_DEFAULT_VECTOR_WEIGHT/SEARCH_FUSION_STRATEGY for your own deployment.

Future work, not built yet. multi_result and temporal recall suggest a reranker could help beyond what fusion-weight tuning alone achieves. Whether negative-category queries should return a score-floor-suppressed empty result instead of five low-relevance ones is a real product question, deliberately left open: it would change search()'s return contract, and it is worth doing only if a real vault's negative-query scores turn out close enough to real hits to mislead.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vacancy_radar-0.2.0.tar.gz (167.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vacancy_radar-0.2.0-py3-none-any.whl (75.6 kB view details)

Uploaded Python 3

File details

Details for the file vacancy_radar-0.2.0.tar.gz.

File metadata

  • Download URL: vacancy_radar-0.2.0.tar.gz
  • Upload date:
  • Size: 167.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for vacancy_radar-0.2.0.tar.gz
Algorithm Hash digest
SHA256 26a8b44146a01c06f1408b319a56371856f8b5ae5401ca49d9f070ae720dd882
MD5 0f03024e68b70443fcc52b60bf5997c5
BLAKE2b-256 fa4124f783a6f07e372dc3efe927156bf7fbd49e6e78d653dea4e6c8eebba955

See more details on using hashes here.

File details

Details for the file vacancy_radar-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: vacancy_radar-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 75.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for vacancy_radar-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6bea318ca3f8f5e1fb9ea10c2c49445ad53e2339679a1ec13ff78f1d33b9624e
MD5 a410814561e173a48b08260563a4b878
BLAKE2b-256 a6e0d2af49c23c079109271fb2dcb4cdc66c2a6f40180d7bdd847c22eb8843e3

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page