Resume Writing skill for Claude Code

Impact bullets with real numbers, never invented ones. Claude rewrites engineering resumes into specific, defensible bullets built from action, scope and result, and asks you for missing numbers instead of making them up. It maps a job description to real evidence, flags genuine gaps rather than stuffing keywords, picks length and section order by seniority, and checks the text an ATS will parse with a bundled linter.

  • nameresume-writing
  • size196 lines · ≈2,500 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes, rewrites, and reviews technical resumes and CVs for software, data, and infrastructure roles - impact bullets built from action, scope, and measurable result, tailoring to a specific job description without keyword stuffing, ATS-safe single-column formatting, length and section order by seniority, GitHub and portfolio links, LinkedIn alignment, and cover letters when they matter, with a strict rule never to invent achievements or numbers. Use when the user shares a resume or CV to improve, asks to tailor it to a job posting, wants bullets rewritten, or needs a cover letter or LinkedIn summary.

Try asking

  • Rewrite my resume bullets so they show impact instead of duties
  • Tailor this CV to the attached senior backend engineer posting
  • Review my new-grad resume and tell me what an ATS will choke on

Use it when

  • A resume reads as a list of responsibilities rather than outcomes
  • You are applying to a specific posting and want an honest tailored version
  • You need to check layout and wording before uploading to an ATS
  • A cover letter or LinkedIn summary has to match the resume

Skip it when

  • You only want general line editing of an existing document — use prose-editing instead
  • You are writing an academic paper or thesis — use academic-writing instead

How to check the result

The linter shows no FIX findings, every number, title and date in the new version traces to the original or the user's answers, and the target role and stack are obvious in the top third of page one.

The common mistake it prevents

Polishing a bullet until 'contributed to' quietly becomes 'led', or adding a plausible metric the candidate never gave and cannot defend in an interview.

~/.claude/skills/resume-writing/SKILL.md

--- name: resume-writing description: Writes, rewrites, and reviews technical resumes and CVs for software, data, and infrastructure roles - impact bullets built from action,… license: MIT ---

A resume is done well when a recruiter skimming for a few seconds sees the right title, level, and core stack, an engineer reading closely finds specific, checkable outcomes, an ATS parses every section correctly, and every claim on it is something the candidate can defend in an interview. Honesty is a hard constraint, not a style choice.

Hard rules

  • Never invent achievements, metrics, titles, dates, employers, degrees, or skills. If a bullet needs a number the user has not given, ask for it or write the bullet without one.
  • Never inflate scope (“led” when they contributed, “architected” when they implemented). Ask “what exactly was your part?” when ownership is unclear.
  • Estimates are allowed only if the user supplies them and they are labelled honestly in wording (“about 30%”, “roughly 2M requests a day”).
  • Do not add keywords for skills the person does not have, and never hide text (white font, tiny text) for ATS. Recruiters see it and it ends the application.
  • Keep the user’s facts intact through every rewrite; diff against the original before delivering.

Workflow

- [ ] 1. Collect: current resume, target role or job description, constraints
- [ ] 2. Interview for missing facts (scope, results, ownership, dates)
- [ ] 3. Choose format, length, and section order for the seniority and market
- [ ] 4. Rewrite bullets: action + scope + result
- [ ] 5. Tailor to the job description (honest overlap only)
- [ ] 6. Format for ATS and humans
- [ ] 7. Lint, fact-check against the original, and deliver with open questions

1. Collect

Ask for: the current resume (text or file), the target job description or role type, location and market (US resume vs. UK/EU CV conventions), years of experience, and any gaps or transitions the user wants handled. If there is no job description, tailor to the role family and say so.

2. Interview for facts

For each recent role, ask only what is missing:

GapQuestion to ask
No scopeHow many users, requests, services, engineers, customers, or dollars did this touch?
No resultWhat changed afterwards? Latency, cost, revenue, incidents, time saved, adoption?
Unclear ownershipDid you lead it, build part of it, or support it? Who else was involved?
Vague techWhich languages, frameworks, and infrastructure did you actually use daily?
No baselineWhat was it before? (A percentage without a baseline is weak.)

If the user has no numbers, use honest non-numeric outcomes: “adopted by all four product teams”, “replaced the manual weekly release”, “first on-call runbook for the service”.

3. Format, length, section order

ProfileLengthSection order
Student / new grad1 pageContact, Education, Projects, Experience/Internships, Skills
1 to 7 years1 page, 2 if dense and relevantContact, (Summary), Experience, Projects/Open source, Skills, Education
Senior / staff / manager2 pagesContact, Summary, Experience, Selected projects or talks, Skills, Education
Academic / research CVNo limit; complete recordContact, Education, Publications, Research experience, Teaching, Awards
Career changer1 to 2 pagesContact, Summary stating the pivot, relevant Projects, Experience reframed, Skills

Market notes: US resumes omit photo, date of birth, and marital status. Some European markets expect a photo or personal details; follow the user’s market and ask if unsure.

Detail fades with age: 3 to 6 bullets for the last two roles, 1 to 3 for older ones, one line each for roles older than about 10 to 15 years, unless older work is the most relevant.

4. Bullets: action + scope + result

Pattern: strong verb + what you did + scope + measurable or observable result, often “by” or “so that” linking method and outcome.

WeakStrong (facts supplied by the user)
Responsible for the payments serviceOwned the payments service (40k transactions/day); cut failed charges 18% by adding idempotent retries
Worked on CI improvementsRebuilt CI on GitHub Actions with test sharding, reducing median pipeline time from 24 to 9 minutes for 60 engineers
Helped with migration to KubernetesMigrated 14 services from EC2 to EKS with zero customer-facing downtime; wrote the migration runbook the team reused
Mentored juniorsMentored 3 junior engineers; two promoted within 18 months

Rules:

  • Past tense for past roles, present tense for the current one.
  • One idea per bullet, one to two lines. Cut “successfully”, “various”, “utilized”.
  • Lead with the result when it is the impressive part.
  • Vary verbs by what actually happened: built, shipped, reduced, migrated, designed, automated, led, debugged, scaled, negotiated, mentored.
  • Name technologies inside bullets where they were used, not only in a skills dump.
  • Engineering impact categories when “revenue” does not apply: latency, reliability (incidents, SLO), cost, developer time, security findings closed, adoption, scale handled.

5. Tailor without keyword stuffing

  1. Extract from the job description: must-have skills, nice-to-haves, level signals (ownership, scope, leadership), and repeated phrases.
  2. Map each must-have to real evidence in the resume. Mark each as evidenced, evidenced but buried, or absent.
  3. Evidenced but buried: move it up, rename to the employer’s term if equivalent (“CI/CD” vs “build pipelines”), or rewrite the bullet so it leads.
  4. Absent: leave it out. Tell the user which must-haves are gaps so they can address them in a cover letter or decide not to apply.
  5. Adjust the summary and the order of bullets and skills; do not rewrite history.

Use the exact spelling of tools the posting uses (for example “PostgreSQL” vs “Postgres”, “Kubernetes” vs “K8s”) once, naturally, where the experience is real.

6. ATS-safe formatting

  • Single column. No tables, text boxes, or multi-column layouts for core content.
  • Contact details in the body, not in a document header or footer.
  • Standard headings: Experience, Education, Skills, Projects.
  • No icons, skill bars, star ratings, photos (unless the market expects one), or text in images.
  • Common fonts at 10 to 12 pt; consistent date format (Mar 2023 – Present).
  • Submit the format the employer asks for. Otherwise a PDF exported from a word processor (selectable text) is usual; keep a DOCX ready for portals that request it.
  • Test: copy all text out of the PDF (or run pdftotext -layout cv.pdf cv.txt); if order or sections are scrambled, so is the ATS view.

Skills section: group by type (Languages, Frameworks, Data, Infrastructure, Tools). Drop proficiency bars and vague soft skills. List only what they could be interviewed on.

  • Links: GitHub only if it shows representative work (pin 3 to 6 repos with READMEs); portfolio for frontend, design, and data roles; publications or talks for research and staff roles. Write full readable URLs, not “click here”.
  • LinkedIn: titles, employers, and dates must match the resume exactly. The headline can be broader (“Backend engineer, payments and distributed systems”). The About section is first person and conversational; the resume is not.
  • Cover letter: write one when the posting asks, when changing careers, relocating, explaining a gap, or applying to a small company or a named hiring manager. Skip it when the portal makes it optional and the fit is obvious. Structure: why this company (one specific reason), the two strongest matches with evidence, the gap or context if any, close. Under 300 words. No restating the resume line by line.

Gotchas

  • An “Objective” line (“seeking a challenging role”) wastes the top third of the page; use a two-line summary with title, years, domain, and strongest stack, or nothing.
  • Skills listed but never shown in a bullet read as padding to engineers.
  • Employment gaps: a one-line honest entry (career break, caregiving, study, layoff) is better than unexplained gaps or stretched dates.
  • Contract and agency work: name the client when allowed and group short contracts under one heading.
  • Internal jargon (project codenames, team acronyms) means nothing outside the company.
  • Percentages without baselines and scale (“improved performance 300%”) invite scepticism.
  • Rewrites drift: a polished bullet can quietly upgrade “contributed” to “led”. Check.

Verification

  1. Lint the text version (stdlib script):

    python3 scripts/resume_lint.py resume.md --level senior
    python3 scripts/resume_lint.py resume.txt --jd job.txt --level mid

    FIX findings (weak openers, first person, buzzwords, icon characters, missing email or Experience heading) must be resolved; exit code 1 while any remain. CHECK findings (bullets without scope or result, length, repeated verbs, column gaps, missing JD terms) are prompts for questions to the user, never licence to invent numbers or skills.

  2. Fact diff: list every number, title, date, and technology in the new version and confirm each appears in the original or in the user’s answers.

  3. Read the top third of page one as a recruiter: is the target title, level, and core stack obvious at a glance?

  4. Confirm the length and section order match the table for the user’s level.

Output contract

## Revised resume
<full resume text, ready to paste into the user's template>

## What changed
- <bullet rewritten for impact; facts unchanged>
- <section order changed for new-grad profile>

## Job description match
| Requirement | Evidence in resume | Status |
|---|---|---|
| Kubernetes in production | Acme: migrated 14 services to EKS | Evidenced |
| Terraform | — | Gap: mention in cover letter or skip |

## Questions before this is final
- <scope or result you need for bullet X>
- <claims that need confirmation, e.g. "did you lead or contribute?">

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/resume_lint.py 166 lines · 7.8 KB
#!/usr/bin/env python3
"""Lint a resume in plain text or Markdown for common technical-resume defects.

Standard library only. Checks contact details, section headings, bullet quality
(weak openers, first person, buzzwords, missing scope/result, length, repeated
verbs), characters and layouts that often confuse ATS parsers, and length by
word count. With --jd, lists frequent job-description terms the resume never
mentions (add them only if they are true).

Usage:
  python3 resume_lint.py resume.md
  python3 resume_lint.py resume.txt --jd job.txt --level senior

Exit code 1 if any FIX finding exists; CHECK findings are judgement calls.
Export a PDF or DOCX to text first (for example `pdftotext -layout cv.pdf cv.txt`)
to lint what a parser will actually see.
"""
from __future__ import annotations

import argparse
import re
import sys
from collections import Counter

WEAK_OPENERS = [
    "responsible for", "helped", "helping", "worked on", "working on", "assisted",
    "participated in", "involved in", "tasked with", "duties included", "in charge of",
    "contributed to", "was part of", "handled",
]
BUZZWORDS = [
    "results-driven", "results driven", "team player", "hard-working", "hardworking",
    "detail-oriented", "self-starter", "go-getter", "synergy", "rockstar", "ninja",
    "guru", "passionate", "dynamic", "think outside the box", "proven track record",
    "highly motivated", "best-in-class", "world-class", "wizard", "dedicated professional",
]
FIRST_PERSON = re.compile(r"\b(I|I'm|I've|me|my|mine|myself)\b")
BULLET = re.compile(r"^\s*(?:[-*•▪●–]|\d+[.)])\s+(.*)$")
HEADING = re.compile(r"^\s*(?:#{1,6}\s+)?([A-Z][A-Za-z &/]{2,40})\s*:?\s*$")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
PHONE = re.compile(r"\+?\d[\d ()./-]{7,}\d")
LINK = re.compile(r"(github\.com|gitlab\.com|linkedin\.com|https?://)", re.I)
NUMBERISH = re.compile(r"\d|\b(one|two|three|four|five|six|seven|eight|nine|ten|dozens?|hundreds?|thousands?|millions?)\b", re.I)
RISKY_CHARS = re.compile(r"[★☆✓✔✖■-◿☀-➿-\U0001f300-\U0001faff]")
STANDARD_SECTIONS = {"experience", "work experience", "professional experience", "employment",
                     "education", "skills", "technical skills", "projects", "summary",
                     "publications", "certifications", "open source", "awards", "volunteering"}
STOP = set("""a an and are as at be by for from has have in into is it its of on or our
that the their this to we will with you your who what which about across all also any can
etc more must new not other per such than them they using use work working within years year
team teams experience strong ability including role join looking build building help ensure
plus nice well based like etc."""
           .split())
WORDS_BY_LEVEL = {"junior": 600, "mid": 800, "senior": 1100, "staff": 1300}


def lint(text: str, jd: str | None, level: str) -> list[tuple[str, str]]:
    out: list[tuple[str, str]] = []
    lines = text.splitlines()
    words = re.findall(r"\b[\w'+#.-]+\b", text)

    if not EMAIL.search(text):
        out.append(("FIX", "No email address found; put contact details in the body, not a header/footer."))
    if not PHONE.search(text):
        out.append(("CHECK", "No phone number found (optional in some markets, expected in most)."))
    if not LINK.search(text):
        out.append(("CHECK", "No GitHub, LinkedIn, or portfolio link found."))

    headings = set()
    md_mode = any(l.startswith("#") for l in lines)
    seen_h1 = False
    for l in lines:
        st = l.strip()
        if md_mode:
            mh = re.match(r"^(#{1,6})\s+(.+)$", st)
            if not mh:
                continue
            if mh.group(1) == "#" and not seen_h1:
                seen_h1 = True  # the name line
                continue
            if mh.group(1) in ("#", "##") and len(mh.group(2).split()) <= 4:
                headings.add(mh.group(2).strip().rstrip(":").lower())
        elif (m := HEADING.match(st)) and len(st.split()) <= 4:
            label = m.group(1).strip()
            if label.isupper() or st.endswith(":") or label.lower() in STANDARD_SECTIONS:
                headings.add(label.lower())
    if not any("experience" in h or h == "employment" for h in headings):
        out.append(("FIX", "No 'Experience' heading found; ATS parsers look for standard section names."))
    odd = sorted(h for h in headings if h not in STANDARD_SECTIONS and not any(s in h for s in ("experience", "skill", "project")))
    if odd:
        out.append(("CHECK", f"Non-standard headings (fine if intentional): {', '.join(odd[:6])}"))

    limit = WORDS_BY_LEVEL[level]
    if len(words) > limit:
        out.append(("CHECK", f"{len(words)} words; typical ceiling for {level} is about {limit}. Cut older or weaker bullets."))

    verbs: Counter[str] = Counter()
    bullets = 0
    for n, line in enumerate(lines, 1):
        if RISKY_CHARS.search(line):
            out.append(("FIX", f"line {n}: icon/symbol character may not parse: {line.strip()[:60]}"))
        if "\t" in line.strip() or re.search(r"\S {4,}\S", line):
            out.append(("CHECK", f"line {n}: tab or wide gap suggests a column layout; parsers may merge columns."))
        m = BULLET.match(line)
        if not m:
            continue
        b = m.group(1).strip()
        bullets += 1
        low = b.lower()
        first = re.sub(r"[^a-z-]", "", low.split()[0]) if low.split() else ""
        if first:
            verbs[first] += 1
        for w in WEAK_OPENERS:
            if low.startswith(w):
                out.append(("FIX", f"line {n}: weak opener '{w}'; start with what you did: {b[:70]}"))
                break
        if FIRST_PERSON.search(b):
            out.append(("FIX", f"line {n}: first person in a bullet: {b[:70]}"))
        for w in BUZZWORDS:
            if w in low:
                out.append(("FIX", f"line {n}: buzzword '{w}' claims a trait instead of showing evidence."))
        if not NUMBERISH.search(b):
            out.append(("CHECK", f"line {n}: no scope or measurable result; ask the user for a real number, never invent one: {b[:60]}"))
        if len(b) > 220:
            out.append(("CHECK", f"line {n}: bullet is {len(b)} chars; aim for one to two lines (under ~200)."))
        elif len(b) < 35:
            out.append(("CHECK", f"line {n}: bullet is very short; add scope or result: {b}"))

    for verb, c in verbs.most_common():
        if c >= 4:
            out.append(("CHECK", f"'{verb}' opens {c} bullets; vary verbs so each names the specific action."))
    if bullets == 0:
        out.append(("CHECK", "No bullets detected; experience reads faster as 3-6 bullets per recent role."))

    if jd:
        res_low = text.lower()
        tokens = (t.rstrip(".-") for t in re.findall(r"[a-z][a-z0-9+#.-]{1,}", jd.lower()))
        jd_terms = Counter(t for t in tokens if t not in STOP and len(t) > 2)
        missing = [t for t, c in jd_terms.most_common(60) if c >= 2 and t not in res_low]
        if missing:
            out.append(("CHECK", "JD terms used 2+ times but absent from the resume (add only where true): "
                        + ", ".join(missing[:20])))
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("resume", help="resume as .txt or .md")
    ap.add_argument("--jd", help="job description as plain text")
    ap.add_argument("--level", choices=sorted(WORDS_BY_LEVEL), default="mid")
    a = ap.parse_args()
    try:
        text = open(a.resume, encoding="utf-8").read()
        jd = open(a.jd, encoding="utf-8").read() if a.jd else None
    except OSError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    findings = lint(text, jd, a.level)
    for sev, msg in findings:
        print(f"{sev:5} {msg}")
    fixes = sum(1 for s, _ in findings if s == "FIX")
    print(f"\n{a.resume}: {fixes} FIX, {len(findings) - fixes} CHECK")
    return 1 if fixes else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc