job-apply
Submit a job application through the employer's ATS (Greenhouse, Lever, Ashby, Teamtailor, Workable, Recruitee, Breezy, traffit, SmartRecruiters, LinkedIn Easy Apply) with Playwright, then record it i
SKILL.md.claude/skills/job-apply/SKILL.md
GitHubjob-apply — one application, by script
The browser work is done by scripts/apply.mjs (Playwright on the installed Chrome). You never read forms or take screenshots: you run a command and read one JSON line.
Facts the forms need
profile.json is the single source for every field: name, phone, links, CV path, salary (number per currency and a sentence), notice period, work authorisation, languages, education, work history. Edit it, never the script, when a fact changes. profile.md is the same in prose, with the reasoning behind each answer. Both are personal and stay out of git; a fresh checkout starts by copying profile.example.json and profile.example.md to those names.
Run it
node scripts/apply.mjs <applyUrl> --url <postingUrl> --company "<co>" --title "<title>" \
--source <indeed|linkedin|himalayas> --cover auto --stack "<comma list from jobs-fetch>" \
[--location ".."] [--salary ".."] [--pay '<PayRange[] json>'] [--answers <json>] [--office-ok] [--dry-run] [--headed] [--debug]
--office-ok: the company is one of the giants in Settings → "My terms" (Google, Apple, Netflix, Meta, Microsoft, Amazon, Nvidia, …;OFFICE_OKin src/profile.ts reads them) and the office is in the city named there (Warsaw). Then LinkedIn's Hybrid/On-site tag does not stop the run.applyUrlis the ATS page (applyUrlfromjobs-fetch),--urlis the posting as the dashboard knows it — that is the funnel's dedupe key.--cover autowrites a four-paragraph letter from a template, adding one sentence per stack hint (LLM, GraphQL, Electron, Next.js, accessibility). Pass a file path or text instead to write your own.--payis the posting's published ranges (the dashboard'spay, e.g.[{"contract":"b2b","min":5800,"max":8300,"currency":"USD","unit":"month"}]): a salary field asks for the top of the range on its contract and currency when that beats profile.json's figure. Without it the profile's figures stand.--dry-runfills everything, submits nothing, saves a full-page screenshot under~/.job-hunt/shots/and prints the field→value map. Use it the first time a new board shows up.
Result line (stdout, JSON) and exit code:
result |
exit | Meaning / what to do |
|---|---|---|
applied |
0 | Submitted and confirmed; funnel says #id inserted. Done. |
dry-run |
0 | Filled, not sent. Read filled, then run again without --dry-run. |
missing-answers |
2 | A required field matched no rule. missing[] lists label (cut at 200 characters: the --answers key), the whole question, type and options. Write {"label substring": "answer"} into a JSON file and re-run with --answers. For dropdowns the answer may be `"a |
submit-failed |
3 | Submit clicked, no confirmation. errors[] has the validation text; screenshot shows the page. Fix the rule, re-run. |
no-form / needs-account / captcha |
4 | Nothing to fill, or the board wants an account or a CAPTCHA. Hand the URL to the user — never create accounts, never solve CAPTCHAs. |
board-form |
4 | A job board's posting whose application only its own form takes: justjoin.it offers without an employer link, every NoFluffJobs posting (both its modes sit behind a CAPTCHA), Himalayas. Answered at once, no browser opened. Hand the posting to the user. |
closed |
4 | The board says the offer is gone. Hide the job; not recorded as an attempt. |
not-remote |
5 | LinkedIn's own tag says hybrid/on-site. Hide the job with that reason — unless it is a giant's Warsaw office: re-run with --office-ok. |
needs-code |
6 | The board e-mailed a "confirm you're a human" code (Greenhouse does). Re-run with --headed: the window stays open and the user types the code. The script never reads the mailbox. |
Rules live in answerFor() in the script: label regex → value. When a
missing-answers label is one that will recur (a new phrasing of "notice
period", a Polish label), put the answer in the bank instead of an
--answers file:
node scripts/apply.mjs --remember "<label substring>" "<answer>"
It goes into answers in profile.json and is used on every later form
(after this run's --answers, before the built-in rules). Only facts the user
has confirmed go in the bank; ask when unsure.
Every outcome short of applied is recorded as an attempt (the dashboard's
"needs you" list, node scripts/funnel.mjs attempts) until the job is applied
to or hidden — no need to keep a list in the chat.
Boards
| Board | Entry | Notes |
|---|---|---|
Greenhouse (job-boards.greenhouse.io, .eu., or any page with ?gh_jid= — an embedded board in an iframe) |
form on the posting page | react-select dropdowns; "Enter manually" opens the cover-letter box; e-mails an 8-character security code on submit → the board runs headed and the user types it (needs-code when headless). A thank you for applying e-mail from greenhouse-mail.io is the proof of receipt; the script's applied means the form was replaced by a confirmation |
| Lever | /apply |
plain form |
| Ashby | /application |
"Autofill from resume" first, then fields |
Teamtailor (careers.<co>.com/jobs/<id>-slug) |
"Apply for this job" | parses the CV, wait ~7 s |
Workable (apply.workable.com) |
"Apply now" | multi-step |
Recruitee (<co>.recruitee.com/o/<slug>) |
/c/new |
free-text question takes the cover letter |
Breezy (<co>.breezy.hr/p/<id>) |
/apply |
currency + period selects next to the salary number; Work History / Education are parsed from the CV |
traffit (<co>.traffit.com/public/an/<hash>) |
the apply link's href (/public/form/a/…), whatever its text — employers rename it («join cerebre»), so it is found by its target |
Polish labels: kraj, miasto, obszar technologiczny, rola projektowa, stawka PLN/h |
| SmartRecruiters | "I'm interested" → oneclick-ui |
one long form |
| LinkedIn (logged-in profile) | the job page | Reads the Remote/Hybrid/On-site chip first (not-remote otherwise). Easy Apply = native <dialog> with Next → Review → Submit application. "Apply on company website" (data-view-name="job-apply-button") opens a "Share your profile?" dialog → the script presses Continue, then follows the new tab to the employer's ATS and continues there (often an embedded Ashby/Greenhouse iframe, which the script opens standalone) |
justjoin.it (justjoin.it/job-offer/<slug>) |
the offer's applyUrl in the page data |
the script reads it first and fills the employer's form (traffit, recruitee, Greenhouse…; 65 of 86 offers on 2026-10-24); null = justjoin's own form → board-form |
| NoFluffJobs, Himalayas | — | board-form at once: NFJ gates both its form and its redirect with a CAPTCHA; Himalayas hides the employer's link behind Cloudflare |
| Workday, Cognizant, EPAM, Luxoft, Indeed Apply | — | account required → needs-account, list for the user |
LinkedIn login is a one-off: node scripts/apply.mjs --login opens a window on
the automation profile (~/.job-hunt/chrome-profile); the user logs in and
closes it. Other boards use ~/.job-hunt/chrome-profile-ats, so a LinkedIn
window never blocks them.
Batch
node scripts/apply-batch.mjs --fetched <fetched.json> [--only 2,10] [--skip 5] [--answers <dir>] [--dry-run] [--out results.json]
One line per job: idx | company — title | result | detail | seconds. Rows
that need an account, errored in fetch, or are closed are reported as
skipped. --answers <dir> picks <dir>/<idx>.json per job. Re-running with
the same --out skips rows already applied.
What "applied" means
applied is printed when the submit button was pressed, the form is gone and
the page shows an application-specific confirmation (thank you for applying,
application submitted…). Generic "thank you" text is not enough — job
descriptions contain it. Boards that confirm by e-mail (Greenhouse, Breezy,
Ashby when configured) can be double-checked in the inbox; a CAPTCHA puzzle
(Recruitee uses one) or a security code means the submission did not go
through until the user finishes it.
Hard limits
No account creation, no passwords, no CAPTCHAs, no verification codes read
from the mailbox, no fields the profile does not answer (they come back as
missing-answers). A successful submit is recorded immediately in the
funnel as applied via POST /api/applications.