JobApplier
Give it a job posting URL. It opens the page in your own Chrome (a dedicated profile you log into once), finds the application form, and fills it out field-by-field with an LLM grounded in your resume — then, only if you allow it, submits and confirms success. Every attempt is logged with a full Q&A trail and, on any snag, a screenshot.
You are responsible. JobApplier submits real applications on your behalf. You are responsible for the accuracy of every answer it submits and for complying with the terms of the websites you use. It does not guarantee compliance with any third party's terms of service. Start with
--dry-run.
Quick start
pipx install job-applier # or: uv tool install job-applier
job-applier init # creates your config directory, stores your API key, imports your resume
job-applier chrome # starts a dedicated Chrome; log in to your job sites once
job-applier doctor # checks everything is ready
job-applier "https://company.example.com/careers/apply/123" --dry-run # fill, validate, don't submit
job-applier "https://company.example.com/careers/apply/123" # the real thing
No browser is downloaded or bundled: attach mode uses the Chrome/Edge you already have.
(playwright install chromium is only needed if you use the old launch mode without --cdp-url.)
Requires Python 3.10+. Works on Windows, macOS and Linux.
The last line printed is the outcome:
status: submitted
log written to: <your config dir>/results/2026-09-18_regular_job_applications.json
Where your files live
Nothing application-owned is kept in a repository or the current directory. Everything is under one
per-user directory (job-applier doctor prints it; override with JOBAPPLIER_HOME):
| OS | Directory |
|---|---|
| Linux | ~/.config/job-applier |
| macOS | ~/Library/Application Support/job-applier |
| Windows | %APPDATA%\job-applier |
config.toml settings (CLI flag > environment variable > config.toml > default)
.env API key fallback, only if no OS keyring is available (owner-only permissions)
resume.md / resume.pdf your resume: text the LLM reads / the PDF uploaded to resume fields
human_prompt.md persona / instructions wrapper for the LLM
preferred_answers/ fixed answers that override the resume (general.md, amazon.md, ...)
results/ <date>_regular_job_applications.json — the Q&A trail and outcome of every run
screenshots/ taken on any failed action
logs/ one structured JSON-lines log per run (no answers, no keys)
cache/qa_cache.json answer cache across runs
state/ duplicate-ledger and batch-queue state
chrome-profile/ the dedicated browser profile (see below)
Upgrading from the old checkout layout? If the directory above doesn't exist yet and ./data/resume.md
does, the old ./data/... paths keep working. job-applier init offers to copy them into the new location.
The dedicated Chrome profile
job-applier chrome starts Chrome (or Edge) with:
--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --user-data-dir=<config dir>/chrome-profile
- It is a separate browser profile, not your everyday one. JobApplier never reads, copies or touches your normal Chrome profile, and refuses to use it.
- Why a separate profile: since Chrome 136,
--remote-debugging-portis ignored on the default profile (so nothing can attach to your real profile). A dedicated--user-data-diris the supported route. - Login persistence: log in to LinkedIn, Greenhouse, Lever, Workday, … in that window once; the sessions persist in the profile between runs.
- Attach mode (
--cdp-url http://127.0.0.1:9222, on by default afterjob-applier init): every run opens a new tab, works only inside it, and closes only that tab — also on failure and Ctrl+C. It never uses your existing tabs, never resizes the window, and never closes the browser.
CDP security
The DevTools port gives full control of the browser, including your logged-in sessions. Therefore:
it is bound to 127.0.0.1 only; JobApplier refuses any non-loopback --cdp-url; and job-applier doctor
fails if the port answers on a non-loopback address. Never expose port 9222 (no port forwarding, no
0.0.0.0, no tunnels).
Troubleshooting port 9222
| Symptom | Fix |
|---|---|
could not attach to a browser at http://127.0.0.1:9222 |
Run job-applier chrome, then job-applier doctor |
| Chrome opened but nothing answers on 9222 | Another Chrome is already using the same profile — close it and re-run |
| Port already in use by something else | job-applier chrome --port 9333, then use --cdp-url http://127.0.0.1:9333 (or set chrome_port/cdp_url in config.toml) |
answers, but does not look like a Chrome/Chromium DevTools server |
Another program owns the port; pick another port |
| Chrome/Edge not found | Set JOBAPPLIER_CHROME_PATH to the executable |
Safety features
| Feature | What it does |
|---|---|
--dry-run |
Opens the application, fills and validates it, writes the normal result — and never submits (status dry_run). |
--review |
Fills everything, then pauses before the final Submit, lists any sensitive answers, and asks you to type submit. Anything else cancels (status review_declined). Needs an interactive terminal. |
| Sensitive fields | One classification for every HR system: SENSITIVE (salary, sponsorship/work authorization, EEO/demographics, veteran, disability, criminal history…) and ATTESTATION (legal certifications/consents). Those without a configured preferred answer are flagged and the application is not submitted without --review confirmation (status review_required, exit code 4). Set sensitive_policy = "auto" (or --sensitive-policy auto) to trust the LLM instead. |
| Duplicate protection | URLs already submitted are skipped (skipped_duplicate). URL variations are normalized (tracking parameters, fragment, trailing slash) but job-id parameters are kept. An unconfirmed Submit click also blocks a repeat until you check the site and pass --force. |
| No blind retries | Only transient browser/network failures are retried (with backoff, --retries), and never after a Submit click. Config errors, CAPTCHA/login walls, validation failures, cancellation and budget stops are never retried. |
| Cost limits | --max-api-calls N and --max-spend USD are hard limits per run. Spend needs price_input_per_mtok / price_output_per_mtok in config.toml (JobApplier ships no price table) unless the provider reports cost (OpenRouter). |
| Pacing | Non-zero defaults (startup buffer 2 s, 0.3–1.2 s between actions). Override with --startup-buffer-ms, --step-delay-min-ms, --step-delay-max-ms (0 disables). |
--wait-for-submit (you click Submit yourself, for invisible-reCAPTCHA sites) still works and is honored by
the ApplyToJobs flow; --dry-run takes precedence over it.
Commands
job-applier init scaffold the config directory (--yes for non-interactive)
job-applier chrome start the dedicated Chrome (--port, --browser chrome|edge)
job-applier doctor verify config, Chrome/CDP, API key, resume, preferred answers (--offline)
job-applier run jobs.txt apply to many URLs, resumable
job-applier "<url>" [flags] apply to one URL
job-applier --version
Batch mode
jobs.txt — one URL per line; optionally URL | Company Name | HR System; # comments allowed:
https://boards.greenhouse.io/acme/jobs/123
https://acme.wd1.myworkdayjobs.com/en-US/careers/job/X | Acme Inc | Workday
Progress is saved after every application. After a crash or Ctrl+C, run the same command again: finished
URLs are not redone; anything else is retried (the duplicate ledger still stops a second submission if a
Submit click had gone out). A --dry-run batch keeps separate state from a real one.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success: submitted, skipped_duplicate, or a dry_run that completed |
| 1 | Application failed: blocked, bot_detected (CAPTCHA/login wall), validation/submit failure |
| 2 | Configuration error (missing API key/resume, bad flag, bad config file, non-loopback --cdp-url) |
| 3 | Cancelled: Ctrl+C (interrupted) or declined at the --review prompt |
| 4 | Review required: sensitive answers with no preferred answer; nothing was submitted |
| 5 | Runtime/browser error (Chrome unreachable, crash, timeouts) |
job-applier run returns the most severe code among its applications (3 > 2 > 5 > 4 > 1 > 0).
CAPTCHAs and login walls
JobApplier never solves or bypasses a CAPTCHA. On a challenge or login wall it screenshots and stops with
bot_detected (exit 1). With --wait-for-captcha a CAPTCHA instead pauses for you to solve it in the
browser window, then the flow continues. Because the dedicated profile keeps your logins, most login walls
disappear after you sign in once.
Step by step: what happens
| # | Step | Detail |
|---|---|---|
| 1 | Navigate | Opens the URL in a new tab of the attached Chrome (or a launched browser) |
| 2 | Duplicate check | Skips URLs already submitted |
| 3 | Bot-check | Screenshots and stops if a captcha/login wall/challenge is detected |
| 4 | Find the form | Uses it if present, otherwise clicks an "Apply" button/link to open it |
| 5 | Fill fields | Asks the LLM (resume.md + human_prompt.md + preferred answers) for each field's answer and applies it, uploading resume.pdf for resume fields; leaves alone what is already set |
| 6 | Gate | Sensitive answers are flagged; --dry-run / --review / policy decide whether the final Submit is clicked |
| 7 | Advance | Clicks Next/Submit, repeats for each page |
| 8 | Confirm | Verifies a success message ("thank you" / "application received" / …) |
| 9 | Log | Writes the full record (Q&A, screenshots, status, timings, versions) throughout |
Preferred answers (override the resume)
The LLM answers from your resume by default. For questions where you want a fixed answer — relocation,
sponsorship, "how did you hear about this role", EEO self-identification — write the answer down once.
Preferred answers live in Markdown files in <config dir>/preferred_answers/ (job-applier init copies
templates there):
| File | Used for |
|---|---|
general.md |
Every application |
amazon.md, henryscheinone.md, workday.md … |
One company — file named after the company, lowercase, letters and digits only |
Each entry is a question and an answer; leave an answer empty to let the LLM use your resume:
- Q: Are you willing to relocate?
A: Yes
- Q: Salary expectations
A:
Precedence: company file > general file > resume. To point somewhere else, set
JOBAPPLIER_PREFERRED_ANSWERS_GENERAL / JOBAPPLIER_PREFERRED_ANSWERS_<COMPANY> (company name upper-cased,
letters and digits only: Henry Schein One → ..._HENRYSCHEINONE) or preferred_answers_dir in
config.toml. Sensitive fields (see above) with an answered entry are automated; those without one wait for
your --review. Changing a preferred-answers file automatically ignores older cached answers.
Configuration
Precedence, resolved in one place (job_applier/config.py): **CLI flag > environment variable > config.toml
default.**
config.tomlkeys are the setting names; each has aJOBAPPLIER_<NAME>environment variable (see.env.example).
cdp_url = "http://127.0.0.1:9222"
max_api_calls = 40
max_spend_usd = 0.50
price_input_per_mtok = 3.0 # your model's price, USD per million tokens
price_output_per_mtok = 15.0
step_delay_min_ms = 300
step_delay_max_ms = 1200
API key: a real environment variable wins, then the OS keyring (job-applier init stores it there),
then a .env file. doctor reports only where the key came from, never the key.
Useful flags
| Flag | What it does |
|---|---|
--cdp-url http://127.0.0.1:9222 |
Attach to the dedicated Chrome instead of launching a browser |
--dry-run / --review |
See Safety features |
--force |
Apply even if the URL was already submitted |
--retries N |
Extra attempts after a transient browser/network failure |
--max-api-calls N, --max-spend USD |
Hard per-run LLM limits |
--sensitive-policy review|auto |
What to do with sensitive answers that have no preferred answer |
--no-headless, --slow-mo 250, --devtools, --browser-channel chrome |
Launch-mode browser options (not used when attached) |
--verbose, --playwright-debug |
Console progress; Playwright's own noisy log |
--pause-on-block |
On a blocked/error finish, pause in the Playwright Inspector (launch mode) |
--wait-for-captcha, --wait-for-submit |
Human-in-the-loop CAPTCHA / submit |
--action-tool pydoll |
Use the experimental Pydoll backend (pip install "job-applier[pydoll]") |
--company-name, --hr-system-name |
Select a specialized flow, see below |
--resume-md, --resume-pdf, --human-prompt, --output-json |
Override the default paths |
--startup-buffer-ms, --step-delay-min-ms, --step-delay-max-ms |
Pacing |
Switching the browser tool
Every browser action goes through job_applier/actions.py, which forwards to the backend named by
JOBAPPLIER_ACTION_TOOL / --action-tool. Backends live in job_applier/actiontools/ (playwright.py,
pydoll.py); flows never call a browser library directly. To add a tool, subclass ActionTool
(actiontools/base.py) and register it in actiontools/__init__.py.
Playwright is the supported backend. Pydoll is experimental: its attach mode and tab lifecycle are
verified against a real browser in the test suite (tests/test_attach.py), but the full apply flows have only
been exercised with Playwright, and it does not support request interception (Ashby's resume-autofill blocking
is skipped with a warning).
Per-company / per-HR-system flows
job-applier "<url>" --hr-system-name "Greenhouse" --company-name "Acme Inc"
| Given | JobApplier looks for |
|---|---|
--hr-system-name |
A matching module in job_applier/hr_company_applyflows/ (tried first) |
--company-name |
Same folder, tried if no HR-system match |
| Neither, or no match | generic.py — the default flow |
Currently supported:
| Companies | HR systems |
|---|---|
| Henry Schein One, Amazon | Ashby, BambooHR, ApplyToJobs, Greenhouse, Rippling ATS, Jobvite, Workday |
Adding one: drop a module named after the normalized name (lowercase, no punctuation) in
job_applier/hr_company_applyflows/, exposing:
def run(page, settings, record, log, client_bundle, resume_text, human_prompt_text, job_url, qa_cache):
...
Reuse pieces from job_applier/apply_flow.py (fail, fill_form_once, find_next_button, has_success_text,
…) — see generic.py. Final Submit clicks must go through apply_variations.click_with_retry_budget, which is
where --dry-run/--review are enforced for every flow; a flow that submits some other way would bypass them.
Output
Each run appends/updates a record in results/<date>_regular_job_applications.json. The schema is
versioned (schema_version); fields are only ever added within a version.
{
"schema_version": 1,
"job_applier_version": "0.2.0",
"job_url": "https://company.example.com/careers/apply/123",
"normalized_url": "https://company.example.com/careers/apply/123",
"company_name": "Acme Inc",
"hr_system_name": "Greenhouse",
"backend": "playwright",
"browser": { "name": "chromium", "version": "150.0.0.0", "attached": true },
"started_at": "2026-09-17T10:00:00",
"finished_at": "2026-09-17T10:02:31",
"duration_seconds": 151.2,
"status": "submitted",
"error_class": null,
"dry_run": false, "review": false, "reviewed": false, "submit_attempted": true,
"attempts": 1, "retries": [],
"questions": [{ "label": "First name", "kind": "text", "answer": "Umer" }],
"flagged_fields": [],
"llm_usage": { "api_calls": 12, "input_tokens": 9400, "output_tokens": 310, "spend_usd": null },
"screenshot": null, "action_failures": [], "note": null
}
| Field | Meaning |
|---|---|
status |
submitted, dry_run, review_required, review_declined, interrupted, skipped_duplicate, blocked, bot_detected, error, or in_progress |
error_class |
config, browser, captcha_or_login, validation, submit_failed, cancelled, budget, llm, unknown |
questions |
Every field the form asked, with the answer given |
flagged_fields |
Sensitive answers that had no preferred answer |
Privacy and personal data
- Everything stays on your machine, except what is sent to your LLM provider: field labels/options, the
job URL, and your resume text (
resume.md) and preferred answers as prompt context. Nothing else is sent anywhere. There is no telemetry and no analytics. - Results, screenshots, the answer cache and logs contain personal information (your answers, page screenshots). They live in your per-user directory; keep them out of repositories and do not attach them to bug reports without redacting them.
- Logs are structured JSON lines, redact anything that looks like an API key, and never contain form answers or resume text (only field labels). The results JSON does contain the Q&A trail by design.
- API keys are stored in the OS keyring when available; otherwise in
.envwith owner-only permissions (Unix mode 600; on Windows the file inherits your user-profile permissions).
Limitations
- Supported flows are the HR systems and companies listed above; everything else uses the generic flow,
which may get stuck (
blocked) on unusual forms. - Submit detection is by control label (Submit / Send application / Finish / Apply after fields are filled).
A site that submits through an unlabeled control would bypass
--dry-run/--review— please report it. --reviewneeds an interactive terminal; batch runs with--reviewend asreview_required.- Two application URLs for the same job that differ in a meaningful query parameter are treated as different applications, so the duplicate check can miss them.
- Chrome-specific: attach mode drives Chrome/Edge (Chromium) only.
Development
pip install -e ".[dev,pydoll]"
python -m playwright install chromium # the test suite uses it (flow fixtures, real-CDP attach tests)
python -m pytest
python scripts/check.py # ruff + mypy + pytest (add --fast to skip the Chromium tests)
CI runs on Windows, macOS and Linux with Python 3.10–3.14. A manual "Live smoke test" workflow loads a real
application form with --dry-run; live submissions are never part of CI.
Standalone — no runtime dependency on PlaywrightURLJsonExtractor or Linkedin-RegularApplyBot.
job_applier/detector is a vendored copy of PlaywrightURLJsonExtractor's bot-detection code; the LLM-calling
pattern is adapted from its qa.py.
Release files for job-applier 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| job_applier-0.2.0.tar.gz | 169.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| job_applier-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 334.6 kB
Release files / job_applier-0.2.0.tar.gz
| Download URL | job_applier-0.2.0.tar.gz |
|---|---|
| Size | 169.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
18d7f0174a0ebddc81ec40727488048c611e717aa76a7ec7cacc549099306e87
|
|
BLAKE2b-256 checksum How to use checksums |
f41a5ad7f6b2ab46eb021b48c8091d923d7d2215f69c1102346459b92c94777f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / job_applier-0.2.0-py3-none-any.whl
| Download URL | job_applier-0.2.0-py3-none-any.whl |
|---|---|
| Size | 164.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0ff1cea9a74fa3f7cc9e66e5a71be03caedc0efed4623c061c2f33c2f40f8c2a
|
|
BLAKE2b-256 checksum How to use checksums |
0526fc851f26734a13b9f1564040cd154826ce0a9993688df5c08185539e96f1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|