job-apply-agent-mcp
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.exampleSupported ATS systems
ATS | Example URL | Auto-detected |
Lever |
| Yes |
Greenhouse |
| Yes |
Greenhouse (alt) |
| Yes |
Embedded Greenhouse |
| No — use |
What you need to actually run a test
Python 3.10+
Install dependencies
pip install -r requirements.txt playwright install chromiumplaywright installdownloads the actual browser binary - this is a separate step frompip install, easy to forget.An LLM API key. Stagehand still launches the browser and is the fallback for widgets Playwright cannot target. Copy
.env.exampleto.envand fill inMODEL_API_KEY(Anthropic, OpenAI, or Azure OpenAI all work). Playwright-first fixture tests do not need a model key.Generate the dummy resume once:
python data/generate_dummy_resume.pyA 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
--liveagainst 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
--liveagainst that.
(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_KEYblank to just run locally - simplest for early testing.
Running it
# 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-promptSet 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):
python -m src.mcp_serverPoint Hermes at it (~/.hermes/config.yaml or your active profile):
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 |
| Lever vs Greenhouse from the job URL |
| Load |
| Save a profile for that chat |
| Dry-run fill + verify, then attach form screenshot(s) for Hermes |
| Save extra field values from the Slack user ( |
| Retry the last dry-run job after extra answers are saved |
| Live submit — only after reviewing the dry-run / screenshots |
| Submit an OTP while a live handoff session is open |
| Close a CAPTCHA live-view 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 leveror--ats greenhouseexplicitly.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 Stagehandact()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, plusextra_answersfor job-specific questions that are not in the schema. Existing profiles still load; new sections default to empty. Dummy values live indata/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_answersinstead 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.timeoutat 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.unknownmeans 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 whilemissingis 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 asfill_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.