Back to Work
Automation

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

Node.jsTypeScriptPlaywrightChromiumResume OS APILinkedIn Easy Apply

Visual Proof

Problem & Solution

Problem

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
Solution

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.

System Architecture
Orchestrator
Node.jsTypeScriptCLI entry pointWorkflow router
Browser Automation
Playwright (Chromium)Persistent profileMulti-strategy locatorsScreenshot capture
Intelligence Layer
Job scorer (regex rule engine)Form field resolver (question → answer)Region detectorSalary/unit/currency parser
Remote Integration
Resume OS APITailored PDF generationATS scoreApplication tracking
State & Audit
Applied-jobs registry (JSON)Quota tracker (JSON)Audit bundle (screenshots + action log)Application logger
Data Flow
  1. 1

    Agent opens LinkedIn search URL in persistent Chromium profile (already authenticated)

  2. 2

    Job card extractor scrapes visible cards: title, company, location, job URL

  3. 3

    For each card: navigate to job page, extract full JD text from DOM

  4. 4

    Job scorer evaluates title + JD text: applies weighted pattern rules, hard rejection rules, flag detection

  5. 5

    SKIP/REVIEW jobs are logged and skipped; ACCEPT jobs proceed

  6. 6

    Quota check: daily and weekly apply counts read from local JSON — halts batch if limit reached

  7. 7

    Resume OS API called with JD + title + company → returns tailored PDF filename and ATS score

  8. 8

    PDF downloaded to local temp path via Playwright's network context

  9. 9

    Easy Apply modal opened; per-step loop: fillKnownFields → uploadResume → detect Next/Submit button

  10. 10

    On Submit: optional terminal confirmation gate; audit bundle snapshot taken before and after

  11. 11

    Post-submit: Resume OS marked APPLIED, job ID written to registry, quota counter incremented, full log saved

Technical Challenges

01

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.

02

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.

03

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.

04

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

01

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.

02

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.

03

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.

01

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.

02

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.

03

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.

04

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.

05

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.