a.k.a. "copy of OptimizeResumeAsPerJD — Enhanced" — the n8n workflow that will juice your resume, lie to your ATS, and spam your Telegram
ATS Score → 💯 · JD Keyword Coverage → 100% · Telegram → Cha-ching
"If at first you don't score 95, loop, loop again." — Every dev ever
WARNING: This README contains sarcasm. If you are a robot with no sense of humour, skip straight to §5 — Requirements. If you are a human, strap in. 🍿
- What In The Name Of Hiring Is This?
- Why Does This Exist? (A Tragic Backstory)
- The Big Picture (Architecture, Minus The Buzzwords)
- The ATS Death Loop (How The Magic Happens)
- Requirements (Or What You Actually Need)
- Installation (Import This Sucker)
- Configuration (Turn The Knobs, Break Things)
- How To Use (Triggering The Beast)
- Node-By-Node Tour (The Fleshy Bits)
- File Naming Conventions (Yes, This Gets Its Own Section)
- The LaTeX Compile Pipeline (Where PDFs Are Born)
- Logs & Debugging (Crying With Evidence)
- Known Issues & Pain Points (Spoiler: There Are Many)
- Troubleshooting Cheat Sheet (When The Loop Loops Forever)
- Working On This Workflow (Live REST Editing 101)
- API Reference (The Boring But Useful Bits)
- Execution Log (A Hall Of Shame And Triumph)
- Roadmap (Things We Keep Meaning To Do)
- FAQ (Frequently Agonized Questions)
- License & Disclaimers (Lawyer-Approved Cynicism)
- Changelog (The Scar Tissue)
- Author
This is an n8n workflow that takes a sad, unoptimized resume 📄 and a job description 🎯, chews on both with an LLM, and refuses to shut up until the resume scores ≥ 95% against that specific JD. Then — and only then — it compiles the result into a beautiful PDF 🖨️, generates a cover letter 💌, and shoves both files into your Telegram chat 📨 along with a smug caption.
In plainer terms: it's a resume-recycling, keyword-stuffing, loop-obsessed ATS-bait generator that treats "good enough" like it's a four-letter word.
| Feature | Description | Status |
|---|---|---|
| 📄 Resume Extraction | Extracts structured data from your resume using AI | ✅ |
| 🧠 JD Intelligence | Analyzes job description for ATS keywords & priority signals | ✅ |
| ✍️ Smart Rewriting | Rewrites resume content — bullets, summary, skills — to match JD naturally | ✅ |
| 🔁 ATS Death Loop | Rebuilds & re-scores until the resume hits >= 95 (max 10 iterations) |
✅ |
| 🖨️ LaTeX PDF Compilation | Compiles a clean, professional PDF using pdflatex | ✅ |
| 📬 Telegram Delivery | Delivers final PDF + Cover Letter directly to your chat | ✅ |
| 📊 ATS Scoring | Scores your resume match % with gap analysis (before and after) | ✅ |
| 📝 Cover Letter Gen | Generates a tailored HTML cover letter | ✅ |
Watch the n8n workflow process a resume step-by-step — from form submission to Telegram delivery. (Media lives in the
assets/folder of this repo.)
- 🖱️ Interactive Workflow Demo — click through the simulated execution, node by node.
- 📊 Download the Presentation — a full slide deck of the project.
| Who | What They Do | Mood |
|---|---|---|
| 🤖 Resume extractor | Turns your PDF/DOCX into structured JSON | Judgemental |
| 🤖 JD extractor | Turns the job posting into skill wishlists | Hungry |
| ✍️ Writer (OpenRouter Chat Model2) | Generates the optimized LaTeX resume | Dramatic |
| 🧪 ATS Score Agent | Scores the freshly-baked resume | Nitpicky |
| 🔁 ATS Loop Guard / ATS Check | The bouncer that decides if you leave the club | Merciless |
| 💌 Cover Letter Agent | Writes the "I'm perfect, hire me" letter | Kiss-assy |
| 🧩 Build Caption + Cover Letter | Glues the Telegram caption together | OCD |
| 📡 Telegram nodes | The final messengers of your dreams | Pushy |
Somewhere in the universe, a perfectly qualified engineer was rejected by an Applicant Tracking System (ATS) because their resume said "experienced with stuff" instead of "Terraform, gRPC, and Kafka." 🔥
The ATS is a robot. Robots are literal. If the keyword is not on the page, the robot assumes you're unqualified. So this workflow was born to game the robot — ethically-ish — by making sure every keyword the JD wants shows up, verbatim, in the final resume. 🎯
It's not a magic wand. It's a very persistent rubber stamp that loops until your score crosses the magical 95/100 line, and it does so by:
- Extracting the JD's priority keywords.
- Injecting the missing ones into your Skills section (yes, there's an
auto-fix in there — see the
Prepare compilation readynode). - Re-scoring. Re-suffering. Repeating. 🔄
Behind all the sarcasm is a surprisingly sound three-stage intelligence pipeline:
| Stage | What Happens | AI Magic |
|---|---|---|
| 1 — Resume Intelligence | The resume extractor doesn't just copy-paste text | Consolidates multi-role histories, maps projects to employers by date, reassembles fragmented two-column layouts, standardizes date formats (Sept '21 → September 2021) — with strict anti-hallucination rules |
| 2 — JD Intelligence | The JD extractor runs a detect-only analysis | Identifies high-priority keywords ("Required"/"Must"), classifies the domain, maps responsibility types, and builds a placement strategy telling the next stage exactly which skill goes in which resume section |
| 3 — ATS-Optimized Generation | The LaTeX generator does its thing | Rewrites experience bullets with strong action verbs, injects JD keywords at the right density, reorders skills by JD priority, and outputs clean, compilable LaTeX |
🎯 No hallucination. No invented facts. No "trust me, bro" bullet points.
┌─────────────────────┐ ┌─────────────────────┐
│ Resume (PDF/DOCX) │ │ Job Description │
│ 📄 │ │ 🎯 │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Extract from File1 │ │ (Form webhook) │
│ (PDF-only, lol) │ │ Resume & JD │
└─────────┬───────────┘ │ submitted (Webhook)│
▼ └─────────┬───────────┘
┌─────────────────────┐ │
│ Resume extractor │◄─────────────────┘
│ (LLM → JSON) │
└─────────┬───────────┘
▼
┌─────────────────────┐
│ JD extractor │
│ (LLM → JSON) │
└─────────┬───────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ ═══════════════ THE ATS DEATH LOOP ═══════════════ │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌──────────┐ │
│ │ ATS Loop │──▶│ Generated resume │──▶│ Prepare │ │
│ │ Guard │ │ unformatted │ │ compile │ │
│ └─────────────┘ └──────────────────┘ └────┬─────┘ │
│ │ │
│ ┌─────────────┐ ┌──────────────────┐ ┌────▼─────┐ │
│ │ ATS Check │◀──│ ATS Score Agent │◀──│ Compile │ │
│ └──────┬──────┘ └──────────────────┘ │ latex │ │
│ │ └────┬─────┘ │
│ ▼ │ │
│ ┌──────────────────┐ │ │
│ │ If ATS >= 95 ? │──No──▶ back to loop 🔁 │ │
│ └────────┬─────────┘ │ │
│ │Yes │ │
│ ══════════════════════════════════════════════╪═══════ │
└────────────────────────────────────────────────│───────┘
▼ │
┌─────────────────────┐ │
│ ATS Score Agent │◄─────────────────────────┘
│ (Before, original) │ (scores your ORIGINAL resume)
└─────────┬───────────┘
▼
┌─────────────────────┐
│ Cover Letter Agent │
└─────────┬───────────┘
▼
┌─────────────────────┐
│ Build Caption + │
│ Cover Letter │
└─────────┬───────────┘
▼
┌─────────────────────┐ ┌─────────────────────┐
│ Send resume via │ │ Send Cover Letter │
│ telegram 📨 │ │ via Telegram 💌 │
└─────────────────────┘ └─────────────────────┘
Total: 23 nodes. Because 22 would have been too modest.
This is the heart of the beast and the reason your n8n instance may or may not be spinning hot right now. Here's the contract:
| Parameter | Value | Why |
|---|---|---|
| Target score | >= 95 |
Because "good enough" is for quitters |
| Max iterations | MAX_ITERATIONS = 10 |
Because we DO have limits (barely) |
| Loop exit | score >= 95 OR score === null OR forceDone |
A happy ending or a shrug |
| Rebuild mode | Active when iteration > 1 |
Because first drafts are trash |
The writer prompt has two personalities crammed into one:
- Iteration 1 (fresh build): "YOUR TASK: generate the resume from scratch."
- Iteration 2+ (rebuild): The prompt swaps in a REBUILD MODE block that says (paraphrased): "You previously generated a resume that scored X. Here are the gaps the ATS found. Fix ONLY those gaps. Don't start over. Don't be cute."
The literal prompt structure:
This prevents the LLM from having an identity crisis every loop and throwing away good work. It doesn't always listen, but we pretend it does. 😌
- The
ATS Score Agentscores the newly generated resume against the JD. ATS Checkruns the termination logic (it's the guard withMAX_ITERATIONS).- If
score >= 95→ the If ATS Score >= 95 node sends you down the golden path →ATS Score Agent (Before)scores your original resume so you can see how sad it was. - If not → back to
ATS Loop Guard, iteration++, suffer again. 🔁
🚨 Do not edit the loop nodes' connection wiring. The
Ifnode's FALSE branch feeds back intoATS Loop Guardand that's what makes the universe not explode. Touch it and you get an infinite loop of despair (or worse, a linear pipeline — shudder).
| Requirement | Version/Detail | Excuse |
|---|---|---|
| n8n | Local instance (this was built against localhost:5678) | Because cloud bills are scary |
| n8n API key | From n8n Settings → API | So scripts can PUT while you sleep |
| OpenRouter API key | For the LLM calls | Because your resume deserves "gpt-oss-20b:free" |
| Telegram Bot token | For the sendDocument calls | So your chat gets blessed |
| latex.ytotech.com | Public LaTeX → PDF compile service | We don't ship TeX locally, we're not animals |
| Python 3.11+ (optional) | For the helper scripts (fixtures, trigger) | Because PowerShell is a hostage situation |
| A sense of humour | Essential | Non-negotiable |
If the table above is too sarcastic for you, here is the same information in responsible-parent form:
| # | Requirement | Details |
|---|---|---|
| 1️⃣ | n8n installed (self-hosted) | v2.14 or higher recommended |
| 2️⃣ | OpenRouter API key 🆓 | Get one here — the free tier runs this whole thing |
| 3️⃣ | Telegram Bot 🤖 | Created via @BotFather |
| 4️⃣ | Telegram Chat ID | Your personal/group chat ID (negative for groups) |
| 5️⃣ | Internet access 🌐 | To latex.ytotech.com & the AI APIs |
| 6️⃣ | Optional: Google Gemini / OpenAI keys | Only if you swap the OpenRouter models for native Gemini/GPT nodes |
- Open your n8n instance (
http://localhost:5678). - Click Workflows → Create Workflow → ⋮ menu → Import from File.
- Select
optimize-resume-ats-workflow.json(the exported file living next to this README in this very folder. Yes, really.). - Fix the credentials if n8n asks (OpenRouter, Telegram, HTTP request).
- Activate the workflow. 🚀
- Pray.
⚠️ If importing gives you a blank stare, verify your n8n version supports HTTP Request v3 nodes and Form v2 webhooks. The workflow uses the modern spice, not the vintage 2019 flavor.
- Open your n8n instance.
- Go to Workflows → click Import.
- Upload the exported JSON file (
optimize-resume-ats-workflow.jsonor the repo'sOptimizeResumeAsPerJD — Enhanced.json). - The workflow appears with all 23 nodes pre-configured.
- In n8n, go to Settings → Credentials → Add Credential.
- Search for OpenRouter (or the OpenAI-compatible chat model credential).
- Paste your OpenRouter API key.
- Assign it to all OpenRouter Chat Model nodes (Model1 through Model5 + the base model — that's six nodes, in case you lost count).
💡 You can use the same credential for all nodes.
- Open Telegram → search for @BotFather →
/newbot→ copy the Bot Token. - In n8n: Credentials → Add → Telegram → paste the token → Save.
- Get your Chat ID: add the bot to a group (or DM it), send a message,
then visit
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesand read"chat": {"id": ...}. Groups give you a negative number like-1001234567890. - Open both
Send resume via telegramandSend Cover Letter via Telegram, set the Chat ID, and assign your Telegram credential.
Remember §7.4? The exported JSON ships with YOUR_* placeholders so strangers
can't hijack your stuff. Fill in YOUR_TELEGRAM_CHAT_ID,
YOUR_TELEGRAM_CREDENTIAL_ID, YOUR_OPENROUTER_CREDENTIAL_ID, and
YOUR_FORM_WEBHOOK_ID (or let n8n regenerate the webhook on activation).
- Toggle Inactive → Active (top-right corner).
- Click the
Resume & JD submittedForm Trigger node. - Copy the Production URL — that's your public form URL. 🎉
This project was deployed with a Cloudflare Tunnel for local hosting — your PC, Python, and a free tunnel. No cloud subscriptions, no "your URL changed again" drama. 🚀
| Benefit | Why It Matters |
|---|---|
| 🆓 Free HTTPS | No "URL changes" drama |
| 🔁 Stable & persistent | Long-running tunnel for continuous bots |
| ⚙️ Auto-configured | Python script reads tunnel URL & sets env vars automatically |
| 🧘 Set & forget | Less mental overhead than ngrok |
Download from developers.cloudflare.com and verify:
cloudflared --versionCreate start_n8n.py in your project folder:
import subprocess, re, os, time, threading
def start_tunnel():
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", "http://localhost:5678"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
for line in proc.stdout:
match = re.search(r'https://[a-z0-9-]+\.trycloudflare\.com', line)
if match:
url = match.group(0)
os.environ["WEBHOOK_URL"] = url
os.environ["N8N_PROTOCOL"] = "https"
os.environ["N8N_HOST"] = url.replace("https://", "")
print(f"✅ Tunnel URL: {url}")
break
proc.wait()
threading.Thread(target=start_tunnel, daemon=True).start()
time.sleep(5)
os.system("n8n start")python start_n8n.pyIt auto-starts the tunnel, extracts the public HTTPS URL, sets
WEBHOOK_URL/N8N_PROTOCOL/N8N_HOST, and starts n8n. The webhook stays
live indefinitely — no URL changes, no manual restarts.
- 🔑 Add the OpenRouter credential (for all model nodes)
- 🤖 Add the Telegram Bot credential (from @BotFather)
- 🆔 Set Chat ID in both Telegram nodes
- 🔗 Copy the Production URL from the Form Trigger
- 🚀 Activate the workflow
| Model node | Node it feeds | Temperature | Why |
|---|---|---|---|
| OpenRouter Chat Model (base) | Resume extractor | 0.2 |
Facts, not fanfiction |
| OpenRouter Chat Model1 | JD extractor | 0.2 |
Let's not imagine keywords |
| OpenRouter Chat Model2 | Writer | 0.5 |
Slightly dramatic, keeps structure |
| OpenRouter Chat Model3 | ATS Score Agent | 0.2 |
Scoring should be sober |
| OpenRouter Chat Model4 | Cover Letter Agent | 0.6 |
Sappy letters need a spark |
| OpenRouter Chat Model5 | ATS Score Agent (Before) | 0.2 |
Also sober |
Temperature is set via parameters.options.temperature on each model node.
- Chat ID:
YOUR_TELEGRAM_CHAT_ID(the real one is scrubbed from the exported JSON — see §7.4 for the full placeholder rundown). - Resume node:
operation: sendDocument,binaryPropertyName: pdfData. - Cover letter node:
operation: sendDocument,binaryPropertyName: coverLetterFile.
Compile latex code POSTs to:
https://latex.ytotech.com/builds/sync
With a body like:
{
"compiler": "pdflatex",
"resources": [{ "main": true, "content": "\\documentclass... \\end{document}" }]
}- 201 → PDF bytes. 🎉
- 400 → your LLM produced invalid LaTeX. Again. 🙃
- Retry:
retryOnFail: true,waitBetweenTries: 5000.
This workflow ships wired to OpenRouter (openai/gpt-oss-20b:free) because
the free tier is a glorious cheat code. But the nodes are model-agnostic — swap
in whatever flavour you fancy. For reference, the original template supported
these families:
| Model ID | Speed | Quality | Best For |
|---|---|---|---|
models/gemini-2.0-flash-lite-001 |
⚡⚡⚡ | ✅ | Resume/JD Parsing (fast) |
models/gemini-2.5-flash-image |
⚡⚡⚡ | ✅✅ | Parsing + Reasoning |
models/gemini-3.1-flash-image-preview |
⚡⚡ | ✅✅✅ | LaTeX Generation |
models/gemini-3-pro-image-preview |
⚡ | ✅✅✅✅ | ATS Scoring, Cover Letter |
| Model ID | Speed | Quality | Best For |
|---|---|---|---|
gpt-5-mini |
⚡⚡⚡ | ✅ | Fast parsing tasks |
gpt-5.4-image-2 |
⚡⚡ | ✅✅✅ | Advanced gen + reasoning |
| Model ID | Notes |
|---|---|
openai/gpt-oss-20b:free |
Free, shockingly competent, zero guilt |
💡 Models are also listed in
config.jsonfor reference. To switch models, just change themodelparameter on each OpenRouter Chat Model node and re-import.
The ATS Score Agent uses a 4-factor weighted scoring model — yes, there is actual arithmetic behind the sarcasm:
ATS Score = (Keyword Coverage × 0.40) + (Skills Alignment × 0.30)
+ (Experience Relevance × 0.20) + (Role Title Match × 0.10)
| Score Range | Recommendation | Visual |
|---|---|---|
| 80–100 | 🚀 Strong Match | 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 |
| 60–79 | ✅ Good Match | 🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜ |
| 40–59 | 🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜ | |
| 0–39 | 🔴 Weak Match | 🟩🟩🟩🟩⬜⬜⬜⬜⬜⬜ |
The loop refuses to ship anything below 95 — so if you see a "Good Match" leave the building, it means the writer was on iteration 10 and gave up. 😬
The Cover Letter Agent writes 5-paragraph HTML cover letters designed for email delivery:
| Paragraph | Content | Purpose |
|---|---|---|
| 1️⃣ | Strong hook with company/role reference | Grab attention |
| 2️⃣ | Why this role & company specifically | Show research |
| 3️⃣ | 2–3 strongest matching achievements (quantified) | Prove capability |
| 4️⃣ | Address biggest skill gap as a learning narrative | Show growth mindset |
| 5️⃣ | Confident, action-oriented closing | Drive response |
🎨 Output is clean HTML with inline CSS — works in Gmail, Outlook, etc. Sent to Telegram as a
.htmlattachment because PDFs are for the resume only.
The exported optimize-resume-ats-workflow.json is deliberately scrubbed.
Any value that could let a stranger hijack your bot, drain your API credits, or
spam your chat is replaced with an angry-looking YOUR_... placeholder. That's
not a bug. That's self-preservation. 🛡️
The real values live only in your live n8n instance (and in whatever vault your paranoia prefers). When you import the JSON, you must fill the placeholders back in or the workflow will refuse to run / silently no-op.
Here is the complete inventory. Learn it. Live it. Replace it. 📋
| Placeholder | What It Is | Where It Lives (in the JSON) | What To Put There |
|---|---|---|---|
YOUR_FORM_WEBHOOK_ID |
The trigger ID that makes your web form reachable at http://localhost:5678/form/<id> |
Node Resume & JD submitted → top-level webhookId |
The webhookId shown on the node in the n8n editor (the UUID after /form/). If missing, n8n regenerates one on activation. |
YOUR_TELEGRAM_CHAT_ID |
The chat/group/channel your bot posts files into | Nodes Send resume via telegram and Send Cover Letter via Telegram → parameters.chatId |
Your Telegram chat ID, e.g. -1001234567890 for a group. Get it from a bot like @userinfobot or by watching the update stream. |
YOUR_TELEGRAM_NODE_WEBHOOK_ID |
Internal webhook ID on the Telegram nodes (leftover from the original template) | Nodes Send resume via telegram and Send Cover Letter via Telegram → top-level webhookId |
Whatever n8n assigns on import. This field is not used for sendDocument, so a random UUID is fine. |
YOUR_TELEGRAM_CREDENTIAL_ID |
The credential ID that points to your Telegram bot token | Telegram nodes → top-level credentials.telegramApi.id |
The credential UUID shown in n8n → Credentials. Re-associate the credential in the node instead of hand-editing if you can. |
YOUR_OPENROUTER_CREDENTIAL_ID |
The credential ID that points to your OpenRouter API key (all 6 model nodes) | OpenRouter model nodes → top-level credentials.openRouterApi.id |
The credential UUID shown in n8n → Credentials (OpenRouter). Re-associate via the editor to be safe. |
🔐 Why only IDs and not the keys themselves? n8n never stores API keys in the workflow JSON — it stores a credential ID that references the key in n8n's credential vault. So scrubbing the IDs (plus webhook IDs and chat ID) is sufficient to make the export shareable. The keys themselves were never in the file to begin with. Lucky you.
🚨 Sensitive things that are NOT in the JSON but ARE in the README lore:
- The n8n API key (
X-N8N-API-KEY) — never committed; useYOUR_N8N_API_KEY.- The OpenRouter API key — never committed; lives in the credential.
- The Telegram bot token — never committed; lives in the credential.
The golden rule: if it looks like a real UUID, a real chat ID, or a real webhook ID when you open the exported file, stop and scrub it before sharing. When in doubt, n8n's own Workflow → Export → Download flow is the blessed path — it strips these automatically. We scrubbed by hand here because we're control freaks. 🎛️
The workflow exposes an n8n Form v2 webhook:
http://localhost:5678/form/YOUR_FORM_WEBHOOK_ID
The form has two fields. Do NOT trust the labels. The REAL multipart field names (verified by peeking at the generated HTML, because labels lie) are:
| Multipart field | Content | Description |
|---|---|---|
field-0 |
File upload | The resume (.pdf… or .docx if you enjoy pain — see Known Issues) |
field-1 |
Textarea | The job description |
🔑 The
YOUR_FORM_WEBHOOK_IDplaceholder replaces the real webhook trigger ID in the exported JSON — see §7.4 for the full placeholder rundown.
There's a PowerShell trigger script that submits the form programmatically via
.NET HttpClient multipart:
.\trigger-form.ps1 -Resume "sample-resume.pdf" -Jd "sample-jd.txt"CRITICAL GOTCHA discovered by blood, sweat, and tears: the JD text MUST be
sent as ByteArrayContent WITHOUT a Content-Type header or filename. If
you send it as StringContent (text/plain), n8n treats it as an uploaded
file and the JD lands in the binary section while the JSON value stays
null — which makes the JD extractor feed on air and the whole thing scores
garbage. You don't want that. Trust us.
sample-resume.pdf— generated with reportlab so you don't have to.sample-resume.docx— the masochist's alternative.sample-jd.txt— a "Senior Backend Engineer (Python)" JD with the nice-to-haves Terraform, gRPC, and Kafka (so the loop has something to chase).
The webhook. Everything fun starts here. Captures field-0 (resume) and
field-1 (JD). Output JSON keys: Resume (file descriptor),
Job Description 🎯 (text), submittedAt, formMode. Binary keys:
Resume, Job_Description___.
Extracts text from the uploaded file. PDF ONLY. Yes, the form happily
accepts .docx. No, the extractor does not care. It will gleefully error with
Invalid PDF structure if you upload a Word doc. This mismatch is a feature
now. 🔪
Takes the raw extracted text and produces a strict JSON with the candidate's name, contact info, skills, work history (one entry per sequential role), and projects nested under the right job. Heavily prompted to ignore placeholder names like "Nitin Singh" (we're not bitter, we're thorough).
Key output field: full_name — used later for the filename. 🏷️
Parses the job description into:
priority_keywords.high(the "if you don't have this, don't apply" list)must_have_skillsskills.technical_skillsdomain
These feed the keyword-coverage auto-fix AND the writer's REBUILD MODE.
The loop's turnstile. Carries iteration, score, gaps, and the resume+JD
payload. Checks MAX_ITERATIONS and the termination condition
(done = score >= 95 || score === null || forceDone). If done → outputs to
Generated resume unformatted… wait, no. If NOT done → writer. If done → the
payload flows to the finish line via a different path. (This is the node you
point the FALSE branch of the If back at.)
The star. Produces LaTeX. Uses the REBUILD MODE prompt on iterations > 1.
Its output is a raw LaTeX blob that will 100% have markdown fences and stray
preamble until Prepare fixes it.
A Code node that does the heavy lifting:
- Strips markdown fences (
```latex). - Cuts everything before
\documentclass. - Cuts everything after
\end{document}. - ATS keyword auto-fix: parses the JD extractor output, collects all
keywords, finds which are missing from the LaTeX, and injects a
\item \textbf{JD-Matched Keywords:} <comma-separated escaped keywords>into the Skills section. Then recomputeskeywordCoverage. - Builds
requestBodyfor the compile step. - Computes
filename= candidate name only (sanitized, spaces →_).
Output JSON: requestBody, filename, candidateName, latexPreview,
keywordCoverage, missingKeywords.
HTTP Request (v3) to latex.ytotech.com. No local TeX. We outsource the pain.
Code node that carries the binary pdfData forward alongside filename,
candidateName, and keywordCoverage.
Scores the generated resume against the JD. Returns JSON like:
{
"score": 97,
"recommendation": "Strong Match",
"gaps": ["Add Terraform", "Mention gRPC", "Spell Kafka correctly"],
"top_matching_keywords": ["Python", "AWS", "CI/CD"]
}Code node. The referee. Computes done using the loop contract. Feeds the
If ATS Score >= 95 node.
- TRUE →
ATS Score Agent (Before)(golden path). - FALSE → back to
ATS Loop Guard(the highway to iteration N+1).
Scores the ORIGINAL uploaded resume so your Telegram caption can display the humbling before/after comparison.
Writes an HTML cover letter. Output wrapped in markdown fences (cleaned in Build Caption). Self-soothing flattery machine.
The Swiss Army code node:
- Parses both ATS JSON scores (before + after).
- Renders the emoji score bar (
scoreBar). - Builds the caption with
📄 File: ${resumeFilename}. - Cleans the cover letter HTML (strips fences, finds the
<html>tag). - Converts HTML → base64 binary for Telegram.
- Computes
resumeFilenameandcoverLetterFilenamefrom the name-only base.
Output JSON: caption, filename, resumeFilename, coverLetterFilename,
candidateName, atsScore, beforeScore, atsRecommendation,
keywordCoverage, coverLetterHtml, htmlBase64.
Output binary: pdfData (the compiled resume PDF), coverLetterFile
(HTML content, fileName = coverLetterFilename).
sendDocument with binaryPropertyName: pdfData, fileName =
={{ $json.filename }}_resume.pdf.
sendDocument with binaryPropertyName: coverLetterFile, fileName =
={{ $json.filename }}_coverletter.html.
We fought, we bled, and we ended up with this majestic scheme:
| Artifact | Naming Rule | Example |
|---|---|---|
| Base name | {{ $json.filename }} = candidate name only, sanitized ([^a-zA-Z0-9\s] stripped, spaces → _) |
Alex_Morgan |
| Resume PDF | <name>_resume.pdf |
Alex_Morgan_resume.pdf |
| Cover letter | <name>_coverletter.html |
Alex_Morgan_coverletter.html |
Why lowercase suffixes? Because uppercase is for people who don't debug their own n8n at 2 AM. Also, the user explicitly asked. 📏
Where the name comes from: Prepare compilation ready pulls full_name
from the Resume extractor output via regex:
/"full_name"\s*:\s*"([^"]+)"/.
📌 Important:
filenameitself contains NO extension. The suffixes are appended at the Telegram layer. Do not "helpfully" add_Resume.pdfback tofilenameinPrepare— you'll break every downstream node that does$json.filename + "_resume.pdf"and getAlex_Morgan_Resume.pdf_resume.pdf, which is the filenaming equivalent of a wardrobe malfunction.
Writer LLM ──▶ raw LaTeX blob (with fences, junk, hopes)
│
▼
Prepare compilation ready ──▶ cleans → injects keywords → requestBody
│
▼
Compile latex code ──▶ POST latex.ytotech.com/builds/sync
│
├── 201 ──▶ pdfData binary ✅
│
└── 400 ──▶ invalid LaTeX from the LLM 😭
Why 400s happen: The writer is an LLM. LLMs occasionally output
\href{mailto:}{$|$ or forget a closing }. It's not a service outage — the
service is fine (a tiny "Hello" doc compiles to 201 in milliseconds). It's
your writer having a bad day. Mitigations: retry, lower temperature, or pray.
Timeout myth debunked: n8n's HTTP Request v3 default timeout is actually
300s (requestOptions.timeout = 300_000), not 60. So a "timeout" error on
a 73-second compile is a red herring — check for a 400 first.
- Executions tab → find your execution → inspect each node's input/output.
- Look at
json.outputof the LLM agent nodes (that's where score, gaps, keyword lists, and extracted names live).
n8n keeps an event log at:
C:\Users\Nitin Kumar\.n8n\n8nEventLog.log
Every node start/finish per executionId is timestamped there. Grep it when
the UI lies.
$key = "<YOUR_API_KEY>"
Invoke-RestMethod -Uri "http://localhost:5678/api/v1/executions?limit=5" `
-Headers @{ 'X-N8N-API-KEY' = $key }Save the exact requestBody from a failed compile and replay it against
latex.ytotech.com with curl. Compare against a known-good body. This is how
we proved the compile failures were bad LaTeX, not a dead service. 🧪
| # | Issue | Symptom | Workaround |
|---|---|---|---|
| 1 | Form accepts .docx but extractor is PDF-only |
Invalid PDF structure |
Use PDFs. Accept your fate. |
| 2 | StringContent JD sends JD as a file |
JD JSON value = null, scoring is garbage |
Send JD as ByteArrayContent w/o Content-Type/filename |
| 3 | LLM produces invalid LaTeX | 400 from latex.ytotech.com | Retry / lower temperature / check requestBody |
| 4 | Form labels ≠ multipart names | Wrong field names in trigger script | Use field-0 / field-1 |
| 5 | PowerShell BOM breaks PUT bodies | Failed to parse request body |
Write body without BOM + curl.exe --data-binary |
| 6 | Cover letter is HTML but user wanted .pdf |
Philosophical crisis | Resolved: it stays .html. The user is the boss. |
| 7 | filename containing extension breaks suffixes |
name_resume.pdf_resume.pdf |
filename = name only, period |
| 8 | "The connection timed out" at compile | Panic | It's a 400 in a trench coat. Check requestBody. |
| 9 | Emojis in shell output break consoles | UnicodeEncodeError |
Run python with -X utf8 and reconfiguring stdout |
| 10 | LLM sometimes ignores REBUILD MODE | Score doesn't improve across loops | Wait for iteration 3. Or 4. Or 10. |
Q: The workflow never stops. My fans are screaming.
A: Check MAX_ITERATIONS in ATS Loop Guard AND ATS Check. Verify the If
FALSE branch still feeds ATS Loop Guard. If score keeps coming back as
null (JD parsing failed), the loop bails out with done = true — but if the
JD was fine and the LLM just won't cross 95, it's the model, not the wiring.
Q: ATS score is high but the Telegram files are wrong.
A: Check Prepare output filename. If it contains an extension, see
§10. Then check Build Caption output resumeFilename/coverLetterFilename.
Q: The caption says "File: undefined".
A: filename was undefined at caption build time. Make sure Prepare
actually emitted filename and that Compile & convert carried it.
Q: PDF comes back 400.
A: Grab requestBody from Prepare, replay it:
curl.exe -s -X POST https://latex.ytotech.com/builds/sync -H "Content-Type: application/json" --data-binary "@requestBody.json"
If 400 → bad LaTeX. If 201 → your node config is haunted.
Q: The JD keyword coverage is 0%.
A: JD extractor output JSON structure doesn't match the parse logic
(priority_keywords.high, must_have_skills, skills.technical_skills).
Inspect the raw extractor output. The LLM renamed a key. They do that.
Q: I changed a node via the UI and now the API body won't PUT.
A: settings must contain ONLY executionOrder on PUT. The UI adds
binaryMode, availableInMCP, etc., and the API will scream
request/body/settings must NOT have additional properties. Strip them.
The OG README knew a few tricks we kept in the medicine cabinet:
| Error | Likely Cause | Fix |
|---|---|---|
Missing \begin{document} |
LLM wrapped output in markdown fences | ✅ Auto-fixed by Prepare compilation ready node |
MISSING_COMPILATION_SPECIFICATION |
Double == in HTTP body expression |
Use single =: ={{ $json.requestBody }} |
XeTeXglyph error |
fontawesome5 package used |
Remove \usepackage{fontawesome5} from LaTeX skeleton |
| Empty PDF / blank resume | Scanned image PDF | Convert to text-based PDF first, or use DOCX format |
| Telegram bot not sending | Bot not added to group | Add bot to group and make it admin |
| AI API errors | Rate limit on free tier | Add a Wait node (5s) between AI agents |
Because editing a 23-node loop through the UI while it's mid-execution is a great way to lose your lunch, we edit via the n8n REST API. The golden rules:
- Base:
http://localhost:5678/api/v1 - Auth header:
X-N8N-API-KEY: <your-key> - GET:
GET /workflows/<id>— fetch the live workflow. - PUT:
PUT /workflows/<id>— save changes. Body ={ name, nodes, connections, settings }.
{
"name": "copy of OptimizeResumeAsPerJD — Enhanced",
"nodes": [ ... ],
"connections": { ... },
"settings": { "executionOrder": "v1" }
}- Only
name,nodes,connections,settings. settings→ ONLYexecutionOrder. NotbinaryMode. NotavailableInMCP. NOTHING ELSE. 🔨- PATCH is rejected (405) on this n8n. PUT or GTFO.
- Write the body as UTF-8 without BOM.
- Send with
curl.exe --data-binary "@body.json". Invoke-RestMethod -InFilewill betray you with a BOM andbody-parserwill reject the whole thing.
GET /workflows/<id>→ save snapshot.- Modify the node(s) in a script (Python is kinder to unicode/emojis than PowerShell string munging — the captions are full of 📄🟩⬜🎯).
- Build the PUT body, write no-BOM,
curl.exe --data-binary. - Check
versionIdincrements in the response. - Re-trigger and watch the Executions tab like a hawk. 🦅
| Method | URL | Purpose |
|---|---|---|
| GET | /api/v1/workflows/<id> |
Fetch workflow |
| PUT | /api/v1/workflows/<id> |
Update workflow |
| POST | /api/v1/workflows/<id>/activate |
Activate |
| POST | /api/v1/workflows/<id>/deactivate |
Deactivate |
| GET | /api/v1/executions?limit=N |
List executions |
| GET | /api/v1/executions/<id> |
Execution detail |
| Service | Endpoint | Used for |
|---|---|---|
| OpenRouter | (via n8n OpenAI-compatible credential) | All LLM calls |
| LaTeX | https://latex.ytotech.com/builds/sync |
PDF compilation |
| Telegram | Bot API (via n8n node) | File delivery |
| n8n Form | http://localhost:5678/form/YOUR_FORM_WEBHOOK_ID |
Manual trigger |
Resume extractor → JSON containing full_name, contact, experience
(one entry per role), skills, projects.
JD extractor → { priority_keywords: { high: [] }, must_have_skills: [], skills: { technical_skills: [] }, domain: ... }
ATS Score Agent → { score, recommendation, gaps: [], top_matching_keywords: [] }
Prepare → { requestBody, filename, candidateName, latexPreview, keywordCoverage, missingKeywords }
Build Caption → { caption, filename, resumeFilename, coverLetterFilename, candidateName, atsScore, beforeScore, atsRecommendation, keywordCoverage, coverLetterHtml, htmlBase64 }
| # | Status | The Drama |
|---|---|---|
| 347 | ✅ success | Warm-up run |
| 348 | ✅ success | Another warm-up. Weird vibes. |
| 349 | ❌ error | Invalid PDF structure — someone sent a .docx to a PDF-only extractor. Rookie mistake. |
| 350 | ✅ success | JD was null (wrong field names). Still scored 97. The LLM hallucinated its way to victory. |
| 351 | ✅ success | JD still null (StringContent → treated as file). ATS 98. Gaps: Terraform, gRPC, Kafka. |
| 352 | ❌ error | "The connection timed out" at compile. It was actually a 400 in a trench coat. |
| 353 | ❌ error | JD fix verified! JD parsed as String (1359 chars). Then compile → 400. The writer wrote `\href{mailto:}{$ |
| 354 | ✅ success | Full clean run. Compile worked. Both Telegram sends fired. The loop earned its keep. |
- Make
Extract from File1accept.docxso the form stops lying. - Convert the cover letter to a real PDF so the
.pdffilename makes sense. - Add a resume-vs-resume A/B score breakdown in the Telegram caption.
- Persist
MAX_ITERATIONSas a workflow-level setting instead of a magic constant. - Auto-retry failed LaTeX compiles with a "please fix your LaTeX" nudge to the writer.
- Sanitize the caption so emojis don't break Python consoles (we just work around it).
- A dashboard. Everyone wants a dashboard. (No one will maintain it.)
The original template came with a delightful wishlist. We kept it because we, too, like to dream:
- 📧 Email Delivery (Gmail): Add a Gmail node after
Compile & convert to pdf fileto send the PDF + Cover Letter as an email attachment. Great for sharing directly with recruiters. - ⚡ Parallel AI Processing: Wire
Resume extractorandJD extractorstraight fromExtract from Fileto run in parallel — cuts processing time by ~40%! - 📨 Instant Acknowledgement: Add a Telegram/Gmail node right after the Form Trigger: "✅ We've received your resume. Your optimized version will arrive in ~90 seconds."
- 🔔 Error Alerts via Telegram: An Error Trigger workflow that DM's you the full error details when any node fails.
- 📊 Google Sheets Logging: Log candidate name, JD domain, ATS score, timestamp — for usage tracking.
- 🌐 Multi-language Support: Detect resume language and generate the optimized resume in the candidate's preferred language.
- 🧪 A/B Testing Mode: Generate 2 resume versions with different keyword strategies and let you pick the better ATS score.
- 🏢 Company Research Agent: Add a web search node that researches the company before generating — even more tailored content.
- 💾 Version History: Store all generated resumes with version tracking — compare & revert anytime.
Q: Does this guarantee I get the job? A: Absolutely not. It guarantees the ATS robot sees the keywords. The human recruiter may still hate you. That's their problem. 🤷
Q: Is gaming the ATS ethical? A: That's between you and your conscience. We're a workflow, not a therapist.
Q: Why is the temperature 0.5 on the writer? A: High enough to be creative, low enough to mostly produce valid LaTeX. "Mostly" is doing a lot of heavy lifting here. 😅
Q: Why does the form say .docx is OK?
A: Great question. Ask the original author. We just found out the hard way.
Q: Can I run this on n8n Cloud?
A: Sure. But the localhost URLs in this README (and any hardcoded
localhost:5678 references) will need adjusting. Also bring snacks for the
API rate limits.
Q: Why 95 and not 100? A: Because the LLM scoring another LLM's resume at a perfect 100 every time would mean one of them is broken. 95 is aspirational but reachable.
- License: WTFPL with a side of "don't blame us if you get rejected." Actually, no formal license — it's your workflow now. Do what you want. (But see next bullet.)
- Disclaimer: This workflow may, at any time, produce invalid LaTeX, hallucinate a JD, spam your Telegram, or loop until your CPU begs for mercy. We accept no liability for hiring outcomes, broken keyboards, or existential crises triggered by watching your before-score.
- A note on automation: Auto-optimizing applications at industrial scale is a thing. Use responsibly. The ATS may eventually learn. We'll deal with that in v2. 🦾
filenameinPrepare compilation readyis now candidate name only (no_Resume.pdfsuffix).Build Caption + Cover LetterderivesresumeFilename(.pdf) andcoverLetterFilename(.html) from that base.- Telegram nodes append
_resume.pdf/_coverletter.htmlat send time. - Caption
📄 File:line now shows the full resume filename. - Fixed:
settingson PUT stripped to{ executionOrder }only.
- Fixed the
---# ATS OPTIMIZATION RULESheader being glued to the previous line (a missing newline made the REBUILD MODE comment invisible). - Re-PUT via no-BOM body +
curl.exe --data-binary.
- REBUILD MODE block added to writer prompt (
{% if $json.iteration > 1 %}). MAX_ITERATIONS = 10in bothATS Loop GuardandATS Check.- Temperatures tuned (0.2/0.5/0.6 across the six model nodes).
- Workflow activated.
- 23 nodes of optimistic resume juice, imported from a copy of "OptimizeResumeAsPerJD — Enhanced."
Nitin Kumar 🔗 LinkedIn 🔗 GitHub
Built with ☕, too many LaTeX error logs, and the firm belief that your resume should work as hard as you do.
You now possess the complete, unvarnished truth about this workflow. It loops. It scores. It yells at LLMs. It delivers PDFs. It occasionally loses its mind and writes broken LaTeX. But when it works — and it does work — it turns a mediocre resume into a keyword-saturated, ATS-approved, Telegram-blessed document of pure ambition. 🚀
Go forth. Score 95. Change careers. Buy a treadmill. We believe in you. 💪
— The Maintainer(s) Who Definitely Commented Every Line of This, Probably
