Resume as code: building every CV from one set of sources

For years my resume was a single Word document. Every application meant opening it, tweaking a bullet or two, saving a copy with the company name in the filename, and then forgetting which copy had the fix I’d made three applications ago. On top of that there’s a Spanish version for Costa Rican companies, and the experience section on this website, which had quietly drifted years out of date.

So I did what I’d do with any other piece of software that has several outputs and one truth behind them: I split the content from the presentation and wrote a build step. This post walks through how it’s laid out, the two scripts that do the work, and the real goal behind it: a resume that’s actually tailored to the job description in front of me, without forking the whole document every time.

How it got here

It happened in stages, and each one was a reaction to something that hurt:

  1. One hand-made docx. Nice design, impossible to keep in sync with anything.
  2. Markdown plus a generator. I moved the content into resume.md and wrote a script that rebuilds the docx from it. The markdown became the source of truth, and the docx became a build artifact.
  3. A Spanish file and job-specific copies. resume.es.md arrived, and so did resume.<company>.md files: whole copies of the resume reworded for a single posting. They worked, but every copy was a fork. A fix to one role had to be made in four places, and usually wasn’t.
  4. Per-role and per-project sources. The current version. Instead of whole resumes, I keep the pieces a resume is built from, and each resume is just a recipe for which pieces to use.

The structure

Everything lives in a docs/career/ folder next to the website:

docs/career/
├── profile.md          # name, contact, summary, skills, education, languages
├── roles/
│   ├── gorilla-logic.md    # one file per job
│   ├── mobiquity.md
│   ├── logn.md
│   └── ...
├── projects.md         # the project bank, tagged
└── variants/
    ├── full.md         # the classic resume, with bullets
    ├── minimal.md      # a condensed, paragraph-per-role format
    └── site.md         # feeds this website instead of a document

Roles

Each job is its own file. The front matter holds the facts that don’t change between resumes, and the body holds the text in both languages, side by side:

---
heading: Lead Android Engineer | Log(n)
heading_es: Lead Android Engineer | Log(n)
start: 2013-11
end: 2016-09
tech: Java, Android SDK, REST APIs
---

## Summary [en]
Led Android development across multiple client projects, ...

## Summary [es]
Liderazgo del desarrollo Android en múltiples proyectos de clientes, ...

## Details [en]
- Led Android development across multiple client projects, ...

## Details [es]
- ...

## Notes

Details is the bullet list for the full resume, and Summary is a single paragraph for the condensed one. Keeping English and Spanish in the same file was deliberate: when I edit one, the other is right there, so the Spanish version stops silently falling behind.

The ## Notes section is never rendered. It’s a scratchpad for things I remember about a job that aren’t on any resume yet: a library we adopted, an incident I handled, a metric worth quoting. Fifteen years of work is a lot to recall on the spot, and that list grows whenever something comes back to me.

The project bank

projects.md is where tailoring happens. Every client or personal project I can talk about gets an entry with a slug, a list of tags, and one or two lengths of text:

## finish-line
tags: android, kotlin, compose, tech-lead, architecture, mentoring, ci-cd,
      jenkins, bitrise, bluetooth, retail, mdm

### Notable [en]
**Finish Line** (Gorilla Logic) — In-store Android suite: price update app
with Bluetooth/ZPL printing plus product availability app, ...

### Story [en]
...

Notable is a one-liner for the full resume’s project list. Story is a longer paragraph for when a project is promoted to a “top project”. The tags don’t show up anywhere on the page; they exist so I can line a job description up against the bank and see which projects back up what the posting asks for.

Variants

A variant is the recipe. It’s tiny, because all the content lives elsewhere:

---
style: minimal
md_en: resume.minimal.md
md_es: resume.minimal.es.md
docx_en: Franklin_Garcia_Resume_Minimal.docx
docx_es: Franklin_Garcia_Curriculum_Minimal.docx
projects: davita, finish-line, zocdoc, google-art-project, corptoaproccel
---

style picks the format: full renders the bullets, and minimal renders a summary paragraph and a “Tech stack” line per role, plus the chosen top projects. projects picks which entries from the bank appear, in that order, and an optional roles line can leave out jobs that don’t matter for a given application. A variant can also set its own title (the headline under my name) and carry its own ## Summary [en] or ## Skills [en] sections, which replace the profile’s text for that resume only. That’s how one application gets a summary that leads with Java and enterprise systems while another leads with Android architecture, with neither of them touching the shared profile.

The scripts

There are two, both pure Python standard library: no pandoc, no python-docx, nothing to install.

build-resume.py: assembling the content

This one reads a variant, loads the profile, the roles (sorted newest first) and the selected projects, and writes a markdown resume in English and in Spanish. The parsing is intentionally dumb: key: value front matter, then splitting on ## and ### headings, with [en] and [es] in the heading names doing the language selection. Dates are stored as YYYY-MM and formatted per language (“Setiembre 2016” in the Spanish version, as we write it in Costa Rica).

python3 scripts/build-resume.py              # every variant
python3 scripts/build-resume.py minimal      # just one
python3 scripts/build-resume.py --no-docx    # markdown only

It fails loudly if a variant names a project slug that doesn’t exist, which catches the typos that would otherwise ship as a silently missing project.

The site variant is the odd one out. Instead of a document, it writes two JSON files that this website loads as content collections: the roles for the experience section on the homepage, and the whole project bank for /work. So the website and the PDF I send a recruiter now come from the same sentences, and can’t disagree with each other.

generate-resume-docx.py: rendering the document

I didn’t want to redesign my resume in code, and I definitely didn’t want to maintain a styling layer for Word. So the generator cheats in a useful way: it takes the original, hand-made docx as a template, and rewrites only the word/document.xml file inside it.

A docx is just a zip of XML files. The fonts, styles.xml, numbering definitions and page setup all live in separate files, so if you replace only the document body, every output inherits the original design: Arial, the dark blue headings with a thin rule underneath, 10pt bullets. The script parses the generated markdown into sections, emits the matching paragraph XML, and zips it back up. The Spanish docx gets the exact same design for free, since it’s built from the same template.

That template also makes the docx the one place a few private details belong. My phone number goes on the document I send directly to recruiters, but not on the public website, so it lives as a constant in the generator rather than in any of the markdown sources. Nothing built for the web ever sees it.

The PDF is one more conversion at the end. LibreOffice can run headless, so turning the generated docx into a PDF is a single command:

soffice --headless --convert-to pdf --outdir docs docs/Franklin_Garcia_Resume.docx

PDFs are gitignored. They’re exports of an export, so there’s still exactly one place to edit, and that place is the career sources.

The point: a resume per job description

All of this exists to make one workflow cheap. When I find a posting I want to apply to:

  1. Read the job description and pull out what it’s really asking for: the stack, the seniority signals, the domain.
  2. Match that against the tags in the project bank, and pick the four or five projects that prove it, in the order a recruiter skimming for ten seconds should see them.
  3. Create variants/<company>.md with that project list, plus a headline, summary or skills override if the posting leans a different direction than my default profile.
  4. Run build-resume.py <company>, then convert the docx files to PDF.

Once the sources were structured like this, I noticed every step of that list was mechanical except the judgement in step 2, which is exactly the kind of work an agent is good at when it has the right material in front of it. So I turned the workflow into a Claude Code skill:

/resume-job-description https://example.com/jobs/senior-android-engineer

It takes either a URL or the pasted text of a posting. If the page sits behind a login, as many job boards do, it asks for the text instead of guessing. Then it:

  • extracts the must-haves, nice-to-haves, seniority signals, domain and the posting’s language;
  • reads the profile, every role file (Notes included) and the project bank;
  • builds a table mapping each requirement to the project or role that backs it up, rated strong, indirect, only in Notes, or gap;
  • writes the variant file, with the projects ordered by strength of match and English and Spanish overrides where they’re warranted;
  • runs the build and the PDF conversion, then reads the generated resume back to check it.

The rule I care most about is in the skill’s instructions in plain words: never fill a gap with invented experience. A variant can only select, reorder and re-emphasise what’s already true. If the posting asks for something I haven’t done, the skill tells me about the gap instead of covering it. And if the evidence exists but is only in a role’s Notes, it suggests promoting it into that role’s real text, in both languages, so every future resume gets it too, not just this one.

That last part turned out to be a nice side effect. Every job description is now also a prompt to remember something, along the lines of “I did use that on the project, it just isn’t written down anywhere.”

The key difference from the old copy-and-edit approach is that a variant selects and orders; it doesn’t rewrite. When I improve the description of a role, every future resume gets the improvement, including the website. And when an application is done, deleting the variant file loses nothing, because the content it pointed at is still in the sources.

It also changed how I write the content. Because every project needs a tagged entry and every role needs a short and a long form, I ended up documenting projects I’d never have bothered to put on a single, one-size-fits-all resume. Some of them turn out to be exactly the evidence a specific posting is looking for.

One experiment is still running: the minimal style, a paragraph per role instead of a wall of bullets, with the top projects carrying the detail. Whether it becomes the default depends on how recruiters respond to it. The nice thing is that switching formats is now a one-word change in a variant, not a rewrite.

← All posts