Personal
Job Search Tool
A local-first job search system where the dashboard, REST API, and MCP tools share one application layer, so filtering and state changes behave identically on every surface.
A job search that runs on your own machine: a scheduler scrapes boards on an interval, scores each posting against criteria you write, and keeps what clears the bar. You then read it through a dashboard, an HTTP API, or an AI agent, and all three are the same thing underneath.
- Shape
- A scheduler and one ASGI application
- Surfaces
- React dashboard · REST API · MCP tools
- Storage
- SQLite, plus a local ChromaDB index for similarity
- Repository
- VincenzoImp/job-search-tool
The problem
Three surfaces over one dataset is where duplicated logic goes to hide. The dashboard filters one way, the API another, and the agent’s tools a third, and none of them is wrong until a rule changes and only two of them learn about it.
What I built
One application service, and every surface is a client of it. The REST layer and the MCP layer each construct it through the same helper; both delegate to it, and neither touches the database directly. The dashboard reaches it through the API. Adding a surface is wiring, not a second implementation of the rules.
The scoring is deliberately not clever. Each keyword category you define carries a weight, and a posting scores that weight once if any of its keywords match. There is no model in the loop and no embedding in the score: categories, keywords and weights are entirely yours, and two thresholds decide what gets archived and what gets a Telegram message. Semantic search exists, but as its own surface, running a small local embedding model rather than calling anything out.
Hard parts
Two processes writing one vector index corrupts it. The scheduler and the web app shared an on-disk Chroma index, which is not safe for concurrent writers: its internal capacity bookkeeping broke and the scheduler died with a native segfault complaining that an index with capacity N and N entries could not add one more. The fix made the scheduler the only writer, moving embedding cleanup out of the web process and onto a periodic job. The cost is freshness – deletions are pruned on a timer instead of immediately – and that is written down next to every method it affects.
Protecting the rows that matter in SQL, not in code. Every delete query carries
AND bookmarked = 0 AND applied = 0, and it is not configurable. A retention policy that can be
misconfigured into deleting the job you already applied to is a retention policy with a bug in
it; putting the guard in the query means no caller can forget.
Refusing a configuration that contradicts itself. The notify threshold must be at least the save threshold, and the loader rejects anything else rather than warning. Notifying about postings you do not even archive is not a preference, it is a mistake, and the config layer treats it as one.