A local job-hunting agent. Point it at company career pages and it scrapes every open role, filters them against your resume, fetches the full description for the survivors, scores each one 0–100, and drafts a cover letter for the ones worth applying to.
It runs on your machine, against your own API keys, and stores everything in one SQLite file. Nothing leaves your laptop except the pages it fetches and the prompts it sends to whichever model you point it at.
resume.md ──▶ compile ──▶ discover ──▶ filter ──▶ read ──▶ score ──▶ board
412 found 50 kept 22 read 18 scored
The whole design is one idea: cheap filters run before expensive ones. A job title costs a fraction of a model call to judge. A description costs a page fetch and a full scoring call. So titles are filtered first — most of them by rules that cost nothing at all — and only the survivors are ever fetched or scored.
Why it is built this way — the trade-offs, the failure modes, the things deliberately left out — is explained inline. The code is commented for a reader, not for a linter.
- Node 23.6+ — it runs TypeScript directly and uses the built-in
node:sqlite - An LLM connection, any of:
- Claude CLI (uses your existing sign-in, no key needed)
- Google AI Studio — the free tier is enough to run this
- Anthropic API, OpenRouter, Ollama, LM Studio, or anything OpenAI-compatible
- A Firecrawl key is optional. Discovery is offline by default; Firecrawl only runs as a last resort on boards where every free strategy found nothing, and can be disabled entirely.
npm install
npm run build # builds the UI into ui/dist
npm run server # http://localhost:3000Setup opens on first run and asks for the four things a run cannot start without: your resume, the roles you want, the pages to watch, and a model to read with. Two minutes. You can reopen it any time from Settings → Data → Run setup again.
Some career pages are JavaScript-only — no links in the HTML until the app has run. Discovery handles most of them without a browser (platform APIs, sitemaps, embedded JSON), but for the rest:
npx playwright install chromiumThen enable it in Pipeline → Discover → Browser rendering. It is not downloaded automatically, because most boards never need it.
Everything is configured in the app and stored in jobpilot.db. .env is only for what is
needed before the app is up — copy .env.example:
| Variable | Purpose |
|---|---|
PORT |
Default 3000 |
HOST |
Default 127.0.0.1. Read the security note before changing this |
FIRECRAWL_API_KEY |
Optional fallback scraper |
LLM_API_KEY |
Optional; the UI stores a key per connection instead |
VITE_LOGO_TOKEN |
Optional logo.dev key for company logos. Without it you get letter avatars and nothing is sent to a third party |
There is no authentication. The server binds to 127.0.0.1 for exactly that reason: anyone
who can reach the port can read your job data, change settings, and start runs that spend your
API credits.
HOST=0.0.0.0 is supported and prints a warning on boot. Don't use it without an
authenticating proxy in front.
Job descriptions are attacker-controlled text and go into three prompts. They are delimited and marked as data, and the Claude CLI is spawned with no tools, so there is no filesystem for a malicious posting to reach. If you ever add tools to that call, that mitigation is gone.
A plugin engine, not a scraper. Every strategy runs in parallel and returns job references with a confidence; results are merged, keeping the richest version of each job.
| Strategy | Confidence |
|---|---|
| Platform adapter — Workday, Greenhouse, Lever, Ashby, Breezy, BambooHR, Freshteam, Zoho Recruit, Hirestream, WebHR | 100 |
| Network observer — JSON the page fetched while rendering (browser) | 100 |
| Learned API — an endpoint a previous crawl recorded | 96 |
JSON-LD schema.org/JobPosting |
95 |
Embedded JSON — __NEXT_DATA__, Nuxt, Remix |
88 |
| Sitemap | 75 |
| Infinite scroll (browser) / pagination | 70 / 65 |
| Link scan — scores every anchor on path, text, and patterns learned from this host | 60 |
| Firecrawl — second pass, only if everything above found nothing | 80 |
Each crawl writes a fingerprint to .cache/discovery/<host>.json: which strategies worked, the
job URL pattern, any API endpoints. The next crawl reuses it. Measured on a real site: first
crawl 9.4s, second 1.2s.
Discovery is a standalone package importing nothing from the rest of the app, with its own CLI:
node src/discovery/cli.ts https://company.com/careers --no-browserThe highest-value contribution: one adapter covers every company on that platform, and most
are about 20 lines — a list endpoint, a URL shape, and the fields to map. See
src/sources/careers.ts; scrapeGreenhouse is a good template.
Still missing: SmartRecruiters, Personio, Recruitee, JazzHR, Workable, iCIMS.
src/
server.ts HTTP API, SSE progress, static UI
pipeline.ts the five stages, in order
candidate/ compiled resume, title rules, the measured half of a score
model/ LLM client, connections, rate limits, prompts, output schemas
sources/ career boards, extraction, the Firecrawl bridge
discovery/ the standalone discovery engine
data/ SQLite schema, migrations, every query
ui/ React, Vite, Tailwind
test/ node:test, no framework
npm test # 111 tests, no network
npm run dev # Vite dev server with an API proxy
npm start # one pipeline run, headless, straight to the terminalA run on a free model tier costs nothing. In practice:
- Discovery is free unless a board defeats every offline strategy
- Titles are filtered by rules first; only ambiguous ones reach a model
- Descriptions are fetched free wherever a platform API, JSON-LD or a readable page exists
- Scoring is one call per job, against a ~400-token compiled profile rather than your resume
- Boards read in the last 12 hours are skipped entirely
Every dial is exposed on the Pipeline page, and the run overlay shows what was actually spent.
MIT — see LICENSE.
