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):
- Seed by hand with your target list (AI infra, ML systems, trading/infra firms).
- Detect the ATS from a careers URL:
boards.greenhouse.io,jobs.lever.co,jobs.ashbyhq.com,myworkdayjobs.comin the link or page source. - 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.
- 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), plusneeds_humanandfailed.
Matching and ranking¶
Rank in three cheap-to-expensive passes so the LLM only sees jobs that survive the hard filters.
- 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.
- Semantic pre-score (embeddings, ~free): embed your profile and each job description; keep the top ~20% by cosine similarity.
- 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'sresume_variantfield 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.
- Open the apply URL in Playwright with a persistent browser profile (headed at first, so you can watch).
- 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.
- Resolve answers: standard fields from the profile, custom questions via the
answers bank, the rest via LLM draft or
needs_human. - 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. - Verify before submit: re-read every field, check nothing required is empty, take a full-page screenshot.
- Review gate: push the screenshot + answers to your queue. On approval, click submit and wait for the confirmation text or URL.
- Record: status, confirmation screenshot, timestamp; on any exception, save a trace
(
context.tracing) and markfailed.
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 |