18 KiB
Mode: pdf — ATS-Optimized PDF Generation
Full pipeline
- Read
cv.mdas the source of truth - Ask the user for the JD if it is not in context (text or URL)
- Extract 15-20 keywords from the JD
- Detect JD language → CV language (EN default)
- Detect company location → paper format:
- US/Canada →
letter - Rest of the world →
a4
- US/Canada →
- Detect role archetype → adapt framing
- Build an internal recruiter-side risk map from the JD using
modes/heuristics/recruiter-side.md: likely doubts, matching evidence, and which document section should address each doubt - Rewrite Professional Summary by injecting JD keywords + exit narrative bridge ("Built and sold a business. Now applying systems thinking to [JD domain].")
- Select top 3-4 most relevant projects for the job
- Reorder experience bullets by JD relevance and by the risk map: strongest matching evidence first
- Build competency grid from JD requirements (6-8 keyword phrases)
- Inject keywords naturally into existing achievements (NEVER invent)
- Apply the six-second clarity gate from
modes/heuristics/recruiter-side.md: top third must make target role, strongest fit, and proof obvious - Read
namefromconfig/profile.yml→ normalize to kebab-case lowercase (e.g. "John Doe" → "john-doe") →{candidate} - Build the render payload (see the JSON Input Schema below) from the tailored content — emit compact structured JSON, not full HTML markup — and write it to
/tmp/cv-{candidate}-{company}.json - Run:
node build-cv-html.mjs /tmp/cv-{candidate}-{company}.json output/cv-{candidate}-{company}.html {template}— where{template}is the path printed by Selecting the template below (omit the argument to use the basecv-template.html). The script merges the payload into that template, owning every tag, CSS class, and the HTML escaping. Write tooutput/(NOT a temp dir — the recorded HTML is what the dashboard'sDhotkey regenerates from, so it must survive temp cleanup) - Run the fact gate:
node verify-cv-facts.mjs output/cv-{candidate}-{company}.html- This is a hard gate before PDF rendering.
- If it fails, stop and fix the generated HTML by removing invented metrics or adding verified evidence to
cv.md,article-digest.md, orconfig/cv-facts.json.
- Execute:
node generate-pdf.mjs output/cv-{candidate}-{company}.html output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf --format={letter|a4} --report={report number}—{report number}is the NNN from the report filename/link (e.g.008forreports/008-acme-….md), not the tracker#column. Pass it whenever the application has (or will have) a report; it records the PDF↔report linkage indata/pdf-index.tsvso the dashboard can open and regenerate the exact PDF. Omit it only for one-off CVs with no tracker entry. - Report: PDF path, number of pages, keyword coverage %
ATS Rules (clean parsing)
- Single-column layout (no sidebars, no parallel columns)
- Standard headers: "Professional Summary", "Work Experience", "Education", "Skills", "Certifications", "Projects"
- No text in images/SVGs
- No critical info in PDF headers/footers (ATS ignores them)
- UTF-8, selectable text (not rasterized)
- No nested tables
- Distributed JD keywords: Summary (top 5), first bullet of each role, Skills section
- No hidden text, keyword stuffing, or white-font tricks. Optimize for parseability plus human review.
Recruiter Review Gates
- The summary should answer: "What role is this person targeting, and why this one?"
- The first screen should show 1-2 proof points that map to the JD's highest-risk requirements.
- Bullets should emphasize outcomes, systems, users, or business effects rather than task history.
- Logistics such as location, work authorization, salary, and availability belong in the CV only when appropriate for the market and profile; otherwise handle them in form answers or recruiter scripts.
PDF Design
- Fonts: Space Grotesk (headings, 600-700) + DM Sans (body, 400-500)
- Fonts self-hosted:
fonts/ - Header: name in Space Grotesk 24px bold + gradient line
linear-gradient(to right, hsl(187,74%,32%), hsl(270,70%,45%))2px + contact row - Section headers: Space Grotesk 13px, uppercase, letter-spacing 0.05em, color cyan primary
- Body: DM Sans 11px, line-height 1.5
- Company names: accent purple color
hsl(270,70%,45%) - Margins: 0.6in
- Background: pure white
Section order (optimized "6-second recruiter scan")
- Header (large name, gradient, contact, portfolio link)
- Professional Summary (3-4 lines, keyword-dense)
- Core Competencies (6-8 keyword phrases in flex-grid)
- Work Experience (reverse chronological)
- Projects (top 3-4 most relevant)
- Education & Certifications
- Skills (languages + technical)
Keyword injection strategy (ethical, truth-based)
Examples of legitimate reformulation:
- JD says "RAG pipelines" and CV says "LLM workflows with retrieval" → change to "RAG pipeline design and LLM orchestration workflows"
- JD says "MLOps" and CV says "observability, evals, error handling" → change to "MLOps and observability: evals, error handling, cost monitoring"
- JD says "stakeholder management" and CV says "collaborated with team" → change to "stakeholder management across engineering, operations, and business"
NEVER add skills that the candidate does not have. Only reword real experience using the exact JD vocabulary.
Template HTML
Before generating: read modes/_custom.md (if it exists) and apply its formatting/content house rules to every CV in this session — including every item of a batch. Rules recorded there (date formats, section-order preferences, content to always/never include) are persistent user instructions, not suggestions; if the user corrects the same thing twice in conversation, write it into modes/_custom.md so it stops drifting.
Selecting the template
Resolve which template to fill with the shared resolver (do not hardcode cv-template.html):
- If the user named a template this turn (e.g. "use the modern template"), run:
node cv-templates.mjs resolve cv "<name>" - Otherwise run:
node cv-templates.mjs resolve cv(this returns thecv.templatedefault fromconfig/profile.yml, or the basecv-template.htmlwhen unset).
The command prints the absolute path of the template to fill; a non-zero exit means the named template is missing or invalid — surface that message to the user instead of silently falling back.
To show the user their options (e.g. "what CV templates do I have?"), run node cv-templates.mjs list cv and present each displayName.
build-cv-html.mjs fills that resolved template from the JSON payload you build — it owns every tag, CSS class, and the HTML escaping, so you never emit full HTML markup and do not escape &/</>/quotes yourself. Pass the resolved path as the third argument (node build-cv-html.mjs <input.json> <output.html> <template.html>); omit it to fall back to the base cv-template.html. This is the HTML twin of build-cv-latex.mjs (see modes/latex.md) and cuts the PDF step's output tokens from full markup down to the compact payload below (#557).
JSON Input Schema
Write a JSON file with this structure, then run node build-cv-html.mjs <input.json> <output.html> [template.html] (the optional third argument is the template path from Selecting the template; omit it for the base cv-template.html).
{
"lang": "en",
"page_format": "letter",
"candidate": {
"name": "Jane Smith",
"phone": "+1 415 555 0100",
"email": "jane@example.com",
"linkedin": { "url": "https://linkedin.com/in/janesmith", "display": "linkedin.com/in/janesmith" },
"portfolio": { "url": "https://janesmith.dev", "display": "janesmith.dev" },
"location": "San Francisco, CA",
"photo": ""
},
"sections": {
"summary": "Professional Summary",
"competencies": "Core Competencies",
"experience": "Work Experience",
"projects": "Projects",
"education": "Education",
"certifications": "Certifications",
"skills": "Skills"
},
"summary": "Personalized summary with JD keywords injected (honest vs cv.md).",
"competencies": ["RAG Pipelines", "LLMOps", "Kubernetes & Docker"],
"experience": [
{
"company": "Company Name",
"role": "Job Title",
"location": "Remote",
"dates": "June 2022 - Present",
"bullets": ["Achievement bullet with JD keywords injected", "Another quantified-impact bullet"]
}
],
"projects": [
{ "name": "Project Name", "badge": "Open Source", "tech": "Python, FastAPI", "description": "What it does." }
],
"education": [
{ "title": "B.S. Computer Science", "org": "University Name", "year": "2022", "description": "Optional line." }
],
"certifications": [
{ "title": "Certified Kubernetes Administrator", "org": "CNCF", "year": "2024" }
],
"skills": [
{ "category": "Languages", "items": "Python, JavaScript, C++" },
{ "category": "Frameworks", "items": ["FastAPI", "React", "PyTorch"] }
]
}
Field reference
| Field | Type | Notes |
|---|---|---|
lang |
string | CV language code (en, es, ja, ar). Drives language-specific CSS: ja enables a CJK font fallback so Japanese renders instead of tofu (□); ar enables RTL + Arabic fonts. Defaults to en. |
page_format |
string | letter → 8.5in page width, a4 → 210mm. Defaults to letter. Pass the SAME value to generate-pdf.mjs --format. |
candidate.name |
string | From profile.yml. |
candidate.phone |
string | Optional — omit or leave empty to drop the tel: link and its separator (no empty cell). |
candidate.email |
string | From profile.yml. |
candidate.linkedin |
{url, display} |
Optional — omit to drop the item and its separator. |
candidate.portfolio |
{url, display} |
Optional — omit to drop the item and its separator. |
candidate.location |
string | From profile.yml. |
candidate.photo |
string | Opt-in profile photo (#264): a local path or data: URL. Empty/absent emits no <img>, rendering pixel-for-pixel identical to the photoless layout (US/UK/many-market ATS penalize photos; opt in for DACH/European markets). |
sections |
object | Optional localized section titles; any omitted key falls back to the English default shown above. |
summary |
string | Personalized summary with keywords. |
competencies |
string[] | 6-8 keyword phrases → competency tags. |
experience[] |
object | company, role, location (optional), dates, bullets (reordered, keyword-injected). |
projects[] |
object | name, badge (optional), tech (optional), description (a bullets array is also accepted and joined into the description line). |
education[] |
object | title (degree), org (institution), year, description (optional). |
certifications[] |
object | title, org, year. |
skills[] |
object | category + items (comma-separated string or string array). |
build-cv-html.mjs errors out (non-zero exit) if any template placeholder is left unresolved, so a malformed payload fails loudly instead of shipping a broken CV. Run node build-cv-html.mjs --test for a self-test render.
Profile photo (opt-in, market-specific)
The {{PHOTO}} slot is off by default and intentionally market-specific:
- DACH / much of continental Europe (Germany, Austria, Switzerland): a professional photo is standard and often expected. Opt in by setting
candidate.photoinconfig/profile.yml(a local file path or adata:URL). - US / UK / Canada / Australia and many ATS-first markets: photos are discouraged and can trip bias-avoidance filters. Leave
candidate.photoempty — the{{PHOTO}}line is dropped entirely, no<img>is emitted, and the CV renders pixel-for-pixel identical to today's photoless layout.
When set, the photo floats into the top corner (mirrored for RTL/Arabic) and the header/summary text wraps beside it; .cv-photo in cv-template.html controls its size and framing.
Canva CV Generation (optional)
If config/profile.yml has cv.canva_resume_design_id set, offer the user a choice before generating:
- "HTML/PDF (fast, ATS-optimized)" — existing flow above
- "Canva CV (visual, design-preserving)" — new flow below
If the user has no cv.canva_resume_design_id, skip this prompt and use the HTML/PDF flow.
Canva workflow
Step 1 — Duplicate the base design
a. export-design the base design (using cv.canva_resume_design_id) as PDF → get download URL
b. import-design-from-url using that download URL → creates a new editable design (the duplicate)
c. Note the new design_id for the duplicate
Step 2 — Read the design structure
a. get-design-content on the new design → returns all text elements (richtexts) with their content
b. Map text elements to CV sections by content matching:
- Look for the candidate's name → header section
- Look for "Summary" or "Professional Summary" → summary section
- Look for company names from cv.md → experience sections
- Look for degree/school names → education section
- Look for skill keywords → skills section c. If mapping fails, show the user what was found and ask for guidance
Step 3 — Generate tailored content
Same content generation as the HTML flow (Steps 1-11 above):
- Rewrite Professional Summary with JD keywords + exit narrative
- Reorder experience bullets by JD relevance
- Select top competencies from JD requirements
- Inject keywords naturally (NEVER invent)
IMPORTANT — Character budget rule: Each replacement text MUST be approximately the same length as the original text it replaces (within ±15% character count). If tailored content is longer, condense it. The Canva design has fixed-size text boxes — longer text causes overlapping with adjacent elements. Count the characters in each original element from Step 2 and enforce this budget when generating replacements.
Step 4 — Apply edits
a. start-editing-transaction on the duplicate design
b. perform-editing-operations with find_and_replace_text for each section:
- Replace summary text with tailored summary
- Replace each experience bullet with reordered/rewritten bullets
- Replace competency/skills text with JD-matched terms
- Replace project descriptions with top relevant projects c. Reflow layout after text replacement: After applying all text replacements, the text boxes auto-resize but neighboring elements stay in place. This causes uneven spacing between work experience sections. Fix this:
- Read the updated element positions and dimensions from the
perform-editing-operationsresponse - For each work experience section (top to bottom), calculate where the bullets text box ends:
end_y = top + height - The next section's header should start at
end_y + consistent_gap(use the original gap from the template, typically ~30px) - Use
position_elementto move the next section's date, company name, role title, and bullets elements to maintain even spacing - Repeat for all work experience sections d. Verify layout before commit:
get-design-thumbnailwith the transaction_id and page_index=1- Visually inspect the thumbnail for: text overlapping, uneven spacing, text cut off, text too small
- If issues remain, adjust with
position_element,resize_element, orformat_text - Repeat until layout is clean
e. Show the user the final preview and ask for approval
f.
commit-editing-transactionto save (ONLY after user approval)
Step 5 — Export and download PDF
a. export-design the duplicate as PDF (format: a4 or letter based on JD location)
b. IMMEDIATELY download the PDF using Bash:
curl -sL -o "output/cv-{candidate}-{company}-canva-{YYYY-MM-DD}.pdf" "{download_url}"
The export URL is a pre-signed S3 link that expires in ~2 hours. Download it right away. c. Verify the download:
file output/cv-{candidate}-{company}-canva-{YYYY-MM-DD}.pdf
Must show "PDF document". If it shows XML or HTML, the URL expired — re-export and retry. d. Report: PDF path, file size, Canva design URL (for manual tweaking)
Error handling
- If
import-design-from-urlfails → fall back to HTML/PDF pipeline with message - If text elements can't be mapped → warn user, show what was found, ask for manual mapping
- If
find_and_replace_textfinds no matches → try broader substring matching - Always provide the Canva design URL so the user can edit manually if auto-edit fails
Cover Letter Sub-flow
After generating the CV PDF, offer to generate a cover letter:
CV PDF generated: output/{path}
Want a cover letter for this role too?
- Say "yes" or "cover letter" to generate one now
- Or run `/career-ops cover {slug}` later
Apply voice-dna.md (if present) to the cover letter — full guardrail, conversational voice included (Tier 1 + Tier 2). The CV PDF itself stays Tier 1 only (formal ATS register). See _shared.md → Voice DNA.
If the user says yes, run the full cover letter flow from modes/cover.md in slug mode:
- Load the existing
## Cover Letter Draftfrom the evaluation report as a starting point - Run company research (Step 3 of cover.md)
- Present keyword list for confirmation (Step 4)
- Surface any gaps (Step 5)
- Ask the four prompts: why / problems / approach / tone (Step 6)
- Draft in chat, wait for approval (Steps 7-8)
- Generate cover letter PDF via
node generate-cover-letter.mjs(Step 9) - Report both PDF paths
Do not auto-generate the cover letter PDF without going through the interactive steps above.
Post-generation
Update tracker if the job is already registered: change PDF from ❌ to ✅.