Skip to main content

vacancy-radar

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.

Status

Vault, index, retrieval, CLI (read and write), and MCP server are all implemented. Stage 9 (import) is too — conversationally, via create_vacancy/find_existing over MCP (see docs/import-guide.md), not an xlsx/CSV importer; this project ships no spreadsheet parser or interchange format.

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["list / show / search\nread it back"]
        C1 --> C2 --> C3 --> C4 --> C5
    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["list_vacancies / get_vacancy / search"]
        M1 --> M2 --> M3 --> M4 --> M5
    end

    C5 --> Vault[("Markdown vault\n+ LanceDB index")]
    M5 --> 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.

Install

Use it

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

# Run it once, without installing anything permanently.
uvx vacancy-radar --version

# Or put `vacancy-radar` on PATH for good.
uv tool install vacancy-radar

The MCP server ships in the same distribution, but its script name differs from the distribution name, so it needs --from (see MCP server below for the client config):

uvx --from vacancy-radar vacancy-radar-mcp --vault /path/to/vault

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.

Work on it

For contributing to vacancy-radar itself, from a clone:

uv sync --all-groups

Every command example below is written in this contributor form, with a uv run prefix. If you installed the tool, drop the uv runuv run vacancy-radar list becomes vacancy-radar list.

Getting started

Every command below expects a vault to already exist at VAULT_PATH (or an explicit path, where the command takes one). Create one first:

uv run vacancy-radar init /path/to/vault
export VAULT_PATH=/path/to/vault

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 (see [design.md](openspec/changes/vault-init/ design.md) for the full rationale).

Usage

uv run vacancy-radar --version

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 (see openspec/changes/archive/2026-08-06-cli-read/ design.md's --json contract).

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

# Show one vacancy or company - which kind it is is auto-detected.
uv run 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.
uv run vacancy-radar search "backend engineer" --scores

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

Writing

note, status set, add, new, and reindex write to the vault. Every write is append-only or a validated status transition (see openspec/changes/archive/2026-08-06-cli-write/design.md) - nothing here deletes or overwrites existing content, and there is no delete command anywhere in the CLI. note, status set, and add accept a vacancy by id, company name, or unambiguous fuzzy match, resolved the same way show resolves its argument; an ambiguous match prints the candidates 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 (if it resolves to
# exactly one vacancy), or an unambiguous fuzzy match - the same rule
# `show` uses. A company with several vacancies needs the vacancy id.

# Append a dated note. The filename is always derived from the date -
# never something you (or an LLM, at a later stage) supply.
uv run 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.
uv run vacancy-radar status set 2022-03-22-acme-corp-backend-engineer \
    applied --applied-date 2026-01-15
uv run vacancy-radar status set 2022-03-22-acme-corp-backend-engineer \
    found --force

# 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.
uv run 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).
uv run 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.
uv run 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.
uv run 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:

cp ~/Downloads/posting.pdf \
    /path/to/vault/acme-corp/2022-03-22-acme-corp-backend-engineer/documents/
uv run 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

vacancy-radar-mcp exposes the same core.py functions the CLI above calls, as MCP tools/resources/prompts over stdio, for any MCP client (Claude Desktop, etc.) - see openspec/changes/mcp-server/design.md for the full rationale.

Point your client at the published distribution — no clone required. For Claude Desktop, that is claude_desktop_config.json:

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

--from vacancy-radar is required, not decorative: 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. A bare uvx vacancy-radar-mcp looks for a distribution that does not exist.

For Claude Code, register the same command with claude mcp add instead of editing JSON by hand:

claude mcp add vacancy-radar -- \
    uvx --from vacancy-radar vacancy-radar-mcp --vault /path/to/vault

If you have installed it persistently with uv tool install vacancy-radar, vacancy-radar-mcp is already on PATH and the config's command can be that directly, with just ["--vault", "/path/to/vault"] as args.

Working on the server itself, from a clone (after uv sync --all-groups):

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

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.

Eleven 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
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 (design.md's exposure decision). create_vacancy is exposed, for conversational import (see docs/import-guide.md): 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.

Development

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

Pre-commit hooks

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:

uv run pre-commit run --all-files

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 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 hypothesized at stage 4, 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 (stage 4's 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 stage 4's guess, not a confirmation of it. See config.py's SearchConfig docstring and this change's tasks.md (section 5) for the full per-category sweep.

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 here. multi_result and temporal recall suggest a reranker could help beyond what fusion-weight tuning alone achieves — not implemented in this change per its constraints. 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 (see design.md): it would change search()'s return contract and belongs in its own change if a real vault's negative-query scores ever 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.1.0.tar.gz (152.6 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.1.0-py3-none-any.whl (68.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: vacancy_radar-0.1.0.tar.gz
  • Upload date:
  • Size: 152.6 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.1.0.tar.gz
Algorithm Hash digest
SHA256 8bb66b6b186acd7cfee7662e8ee2de6754511d9e5fa32b89933973aeb178b970
MD5 d284b120b3a6c85bdc06bd1729516750
BLAKE2b-256 5014cc7bca4235f362e68aaec9dfad032a1a5524fefbef40c6f7ecb37c2b6330

See more details on using hashes here.

File details

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

File metadata

  • Download URL: vacancy_radar-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 68.1 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7844886575db9d25897bdab185910bcd8c1cfbb2c56307358cc5c08dac1cc432
MD5 b9a4337cb089c8a5b1053cd7a7435766
BLAKE2b-256 dcce2ee1061c70975004c350b56c476c86521c01c46bc01825a0ab0b97120801

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