Self-evolving, agent-driven job hunter. An MCP server (no LLM of its own) that drives YOUR logged-in Chrome to apply via email, LinkedIn Easy Apply, ATS forms & connections — and learns from every...
Overview
Your coding agent is the brain. JobPilot has no model of its own. It is the MCP tool server, the browser hands, the dedup memory, and the loop that turns a stuck page into a fix on GitHub. Indeed Apply now works in the normal signed-in window. One brain. It never starts a second brain. The worker only collects leads. Every email, Easy Apply, portal application, and connection request is a decision the brain makes, then a tool it calls. A single lead moves like this. The brain picks the channel. The tool does one concrete action. When a page wins, the system does not drop the lead and forget. It keeps the evidence and the fix comes back as code. No API keys. No SMTP. Sessions stay in your browser. Personal data stays in data/, which git ignores. Newest posts first. The brain chooses. Tools do not pick a channel on their own. Email is uncapped because it is your Gmail. The brain paces it.
README
The shape of it
One brain. It never starts a second brain. The worker only collects leads. Every email, Easy Apply, portal application, and connection request is a decision the brain makes, then a tool it calls.
flowchart TB
you["You"] --> brain["Brain — Grok, Codex, Claude, Gemini, or any MCP CLI"]
brain -->|"reads AGENTS.md, calls tools"| mcp["JobPilot MCP"]
subgraph tools ["Tools the brain drives"]
direction LR
search["Search the open web"]
act["Email · Easy Apply · ATS · Connect"]
memory["Dedup memory"]
evolve["Evolution memory"]
end
mcp --> tools
search --> db[("jobs.db")]
act --> chrome["Your logged-in Chrome"]
chrome --> gmail["Gmail"]
chrome --> linkedin["LinkedIn"]
chrome --> ats["Greenhouse · Lever · Ashby · Workable"]
act --> memory
memory --> db
worker["Data-only worker\nscout + dashboard"] --> db
db --> brain
chrome -->|"stuck page, screenshot + HTML"| evolve
evolve -->|"patch the module"| repo["GitHub"]
repo -->|"next session reads the fix"| brain
A single lead moves like this. The brain picks the channel. The tool does one concrete action.
flowchart LR
lead["Fresh lead"] --> judge{"Relevant?\nAlready contacted?"}
judge -->|no| skip["Record a skip and why"]
judge -->|email| mail["send_email\nCV attached"]
judge -->|Easy Apply| easy["apply_linkedin_easy\nstop at the safety cap"]
judge -->|ATS or board| form["apply_portal\nreal Greenhouse / Lever / Ashby / Workable"]
judge -->|poster only| connect["linkedin_connect\nshort note"]
mail --> done["Never touch that lead again"]
easy --> done
form --> done
connect --> done
skip --> done
When a page wins, the system does not drop the lead and forget. It keeps the evidence and the fix comes back as code.
flowchart LR
fail["Stuck page"] --> snap["Screenshot + HTML\nunder data/evolution"]
snap --> play["PLAYBOOK.md"]
play --> patch["Brain patches the module"]
patch --> pr["contribute_improvements\nPR to GitHub"]
pr --> sync["Sync main\nnext run is smarter"]
sync --> fail
What the brain does, and what the tools do
| Brain (your CLI) | JobPilot | |
|---|---|---|
| Decides | Which roles, which country, which channel, what the email says, when to stop | Nothing. No LLM lives here. |
| Acts | Calls one tool per action | Drives your Chrome, sends the mail, fills the form, writes the row |
| Remembers | Reads the bank and the playbook | data/jobs.db dedups email, apply, and connect across runs and across agents |
| Improves | Patches the module that failed | Saves the encounter, opens the PR, refuses to commit data/ or credentials |
No API keys. No SMTP. Sessions stay in your browser. Personal data stays in data/, which git ignores.
Four channels
Newest posts first. The brain chooses. Tools do not pick a channel on their own.
| Channel | Tool | What “done” means |
|---|---|---|
send_email |
Sent from your Gmail. CV attached. Portfolio and links come from the profile. A JobPilot repo line is added only when that user saved links.jobpilot. |
|
| LinkedIn Easy Apply | apply_linkedin_easy |
Modal walked, questions answered, “Application submitted” seen. |
| External form | apply_portal |
Job-board URL resolved to the real ATS, then submitted. |
| Connect | linkedin_connect |
A short note to the poster when there is no email and no form. |
Email is uncapped because it is your Gmail. The brain paces it. Easy Apply is capped: JobPilot refreshes the public daily limit, stops at 80% (default 50/day, so stop at 40), and waits a random 90–240s between submissions. A failed lookup never raises the cap.
v3.0.0 — what this stable line solved
This is the line we will keep. New work happens after this tag.
The architecture
- The worker is data-only when you run
npm run agent. It scrapes intodata/jobs.dband refreshes the dashboard. It does not email, apply, or connect. - One brain never starts another. There is no dispatcher that launches Codex, Grok, Claude, or Gemini.
AGENTS.mdis the only operating guide. Any MCP coding agent can drive the same tools.
Outreach that used to fail quietly
- Known-fake hiring addresses live in
src/fake-emails.txt, one address per line. Send refuses that list. Adding a decoy is a file edit, not a new regular expression. - Titles that match the profile (backend, Python, AI, platform, and the rest of the role list) stay in the email bank. They are no longer dropped as “not the CV.”
- An answer you type in the Questions tab survives when the worker saves the database. The newer answer wins, so a second process cannot put the question back.
- Salary questions can convert the profile currency into SAR and QAR, and current pay stays separate from the expected range.
- Lever’s real control is
Submit application. The hidden hCaptcha button is no longer treated as the submit button. A live captcha can still stop the submit. That is an open problem, not a solved one. - Phone-country widgets and cookie bars are closed before an external form is submitted.
- Easy Apply treats an intercepted LinkedIn click as a throttle and backs off, instead of hammering the page.
- Each job can select a private CV variant from
preferences.cvVariants. The canonical CV stays the fallback. Resumes never go in git. - Search treats Middle East, EU, relocation, and visa sponsorship as desirable. Country scope still blocks outreach outside the profile when that scope is on.
The loop around the code
contribute_improvementssyncs, files blockers, and opens or reviews PRs. Forks track upstream. The main repo reviews useful PRs from any author.- The CI security gate blocks
data/, credentials, and secrets. The syntax check skips files a PR deleted, so a deletion does not fail the build. - Commits from the evolver are authored as the GitHub username, not a personal name or a personal email.
Since v3.2.0
Indeed Apply now works in the normal signed-in window, with the debug port closed. Three in-site applications reached the page that says the application was submitted. The jobs home is https://pk.indeed.com/. A from= tracking parameter is not part of that page. Typing viewjob?jk= into the address bar stops on Additional Verification. Leave that page. Do not click the check. Glassdoor job pages load after Google sign-in, and in-site Apply now there has still not been submitted. Issue #1 is closed. Issue #2 stays open for a driven Glassdoor browser.
Path traces
Every agent reads docs/paths/ before it drives a site, then writes what happened. AGENTS.md makes this mandatory for every brain. One directory per site. One row is one hop: the URL, the option used, the next page, and the result.
flowchart LR
read["Read docs/paths/site/path.md"] --> go["Follow a success hop"]
go --> page["A new page opens"]
page --> row["Add one row"]
row --> next["The next agent starts from that row"]
Follow success and reached. Do not follow stop or refused. Correct a row that a later run proves wrong. No personal data and no screenshots. classifyBoardPage() in src/mcp/board-pages.js names the same actions. Indeed and Glassdoor each have a log. A new site gets its own directory on the first hop. The dashboard Paths tab draws every hop from those files.
Still open
These are real walls. JobPilot records them and routes around them. It does not pretend to slip past a bot check.
- Glassdoor — a driven browser stops on “Humans only” (#2). A normal window plus Google sign-in works. Job pages there show Apply on employer site. In-site Apply now has not been submitted.
- Workday, iCIMS, Taleo, SuccessFactors — account-gated, multi-step ATS (#3)
- Some job-board redirects — Arbeitnow, We Work Remotely, Himalayas (#4)
- hCaptcha on Lever — the submit button is found. A captcha challenge still needs a person.
Questions, asked once
JobPilot does not invent salary, visa, or notice period.
flowchart LR
q["Unknown question"] --> park["Questions tab"]
park --> you["You answer once"]
you --> keep["Stored answer"]
keep --> reuse["Reused on the next form\nfuzzy match included"]
profile["Profile + CV"] --> auto["Agent-answered row"]
auto --> review["You can override it"]
Verify the path with node scripts/verify-loop.mjs.
If LinkedIn or Gmail is logged out, the dashboard shows one login link and that site waits. It does not retry in a loop.
Glassdoor, then Indeed
Sign in to these two with the worker stopped and the debug window closed. The dashboard button Open Glassdoor for sign-in opens the JobPilot profile as a normal Chrome window.
- On Glassdoor, choose Continue with Google.
- Then sign in to Indeed with that same Google account.
Opening Indeed first sticks on a check with no form. JobPilot does not solve that check. The notes for the next agent are in docs/board-login.md.
Keep the debug port closed after sign-in. Indeed Apply now is the in-site form in that same window. The hop log is docs/paths/indeed/path.md. Applications reached “Your application has been submitted”. A captcha challenge still stops the apply. The debug-port path is unchanged. Issue #1 is closed. Issue #2 stays open. Glassdoor’s log is docs/paths/glassdoor/path.md.
Start
1. Clone
git clone https://github.com/maazbin/jobpilot
cd jobpilot
2. Open the folder in your agent
Say:
Find relevant jobs, write to the people who are hiring, and keep the evolution loop moving.
The repo already contains the MCP config. start.js installs dependencies, opens Chrome on the debug port, and serves the tools. Approve the server if the CLI asks.
Set JOBPILOT_NO_AUTOWORKER=1 on the MCP server so it stays a tool server and does not start a second worker.
| Agent | Config in the repo | You |
|---|---|---|
| Claude Code | .mcp.json |
Open the folder, approve the server |
| Cursor | .cursor/mcp.json |
Enable it in Settings, MCP |
| Kiro | .kiro/settings/mcp.json |
Open the folder |
| VS Code / Copilot | .vscode/mcp.json |
Start the server |
| Gemini CLI | .gemini/settings.json |
Run gemini from the folder |
| Codex | ~/.codex/config.toml |
Add the jobpilot server there. Do not repoint CODEX_HOME. |
| Grok | .grok/config.toml |
grok mcp add jobpilot --scope project node -- start.js |
3. Let the brain drive
AGENTS.md is the standing guide. The brain checks onboard_status, opens http://127.0.0.1:7788/onboard if the profile has gaps, and then searches, judges, and acts. You upload the CV in that form. You never paste a file path into chat.
Keep the hands alive in another terminal:
npm install
npm run agent
That process only scrapes and refreshes the dashboard. Stop it with the dashboard button or npm run stop.
npm run worker still contains the older action lanes. The supported setup is npm run agent plus your own CLI as the brain, so outreach is never double-sent.
CV variants
The canonical CV is the fallback. Optional variants stay in the private profile:
{
"preferences": {
"cvVariants": [
{ "id": "aws", "label": "AWS CV", "path": "/private/path/aws.pdf", "focuses": ["aws"], "keywords": ["eks", "s3"] }
]
}
}
JobPilot does not invent skills or employers to fill a gap.
Dashboard
Local only: http://127.0.0.1:7788
- Pipeline — fresh leads, newest first, with per-lane status.
- Questions — one answer, remembered. Agent-answered rows can be overridden.
- Evolution — open encounters, screenshot, HTML snapshot, learned answers.
- Paths — every hop in
docs/paths/, one flow per site. Green is success, blue was reached, red is a stop or a refusal. - Audit — emails sent, applications, connects. Dedup warnings included.
The UI is split under src/dashboard/ so one section can change without a rewrite of the page.
Layout
jobpilot/
├── start.js # MCP entry: install, Chrome, respawn
├── mcp-server.js # tool server + evolution loop
├── AGENTS.md # the only brain guide; path traces are required
├── docs/paths/ # one hop log per site; read it, then update it
├── src/
│ ├── worker.js # scout and dashboard; outreach only if not data-only
│ ├── evolve.js # encounters, answers, strategy
│ ├── evolve-loop.js # evolution while the worker is down
│ ├── contribute.js # sync, PR, review — never commits data/
│ ├── tracker.js # jobs.db, dedup, answer merge across processes
│ ├── fake-emails.txt # known-fake addresses, one per line
│ ├── dashboard/ # local UI
│ └── mcp/ # gmail, easy apply, forms, connect, answers, sources
├── scripts/agent-forever.mjs # never-stop supervisor for the data-only worker
└── data/ # profile, CV, DB — gitignored
Privacy
- Profile, CV, jobs, answers, and the Chrome profile stay in
data/. - Contribution refuses to stage
data/,.env, drafts, logs, screenshots, or the database. CI checks the same rule. - Dedup is permanent. Easy Apply stops before the account limit.
Contributing
See CONTRIBUTING.md. A useful change is a new ATS path, a new public source, or a fix for an encounter in data/evolution/PLAYBOOK.md, followed by contribute_improvements.
License
Install
This server does not publish a one-line install command.
Open the repository installation guideConfiguration
{ "mcpServers": { "jobpilot": { "command": "node", "args": ["ABSOLUTE/PATH/TO/start.js"], "env": { "JOBPILOT_NO_AUTOWORKER": "1" } } } }