Skip to content

Job Application Agent — End-to-End Plan

Published 30 September 2026

Goal and principles

Build a Python agent that pulls openings from public ATS job boards, ranks them against your profile, prepares tailored materials, and submits applications through the browser — with you approving each submission until the agent earns trust.

  • Discovery is API-first. Greenhouse, Lever, Ashby and SmartRecruiters expose public, unauthenticated job-listing JSON. Scrape HTML only as a fallback.
  • Applying is browser-first. Candidate-side submission APIs need the employer's API key, so the agent fills the hosted application form with Playwright, the same way you would.
  • Human in the loop by default. The agent drafts; you approve in a review queue. Graduate to auto-submit only for high-confidence matches on ATSes with a proven filler.
  • Quality over volume. Target 5–15 strong applications a day, not hundreds. Spray-and-pray gets flagged by recruiters and ATS spam filters.
  • Respect ToS. Skip LinkedIn and Naukri automation entirely (both prohibit it and ban accounts); use them only to discover company names you then look up on their ATS board.
  • Never fabricate. Every answer comes from your profile or answers bank; unknown questions pause for you.

Architecture

Each box is a Python package and each arrow a DB state change, so any stage can be re-run or tested alone; the review queue is the only place a submission is released.

flowchart LR
    A[connectors] -->|jobs: new| B[ranking]
    B -->|shortlisted| C[materials]
    C -->|drafted| D[apply: fill + verify]
    D -->|screenshot + answers| E[review queue]
    E -->|approved| F[apply: submit]
    F -->|submitted| G[notify / gmail sync]
    D -.->|needs_human / failed| E

Source connectors

Each ATS gets one Connector class with list_jobs(company) -> list[RawJob]; the hard part is knowing which companies use which ATS, so build a company registry first.

ATS Listing endpoint (public, no auth) Company key Apply path
Greenhouse GET boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true board token, e.g. stripe Hosted form at job-boards.greenhouse.io/{token}/jobs/{id}
Lever GET api.lever.co/v0/postings/{company}?mode=json site name Hosted form at jobs.lever.co/{company}/{id}/apply
Ashby GET api.ashbyhq.com/posting-api/job-board/{org}?includeCompensation=true org slug Hosted form at jobs.ashbyhq.com/{org}/{id}/application
SmartRecruiters GET api.smartrecruiters.com/v1/companies/{id}/postings company id Hosted form
Workday POST {tenant}.wd{N}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs (undocumented JSON) tenant + site Multi-page flow, needs an account per tenant — hardest
Company careers pages HTML scrape or sitemap URL Varies — route to manual

Note

Endpoints are as publicly documented or widely used as of the date above; confirm each with one curl before writing its connector.

Building the company registry (companies.yaml, token + ATS per company):

  1. Seed by hand with your target list (AI infra, ML systems, trading/infra firms).
  2. Detect the ATS from a careers URL: boards.greenhouse.io, jobs.lever.co, jobs.ashbyhq.com, myworkdayjobs.com in the link or page source.
  3. Grow it from aggregators such as YC's Work at a Startup, curated GitHub lists, and job-board search results, then run step 2 on each.
  4. Poll every 6–12 hours with polite rate limits (1–2 req/s per host), ETag/If-Modified-Since where supported, and a stable User-Agent.

Normalization and storage

Every connector maps its payload into one Pydantic Job model, stored in SQLite (Postgres later if needed); the application state machine lives beside it.

Table Key columns
companies id, name, ats, board_token, tier, last_polled_at
jobs id, company_id, ats_job_id, title, location, remote, team, description_md, comp_min, comp_max, posted_at, url, apply_url, content_hash, first_seen, last_seen, closed_at
scores job_id, profile_version, score, reasons_json, model
applications id, job_id, status, resume_variant, cover_letter, answers_json, screenshot_path, submitted_at, error
questions normalized_text, answer, source (bank / llm / human), approved
  • Dedup key: (ats, company, ats_job_id); also hash title + company + location to catch the same role posted on two boards.
  • Change detection: re-hash the description each poll; mark jobs missing from a poll as closed after two misses.
  • Application states: new → shortlisted → drafted → approved → submitted → (interview | rejected | ghosted), plus needs_human and failed.

Matching and ranking

Rank in three cheap-to-expensive passes so the LLM only sees jobs that survive the hard filters.

  1. Hard filters (rules, free): location / remote policy (Bangalore, India-remote, or visa-sponsoring), seniority keywords, excluded companies, title allow/deny lists, posted within N days.
  2. Semantic pre-score (embeddings, ~free): embed your profile and each job description; keep the top ~20% by cosine similarity.
  3. LLM judge (paid, only survivors): a structured-output call returns {score 0–100, must_haves_met, gaps, seniority_fit, why_apply, resume_variant}. Cache by (job content_hash, profile_version) so re-polls cost nothing.

Your profile is a single profile.yaml: experience, skills with depth, target roles, comp floor, location rules, deal-breakers. Tune thresholds by labelling 50 jobs yourself and checking the judge agrees on at least 80%.

Application materials

Prefer choosing among 2–3 hand-written resume variants over LLM-rewriting your resume per job; generation is reserved for cover letters and free-text answers.

  • Resume routing: variants such as ai-infra.pdf, backend.pdf, low-latency.pdf; the LLM judge's resume_variant field picks one.
  • Answers bank (answers.yaml): standing answers for the recurring fields — notice period, current/expected CTC, work authorization, relocation, years of experience, links, EEO choices (decline-to-answer by default).
  • Question matcher: normalize each form label, then exact match → fuzzy match (rapidfuzz) → embedding match against the bank. Below a confidence threshold, mark needs_human; your answer is saved back to the bank so the gap closes over time.
  • Free-text answers and cover letters: LLM drafts grounded only in profile.yaml + the job description, with a length cap and a "no claims outside the profile" check. Always shown to you in the review queue.
  • Versioning: store exactly what was sent per application, so you can prep for interviews from it.

Auto-apply engine

Use deterministic per-ATS fillers first and an LLM browser agent only as a fallback: hosted Greenhouse, Lever and Ashby forms are consistent enough that selectors beat an LLM on speed, cost and reliability.

  1. Open the apply URL in Playwright with a persistent browser profile (headed at first, so you can watch).
  2. Extract the form schema: walk inputs, selects, radios, checkboxes and file inputs; capture each label, type, options and required flag. Ashby and Greenhouse also embed question metadata in page JSON — prefer that when present.
  3. Resolve answers: standard fields from the profile, custom questions via the answers bank, the rest via LLM draft or needs_human.
  4. Fill: fill() for text, set_input_files() for resume, option matching for selects and radios; handle React-style comboboxes (Greenhouse location, Ashby selects) with type-then-pick.
  5. Verify before submit: re-read every field, check nothing required is empty, take a full-page screenshot.
  6. Review gate: push the screenshot + answers to your queue. On approval, click submit and wait for the confirmation text or URL.
  7. Record: status, confirmation screenshot, timestamp; on any exception, save a trace (context.tracing) and mark failed.

Fallback for unknown forms: hand the accessibility tree to an LLM that returns {selector, value} actions (or use a library such as browser-use), still behind the review gate.

CAPTCHAs and bot checks: Greenhouse and others can show reCAPTCHA or hCaptcha. Do not use solving services; pause the job as needs_human and let you finish it in the open browser. Keep volume low, add human-like pacing, and reuse one real profile rather than rotating fingerprints.

Workday: needs a login per tenant and a 5–7 page wizard. Leave it to phase 4, or keep it manual.

Tracking and follow-up

The review queue is the product: a small web UI where you approve, edit or skip drafts, and see every application's status.

  • Review UI: FastAPI + HTMX (or Streamlit for speed): list of drafted applications with score, reasons, screenshot, editable answers, and Approve / Skip buttons.
  • Daily digest: Telegram bot or email with new shortlisted jobs and items stuck in needs_human.
  • Inbox sync: poll Gmail (API, read-only scope) for ATS confirmation, rejection and interview emails; match on company + role and advance the status automatically.
  • Metrics: applications per week, response rate by ATS / resume variant / score band — this tells you whether the ranker and resumes are working.

Tech stack and repo layout

Concern Choice
Language / packaging Python 3.12, uv, ruff, pytest
HTTP httpx (async) + tenacity for retries
Models / validation pydantic v2
Storage SQLite via sqlmodel or SQLAlchemy; sqlite-vec for embeddings
Browser playwright (async), persistent context
LLM One provider interface; structured outputs for scoring and form actions
Scheduling APScheduler locally, or cron / systemd timer
UI / notifications FastAPI + HTMX; Telegram bot
Observability structlog, Playwright traces, per-run screenshots
jobagent/
  config/        profile.yaml, answers.yaml, companies.yaml, resumes/
  connectors/    base.py, greenhouse.py, lever.py, ashby.py, smartrecruiters.py, workday.py
  core/          models.py, db.py, dedup.py, scheduler.py
  ranking/       filters.py, embed.py, judge.py
  materials/     resume_router.py, question_matcher.py, writer.py
  apply/         base_filler.py, greenhouse.py, lever.py, ashby.py, llm_fallback.py
  review/        app.py (FastAPI), templates/
  notify/        telegram.py, gmail_sync.py
  tests/         fixtures/ (saved JSON + HTML forms), test_*.py

Save real job JSON and form HTML as fixtures so connectors and fillers are testable offline.

Roadmap

Phase 1 alone is useful — a daily digest of fresh roles at your target firms — so you get value in week 2 while the harder apply engine is still being built. Durations assume evenings and weekends; each gate must pass before the next phase starts.

Risks and mitigations

Risk Mitigation
Wrong or fabricated answer submitted Review gate; answers only from bank/profile; LLM text always reviewed
Form layout changes break a filler Fixture tests, pre-submit verification, fail to needs_human rather than guess
CAPTCHA / bot detection Low volume, one real profile, human finishes flagged forms
Duplicate applications to one company Dedup across boards; per-company cooldown (e.g. max 2 roles per 30 days)
Account bans (LinkedIn, Naukri) Not automated; discovery only
LLM cost creep Filters and embeddings before the judge; cache by content hash
Personal data leakage Keep profile, answers and resumes local; redact before logging
Low response rate Track by variant and score band; tighten targeting rather than raise volume