How I Built a Cover Letter Generator Using LLMs (and What I Learned)
It took me a year to publish the first post on this blog – but hey, better late than never. Let's start with something light (and hopefully useful): the story of how I built a tool that generates cover letters using large language models (LLMs). A mix of coding, discussion with AI, and a sprinkle of personal experience turned into a working solution.
Where Did the Idea Come From?
The idea came to life a year ago while I was between projects and hunting for a new role. As I browsed through job listings, I started using ChatGPT to write cover letters – mostly because doing it manually was, well, a pain.
I had my resume. I had job descriptions. I had opinions about the tone, language, and which experiences to highlight. The problem? All that information was scattered – some in my CV, some on LinkedIn, some in my head.
At some point, I thought: Wouldn't it be nice to have one tool where I upload my CV, paste a job description, add some context – and it writes the letter for me?
So… I built it.
➡️ Demo: cover-letter-generator.zeromski.dev
(No data is stored – it's a simple tool. You can also use fake data generator for demo purposes.)
What Does the App Do?
Based on my personal workflow and insights gained through experimenting with ChatGPT, I built a tool that:
- Lets you upload your CV as a PDF,
- Parses it into structured JSON data,
- Lets you edit and tweak that data,
- Accepts personalization inputs (motivation, values, tone, etc.),
- Generates a tailored cover letter based on a job description.
To build a solid input flow, I talked to GPT-4 for advice though GPT-3.5-turbo would be doing the actual writing. Beyond the basics like CV and job description, GPT-4 suggested a personalization section and career change switch.
Tech Stack
Frontend: React + TypeScript + Vite + TailwindCSS
Backend: Python, mainly to hide the OpenAI API key and expose two endpoints: /parse-cv
and /generate-cover-letter
.
CV Parsing: Simpler Than You'd Think
Here's where things got surprisingly smooth. A few years ago, parsing resumes was a whole industry – complex rule engines, OCR's, and endless frustration. (Remember when skill fall in role or vice versa?) Today? One well-crafted prompt gets the job done.
🔧 POST /parse-cv
- The PDF is parsed into plain text (
PdfReader
). - That text is sent to GPT with a prompt like:
Please parse the following CV/Resume text and extract information into a JSON structure.
Return ONLY valid JSON without any markdown formatting or additional text.
The JSON should have this exact structure:
{
"personalInfo": {
"name": "string",
"email": "string",
"phone": "string",
"location": "string"
},
"summary": "string",
"experience": [
{
"title": "string",
"company": "string",
"duration": "string",
"description": "string"
}
],
"education": [
{
"degree": "string",
"institution": "string",
"year": "string",
"description": "string"
}
],
"skills": ["string"],
"languages": [
{
"language": "string",
"proficiency": "string"
}
]
}
If any information is missing, use empty strings or empty arrays as appropriate.
CV Text:
{cv_text}
We also needed to tell our model how to behave.
{
"role": "system",
"content": "You are a CV parsing assistant. Return only valid JSON."
}
The result? Really impressive. Clean, structured JSON. Roles, skills, and timelines are correctly categorized. Only this and yet so much
{
"personalInfo": {
"name": "Łukasz Żeromski",
"email": "lukzerom@gmail.com",
"phone": "+48 517 866 840",
"location": "ul. Gdyńskich Kosynierów 2/1b, Gdańsk, Poland"
},
"summary": "Proactive Frontend developer with 5 years of commercial IT experience. I always try to understand the big picture of the product and support the entire development process",
"experience": [
{
"title": "FULLSTACK DEVELOPER",
"company": "Grape Up",
"duration": "April 2022 - March 2023",
"description": "At Grape Up, I contributed to the First American project—a large-scale initiative in the real estate and financial sectors. I worked within a SCRUM framework, handling both frontend and backend responsibilities. On the frontend, I utilized React, Redux, Redux Saga, and Immutable.js, while on the backend I worked with Python (using Fast API and Flask) and Node.js."
},
{
"title": "FRONTEND DEVELOPER",
"company": "Talent Alpha",
"duration": "July 2020 - March 2022",
"description": "Developed the Talent Science platform in close collaboration with the UX/UI team, Product Owner, and Backend team. On the frontend, I utilized modern React JS with 100% TypeScript, customized Ant Design, Styled Components, Cypress, and SendGrid. On the backend, I worked with Java, Terraform, and Google Cloud Platform (GCP)."
}
]
}
The Core of It All: Letter Generation
Once the CV is parsed, the user can adjust it, provide motivation/context, and set preferences like language, tone, letter length, career level – even whether they're switching industries.
🔧 POST /generate-cover-letter
The prompt includes everything: the parsed CV, job description, and personal context. Here's its content:
try:
# Convert tone and length to descriptive text
tone_descriptions = {
"formal": "professional and formal",
"friendly": "warm and approachable",
"enthusiastic": "energetic and passionate",
"straightforward": "direct and concise",
"creative": "creative and unique",
"assertive": "confident and assertive"
}
length_descriptions = {
"short": "Keep it concise (2-3 paragraphs, around 200-250 words)",
"standard": "Standard length (3-4 paragraphs, around 300-400 words)",
"long": "Detailed version (4-5 paragraphs, around 450-600 words)"
}
language_instructions = {
"english": "Write the cover letter in English",
"polish": "Write the cover letter in Polish (Polish language)",
"german": "Write the cover letter in German (Deutsche Sprache)",
"french": "Write the cover letter in French (Langue française)",
"spanish": "Write the cover letter in Spanish (Idioma español)",
"italian": "Write the cover letter in Italian (Lingua italiana)",
"dutch": "Write the cover letter in Dutch (Nederlandse taal)",
"portuguese": "Write the cover letter in Portuguese (Língua portuguesa)"
}
career_change_note = ""
if settings["careerChange"]:
career_change_note = """Note: This is a career change application,
so emphasize transferable skills and motivation for the new field."""
prompt = f"""
Write a cover letter based on the following information:
**Applicant Information:**
Name: {cv_data.personalInfo.name}
Summary: {cv_data.summary}
**Key Experience:**
{chr(10).join([f"- {exp.title} at {exp.company}: {exp.description}"
for exp in cv_data.experience[:10]])}
**Education:**
{chr(10).join([f"- {edu.degree} from {edu.institution}"
for edu in cv_data.education])}
**Skills:** {', '.join(cv_data.skills[:50])}
**Job Description:**
{job_description}
**Personalization:**
- Motivation: {personalization["motivation"]}
- Experience to highlight: {personalization["highlightExperience"]}
- Professional values: {personalization["passionValues"]}
**Requirements:**
- Language: {language_instructions.get(settings["language"],
f"Write in {settings['language']}")}
- Tone: {tone_descriptions.get(settings["tone"], settings["tone"])}
- Length: {length_descriptions.get(settings["length"], settings["length"])}
- Role level: {settings["roleLevel"]}
{career_change_note}
Create a compelling cover letter that:
1. Opens with a strong hook related to the specific role
2. Highlights relevant experience and skills from the CV
3. Incorporates the personalization elements naturally
4. Shows genuine interest in the company/role
5. Ends with a confident call to action
Format as a professional cover letter without date/address headers.
"""
response = openai.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "system",
"content": f"""You are an expert cover letter writer who can write
in multiple languages. Write compelling, personalized cover letters
that help candidates get interviews. Always write in the language
specified in the user's requirements.
Current language: {settings.get('language', 'english')}"""
},
{"role": "user", "content": prompt}
],
temperature=0.7
)
return response.choices[0].message.content.strip()
The results? Surprisingly solid. Tone? Spot on. Personal context? Included. Career switch? Adjusted.
But.., not everything worked perfectly out of the box.
At one point, the role “frontend developer” got translated into Polish—literally (in not the best way). In another case, the model took the second paragraph of the letter and simply paraphrased the CV summary. Not ideal.
So I did what needed to be done: added more context to the prompt.
I fine-tuned the string to gently steer the model away from overusing the CV and asked it to keep roles in the original language.
**CRITICAL INSTRUCTIONS:**
- NEVER translate technical terms, programming languages, frameworks, tools,
or job titles (e.g., keep "React", "Frontend Developer", "Python",
"DevOps", "UI/UX", etc. in English)
- Use the profile summary ONLY for understanding the candidate's background -
generate fresh, original content for the cover letter
- Do NOT copy or paraphrase the profile summary directly into the cover letter
Profile Summary (FOR CONTEXT ONLY - DO NOT copy or paraphrase directly):
{cv_data.summary}
And this issue did not happen for me again.
Fun part - How to break Open AI API
As part of adjustments I decided to also add a temperature slider in settings section and pass it to the API. (you can play with it in the demo btw.)
The OpenAI docs describe this parameter with a bit of mystery. “You can think of temperature like randomness,” they say. At the lowest setting, the model is most deterministic—it sticks to what it knows best. The higher the temperature, the more creative (and unpredictable) it becomes. Value can be set between 0 and 2.
Naturally I started testing it from 2.0.
And... I broke it !! 😂. The API started spitting out elfish.

So I decided to cool it down a bit and set it to 1.4. And again I got funny response - but this time it actually generated some non existing words (which is also cool and interesting - why it happend?). And why it did not respond with error but with this response?

English translation:
"My internship at Primea AB allowed me to discover the value of strong Tiują TOKowie rusują lanławnosci ludolioant_SPEC_sb differenterbtówponcyteszzawanie various peatszyzbom.dobiew"
The first 9 words here made sense. The rest? Did not make much sense. (And it wasn't internship)
For english generation it worked already with 1.4. From temperature 1 and below it was working for all languages.
Final Thoughts
I didn’t build a revolution. But I did build something fun, functional, and genuinely helpful to job seekers. What I learned is that getting useful results takes structure, context, and a bit of creativity. With the right setup, LLMs can be surprising.
Biggest surprise? Resume parsing.
Most fun? Bringing it all together — connecting ideas, prompts, and UI into working tool.
While building this project, I was genuinely surprised by some of the outputs — especially the faulty or unexpected ones. It made me realize how tricky it can be to judge whether a model is doing “well,” especially when success isn’t binary. I'm really curious about the methodologies, tools, and frameworks that exist for evaluating LLMs — their strengths, limitations, and how we can meaningfully measure performance beyond just subjective judgment.
But that’s a story for another post.
🔗 Live demo: cover-letter-generator.zeromski.dev
📦 Frontend repo: github.com/lukzerom/cover-letter-fe
🧠 Backend repo: github.com/lukzerom/cover-letter-be