Skip to main content

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-port is ignored on the default profile (so nothing can attach to your real profile). A dedicated --user-data-dir is 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 after job-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.toml keys are the setting names; each has a JOBAPPLIER_<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 .env with 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.
  • --review needs an interactive terminal; batch runs with --review end as review_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)

Source distribution for job-applier 0.2.0
File Size Uploaded
job_applier-0.2.0.tar.gz 169.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for job-applier 0.2.0
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page