Skip to main content
Glama
pragnakalpdev64

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues