This workflow follows the Form → Form Trigger recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "CV Tailor + Cover Letter (easybits)",
"nodes": [
{
"parameters": {
"formTitle": "Tailor my CV for this job",
"formDescription": "Upload the job posting (PDF, image, or screenshot). I'll rewrite your CV bullets and draft a cover letter \u2013 both in the posting's language, both ready to paste.",
"formFields": {
"values": [
{
"fieldLabel": "Job posting",
"fieldType": "file",
"acceptFileTypes": ".png, .jpg, .jpeg, .pdf",
"requiredField": true
},
{
"fieldLabel": "Notes",
"fieldType": "textarea"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.5,
"position": [
0,
0
],
"name": "On form submission"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "REPLACE_WITH_YOUR_SHEET_ID",
"mode": "list",
"cachedResultName": "Master CV",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/REPLACE_WITH_YOUR_SHEET_ID/edit"
},
"sheetName": {
"__rl": true,
"value": "gid=0",
"mode": "list",
"cachedResultName": "Master CV",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1L9Jx4MR-A0DskhZfhHB62GwtSVX2A0y1o8k_rn5F1AY/edit#gid=0"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
304,
0
],
"name": "Load Master CV"
},
{
"parameters": {
"jsCode": "const formData = $('On form submission').first().json;\nconst cv = $input.first().json.cv || [];\n\nreturn [{\n json: {\n notes: formData['Notes'] || '',\n cv: cv\n },\n binary: {\n job_posting: $('On form submission').first().binary['Job_posting']\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
832,
0
],
"name": "Prep Data"
},
{
"parameters": {},
"type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
"typeVersion": 2,
"position": [
1136,
0
],
"name": "easybits: Extract Job Posting"
},
{
"parameters": {
"jsCode": "const job = $input.first().json.data;\nconst cv = $('Prep Data').first().json.cv || [];\nconst notes = $('Prep Data').first().json.notes || '';\n\nfunction toArray(value) {\n // Already an array \u2014 return as-is.\n if (Array.isArray(value)) return value;\n\n // String \u2014 try JSON first, then comma-split as fallback.\n if (typeof value === 'string') {\n const trimmed = value.trim();\n if (!trimmed) return [];\n\n // Try JSON parse first (covers \"[\\\"a\\\", \\\"b\\\"]\" responses).\n try {\n const parsed = JSON.parse(trimmed);\n if (Array.isArray(parsed)) return parsed;\n } catch (e) { /* fall through to comma split */ }\n\n // Fallback: comma-separated string.\n return trimmed.split(',').map(s => s.trim()).filter(Boolean);\n }\n\n return [];\n}\n\nconst mustHaves = toArray(job.must_have_skills);\nconst keywords = toArray(job.keywords);\n\nconst cvText = cv.map(row =>\n `${row.role || ''} ${row.company || ''} ${row.bullets || ''} ${row.skills || ''}`\n).join(' ').toLowerCase();\n\nfunction normalize(s) { return String(s || '').toLowerCase().trim(); }\n\nfunction matches(haystack, needle) {\n const n = normalize(needle);\n if (!n) return false;\n if (haystack.includes(n)) return true;\n if (n.endsWith('s') && haystack.includes(n.slice(0, -1))) return true;\n return false;\n}\n\nconst matchedMust = mustHaves.filter(s => matches(cvText, s));\nconst matchedKw = keywords.filter(s => matches(cvText, s));\nconst missingMust = mustHaves.filter(s => !matches(cvText, s));\nconst missingKw = keywords.filter(s => !matches(cvText, s));\n\nconst totalWeight = (mustHaves.length * 2) + keywords.length;\nconst earned = (matchedMust.length * 2) + matchedKw.length;\nconst scoreBefore = totalWeight === 0 ? 0 : Math.round((earned / totalWeight) * 100);\n\nreturn [{\n json: {\n job,\n cv,\n notes,\n score_before: scoreBefore,\n matched_must_have: matchedMust,\n missing_must_have: missingMust,\n matched_keywords: matchedKw,\n missing_keywords: missingKw\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1440,
0
],
"name": "Score: Before"
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "models/gemini-2.5-pro",
"mode": "list",
"cachedResultName": "models/gemini-2.5-pro"
},
"messages": {
"values": [
{
"content": "=You are tailoring a CV for a specific job application. Output valid JSON only \u2014 no preamble, no markdown fences, no explanation.\n\nJOB POSTING (extracted):\n{{ JSON.stringify($json.job, null, 2) }}\n\nCANDIDATE'S MASTER CV:\n{{ JSON.stringify($json.cv, null, 2) }}\n\nUSER NOTES: {{ $json.notes || '(none)' }}\n\nMUST-HAVES THE CV CURRENTLY DOESN'T COVER: {{ JSON.stringify($json.missing_must_have) }}\n\nINSTRUCTIONS:\n1. Rewrite the candidate's experience bullets to truthfully emphasize overlap with the job's must-have skills first, then keywords.\n2. Mirror the employer's exact phrasing where the candidate has matching experience. Example: if posting says \"orchestration pipelines\" and CV says \"data workflows\", use the posting's term \u2014 but only if the underlying experience genuinely matches.\n3. NEVER invent experience, technologies, or achievements not present in the original CV. If a must-have isn't covered by real experience, do not fabricate it \u2014 list it under \"gaps\" instead.\n4. Preserve every original role, company, and date. Only the bullets change.\n5. Output language must match the job posting's language: {{ $json.job.language }}.\n6. Match the posting's tone: {{ $json.job.tone }}.\n7. Keep the same number of bullets per role as in the original CV. Quality over quantity.\n\nReturn this exact JSON structure:\n{\n \"experiences\": [\n {\n \"role\": \"...\",\n \"company\": \"...\",\n \"dates\": \"...\",\n \"bullets\": [\"...\", \"...\"]\n }\n ],\n \"gaps\": [\"must-have skills the candidate genuinely lacks based on the CV\"]\n}"
}
]
},
"jsonOutput": true,
"builtInTools": {},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1.1,
"position": [
1680,
0
],
"name": "Gemini: Tailor Bullets"
},
{
"parameters": {
"jsCode": "// Extract Gemini's text output. The shape varies depending on the Gemini node version.\nconst input = $input.first().json;\n\nlet rawText = '';\nif (input?.content?.parts?.[0]?.text) {\n // Gemini API native shape (your current case).\n rawText = input.content.parts[0].text;\n} else if (typeof input.text === 'string') {\n rawText = input.text;\n} else if (typeof input.response === 'string') {\n rawText = input.response;\n} else if (typeof input.output === 'string') {\n rawText = input.output;\n} else if (input?.message?.content) {\n rawText = input.message.content;\n} else {\n rawText = JSON.stringify(input);\n}\n\n// Strip markdown fences if present.\nconst cleaned = String(rawText).replace(/```json|```/g, '').trim();\n\nlet tailored;\ntry {\n tailored = JSON.parse(cleaned);\n} catch (e) {\n throw new Error('Could not parse Gemini output as JSON. First 500 chars: ' + cleaned.slice(0, 500));\n}\n\n// Pull previous state from the Score: Before node.\nconst prev = $('Score: Before').first().json;\nconst job = prev.job;\nconst cv = prev.cv;\nconst notes = prev.notes;\n\n// Same toArray helper as Phase 3 \u2014 handles arrays, JSON strings, comma-separated strings.\nfunction toArray(value) {\n if (Array.isArray(value)) return value;\n if (typeof value === 'string') {\n const trimmed = value.trim();\n if (!trimmed) return [];\n try {\n const parsed = JSON.parse(trimmed);\n if (Array.isArray(parsed)) return parsed;\n } catch (e) { /* fall through */ }\n return trimmed.split(',').map(s => s.trim()).filter(Boolean);\n }\n return [];\n}\n\nconst mustHaves = toArray(job.must_have_skills);\nconst keywords = toArray(job.keywords);\n\n// Build the new searchable text from the TAILORED bullets.\nconst tailoredText = (tailored.experiences || []).map(exp =>\n `${exp.role || ''} ${exp.company || ''} ${(exp.bullets || []).join(' ')}`\n).join(' ').toLowerCase();\n\nfunction normalize(s) { return String(s || '').toLowerCase().trim(); }\n\nfunction matches(haystack, needle) {\n const n = normalize(needle);\n if (!n) return false;\n if (haystack.includes(n)) return true;\n if (n.endsWith('s') && haystack.includes(n.slice(0, -1))) return true;\n return false;\n}\n\nconst matchedMust = mustHaves.filter(s => matches(tailoredText, s));\nconst matchedKw = keywords.filter(s => matches(tailoredText, s));\nconst missingMust = mustHaves.filter(s => !matches(tailoredText, s));\nconst missingKw = keywords.filter(s => !matches(tailoredText, s));\n\nconst totalWeight = (mustHaves.length * 2) + keywords.length;\nconst earned = (matchedMust.length * 2) + matchedKw.length;\nconst scoreAfter = totalWeight === 0 ? 0 : Math.round((earned / totalWeight) * 100);\n\nreturn [{\n json: {\n job,\n cv,\n notes,\n tailored_experiences: tailored.experiences || [],\n gaps: tailored.gaps || [],\n score_before: prev.score_before,\n score_after: scoreAfter,\n score_delta: scoreAfter - prev.score_before,\n matched_must_have_after: matchedMust,\n missing_must_have_after: missingMust,\n matched_keywords_after: matchedKw,\n missing_keywords_after: missingKw\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2048,
0
],
"name": "Score: After"
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "models/gemini-2.5-pro",
"mode": "list",
"cachedResultName": "models/gemini-2.5-pro"
},
"messages": {
"values": [
{
"content": "=Write a cover letter for this job application. Output the letter text only \u2014 no preamble, no greeting addressed to \"the hiring team\", no signature line.\n\nJOB:\n- Title: {{ $json.job.job_title }}\n- Company: {{ $json.job.company }}\n- Must-haves: {{ JSON.stringify($json.job.must_have_skills) }}\n- Responsibilities: {{ JSON.stringify($json.job.responsibilities) }}\n- Tone: {{ $json.job.tone }}\n- Language (ISO): {{ $json.job.language }}\n\nTAILORED CV (use as the source of truth for what the candidate has done \u2014 do not reference anything outside this):\n{{ JSON.stringify($json.tailored_experiences, null, 2) }}\n\nUSER NOTES: {{ $json.notes || '(none)' }}\n\nGAPS THE CANDIDATE HAS (must-haves not covered by their experience):\n{{ JSON.stringify($json.gaps) }}\n\nRULES:\n1. Three paragraphs, ~250 words total.\n - Opening: tie the candidate to the company/role with a specific reason (drawn from their actual CV, not generic enthusiasm).\n - Middle: map 2-3 specific experiences from the tailored CV to the job's must-haves. Concrete examples beat platitudes.\n - Closing: forward-looking \u2014 express interest in next steps, mention availability if relevant.\n2. Write in the language indicated by the ISO code: {{ $json.job.language }}. (en = English, de = German, nl = Dutch, etc.)\n3. Match the posting's tone: {{ $json.job.tone }}.\n4. NEVER invent experience, technologies, or achievements. Only reference work present in the tailored CV above.\n5. NEVER claim skills the candidate doesn't have. If a must-have is in the gaps list, do not pretend the candidate has it. Do not mention the gaps either \u2014 just don't claim them.\n6. 6. Open with \"Dear Hiring Team,\" (or the localized equivalent for the posting's language). Do NOT add a date, address block, or subject line. Sign off with \"Best regards,\" followed by a placeholder \"[Your Name]\" so the user fills it in.\n7. Do not use AI-clich\u00e9 phrases like \"I am writing to express my interest\" or \"I would be a great fit\". Write like a real person.\n8. The closing sentence should propose a concrete next step \u2014 e.g., \"Happy to discuss in a short call next week\" or \"Available to start from [date]\". Avoid vague phrases like \"I look forward to discussing how my experience can help\".\n\nOutput: just the letter body as plain text."
}
]
},
"builtInTools": {},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1.1,
"position": [
2288,
0
],
"name": "Gemini: Cover Letter"
},
{
"parameters": {
"jsCode": "// Get the cover letter from this node's input (Gemini #2 output).\nconst coverInput = $input.first().json;\nconst letter = coverInput?.content?.parts?.[0]?.text \n || coverInput?.text \n || coverInput?.response \n || '';\n\n// Get everything else from Score: After.\nconst data = $('Score: After').first().json;\n\n// Build the experience block \u2014 one section per role.\nconst expBlocks = (data.tailored_experiences || []).map(exp => {\n const bullets = (exp.bullets || []).map(b => `\u2022 ${b}`).join('\\n');\n return `${exp.role} \u2014 ${exp.company}\\n${exp.dates}\\n${bullets}`;\n}).join('\\n\\n');\n\n// Optional gaps section \u2014 only included if there are any.\nconst gapsBlock = data.gaps && data.gaps.length\n ? `\\n\\n---\\nGAPS TO ADDRESS IN INTERVIEW:\\n${data.gaps.map(g => `\u2022 ${g}`).join('\\n')}`\n : '';\n\n// Full document body \u2014 single string, line breaks preserved.\nconst body = `TAILORED CV \u2014 ${data.job.job_title || 'Application'}\nMatch: ${data.score_before}% \u2192 ${data.score_after}% (+${data.score_delta})\n\n${expBlocks}${gapsBlock}\n\n=========================\n\nCOVER LETTER\n\n${letter}`;\n\nreturn [{\n json: {\n doc_title: `Application - ${data.job.company || 'Company'} - ${data.job.job_title || 'Role'}`,\n doc_body: body,\n score_before: data.score_before,\n score_after: data.score_after,\n score_delta: data.score_delta,\n gaps: data.gaps || [],\n company: data.job.company,\n job_title: data.job.job_title\n }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2656,
0
],
"name": "Assemble Document"
},
{
"parameters": {
"operation": "completion",
"completionTitle": "Done \u2013 your tailored application is ready",
"completionMessage": "=<p><strong>Match score:</strong> {{ $('Assemble Document').first().json.score_before }}% \u2192 {{ $('Assemble Document').first().json.score_after }}% (+{{ $('Assemble Document').first().json.score_delta }})</p>\n\n<p><strong>Google Doc:</strong> <a href=\"{{ $('Create Google Doc').first().json.documentURL || $('Create Google Doc').first().json.webViewLink }}\" target=\"_blank\">Open your tailored application</a></p>\n\n<p>{{ $('Assemble Document').first().json.gaps && $('Assemble Document').first().json.gaps.length ? '<strong>Gaps to address in interview:</strong> ' + $('Assemble Document').first().json.gaps.join(', ') : '<strong>No gaps detected</strong> \u2014 your CV covers all explicit must-haves.' }}</p>",
"options": {}
},
"type": "n8n-nodes-base.form",
"typeVersion": 2.5,
"position": [
3488,
0
],
"name": "Show Completion Screen"
},
{
"parameters": {
"folderId": "REPLACE_WITH_YOUR_FOLDER_ID",
"title": "={{ $json.doc_title }}"
},
"type": "n8n-nodes-base.googleDocs",
"typeVersion": 2,
"position": [
2976,
0
],
"name": "Create Google Doc"
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "cv",
"options": {}
},
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
528,
0
],
"name": "Aggregate CV Rows"
},
{
"parameters": {
"operation": "update",
"documentURL": "={{ $json.id }}",
"actionsUi": {
"actionFields": [
{
"action": "insert",
"text": "={{ $('Assemble Document').first().json.doc_body }}"
}
]
}
},
"type": "n8n-nodes-base.googleDocs",
"typeVersion": 2,
"position": [
3168,
0
],
"name": "Write to Google Doc"
},
{
"parameters": {
"content": "# \ud83d\udcdd CV Tailor + Cover Letter (easybits)\n\n## What This Workflow Does\nUpload a job posting (PDF, image, or screenshot) and instantly get a tailored CV section and a matching cover letter \u2014 both written in the posting's language and tone, both honest about what you actually have. The workflow scores how well your stored CV matches the posting before and after AI rewriting, so you can see exactly how much keyword surfacing happened. Anything you don't actually have lands in a `gaps` array \u2014 never invented into bullets.\n\n## How It Works\n1. **Upload** \u2014 Drop the job posting into the n8n web form, optionally with notes\n2. **Load CV** \u2014 Reads your structured Master CV from Google Sheets\n3. **Extract** \u2014 easybits Extractor pulls 10 structured fields from the posting (must-haves, keywords, language, tone, etc.)\n4. **Score (before)** \u2014 A Code node calculates a deterministic match score using keyword overlap\n5. **Tailor bullets** \u2014 Gemini rewrites your CV bullets to surface real overlap with the posting, mirrors employer phrasing, flags genuine gaps\n6. **Score (after)** \u2014 Same scoring formula, run against the rewritten bullets \u2014 the delta is the win\n7. **Cover letter** \u2014 A second Gemini call drafts a 3-paragraph letter in the posting's language and tone, referencing only the tailored CV\n8. **Output** \u2014 Both pieces get written into a single Google Doc, titled with company + role\n9. **Done** \u2014 A completion screen shows the score before/after, the Doc link, and any gaps\n\n## Setup Guide\n\n### 1. Run the CV Onboarding Workflow First\nThis workflow assumes a populated Master CV Google Sheet. If you haven't run the **CV Onboarding (easybits)** workflow yet, do that first \u2014 it'll extract your CV into the right structure.\n\n### 2. Install & Connect the easybits Extractor\nOn **n8n Cloud** the easybits Extractor is available out of the box. On **self-hosted n8n**, install it via Settings \u2192 Community Nodes \u2192 enter `@easybits/n8n-nodes-extractor`.\n\nSign up at [easybits.tech](https://easybits.tech) for a free API key. Open the **easybits: Extract Job Posting** node and connect your credential.\n\n### 3. Configure the Extraction Pipeline\nThe Extractor uses 10 fields to pull structured data from the job posting. Paste each description into the corresponding field's description box:\n\n**`job_title`** *(string)*\nThe exact job title as written in the posting. If multiple titles are mentioned, return the primary one. Return null if not stated.\n\n**`company`** *(string)*\nThe hiring company's name. Return null if not stated or if the posting is from a recruiter without naming the company.\n\n**`seniority`** *(string)*\nOne of: intern, junior, mid, senior, lead, principal, executive. Infer from title and years-of-experience requirements. Return null only if there is no signal at all.\n\n**`must_have_skills`** *(array)*\nSkills, tools, or specific qualifications the posting explicitly lists as required, must-have, mandatory, or essential. Return as SHORT noun phrases (1-3 words each). Examples: \"Python\", \"PostgreSQL\", \"Kafka\", \"5 years experience\", \"fluent German\". DO NOT return full sentences or full requirements. Break compound requirements into individual atoms \u2014 e.g., \"Solid understanding of testing, monitoring, and profiling\" must be returned as three separate items: \"testing\", \"monitoring\", \"profiling\". DO NOT include soft skills like \"problem-solving\" or \"attention to detail\". Return empty array if none are explicitly required.\n\n**`nice_to_have_skills`** *(array)*\nSkills listed as preferred, bonus, plus, nice-to-have, or \"ideally\". Same format as must_have_skills. Return empty array if none.\n\n**`responsibilities`** *(array)*\nConcrete duties or tasks the role involves, as short action phrases. Maximum 8 items. Empty array if the posting is purely qualifications-focused.\n\n**`keywords`** *(array)*\nDomain-specific terms, technologies, methodologies, or industry vocabulary worth mirroring in a CV. Exclude generic words like \"team player\". Maximum 15 items.\n\n**`tone`** *(string)*\nWriting style of the posting. One of: formal, professional, casual, technical.\n\n**`language`** *(string)*\nISO 639-1 code of the posting's primary language (e.g., \"en\", \"de\", \"nl\").\n\n**`location`** *(string)*\nWork location as stated. Include \"remote\", \"hybrid\", or city/country. Null if not stated.\n\nThe 10-field cap matches the easybits free plan, so anyone can run this workflow as-is.\n\n### 4. Connect Google Sheets\nOpen the **Load Master CV** node and connect your Google Sheets credential. Replace the placeholder document ID with your Master CV sheet's ID and select the `Master CV` tab.\n\n### 5. Connect Gemini\nOpen both Gemini nodes (**Gemini: Tailor Bullets** and **Gemini: Cover Letter**) and connect your Google AI / Gemini credential. Default model: `gemini-2.5-pro`.\n\n### 6. Connect Google Docs\nOpen both **Create Google Doc** and **Write to Google Doc** nodes, connect your Google Docs credential. In Create Google Doc, pick a Drive and Folder where tailored applications should be saved (a dedicated folder is recommended).\n\n### 7. Activate & Use\nSet the workflow to active, copy the form URL, and upload a real job posting. The completion screen returns the score delta and a link to the generated Google Doc.\n\n---\n\n## \ud83d\udd27 Notes on Implementation\n\n**Honesty guardrail.** Both Gemini prompts forbid inventing experience. Skills the CV genuinely lacks land in a `gaps` array, surfaced on the completion screen \u2014 never faked into bullets or claimed in the cover letter.\n\n**Deterministic scoring.** The before/after match score is calculated in JavaScript, not by an LLM. The same formula runs against the original CV and the tailored bullets, so the delta reflects real keyword surfacing \u2014 not AI claiming it improved things. Must-haves count double in the formula; keywords count single.\n\n**Defensive parsing.** The `toArray()` helper in the Score nodes handles three shapes the Extractor may return for array fields: actual arrays, JSON strings, and comma-separated strings. This makes the workflow robust across different Extractor response shapes.\n\n**Language and tone.** Both come from the Extractor as structured fields, so a German posting yields a German CV section AND a German cover letter \u2014 automatically.",
"height": 1640,
"width": 600,
"color": 7
},
"id": "sticky-overview",
"name": "Sticky: Overview",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-700,
-240
]
},
{
"parameters": {
"content": "## \ud83d\udce5 Job Posting Upload\nHosts a web form with a file field for the job posting (PDF, PNG, JPEG) and an optional notes field for emphasis or context (e.g., \"emphasize my Python experience\"). The file is passed as binary directly to the Prep Data node.",
"height": 220,
"width": 200,
"color": 7
},
"id": "sticky-upload",
"name": "Sticky: Upload",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
-260
]
},
{
"parameters": {
"content": "## \ud83d\udccb Load Master CV\nReads every row of the `Master CV` tab from your Google Sheet \u2014 one row per experience, with `role`, `company`, `dates`, `bullets`, and `skills` columns. Aggregate combines all rows into a single list for downstream processing.",
"height": 220,
"width": 200,
"color": 7
},
"id": "sticky-loadcv",
"name": "Sticky: Load CV",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
180,
-260
]
},
{
"parameters": {
"content": "## \ud83e\uddf0 Prep Data\nBundles the form input (job posting binary + notes) and the loaded CV into one clean object so every downstream node can reference everything from a single place. The binary key is normalized to `job_posting` for the Extractor.",
"height": 220,
"width": 200,
"color": 7
},
"id": "sticky-prep",
"name": "Sticky: Prep",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
600,
-260
]
},
{
"parameters": {
"content": "## \ud83e\udde0 Extract Job Posting\nSends the uploaded posting to the easybits Extractor with 10 fields configured: job_title, company, seniority, must_have_skills, nice_to_have_skills, responsibilities, keywords, tone, language, and location. Response is wrapped in a `data` object.",
"height": 240,
"width": 200,
"color": 7
},
"id": "sticky-extract",
"name": "Sticky: Extract",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
820,
-260
]
},
{
"parameters": {
"content": "## \ud83d\udcca Score: Before\nDeterministic keyword + must-have overlap, calculated in JavaScript \u2014 no LLM involved. Must-haves count double because they're explicitly required by the posting. Same formula runs again later against the tailored bullets, so the delta reflects real keyword surfacing.",
"height": 240,
"width": 200,
"color": 7
},
"id": "sticky-score-before",
"name": "Sticky: Score Before",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1040,
-260
]
},
{
"parameters": {
"content": "## \u270d\ufe0f Tailor Bullets\nFirst Gemini call. Rewrites the CV bullets to truthfully emphasize overlap with the posting's must-haves, mirrors the employer's phrasing where genuine experience matches, and flags real gaps in a `gaps` array. Output is in the posting's language and tone.",
"height": 240,
"width": 200,
"color": 7
},
"id": "sticky-tailor",
"name": "Sticky: Tailor",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1260,
-260
]
},
{
"parameters": {
"content": "## \ud83d\udcc8 Score: After\nSame scoring formula as Score: Before, run against Gemini's tailored bullets. The delta is what makes the result demo-worthy \u2014 and because the calculation is deterministic, the improvement is visibly traceable to which specific keywords got surfaced.",
"height": 220,
"width": 200,
"color": 7
},
"id": "sticky-score-after",
"name": "Sticky: Score After",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1620,
-260
]
},
{
"parameters": {
"content": "## \ud83d\udc8c Cover Letter\nSecond Gemini call. Drafts a 3-paragraph letter (~250 words) referencing only the tailored CV \u2014 never inventing experience, never claiming gap skills. Opens with a specific tie to the company, maps experiences to must-haves, closes with a concrete next step. Language and tone match the posting.",
"height": 260,
"width": 200,
"color": 7
},
"id": "sticky-cover",
"name": "Sticky: Cover Letter",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1840,
-260
]
},
{
"parameters": {
"content": "## \ud83d\udcc4 Assemble Document\nBuilds the final document body: tailored CV section on top (with score header and gaps list), cover letter below. Doc title is set from company + role for easy filing.",
"height": 200,
"width": 200,
"color": 7
},
"id": "sticky-assemble",
"name": "Sticky: Assemble",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2160,
-260
]
},
{
"parameters": {
"content": "## \ud83d\udcdd Write to Google Doc\nTwo-step output. **Create** makes an empty doc with the right title in your chosen Drive folder; **Write** inserts the assembled body text. Both steps target the same `id` from the Create response.",
"height": 220,
"width": 220,
"color": 7
},
"id": "sticky-write",
"name": "Sticky: Write Doc",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2380,
-260
]
},
{
"parameters": {
"content": "## \u2705 Completion Screen\nHTML completion page showing the score delta, a clickable link to the generated Google Doc, and any gaps surfaced by Gemini. The Doc link is constructed from the file ID using the standard Google Docs URL pattern.",
"height": 220,
"width": 200,
"color": 7
},
"id": "sticky-completion",
"name": "Sticky: Completion",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2820,
-260
]
}
],
"connections": {
"On form submission": {
"main": [
[
{
"node": "Load Master CV",
"type": "main",
"index": 0
}
]
]
},
"Load Master CV": {
"main": [
[
{
"node": "Aggregate CV Rows",
"type": "main",
"index": 0
}
]
]
},
"Prep Data": {
"main": [
[
{
"node": "easybits: Extract Job Posting",
"type": "main",
"index": 0
}
]
]
},
"easybits: Extract Job Posting": {
"main": [
[
{
"node": "Score: Before",
"type": "main",
"index": 0
}
]
]
},
"Score: Before": {
"main": [
[
{
"node": "Gemini: Tailor Bullets",
"type": "main",
"index": 0
}
]
]
},
"Gemini: Tailor Bullets": {
"main": [
[
{
"node": "Score: After",
"type": "main",
"index": 0
}
]
]
},
"Score: After": {
"main": [
[
{
"node": "Gemini: Cover Letter",
"type": "main",
"index": 0
}
]
]
},
"Gemini: Cover Letter": {
"main": [
[
{
"node": "Assemble Document",
"type": "main",
"index": 0
}
]
]
},
"Assemble Document": {
"main": [
[
{
"node": "Create Google Doc",
"type": "main",
"index": 0
}
]
]
},
"Create Google Doc": {
"main": [
[
{
"node": "Write to Google Doc",
"type": "main",
"index": 0
}
]
]
},
"Aggregate CV Rows": {
"main": [
[
{
"node": "Prep Data",
"type": "main",
"index": 0
}
]
]
},
"Write to Google Doc": {
"main": [
[
{
"node": "Show Completion Screen",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"availableInMCP": false
},
"tags": []
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
Job seekers save hours crafting personalised applications by automatically tailoring their CV bullets to match specific job postings and generating a compelling cover letter, all triggered by a simple form submission. This workflow suits professionals applying to multiple roles who need quick, targeted documents without manual rewriting, leveraging Google Sheets for storing master CV data and Google Gemini for intelligent content adaptation. The key step involves extracting key requirements from the job description using the easybits extractor node, followed by Gemini refining CV bullets to highlight relevant skills before drafting the cover letter.
Use this workflow when handling high-volume job applications across similar industries, such as tech or marketing, where subtle customisation boosts ATS compatibility and interview chances. Avoid it for highly creative fields like design, where generic AI outputs may lack nuance, or if you prefer full manual control over wording. Common variations include adding email notifications for completed documents or integrating with Google Docs to directly format and export the final files.
About this workflow
CV Tailor + Cover Letter (easybits). Uses formTrigger, googleSheets, @easybits/n8n-nodes-extractor, googleGemini. Event-driven trigger; 25 nodes.
Source: https://github.com/felix-sattler-easybits/n8n-workflows/blob/9360864b0cfb20d9eea54b2214fdb58d61d71157/easybits-cv-tailor-and-cover-letter-workflow/easybits_cv_tailor_workflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
CV Tailor Onboarding (easybits). Uses formTrigger, googleSheets, @easybits/n8n-nodes-extractor, form. Event-driven trigger; 19 nodes.
Waitlist Form Stored In Googlesheet With Email Verification Step. Uses googleSheets, emailSend, form, stickyNote. Event-driven trigger; 19 nodes.
Template - SERP Analysis (Serper). Uses formTrigger, httpRequest, googleSheets, openAi. Event-driven trigger; 36 nodes.
SERP Analysis Template. Uses formTrigger, httpRequest, openAi, googleSheets. Event-driven trigger; 35 nodes.
This workflow uses AI to automatically generate clear and descriptive names for every node in your n8n workflows. It analyzes each node's type, parameters, and connections to create meaningful names,