job-apply-agent-mcp
README.md
# Job Apply Agent - Test Project
A minimal Stagehand + Playwright project that fills **Lever** and **Greenhouse**
job application forms using dummy candidate data stored in `data/candidate.json`.
Defaults to **dry-run mode**: it runs the full flow (navigate, discover
fields from the DOM, Playwright fill, verify) but stops before clicking
submit, so you can safely point it at a real company's posting without
sending a fake application.
## Project structure
```
lever-apply-test/
├── data/
│ ├── candidate.json # dummy candidate profile (edit this to test different data)
│ ├── generate_dummy_resume.py
│ ├── dummy_resume.pdf # generated locally, gitignored
│ ├── users/ # per-user MCP profiles (gitignored)
│ └── screenshots/ # form screenshots (gitignored)
├── hermes/skills/job-apply/
│ └── SKILL.md # Hermes skill for Slack / chat apply flow
├── src/
│ ├── config.py # env var loading
│ ├── schemas.py # Pydantic models (candidate + form/audit schemas)
│ ├── candidate_loader.py # loads/validates candidate.json
│ ├── user_store.py # per-user MCP profiles under data/users/
│ ├── mcp_server.py # MCP stdio server for Hermes
│ ├── base_agent.py # shared Stagehand session + Playwright fill/audit
│ ├── playwright_form.py # DOM discover / fill / typeahead / readback
│ ├── field_mapper.py # label → profile mapping (aliases, then one LLM call)
│ ├── session_handoff.py # CAPTCHA / OTP live-view handoff
│ ├── greenhouse_locators.py # Greenhouse Apply/Next/Submit + classic IDs
│ ├── lever_locators.py # Lever Apply/Submit locators
│ ├── ats_detection.py # auto-detect Lever vs Greenhouse from URL
│ ├── apply_lever.py # Lever automation logic
│ └── apply_greenhouse.py # Greenhouse automation logic
├── tests/
│ └── fixtures/ # local Greenhouse/Lever HTML for Playwright fill tests
├── main.py # CLI entry point
├── start_mcp.sh # run the MCP server from this repo
├── requirements.txt
├── pytest.ini
└── .env.example
```
## Supported ATS systems
| ATS | Example URL | Auto-detected |
|-----|-------------|---------------|
| Lever | `https://jobs.lever.co/company/posting-id` | Yes |
| Greenhouse | `https://boards.greenhouse.io/company/jobs/12345` | Yes |
| Greenhouse (alt) | `https://job-boards.greenhouse.io/company/jobs/12345` | Yes |
| Embedded Greenhouse | `https://careers.example.com/jobs/123` | No — use `--ats greenhouse` |
## What you need to actually run a test
1. **Python 3.10+**
2. **Install dependencies**
```bash
pip install -r requirements.txt
playwright install chromium
```
`playwright install` downloads the actual browser binary - this is a
separate step from `pip install`, easy to forget.
3. **An LLM API key.** Stagehand still launches the browser and is the fallback
for widgets Playwright cannot target. Copy `.env.example` to `.env` and
fill in `MODEL_API_KEY` (Anthropic, OpenAI, or Azure OpenAI all work).
Playwright-first fixture tests do **not** need a model key.
4. **Generate the dummy resume once:**
```bash
python data/generate_dummy_resume.py
```
5. **A real job posting URL to test against.** Dummy candidate data
only gets you halfway - you need an actual live apply page for Stagehand
to interact with.
**Important:** don't run `--live` against a real company's posting - that
sends an actual application under fake data to a real employer, which is
both bad practice and likely a ToS violation. Two safer options:
- Run in **dry-run mode** (the default) against a real posting - it fills
and verifies but never submits, so it's safe to test the fill logic end-to-end.
- Set up your own **sandbox/test job** if your team gets a trial account,
and only use `--live` against that.
6. *(Optional)* A **Browserbase** account if you want cloud browsers, session
replay, or their captcha-handling infrastructure instead of running a
local Chromium window. Leave `BROWSERBASE_API_KEY` blank to just run
locally - simplest for early testing.
## Running it
```bash
# Lever - auto-detected from URL (dry-run, default)
python main.py --job-url https://jobs.lever.co/some-company/some-posting-id
# Greenhouse - auto-detected from URL
python main.py --job-url https://boards.greenhouse.io/some-company/jobs/12345
# Custom-domain Greenhouse embed - explicit override
python main.py --job-url https://careers.example.com/jobs/123 --ats greenhouse
# Watch it work instead of running headless
# (set HEADLESS=false in .env, which is also the default)
# Only once you have your own sandbox posting to test against:
python main.py --job-url <your-sandbox-url> --live
# Test with different candidate data
python main.py --job-url <url> --candidate data/another_candidate.json
# Leave browser open after fill so you can manually verify fields
python main.py --job-url <url> --keep-open
# Skip the terminal prompt for fields still blank after the automatic retry
python main.py --job-url <url> --no-prompt
```
Set `KEEP_BROWSER_OPEN=true` in `.env` to make this the default behavior.
## MCP server (Hermes / Slack)
The apply CLI is unchanged. `src/mcp_server.py` exposes the same flow as MCP tools so Hermes can call them from Slack or another chat app.
Run it locally (stdio):
```bash
python -m src.mcp_server
```
Point Hermes at it (`~/.hermes/config.yaml` or your active profile):
```yaml
mcp_servers:
job_apply:
command: "/absolute/path/to/lever-apply-test/venv/bin/python"
args: ["-m", "src.mcp_server"]
timeout: 600
env:
PYTHONPATH: "/absolute/path/to/lever-apply-test"
```
Or run `./start_mcp.sh` from this repo.
Start Hermes from this repo (or ensure the python module path can see it), then `/reload-mcp`.
Tools:
| Tool | What it does |
|------|----------------|
| `detect_ats` | Lever vs Greenhouse from the job URL |
| `get_candidate_profile` | Load `data/users/{user_id}/profile.json`, or `data/candidate.json` if none |
| `upsert_candidate_profile` | Save a profile for that chat `user_id` |
| `apply_to_job` | Dry-run fill + verify, then attach form screenshot(s) for Hermes |
| `provide_application_answers` | Save extra field values from the Slack user (`label` → value) |
| `resume_application` | Retry the last dry-run job after extra answers are saved |
| `submit_application` | Live submit — only after reviewing the dry-run / screenshots |
| `provide_otp` | Submit an OTP while a live handoff session is open |
| `close_captcha_handoff` | Close a CAPTCHA live-view handoff |
| `close_handoff` | Close any open OTP / CAPTCHA handoff |
Hermes must pass `user_id` (for Slack, the member id like `U01ABC2DEF3`). That is how the server tells users apart.
`apply_to_job` / `resume_application` / `submit_application` return JSON plus **JPEG screenshots** of the filled form (pre-submit, and post-submit when live). The JSON includes `fill_audit` (`filled`, `missing`, `unknown`, `reason`), `fill_attempts`, and `needs_user_input` / `questions` when fields are still blank. Screenshots are also saved under `data/screenshots/`.
**CLI missing fields:** after 1 automatic retry, the terminal asks for each blank field and fills them in the **same browser session**. Use `--no-prompt` to skip. `--keep-open` is still available for visual review.
**Hermes missing fields:** the browser session closes (Slack round-trips are too slow to hold Chromium). If `needs_user_input` is true, Hermes should ask the user for `questions`, call `provide_application_answers`, then `resume_application`. Extra answers are stored on the user profile as `extra_answers` and reused on later jobs.
Example Slack flow: save a profile → “apply to `<job url>`” → if asked, supply missing answers → review the screenshot(s) → “submit it”.
## Notes
- **ATS auto-detection** uses the job URL hostname. If detection fails (e.g.
Greenhouse embedded on a custom careers domain), pass `--ats lever` or
`--ats greenhouse` explicitly.
- **Greenhouse multi-step forms** discover and fill each step, then click Next /
Continue only if Submit is not visible. Optional EEO/demographic sections are
skipped or answered with "Decline to answer" when possible.
- **Resume upload** uses the held Playwright page (`set_input_files`) when CDP
is available, then falls back to Stagehand `act()` if needed.
- **Location / city** fields are treated as searchable typeaheads: Playwright
types the value and clicks a matching suggestion, with Stagehand as fallback.
- **Candidate profile** is nested: `personal`, `answers`, `education`, `skills`,
`documents`, `demographics`, `consent`, plus `extra_answers` for job-specific
questions that are not in the schema. Existing profiles still load; new
sections default to empty. Dummy values live in `data/candidate.json`.
- **Custom / extended questions** (education, skills, DOB, citizenship, relocate,
salary, notice, consent flags) are filled from that structured profile. Anything
still unmatched goes to CLI/Hermes `extra_answers` instead of being invented.
- **Label mapping** matches DOM-discovered fields to the profile with aliases
first (no LLM). Only unmatched labels use **one** off-browser mapping call.
Values always come from the candidate profile. Playwright fills by selector /
label; Stagehand `act()` is last resort for leftover widgets.
- **Form screenshots** are captured after fill (and after live submit) so
Hermes can show the filled application without opening a headed browser.
This needs a CDP URL on the Stagehand session (typical with Browserbase;
local mode may omit it). Keep Hermes `job_apply.timeout` at **600**.
- **Pre-close fill audit**: after fill, the agent reads real `input.value` /
selected options from the DOM and logs **filled** vs **missing** vs
**unknown**. `unknown` means the control was not on this step or could not
be read — it is not the same as empty. If expected fields are still blank,
only those lagging fields are retried once. Live submit is **blocked** while
`missing` is non-empty. Unmapped fields are not invented: CLI prompts
in-session, Hermes asks via chat then retries in a new session. The audit is
logged again just before the session closes and is returned to MCP as
`fill_audit` + `fill_attempts`.
- **CAPTCHA** is not solved automatically. Live submissions may hit CAPTCHA
on both Lever and Greenhouse; dry-run mode never clicks submit.
- **Login-required postings** and account-creation flows are not supported
in this version.
- The exact Stagehand Python method signatures may drift slightly as the SDK
evolves. If something doesn't match, check https://docs.stagehand.dev first.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues