All articles
Build Guides

Build a Resume Tailoring Agent That Rewrites Your CV for Any JD Using Gemini’s Free Tier

FDE Coach EditorialJuly 24, 202611 min read

What We’re Building

A command-line Python agent that takes two inputs—your base resume in Markdown and a job description URL—and produces a tailored PDF where every experience bullet is rewritten to mirror the JD’s keywords, technologies, and impact language. It uses Google Gemini’s free tier (gemini-1.5-flash) for extraction and rewriting, BeautifulSoup for scraping, and pdfkit to render the final document. No API keys that cost money, no cloud dependencies, no fluff.

Feature list:

  • Scrapes any public job posting URL and extracts raw text
  • Parses your base resume (Markdown) into structured sections
  • Uses Gemini to extract a weighted keyword map from the JD
  • Rewrites each experience bullet to emphasize matching skills and quantified impact
  • Preserves your original formatting and section order
  • Renders a clean, ATS-friendly PDF with consistent styling
  • Runs entirely on free-tier compute and API quotas

Architecture and Data Flow

The flow is linear but stateful. We first scrape the JD URL into clean text. That text and your base resume Markdown are sent to Gemini in a single prompt that returns both a keyword analysis and rewritten bullet points. We then splice the rewritten bullets back into the original Markdown structure and render to PDF. The free tier handles roughly 1,500 requests per day—more than enough for a personal job-search pipeline.

Prerequisites and Free-Tier Setup

You need four things, all free:

  1. Python 3.10+ – your local runtime. Download from python.org.
  2. Google Gemini API key – free tier gives you 1,500 requests/day. Go to Google AI Studio, click “Create API Key,” and copy it. No billing required.
  3. wkhtmltopdf – the engine pdfkit uses to render PDFs. Install via your package manager:
    • macOS: brew install wkhtmltopdf
    • Ubuntu/Debian: sudo apt-get install wkhtmltopdf
    • Windows: download from wkhtmltopdf.org and add to PATH
  4. A base resume in Markdown – write one if you haven’t. Structure it with ## Experience, ### Company Name, and bullet points using - . We’ll parse this structure.

Step 1: Project Scaffold and Dependencies

Create a new directory and a virtual environment:

mkdir resume-agent && cd resume-agent
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

Install the required packages:

pip install google-generativeai beautifulsoup4 requests pdfkit markdown

Create a .env file for your API key:

GEMINI_API_KEY=your-api-key-here

Create main.py—this will hold the entire agent. We’ll build it section by section.

Step 2: Scraping the Job Description

We need a function that takes a URL and returns clean, readable text. Most job boards render the description inside <div>, <section>, or <article> tags. We’ll use BeautifulSoup with a fallback to raw text extraction.

import requests
from bs4 import BeautifulSoup

def scrape_jd(url: str) -> str:
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
    }
    resp = requests.get(url, headers=headers, timeout=15)
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    # Try common containers first
    for selector in ["article", "[class*='description']", "[id*='description']", "section"]:
        container = soup.select_one(selector)
        if container and len(container.get_text(strip=True)) > 200:
            return container.get_text(separator="\n", strip=True)

    # Fallback: body text
    body = soup.find("body")
    if body:
        return body.get_text(separator="\n", strip=True)

    return ""

This is deliberately simple. For production, you’d add retries, proxy rotation, and per-site parsers (LinkedIn, Greenhouse, Lever). But for 90% of public JD pages, this works.

Step 3: Loading and Parsing Your Base Resume

We’ll treat the Markdown resume as a structured document. We need to identify sections and, crucially, isolate experience bullets so we can send only those to Gemini for rewriting—preserving everything else (contact info, education, skills summary) untouched.

import re
from pathlib import Path

def load_resume(path: str) -> dict:
    text = Path(path).read_text(encoding="utf-8")
    sections = {}
    current_section = "header"
    sections[current_section] = []

    for line in text.splitlines():
        if line.startswith("## "):
            current_section = line[3:].strip()
            sections[current_section] = []
        else:
            sections[current_section].append(line)

    return sections

def extract_experience_bullets(sections: dict) -> list[tuple[int, str]]:
    """Return list of (line_index, bullet_text) for all experience bullets."""
    bullets = []
    idx = 0
    in_experience = False
    for section_name, lines in sections.items():
        if section_name.lower().startswith("experience"):
            in_experience = True
        else:
            in_experience = False
        for line in lines:
            if in_experience and line.strip().startswith("- "):
                bullets.append((idx, line.strip()[2:]))
            idx += 1
    return bullets

We keep a flat index so we can splice rewritten bullets back into the exact position later.

Step 4: The Gemini Prompting Strategy

This is the core intellectual work. We’re asking Gemini to do two things in one call (to save quota): extract a keyword map from the JD, then rewrite each bullet using that map. The prompt must be explicit about output format so we can parse it reliably.

import os
import google.generativeai as genai
from dotenv import load_dotenv

load_dotenv()
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))

def build_prompt(jd_text: str, bullets: list[str]) -> str:
    bullet_str = "\n".join(f"{i}: {b}" for i, b in enumerate(bullets))
    return f"""You are an expert resume optimization engine.

First, analyze this job description and extract the top 15 keywords/phrases with weights (1-10) based on importance and frequency. Output as JSON array of {{keyword, weight}}.

Second, rewrite each numbered experience bullet below to incorporate relevant keywords naturally while preserving the original achievement and quantified impact. Do NOT fabricate experience. Maintain professional tone. Return as JSON object mapping bullet index to rewritten text.

Job Description:
{jd_text[:8000]}

Experience Bullets:
{bullet_str}

Respond ONLY with valid JSON in this exact format:
{{
  "keywords": [{{"keyword": "...", "weight": 10}}, ...],
  "rewritten": {{"0": "...", "1": "...", ...}}
}}"""

We truncate the JD to 8,000 characters to stay well within Gemini’s context window while preserving the signal. The free tier’s rate limit is 15 RPM—we’ll add a retry wrapper.

Step 5: Rewriting Experience Bullets

Now we call Gemini, parse the response, and splice the rewritten bullets back into the full resume.

import json
import time

def rewrite_bullets(jd_text: str, bullets: list[str], max_retries: int = 3) -> dict:
    model = genai.GenerativeModel("gemini-1.5-flash")
    prompt = build_prompt(jd_text, bullets)

    for attempt in range(max_retries):
        try:
            response = model.generate_content(prompt)
            raw = response.text.strip()
            # Strip markdown code fences if present
            if raw.startswith("```"):
                raw = raw.split("\n", 1)[1].rsplit("\n", 1)[0]
            return json.loads(raw)
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)

def apply_rewrites(sections: dict, rewritten: dict, bullet_map: list[tuple[int, str]]) -> str:
    # Build a lookup: line_index -> new text
    rewrite_lookup = {}
    for new_idx, (orig_idx, _) in enumerate(bullet_map):
        if str(new_idx) in rewritten:
            rewrite_lookup[orig_idx] = rewritten[str(new_idx)]

    # Reconstruct the full Markdown
    output_lines = []
    global_idx = 0
    for section_name, lines in sections.items():
        output_lines.append(f"## {section_name}")
        for line in lines:
            if global_idx in rewrite_lookup:
                output_lines.append(f"- {rewrite_lookup[global_idx]}")
            else:
                output_lines.append(line)
            global_idx += 1

    return "\n".join(output_lines)

This approach guarantees we never touch your education, skills, or contact sections. Only experience bullets get rewritten.

Step 6: Generating the Tailored PDF

We’ll convert the tailored Markdown to HTML using Python’s markdown library, wrap it in minimal CSS for ATS compatibility, and render with pdfkit.

import markdown
import pdfkit

def markdown_to_pdf(md_text: str, output_path: str):
    # Convert MD to HTML
    html_body = markdown.markdown(md_text, extensions=["extra"])

    # Wrap in clean, print-friendly HTML
    full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
  body {{ font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #1a1a1a; max-width: 800px; margin: 0 auto; padding: 20px; }}
  h2 {{ font-size: 14pt; border-bottom: 1.5px solid #333; padding-bottom: 4px; margin-top: 24px; }}
  h3 {{ font-size: 12pt; margin-bottom: 2px; }}
  ul {{ margin-top: 4px; padding-left: 20px; }}
  li {{ margin-bottom: 4px; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"""

    options = {
        "page-size": "Letter",
        "margin-top": "0.5in",
        "margin-right": "0.5in",
        "margin-bottom": "0.5in",
        "margin-left": "0.5in",
        "encoding": "UTF-8",
        "no-outline": None,
    }
    pdfkit.from_string(full_html, output_path, options=options)

Step 7: Running the Agent End-to-End

Tie everything together in main.py:

def main():
    import sys

    if len(sys.argv) != 3:
        print("Usage: python main.py <resume.md> <job_description_url>")
        sys.exit(1)

    resume_path = sys.argv[1]
    jd_url = sys.argv[2]

    print("Scraping job description...")
    jd_text = scrape_jd(jd_url)
    if not jd_text:
        print("Error: Could not extract job description.")
        sys.exit(1)

    print(f"Extracted {len(jd_text)} characters from JD.")

    print("Loading resume...")
    sections = load_resume(resume_path)
    bullets = extract_experience_bullets(sections)
    bullet_texts = [b for _, b in bullets]
    print(f"Found {len(bullet_texts)} experience bullets to rewrite.")

    print("Calling Gemini for keyword extraction and rewriting...")
    result = rewrite_bullets(jd_text, bullet_texts)
    print(f"Top keywords: {', '.join(k['keyword'] for k in result['keywords'][:5])}")

    print("Applying rewrites...")
    tailored_md = apply_rewrites(sections, result["rewritten"], bullets)

    output_pdf = "tailored_resume.pdf"
    print(f"Generating PDF: {output_pdf}")
    markdown_to_pdf(tailored_md, output_pdf)

    print("Done! Tailored resume saved to", output_pdf)

if __name__ == "__main__":
    main()

Run it:

python main.py base_resume.md "https://example.com/jobs/12345"

You’ll see the extracted keywords in your terminal, and tailored_resume.pdf will appear in your working directory.

Sensible Extensions

Once the core loop works, you can extend it in high-leverage ways:

  • Multi-JD batch mode: Point the script at a CSV of URLs and generate a tailored PDF for each, naming them by company.
  • Confidence scoring: Ask Gemini to return a 1-10 match score for each rewritten bullet against the JD. Surface low-confidence bullets for manual review.
  • Cover letter generation: Add a second prompt that generates a three-paragraph cover letter from the same keyword map. Output as page one of the PDF.
  • ATS keyword audit: After rewriting, run the tailored resume through the same keyword extraction to verify coverage. Flag gaps.
  • Git-based versioning: Commit each tailored resume to a private repo with the JD URL and date. Over time, you’ll see which keyword patterns correlate with interview invitations.

If you’re thinking about how this kind of rapid prototyping maps to Forward Deployed Engineering work—where you embed with customers and build exactly what they need on tight timelines—check out our piece on How FDEs Work with Product and Engineering After the Sale Closes.

Common Pitfalls and FAQ

Q: Gemini returns malformed JSON. What do I do? Add a retry with a stricter prompt suffix: “Respond ONLY with the JSON object. Do not include explanations.” The retry logic in Step 5 already handles markdown fences. If it persists, log the raw response and inspect—sometimes the JD contains curly braces that confuse the parser.

Q: The scraped JD is full of navigation text and ads. Switch the BeautifulSoup selector to something more specific for the job board you’re targeting. For Greenhouse, use div#content. For Lever, use div.posting. Build a small registry of site-specific selectors.

Q: My bullets come back sounding robotic or keyword-stuffed. Add a tone constraint to the prompt: “Preserve the original voice and avoid keyword stuffing. Every rewrite must read naturally to a human reviewer.” You can also lower the keyword weight threshold from 15 to 10 to focus only on high-signal terms.

Q: Can I use this for LinkedIn Easy Apply? Yes—the tailored Markdown can be copied directly into LinkedIn’s resume field or converted to DOCX with python-docx instead of pdfkit. The architecture doesn’t change.

Q: What if I hit the free-tier rate limit? Gemini’s free tier allows 15 RPM and 1,500 RPD. For a single user tailoring 10-20 resumes per day, you won’t hit it. If you’re batching hundreds, add time.sleep(5) between calls or upgrade to pay-as-you-go ($0.075 per 1M input tokens).

Q: How does this compare to the on-call incident summarizer you built? Both use Gemini’s free tier as the reasoning engine, but this agent focuses on structured document transformation rather than log analysis. If you’re interested in that pattern, see Build an On-Call Incident Summarizer That Reads Logs and Drafts a Postmortem with Gemini. The prompting architecture is similar—structured input, constrained output format, retry wrapper.

Q: What if I want to run this entirely locally without any API calls? You can swap Gemini for a local Ollama model. The pattern is nearly identical—we covered local LLM integration in Build a Smart Clipboard That Summarizes and Translates Anything You Copy with Ollama. The tradeoff is keyword extraction quality; smaller models struggle with nuanced JD parsing.

Q: I’m an FDE building this for a customer. How do I hand it off to their internal team? Package it as a simple Streamlit app with file upload and URL input fields. Document the prompt engineering decisions so their team can tune for their industry’s jargon. For more on the handoff pattern, read Scaling Yourself: When and How an FDE Hands Off to Core Engineering.

This agent is a concrete example of the FDE mindset: identify a high-friction manual process, wire together free-tier APIs into a working prototype in under 100 lines of logic, and deliver immediate value. The same pattern applies whether you’re optimizing resumes, generating customer-facing reports, or building internal tooling during a customer engagement.

#resume#automation#gemini#job-search

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now