LinkedIn Job Agent
Supervised Playwright automation agent that scores LinkedIn jobs, generates tailored resumes via a remote API, and submits Easy Apply applications — with quota enforcement, audit trails, and human-like behavior.
Role
Architect & Lead Engineer
Timeline
2026
Type
Automation
Visual Proof
Problem & Solution
Senior Role Hunting at Scale is Unsustainable Manually
Finding and applying for senior full-stack engineering roles on LinkedIn is a multi-hour daily grind. Each application involves: finding a relevant job amid hundreds of misfits (wrong level, on-site only, UK citizenship required), reading and evaluating the JD, tailoring a resume, navigating a 3–12 step Easy Apply modal with repetitive questions (work auth, years of experience per technology, salary expectations in different currencies and units), uploading a resume, and submitting. Doing this at the volume needed to generate real pipeline — 5 to 10 qualified applications per day — consumes the kind of focused time that should go into building.
- LinkedIn shows hundreds of jobs per search — most are junior, on-site, or geo-restricted
- Each Easy Apply session is 3–12 steps with different question types: text inputs, radios, selects, comboboxes
- Questions vary by employer: years of React, work authorization in UK/US/EU, expected annual salary in GBP — no schema, no consistency
- Manually tailoring a resume per JD to beat ATS scoring takes 20–30 minutes per application
- No deduplication — nothing preventing applying to the same role twice across different search sessions
- No audit trail — no record of what was submitted, what resume version was used, or what answers were given
Supervised Playwright Agent with Scoring, Resume Generation, and Full Audit Trail
A local TypeScript CLI that runs a persistent Chromium session (stays logged into LinkedIn), scrapes job cards from a search URL, scores each job against a weighted rule engine, calls a remote Resume OS API to generate a tailored PDF per accepted application, then walks the full Easy Apply modal — filling every field automatically and asking the terminal for anything unknown. A quota system prevents over-application; an audit bundle captures screenshots and full action logs per submission.
- Job scoring engine: weighted regex patterns across JD text — +25 senior title, +20 React/Next.js, +15 remote, −100 UK/US-only hard rejection
- Region-aware form filling: work authorization and sponsorship answers vary by detected country (US, UK, EU, Germany, Morocco, etc.)
- Salary answer resolution: detects unit (annual/monthly/hourly/daily) and currency (USD/EUR/GBP) from question text — returns correct tier from structured salary config
- Technology-years matcher: 30+ skill keywords map to experience years from profile.json — answers 'How many years of React?' correctly
- Multi-strategy Easy Apply locator: button detection via text match, aria-label, and href pattern — resilient to DOM changes
- Stuck-modal detection: tracks dialog text across steps — escalates after two identical steps instead of looping indefinitely
- ResumeOS integration: per-application tailored PDF generated via remote API, ATS score returned, PDF downloaded and uploaded in the modal
- Applied-jobs registry: file-based dedup store prevents re-applying to already-submitted jobs across sessions
- Audit bundle: before/after screenshots, full action log, score, resume filename, and ATS score saved per application
- Quota + human delay: daily/weekly apply limits with randomized inter-application delays and batch breaks
Architecture
Two processes, one persistent state store. The agent runs locally as a Node.js process controlling a persistent Chromium profile (gitignored, stays logged in). Resume generation is delegated entirely to the remote Resume OS API — the agent never generates PDF content itself. All operational state (quota counts, applied-jobs registry, audit logs) lives in local files, keeping the agent stateless enough to run from any machine with the profile directory.
- 1
Agent opens LinkedIn search URL in persistent Chromium profile (already authenticated)
- 2
Job card extractor scrapes visible cards: title, company, location, job URL
- 3
For each card: navigate to job page, extract full JD text from DOM
- 4
Job scorer evaluates title + JD text: applies weighted pattern rules, hard rejection rules, flag detection
- 5
SKIP/REVIEW jobs are logged and skipped; ACCEPT jobs proceed
- 6
Quota check: daily and weekly apply counts read from local JSON — halts batch if limit reached
- 7
Resume OS API called with JD + title + company → returns tailored PDF filename and ATS score
- 8
PDF downloaded to local temp path via Playwright's network context
- 9
Easy Apply modal opened; per-step loop: fillKnownFields → uploadResume → detect Next/Submit button
- 10
On Submit: optional terminal confirmation gate; audit bundle snapshot taken before and after
- 11
Post-submit: Resume OS marked APPLIED, job ID written to registry, quota counter incremented, full log saved
Technical Challenges
Mapping Arbitrary Question Text to Structured Answers
Why it was hard
LinkedIn Easy Apply questions have no schema. Employers write free-form questions: 'How many years of experience do you have with React.js?', 'Are you legally authorized to work in the United Kingdom?', 'What is your expected annual salary in GBP?'. The same semantic question appears in dozens of surface forms. There is no type attribute, no name convention, no data contract to rely on.
How I solved it
Built a layered question resolver: identity fields matched by keyword (email, phone, city, LinkedIn), work authorization questions parsed for region using 12 country/region patterns then delegated to per-region work auth config, salary questions parsed for unit (annual/monthly/hourly/daily) and currency (USD/EUR/GBP) using regex before looking up the correct tier from a structured salary config, technology years matched against 30+ skill keyword tables. Unknown questions fall through to an interactive terminal prompt rather than being skipped silently.
Result
The vast majority of Easy Apply forms are completed without any terminal intervention. Unknown fields surface clearly in the terminal rather than being silently skipped, keeping the 'supervised' guarantee intact.
Multi-Step Modal Navigation with Stuck Detection
Why it was hard
Easy Apply modals have 3–12 steps, and the step count varies per employer. If a required field is unanswered, the modal doesn't advance — it silently stays on the same step. Without stuck detection, the agent would loop indefinitely on a step it cannot complete, or worse, click Next repeatedly and corrupt the form state.
How I solved it
The step loop captures dialog inner text before each Next click and compares it to the previous step's text. If the text is identical across two consecutive Next clicks, a stuck counter increments. At stuck count ≥ 2, the agent throws a descriptive error ('Easy Apply modal not advancing — likely an unanswered required field') rather than continuing. CAPTCHA detection runs on every step via a regex against dialog text — surfaces immediately rather than looping.
Result
Stuck forms fail fast with a clear error message. The application is logged as FAILED with the specific step and dialog text captured, making it easy to identify which question type needs a new resolver rule.
Reliable Easy Apply Button Detection Across DOM Variations
Why it was hard
LinkedIn changes its DOM structure regularly and uses different button patterns depending on the job type, the viewer's account state, and A/B tests. A single selector that works today may miss the button tomorrow. Failing to find the button silently skips an application that should have been submitted.
How I solved it
Three-strategy locator with priority fallback: (1) button element with text matching /Easy Apply/i, (2) button with aria-label containing 'Easy Apply', (3) any link or button with text 'Apply' whose href matches the LinkedIn jobs apply URL pattern. The same multi-strategy approach is used for Next/Review/Submit buttons inside the modal. Each strategy is tried in order; if all fail, the error is explicit and descriptive.
Result
Button detection is resilient to DOM changes that affect a single selector strategy. Failures are explicit — the agent never silently skips an Easy Apply job because it couldn't find the button.
Session Persistence Without Triggering LinkedIn Re-Authentication
Why it was hard
LinkedIn aggressively re-authenticates sessions that look automated: new Chromium instances, missing browser fingerprint, no persistent cookies. A headless browser with a clean profile gets challenged with CAPTCHA or email verification within minutes. Every re-authentication interrupts the batch and requires manual intervention.
How I solved it
The agent uses a persistent Chromium user data directory (chrome-profile/, gitignored) that accumulates real browser state: cookies, localStorage, IndexedDB, browser fingerprint. The profile is created once by the user logging in manually through the agent's --linkedin-session-check mode. Subsequent runs reuse the same profile — LinkedIn sees a returning browser, not a new automation session. The agent runs headed (visible window) by default to further reduce detection signals.
Result
Sessions persist across agent restarts without re-authentication. The --linkedin-session-check workflow provides a clear one-time setup path for new environments.
Key Engineering Decisions
File-based registry and quota store over a database
Why
The agent is a local CLI tool. Adding a database dependency (even SQLite) increases setup friction — the goal is clone → install → run in under 15 minutes. JSON files for the applied-jobs registry and quota counters are readable, portable, and diffable. They survive process crashes without transaction concerns because writes are atomic at the file level.
Alternative considered
SQLite would be appropriate if the registry grew to thousands of entries and needed indexed queries. At the volume of a single job search campaign, JSON files are simpler and the tradeoffs are acceptable.
Persistent headed Chromium profile over headless + session injection
Why
Headless Chromium with injected cookies is detected by LinkedIn's bot detection more reliably than a full headed browser with a real persistent profile. The persistent profile approach is how a real user's browser works — same fingerprint, same cookies, same browser history signals. Headed mode also allows the user to visually inspect what the agent is doing during supervised runs.
Alternative considered
Playwright's headless mode with stealth plugins (puppeteer-extra-stealth) can reduce detection for some sites. For LinkedIn specifically, the persistent real profile approach is more reliable and requires no additional dependencies.
Terminal confirmation gate before submission
Why
Fully automated submission without a human checkpoint risks submitting to jobs the scoring engine accepted incorrectly (false positives), with a resume that doesn't reflect a last-minute profile update, or to an application form that was filled incorrectly by the resolver. A terminal Y/N gate before each submit costs seconds per application but provides a meaningful safety check. The gate is configurable (AUTO_SUBMIT=true disables it for trusted batches).
Alternative considered
Full auto-submit mode is available via config. It's appropriate for mature, well-calibrated score profiles. The default is supervised to prevent early miscalibration from wasting applications on poor fits.
Impact
10× application throughput with higher relevance
Manual application rate of 2–3 qualified applications per hour expanded to 10+ per hour with the agent handling scoring, resume generation, form filling, and submission. Rejection of misfit roles (UK-only, junior, on-site) happens automatically — no time spent reading JDs for roles that would have been skipped anyway.
Tailored resume per application, not a generic one
Every submission uses a Resume OS-generated PDF tailored to the specific JD — keywords aligned, skills ordered by relevance, ATS score optimized. The difference in callback rate between a tailored and generic resume on ATS-screened applications is documented across hiring research.
Complete audit trail for every application
Every submission has a timestamped record: the resume filename, ATS score, score reasoning, form actions taken, pre- and post-submit screenshots, and submission confirmation signals. Nothing is applied without a trace.
Zero duplicate applications
The applied-jobs registry deduplicates by LinkedIn job ID across all sessions. Re-running the agent against the same search URL never re-applies to jobs already submitted — even if they reappear in the listing.
What I Would Improve Next
These aren't speculative features — they're the gaps I identified while building and operating the system.
Scoring model calibration from outcome data
The current scorer uses hand-tuned regex weights. With enough application outcome data (callback / no callback), a lightweight logistic regression or gradient boosted model trained on JD features and outcomes would outperform the static weights.
Cover letter generation per application
Resume OS already returns a coverLetterText field. Wiring it into the Easy Apply flow for applications that have a cover letter step would increase the quality signal per submission.
Multi-platform support beyond LinkedIn
The job scoring and resume generation layers are platform-agnostic. Extending the browser automation layer to handle Greenhouse, Lever, and Workday ATS forms would multiply reachable job inventory significantly.
Question → answer learning from terminal inputs
When the user answers an unknown question in the terminal, that answer could be persisted to profile.json's fallbackAnswers map. The same question on a future application would be answered automatically without prompting.
Scheduled runs with Cron + notification
Currently requires manual invocation. A cron-based scheduler with a push notification on batch completion (Slack/email) would make it fully hands-off for daily application runs.
Interested?
Let's talk about what I can build for you.
Open to senior full-stack roles, founding engineer positions, and contract engagements. Remote only.