AutomationFlowsAI & RAG › Tailor Your Cv and Cover Letter to Each Job with Easybits and Gemini

Tailor Your Cv and Cover Letter to Each Job with Easybits and Gemini

ByFelix @easybits on n8n.io

Upload a job posting (PDF, image, or screenshot) and instantly get a tailored CV section and a matching cover letter – 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…

Event trigger★★★★☆ complexityAI-powered27 nodesForm TriggerGoogle Sheets@Easybits/N8N Nodes ExtractorGoogle GeminiFormGoogle Docs
AI & RAG Trigger: Event Nodes: 27 Complexity: ★★★★☆ AI nodes: yes Added:

This workflow corresponds to n8n.io template #15526 — we link there as the canonical source.

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 →

Download .json
{
  "name": "CV Tailor + Cover Letter (easybits) \u2013 V2 after feedback",
  "tags": [],
  "nodes": [
    {
      "name": "On form submission",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        0,
        0
      ],
      "parameters": {
        "options": {},
        "formTitle": "Tailor my CV for this job",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "Job posting",
              "requiredField": true,
              "acceptFileTypes": ".png, .jpg, .jpeg, .pdf"
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Notes"
            }
          ]
        },
        "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."
      },
      "typeVersion": 2.5
    },
    {
      "name": "Load Master CV",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        304,
        0
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/REPLACE_WITH_YOUR_SHEET_ID/edit#gid=0",
          "cachedResultName": "Master CV"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "REPLACE_WITH_YOUR_SHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/REPLACE_WITH_YOUR_SHEET_ID/edit",
          "cachedResultName": "Master CV File"
        }
      },
      "typeVersion": 4.7
    },
    {
      "name": "Prep Data",
      "type": "n8n-nodes-base.code",
      "position": [
        912,
        0
      ],
      "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}];"
      },
      "typeVersion": 2
    },
    {
      "name": "easybits: Extract Job Posting",
      "type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
      "position": [
        1216,
        0
      ],
      "parameters": {},
      "typeVersion": 2
    },
    {
      "name": "Score: Before",
      "type": "n8n-nodes-base.code",
      "position": [
        1520,
        0
      ],
      "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}];"
      },
      "typeVersion": 2
    },
    {
      "name": "Gemini: Tailor Bullets",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "position": [
        1760,
        0
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-2.5-pro",
          "cachedResultName": "models/gemini-2.5-pro"
        },
        "options": {},
        "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": {}
      },
      "typeVersion": 1.1
    },
    {
      "name": "Score: After",
      "type": "n8n-nodes-base.code",
      "position": [
        2128,
        0
      ],
      "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}];"
      },
      "typeVersion": 2
    },
    {
      "name": "Gemini: Cover Letter",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "position": [
        2368,
        0
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-2.5-pro",
          "cachedResultName": "models/gemini-2.5-pro"
        },
        "options": {},
        "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": {}
      },
      "typeVersion": 1.1
    },
    {
      "name": "Assemble Document",
      "type": "n8n-nodes-base.code",
      "position": [
        2736,
        0
      ],
      "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}];"
      },
      "typeVersion": 2
    },
    {
      "name": "Show Completion Screen",
      "type": "n8n-nodes-base.form",
      "position": [
        3648,
        0
      ],
      "parameters": {
        "options": {},
        "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>"
      },
      "typeVersion": 2.5
    },
    {
      "name": "Create Google Doc",
      "type": "n8n-nodes-base.googleDocs",
      "position": [
        3056,
        0
      ],
      "parameters": {
        "title": "={{ $json.doc_title }}",
        "folderId": "REPLACE_WITH_YOUR_FOLDER_ID"
      },
      "typeVersion": 2
    },
    {
      "name": "Aggregate CV Rows",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        608,
        0
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData",
        "destinationFieldName": "cv"
      },
      "typeVersion": 1
    },
    {
      "name": "Write to Google Doc",
      "type": "n8n-nodes-base.googleDocs",
      "position": [
        3344,
        0
      ],
      "parameters": {
        "actionsUi": {
          "actionFields": [
            {
              "text": "={{ $('Assemble Document').first().json.doc_body }}",
              "action": "insert"
            }
          ]
        },
        "operation": "update",
        "documentURL": "={{ $json.id }}"
      },
      "typeVersion": 2
    },
    {
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -96,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "content": "## \ud83d\udccb Load Master CV\nReads every row of the `Master CV` tab from your Google Sheet \u2013 one row per experience, with `role`, `company`, `dates`, `bullets`, and `skills` columns."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        816,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1120,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1424,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "content": "## \ud83d\udcca Score: Before\nDeterministic keyword + must-have overlap, calculated in JavaScript \u2013 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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1728,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2032,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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 \u2013 and because the calculation is deterministic, the improvement is visibly traceable to which specific keywords got surfaced."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2336,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "content": "## \ud83d\udc8c Cover Letter\nSecond Gemini call. Drafts a 3-paragraph letter (~250 words) referencing only the tailored CV \u2013 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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2640,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2944,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "content": "## \ud83d\udcc4 Create Google Doc\nCreates an empty doc with the right title (company + role) in your chosen Drive folder. Outputs the new doc's `id` for the next step."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3552,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "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."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -816,
        -576
      ],
      "parameters": {
        "width": 704,
        "height": 1056,
        "content": "# \ud83d\udcdd CV Tailor + Cover Letter\n(powered by easybits)\n\nUpload a job posting \u2192 get a tailored CV section + matching cover letter in a Google Doc, with a before/after match score and an honest list of skills you're missing. Nothing gets invented \u2013 genuine gaps land in a `gaps` array instead of being faked into bullets.\n\n## How It Works\nUpload posting \u2192 easybits extracts structured fields \u2192 deterministic match score (before) \u2192 Gemini rewrites bullets \u2192 match score (after) \u2192 Gemini drafts cover letter \u2192 both written to a Google Doc.\n\n## Setup\n\n1. **Run the CV Onboarding workflow first** \u2013 it populates the Master CV Google Sheet this workflow reads from.\n2. **easybits Extractor** \u2013 on n8n Cloud it's built in; self-hosted, install `@easybits/n8n-nodes-extractor` via Settings \u2192 Community Nodes. Free API key at easybits.tech.\n3. **Connect credentials** \u2013 Google Sheets (Load Master CV), Gemini (both Gemini nodes, model `gemini-2.5-pro`), Google Docs (both Doc nodes, pick a target folder).\n4. **Replace placeholders** \u2013 your Sheet ID in Load Master CV, your folder in Create Google Doc.\n5. **Activate** and submit the form.\n\n## Extractor Fields (10 \u2013 fits the free plan)\nPaste a description into each field's description box:\n\n- **`job_title`** *(string)* \u2013 exact title as written; null if not stated.\n- **`company`** *(string)* \u2013 hiring company name; null if not named.\n- **`seniority`** *(string)* \u2013 intern / junior / mid / senior / lead / principal / executive.\n- **`must_have_skills`** *(array)* \u2013 required skills as SHORT noun phrases (1\u20133 words). Break compounds into atoms (\"testing, monitoring, profiling\" \u2192 3 items). Exclude soft skills.\n- **`nice_to_have_skills`** *(array)* \u2013 preferred/bonus skills, same format.\n- **`responsibilities`** *(array)* \u2013 concrete duties as short action phrases, max 8.\n- **`keywords`** *(array)* \u2013 domain terms worth mirroring in a CV, max 15. No generic fluff.\n- **`tone`** *(string)* \u2013 formal / professional / casual / technical.\n- **`language`** *(string)* \u2013 ISO 639-1 code (en, de, nl\u2026).\n- **`location`** *(string)* \u2013 remote / hybrid / city; null if not stated.\n\n## Notes\n- **Honesty guardrail.** Gemini never invents experience \u2013 missing must-haves surface as gaps.\n- **Deterministic scoring.** Match score is JavaScript, not an LLM. Same formula runs before and after, so the delta reflects real keyword surfacing. Must-haves count double.\n- **Language & tone** come from the Extractor \u2013 a German posting yields a German output automatically."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        512,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "content": "## \ud83e\uddf1 Aggregate CV Rows\nCombines all the individual experience rows into a single list, so the rest of the workflow can reference the full CV from one place."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Note13",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3248,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 432,
        "content": "## \ud83d\udcdd Write to Google Doc\nInserts the assembled body text (tailored CV + cover letter) into the doc created in the previous step, targeting the same `id`."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "connections": {
    "Prep Data": {
      "main": [
        [
          {
            "node": "easybits: Extract Job Posting",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score: After": {
      "main": [
        [
          {
            "node": "Gemini: Cover Letter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score: Before": {
      "main": [
        [
          {
            "node": "Gemini: Tailor Bullets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Master CV": {
      "main": [
        [
          {
            "node": "Aggregate CV Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate CV Rows": {
      "main": [
        [
          {
            "node": "Prep Data",
            "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
          }
        ]
      ]
    },
    "On form submission": {
      "main": [
        [
          {
            "node": "Load Master CV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write to Google Doc": {
      "main": [
        [
          {
            "node": "Show Completion Screen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini: Cover Letter": {
      "main": [
        [
          {
            "node": "Assemble Document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini: Tailor Bullets": {
      "main": [
        [
          {
            "node": "Score: After",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "easybits: Extract Job Posting": {
      "main": [
        [
          {
            "node": "Score: Before",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Upload a job posting (PDF, image, or screenshot) and instantly get a tailored CV section and a matching cover letter – 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…

Source: https://n8n.io/workflows/15526/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Note: This template only works for self-hosted n8n.

Google Sheets Tool, Form Trigger, Form +3
AI & RAG

This workflow accepts invoice PDFs or images from an n8n Form or Telegram, uses Google Gemini to OCR and extract structured invoice fields and line items, appends the results to Google Sheets, and sen

Form Trigger, Google Sheets, Google Gemini +2
AI & RAG

This n8n template retrieves verbal brand identity markers from any web site.

HTTP Request, Google Gemini, Form Trigger +1
AI & RAG

Smart Tax Accountant: Auto-Categorize Receipts for Tax Return. Uses formTrigger, googleGemini, googleSheets. Event-driven trigger; 13 nodes.

Form Trigger, Google Gemini, Google Sheets
AI & RAG

This n8n template automates scraping content from Skool communities using the Olostep API. It collects structured data from Skool pages and stores it in a clean format, making it easy to analyze commu

N8N Nodes Olostep, Form Trigger, HTTP Request +3