AutomationFlowsAI & RAG › Tailor Your Google Docs Cv to Any Job Using Ollama and Groq

Tailor Your Google Docs Cv to Any Job Using Ollama and Groq

ByRishi @rishiii on n8n.io

An AI-powered n8n workflow that automatically tailors your resume to any job description by injecting relevant keywords — without touching your formatting, layout, or design.

Event trigger★★★★★ complexityAI-powered31 nodesForm TriggerGoogle DocsHTTP RequestGoogle DriveChain LlmOllama ChatGroq Chat
AI & RAG Trigger: Event Nodes: 31 Complexity: ★★★★★ AI nodes: yes Added:
Tailor Your Google Docs Cv to Any Job Using Ollama and Groq — n8n workflow card showing Form Trigger, Google Docs, HTTP Request integration

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

This workflow follows the Chainllm → 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
{
  "id": "7vbNU9m2VfwFjiYP",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "CV Keyword Optimizer",
  "tags": [
    {
      "id": "HoVCqATNz8mJaoNT",
      "name": "CV",
      "createdAt": "2026-05-14T07:08:17.350Z",
      "updatedAt": "2026-05-14T07:08:17.350Z"
    },
    {
      "id": "HxFEjKb6fpCrwRVT",
      "name": "AI",
      "createdAt": "2026-05-14T07:08:17.359Z",
      "updatedAt": "2026-05-14T07:08:17.359Z"
    },
    {
      "id": "hpd2kazKCfVcIYDX",
      "name": "Ollama",
      "createdAt": "2026-05-14T07:08:17.362Z",
      "updatedAt": "2026-05-14T07:08:17.362Z"
    }
  ],
  "nodes": [
    {
      "id": "0328c1d7-2fce-4b45-b808-849a4efc98b4",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        20048,
        1520
      ],
      "parameters": {
        "width": 556,
        "height": 528,
        "content": "## CV Keyword Optimizer\n\n1. User provides CV link + Job URL / copy-paste Job desc\n2. Scrapes job page for description\n3. Local Ollama extracts keywords\n4. Groq Llama 3.3 generates find/replace\n5. Copies original doc, applies replacements\n6. Output: new Google Doc link + changelog\n\n## Pre requisites\n\nLinks - Your CV link (google docs link ) check it should be a google docs file , not a .docx\n\ngroq (generate API key)- https://console.groq.com/home\n\nGoogle cloud API (enable drive and docs API get the client ID and secret)- https://console.cloud.google.com/auth/clients\n\ndocker (pull and run ollama locally)-  \n\n1. docker exec ollama ollama pull llama3.1:8b\n\n2. docker run -d \\\n  --name ollama \\\n  -p 11434:11434 \\\n  -v ollama_data:/root/.ollama \\\n  --restart unless-stopped \\\n  ollama/ollama\n\n\n\n\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "2dd69ba6-ee40-4dbf-b46a-9fff90f33bc9",
      "name": "CV Input Form",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        21008,
        1824
      ],
      "parameters": {
        "path": "9defb867-4b83-40ea-997f-d1e2f14aef4a",
        "options": {
          "buttonLabel": "Generate CV"
        },
        "formTitle": "CV Keyword Optimizer",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Google Docs CV Link",
              "placeholder": "https://docs.google.com/document/d/YOUR_DOC_ID/edit",
              "requiredField": true
            },
            {
              "fieldLabel": "Job Posting URL",
              "placeholder": "https://www.linkedin.com/jobs/view/... (leave empty if pasting JD below)"
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Job Description",
              "placeholder": "Paste the full job description here (leave empty if providing URL above)"
            }
          ]
        },
        "responseMode": "responseNode",
        "formDescription": "Provide your Google Docs CV link and either a job posting URL or paste the job description manually."
      },
      "typeVersion": 2
    },
    {
      "id": "2181bceb-7d2e-492c-b25b-73617e7a4875",
      "name": "Extract Doc ID & Job URL",
      "type": "n8n-nodes-base.code",
      "position": [
        21232,
        1824
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const cvLink = item.json['Google Docs CV Link'] || item.json['google_docs_cv_link'] || '';\n  const jobUrl = (item.json['Job Posting URL'] || item.json['job_posting_url'] || '').trim();\n  const manualJD = (item.json['Job Description'] || item.json['job_description'] || '').trim();\n  let documentId = '';\n  const match = cvLink.match(/\\/document\\/d\\/([a-zA-Z0-9_-]+)/);\n  if (match) { documentId = match[1]; }\n  else if (/^[a-zA-Z0-9_-]{20,}$/.test(cvLink.trim())) { documentId = cvLink.trim(); }\n  if (!documentId) throw new Error('Invalid Google Docs link.');\n  const hasUrl = jobUrl && jobUrl.startsWith('http');\n  const hasJD = manualJD.length > 20;\n  if (!hasUrl && !hasJD) throw new Error('Please provide either a Job Posting URL or paste the Job Description.');\n  results.push({ json: { documentId, jobUrl: hasUrl ? jobUrl : '', originalLink: cvLink, manualJD: manualJD, hasUrl: hasUrl } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "58be7263-c68c-4e0f-9ea8-9755f24e13ac",
      "name": "Read CV from Google Docs",
      "type": "n8n-nodes-base.googleDocs",
      "position": [
        21456,
        1824
      ],
      "parameters": {
        "simple": false,
        "operation": "get",
        "documentURL": "={{ $json.documentId }}"
      },
      "credentials": {
        "googleDocsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "a415a367-4d45-457c-b515-d524b6a71921",
      "name": "Extract CV Text",
      "type": "n8n-nodes-base.code",
      "position": [
        21680,
        1824
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const doc = item.json;\n  let cvText = '';\n  if (doc.body && doc.body.content) {\n    for (const element of doc.body.content) {\n      if (element.paragraph && element.paragraph.elements) {\n        for (const el of element.paragraph.elements) {\n          if (el.textRun && el.textRun.content) cvText += el.textRun.content;\n        }\n      }\n      if (element.table) {\n        for (const row of (element.table.tableRows || [])) {\n          for (const cell of (row.tableCells || [])) {\n            if (cell.content) {\n              for (const ce of cell.content) {\n                if (ce.paragraph && ce.paragraph.elements) {\n                  for (const el of ce.paragraph.elements) {\n                    if (el.textRun && el.textRun.content) cvText += el.textRun.content;\n                  }\n                }\n              }\n            }\n            cvText += '\\t';\n          }\n          cvText += '\\n';\n        }\n      }\n    }\n  }\n  if (!cvText && doc.text) cvText = doc.text;\n  if (!cvText && doc.content) cvText = typeof doc.content === 'string' ? doc.content : JSON.stringify(doc.content);\n  let prevData = {};\n  try { prevData = $('Extract Doc ID & Job URL').first().json; } catch(e) {}\n  results.push({ json: { cvText: cvText.trim(), documentId: prevData.documentId || '', jobUrl: prevData.jobUrl || '', originalLink: prevData.originalLink || '', docTitle: doc.title || 'Untitled CV', manualJD: prevData.manualJD || '', hasUrl: prevData.hasUrl || false } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "5b511fdf-d8d0-4222-8491-6a5f70a1a899",
      "name": "Scrape Job Page",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        21904,
        1824
      ],
      "parameters": {
        "url": "={{ $json.jobUrl }}",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
            },
            {
              "name": "Accept",
              "value": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
            },
            {
              "name": "Accept-Language",
              "value": "en-US,en;q=0.9"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "6113d4bd-eb97-4342-a096-44824121b4d6",
      "name": "Extract Job Text from HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        22128,
        1824
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  let prevData = {};\n  try { prevData = $('Extract CV Text').first().json; } catch(e) {}\n  const manualJD = prevData.manualJD || '';\n  const hasUrl = prevData.hasUrl || false;\n  let text = '';\n\n  if (!hasUrl) {\n    // No URL provided \u2014 use manual JD directly\n    text = manualJD;\n  } else {\n    // URL was provided \u2014 parse scraped HTML\n    let html = '';\n    if (typeof item.json.body === 'string') html = item.json.body;\n    else if (typeof item.json.data === 'string') html = item.json.data;\n    else if (typeof item.json === 'string') html = item.json;\n    else html = JSON.stringify(item.json);\n    text = html\n      .replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n      .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '')\n      .replace(/<nav[^>]*>[\\s\\S]*?<\\/nav>/gi, '')\n      .replace(/<footer[^>]*>[\\s\\S]*?<\\/footer>/gi, '')\n      .replace(/<header[^>]*>[\\s\\S]*?<\\/header>/gi, '')\n      .replace(/<[^>]+>/g, ' ')\n      .replace(/&nbsp;/g, ' ')\n      .replace(/&amp;/g, '&')\n      .replace(/&lt;/g, '<')\n      .replace(/&gt;/g, '>')\n      .replace(/&quot;/g, '\"')\n      .replace(/&#39;/g, \"'\")\n      .replace(/\\s+/g, ' ')\n      .trim();\n    if (text.length > 8000) text = text.substring(0, 8000);\n    // Fallback to manual JD if scrape returned junk\n    if (text.length < 50 && manualJD.length > 20) text = manualJD;\n  }\n\n  if (text.length < 20) throw new Error('No job description available. Please provide either a valid URL or paste the JD manually.');\n\n  results.push({ json: { jobPageText: text, cvText: prevData.cvText || '', documentId: prevData.documentId || '', jobUrl: prevData.jobUrl || '', originalLink: prevData.originalLink || '', docTitle: prevData.docTitle || '' } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "f1dd0811-217d-4181-b07a-cb2a23851d5d",
      "name": "Parse Keywords",
      "type": "n8n-nodes-base.code",
      "position": [
        22672,
        1824
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  let keywords = [];\n  let error = '';\n  try {\n    let content = item.json.text || '';\n    content = content.replace(/<think>[\\s\\S]*?<\\/think>/gi, '').trim();\n    content = content.replace(/^```[a-z]*\\n?/gm, '').replace(/```$/gm, '').trim();\n    const jsonMatch = content.match(/\\{[\\s\\S]*\\}/);\n    if (jsonMatch) content = jsonMatch[0];\n    const parsed = JSON.parse(content);\n    keywords = parsed.keywords || parsed;\n    if (!Array.isArray(keywords)) throw new Error('Not an array');\n  } catch (e) { error = 'Failed to parse keyword response: ' + e.message; }\n  if (!error && keywords.length === 0) error = 'No keywords extracted';\n  let prevData = {};\n  try { prevData = $('Extract Job Text from HTML').first().json; } catch(e) {}\n  results.push({ json: { keywords, error, cvText: prevData.cvText || '', documentId: prevData.documentId || '', jobUrl: prevData.jobUrl || '', originalLink: prevData.originalLink || '', docTitle: prevData.docTitle || '' } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "6d3ec460-3755-43bc-84ad-b4edd5e9581b",
      "name": "Parse AI Response",
      "type": "n8n-nodes-base.code",
      "position": [
        23488,
        1824
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  let replacements = [];\n  let error = '';\n  try {\n    let content = item.json.text || '';\n    content = content.replace(/^```[a-z]*\\n?/gm, '').replace(/```$/gm, '').trim();\n    const jsonMatch = content.match(/\\[[\\s\\S]*\\]/);\n    if (jsonMatch) content = jsonMatch[0];\n    replacements = JSON.parse(content);\n    if (!Array.isArray(replacements)) throw new Error('Not an array');\n    replacements = replacements.filter(r => r.find && r.replace && r.find.trim().length > 5 && r.find !== r.replace);\n    if (replacements.length === 0) error = 'AI returned no valid replacements';\n  } catch (e) { error = 'Failed to parse AI response: ' + e.message; }\n  if (!error && replacements.length === 0) error = 'AI returned empty response';\n  let prevData = {};\n  try { prevData = $('Parse Keywords').first().json; } catch(e) {}\n  results.push({ json: { replacements, error, keywords: prevData.keywords || [], documentId: prevData.documentId || '', jobUrl: prevData.jobUrl || '', originalLink: prevData.originalLink || '', docTitle: prevData.docTitle || 'Untitled CV' } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "415fe2ed-08b3-48d6-8ab1-2d588d91675c",
      "name": "Check for Errors",
      "type": "n8n-nodes-base.if",
      "position": [
        23712,
        1824
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "2053a8aa-c243-4e2a-8e4b-a37255ef29f6",
              "operator": {
                "type": "string",
                "operation": "empty"
              },
              "leftValue": "={{ $json.error }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "955a6c26-6cc5-42ae-af32-c88e1bf90afb",
      "name": "Copy Original CV",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        23936,
        1728
      ],
      "parameters": {
        "name": "={{ $json.docTitle }} - Optimized - {{ new Date().toISOString().split('T')[0] }}",
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.documentId }}"
        },
        "options": {},
        "operation": "copy"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "95f003fa-1877-4101-abc1-e24ef668fcb7",
      "name": "Build Replace Requests",
      "type": "n8n-nodes-base.code",
      "position": [
        24160,
        1728
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const copiedDocId = item.json.id || '';\n  if (!copiedDocId) throw new Error('Failed to copy document \u2014 no ID returned');\n  let prevData = {};\n  try { prevData = $('Parse AI Response').first().json; } catch(e) {}\n  const replacements = prevData.replacements || [];\n  const requests = replacements.map(r => ({\n    replaceAllText: {\n      containsText: { text: r.find, matchCase: true },\n      replaceText: r.replace\n    }\n  }));\n  const requestBody = JSON.stringify({ requests });\n  results.push({ json: { copiedDocId, requestBody, replacementCount: requests.length } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "5835569d-a99f-46d0-89d5-9ed92ba33df9",
      "name": "Apply Replacements to Copy",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        24384,
        1728
      ],
      "parameters": {
        "url": "=https://docs.googleapis.com/v1/documents/{{ $json.copiedDocId }}:batchUpdate",
        "body": "={{ $json.requestBody }}",
        "method": "POST",
        "options": {
          "timeout": 30000
        },
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "rawContentType": "application/json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "nodeCredentialType": "googleDocsOAuth2Api"
      },
      "credentials": {
        "googleDocsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "74c631fb-81e2-4bfd-9d36-bb5cf33037ac",
      "name": "Changes Summary",
      "type": "n8n-nodes-base.code",
      "position": [
        24608,
        1728
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  let prevData = {};\n  try { prevData = $('Parse AI Response').first().json; } catch(e) {}\n  let buildData = {};\n  try { buildData = $('Build Replace Requests').first().json; } catch(e) {}\n  const replacements = prevData.replacements || [];\n  const keywords = prevData.keywords || [];\n  const docId = buildData.copiedDocId || '';\n  const docUrl = docId ? 'https://docs.google.com/document/d/' + docId + '/edit' : 'URL unavailable';\n  const changes = replacements.map((r, i) => ({\n    change_number: i + 1,\n    original: r.find,\n    updated: r.replace,\n    keywords_added: r.replace.split(/[\\s,;]+/).filter(w => !r.find.toLowerCase().includes(w.toLowerCase()) && w.length > 2).join(', ')\n  }));\n  const extractedKeywords = keywords.map(k => k.keyword + ' (' + k.priority + ')').join(', ');\n  results.push({\n    json: {\n      status: 'success',\n      message: '\u2705 Optimized CV created!',\n      total_changes: changes.length,\n      documentUrl: docUrl,\n      documentId: docId,\n      extracted_keywords: extractedKeywords,\n      changes: changes\n    }\n  });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "dcf82b25-0d71-405f-87d2-1bb466905959",
      "name": "Redirect to CV",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        24832,
        1728
      ],
      "parameters": {
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        },
        "respondWith": "text",
        "responseBody": "=<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>CV Optimized</title><meta http-equiv=\"refresh\" content=\"2;url={{ $json.documentUrl }}\"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)}  .card{background:rgba(255,255,255,0.95);padding:48px;border-radius:20px;box-shadow:0 20px 60px rgba(0,0,0,0.3);text-align:center;max-width:480px;animation:fadeIn 0.5s ease}@keyframes fadeIn{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}h1{font-size:2em;margin-bottom:12px}p{color:#555;font-size:1.1em;margin:8px 0;line-height:1.5}.btn{display:inline-block;margin-top:24px;padding:14px 32px;background:linear-gradient(135deg,#667eea,#764ba2);color:white;text-decoration:none;border-radius:10px;font-weight:600;font-size:1em;transition:transform 0.2s,box-shadow 0.2s}.btn:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(102,126,234,0.4)}.spinner{margin:16px auto;width:32px;height:32px;border:3px solid #e0e0e0;border-top:3px solid #667eea;border-radius:50%;animation:spin 0.8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><div class=\"card\"><h1>&#10024; CV Optimized!</h1><p><strong>{{ $json.total_changes }}</strong> keyword changes applied</p><div class=\"spinner\"></div><p>Redirecting to your Google Doc...</p><a href=\"{{ $json.documentUrl }}\" class=\"btn\">Open Document Now &rarr;</a></div><script>setTimeout(function(){window.location.href='{{ $json.documentUrl }}'},1500)</script></body></html>"
      },
      "typeVersion": 1.1
    },
    {
      "id": "179a795c-e461-4820-98a4-b1fbe836435e",
      "name": "Error Output",
      "type": "n8n-nodes-base.code",
      "position": [
        23936,
        1920
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nreturn items.map(item => ({ json: { status: 'error', message: '\u274c Failed: ' + (item.json.error || 'Unknown error'), suggestion: 'Check Ollama is running, GROQ_API_KEY is set, and the job URL is accessible.' } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "b2ccfa04-2e2c-497d-8fa2-f084ec0c4801",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        21360,
        1696
      ],
      "parameters": {
        "color": 3,
        "width": 272,
        "height": 288,
        "content": "## Authorize\n\n- enter the client ID and secret\n- authorize the docs "
      },
      "typeVersion": 1
    },
    {
      "id": "96c50690-c540-44a1-87db-cde24b28081a",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        22900,
        1696
      ],
      "parameters": {
        "color": 3,
        "width": 560,
        "height": 288,
        "content": "## Groq Credentials\n\n- Configure Groq API credentials in n8n Settings \u2192 Credentials\n- Select your Groq account in the Groq Chat Model node\n\n- link https://console.groq.com/home"
      },
      "typeVersion": 1
    },
    {
      "id": "21ffe811-bb56-4043-ba4c-90152b86b23a",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        23824,
        1568
      ],
      "parameters": {
        "color": 3,
        "width": 272,
        "height": 288,
        "content": "## Authorize \n\n\n- authorize here with the last client ID and client secret"
      },
      "typeVersion": 1
    },
    {
      "id": "f744b22e-4c96-4d31-b7ec-4a53df984519",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        24576,
        1568
      ],
      "parameters": {
        "color": 5,
        "width": 352,
        "height": 320,
        "content": "##  Output\n\n- Here is the summary of the changes and the URL for the output file \n\n- open the docs link and download"
      },
      "typeVersion": 1
    },
    {
      "id": "626c2604-5492-4f4c-aae1-3cef5a195169",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        21648,
        1696
      ],
      "parameters": {
        "width": 576,
        "height": 288,
        "content": "## Reading data and extraction of text\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "19eaaf83-47f9-4c2e-8c68-e64abc9b453d",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        20944,
        1696
      ],
      "parameters": {
        "width": 400,
        "height": 288,
        "content": "## Input form and URL\n\n- enter your google docs link and either the job link OR Job description "
      },
      "typeVersion": 1
    },
    {
      "id": "e5541953-cb6d-4eb0-876a-eb0c05e2b8d4",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        22240,
        1696
      ],
      "parameters": {
        "width": 660,
        "height": 288,
        "content": "## AI Keyword Extraction\n\n- Native Ollama Chat Model extracts keywords from Job Desc\n- Basic LLM Chain handles prompt + response\n- No hardcoded API keys"
      },
      "typeVersion": 1
    },
    {
      "id": "f5777c5f-d881-44f8-973e-b70b3a1a7089",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        23456,
        1696
      ],
      "parameters": {
        "width": 352,
        "height": 288,
        "content": "## AI response and error check\n\n- Parse the AI response JSON\n- Check for output errors"
      },
      "typeVersion": 1
    },
    {
      "id": "b13f3b40-01cc-461a-98d8-cc93ed5902b2",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        24128,
        1568
      ],
      "parameters": {
        "width": 368,
        "height": 320,
        "content": "## AI improvement request and implementation\n\n- Suggested keywords added to CV\n- Batch replaceAllText via Google Docs API"
      },
      "typeVersion": 1
    },
    {
      "id": "993ef98b-d98a-442a-83be-6e1fc629923d",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        23824,
        1872
      ],
      "parameters": {
        "width": 272,
        "height": 208,
        "content": "## Error Output\n\n- logs of error"
      },
      "typeVersion": 1
    },
    {
      "id": "19e13a4c-ec17-42d2-b8e0-e3a47e41dd7e",
      "name": "Prepare Replacement Input",
      "type": "n8n-nodes-base.code",
      "position": [
        22944,
        1824
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = [];\nfor (const item of items) {\n  const keywords = item.json.keywords || [];\n  const kwSummary = keywords.map(k => k.keyword + ' (priority: ' + k.priority + ', target: ' + (k.target_bullet || 'any') + ')').join('\\n');\n  results.push({ json: { ...item.json, kwSummary, userPrompt: 'CV:\\n' + item.json.cvText + '\\n\\nKEYWORDS TO INJECT:\\n' + kwSummary } });\n}\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "efd6a6a4-4f9f-4375-9453-cd9a6cc5d128",
      "name": "Keyword Extraction Chain",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        22400,
        1824
      ],
      "parameters": {
        "text": "=CV:\n{{ $json.cvText }}\n\nJOB POSTING PAGE TEXT:\n{{ $json.jobPageText }}",
        "options": {
          "timeout": 300000
        },
        "messages": {
          "messageValues": [
            {
              "message": "You are an expert ATS keyword analyst. You will receive scraped text from a job posting page and a candidate's CV.\n\nYour job:\n1. Read the job posting text. Identify the actual job description, requirements, and qualifications. Ignore navigation menus, ads, and unrelated page content.\n2. Extract MUST-HAVE skills, tools, technologies, methodologies, and domain terms from the job requirements.\n3. Read the CV. Identify which job keywords are MISSING or could replace weaker synonyms.\n4. Rank keywords by ATS impact: exact JD matches score highest.\n5. For each keyword, identify WHICH CV bullet point it fits most naturally into.\n\nReturn ONLY a JSON object (no markdown, no explanation, no code fences):\n{\"keywords\": [{\"keyword\": \"exact term from job posting\", \"priority\": \"high|medium\", \"target_bullet\": \"first 10 words of the CV bullet where this fits best\", \"reason\": \"brief reason\"}]}\n\nRules:\n- Extract 10-20 keywords max\n- Only keywords that genuinely relate to the candidate's existing experience\n- Never suggest keywords that would require fabricating new experience\n- Include both technical skills and soft skills/methodologies\n- Prefer exact job posting phrasing over synonyms\n- Ignore boilerplate text (equal opportunity statements, benefits, company overview) \u2014 focus on role requirements",
              "messageType": "system"
            }
          ]
        },
        "promptType": "define"
      },
      "typeVersion": 1.5
    },
    {
      "id": "317e0eda-e2bb-4bef-8143-ca3252c2a235",
      "name": "Ollama Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOllama",
      "position": [
        22400,
        2040
      ],
      "parameters": {
        "model": "llama3.1:8b",
        "baseUrl": "http://host.docker.internal:11434",
        "options": {
          "numPredict": 4000,
          "temperature": 0.3
        }
      },
      "credentials": {
        "ollamaApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "8b081c1c-e2a7-4572-aa4b-be611345402a",
      "name": "Replacement Generation Chain",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        23216,
        1824
      ],
      "parameters": {
        "text": "={{ $json.userPrompt }}",
        "options": {
          "timeout": 120000
        },
        "messages": {
          "messageValues": [
            {
              "message": "You are an ATS resume editor. You receive a CV, a list of extracted keywords with target bullets, and must produce find-and-replace pairs.\n\nRULES:\n1. ONLY modify experience/work bullet points and skills lines. Do NOT touch: name, contact info, education, certifications, dates, company names, job titles, section headers.\n2. For each replacement: {\"find\": \"exact original text from CV\", \"replace\": \"enhanced text with keywords woven in\"}\n3. The \"find\" value MUST be an EXACT character-for-character substring from the CV. If wrong, the replacement silently fails.\n4. Weave in 1-3 keywords per bullet NATURALLY \u2014 no awkward stuffing.\n5. Keep bullet length similar. Do not bloat.\n6. Replace generic synonyms with exact JD terminology.\n7. Do NOT fabricate metrics, tools, or experiences.\n8. Produce 8-15 replacements max.\n9. You MAY include 1-2 skill line replacements to add related skills.\n10. Return ONLY a valid JSON array. No markdown, no explanation, no code fences.\n\nExample: [{\"find\":\"Built REST APIs for internal tools\",\"replace\":\"Built scalable REST APIs and microservices for internal tools using Node.js\"}]",
              "messageType": "system"
            }
          ]
        },
        "promptType": "define"
      },
      "typeVersion": 1.5
    },
    {
      "id": "3862f05d-3bc0-4882-ba53-946a6d1ccd2a",
      "name": "Groq Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatGroq",
      "position": [
        23216,
        2040
      ],
      "parameters": {
        "model": "llama-3.3-70b-versatile",
        "options": {
          "maxTokens": 4000,
          "temperature": 0.3
        }
      },
      "credentials": {
        "groqApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "e851e5ab-c39e-4de0-a2bb-7a0268aa06de",
  "connections": {
    "CV Input Form": {
      "main": [
        [
          {
            "node": "Extract Doc ID & Job URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Keywords": {
      "main": [
        [
          {
            "node": "Prepare Replacement Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Changes Summary": {
      "main": [
        [
          {
            "node": "Redirect to CV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract CV Text": {
      "main": [
        [
          {
            "node": "Scrape Job Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Replacement Generation Chain",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Scrape Job Page": {
      "main": [
        [
          {
            "node": "Extract Job Text from HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Errors": {
      "main": [
        [
          {
            "node": "Copy Original CV",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Error Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Copy Original CV": {
      "main": [
        [
          {
            "node": "Build Replace Requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ollama Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Keyword Extraction Chain",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Response": {
      "main": [
        [
          {
            "node": "Check for Errors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Replace Requests": {
      "main": [
        [
          {
            "node": "Apply Replacements to Copy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Doc ID & Job URL": {
      "main": [
        [
          {
            "node": "Read CV from Google Docs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keyword Extraction Chain": {
      "main": [
        [
          {
            "node": "Parse Keywords",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read CV from Google Docs": {
      "main": [
        [
          {
            "node": "Extract CV Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Replacement Input": {
      "main": [
        [
          {
            "node": "Replacement Generation Chain",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Replacements to Copy": {
      "main": [
        [
          {
            "node": "Changes Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Job Text from HTML": {
      "main": [
        [
          {
            "node": "Keyword Extraction Chain",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Replacement Generation Chain": {
      "main": [
        [
          {
            "node": "Parse AI Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

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

About this workflow

An AI-powered n8n workflow that automatically tailors your resume to any job description by injecting relevant keywords — without touching your formatting, layout, or design.

Source: https://n8n.io/workflows/15585/ — 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

Episode 11: AI shorts factory app. Uses httpRequest, googleSheets, lmChatOpenAi, lmChatOllama. Event-driven trigger; 96 nodes.

HTTP Request, Google Sheets, OpenAI Chat +15
AI & RAG

My workflow 53. Uses formTrigger, httpRequest, lmChatOpenAi, form. Event-driven trigger; 74 nodes.

Form Trigger, HTTP Request, OpenAI Chat +15
AI & RAG

Episode 23: UGC with nanobanana. Uses lmChatOpenAi, lmChatOllama, lmChatDeepSeek, lmChatOpenRouter. Event-driven trigger; 74 nodes.

OpenAI Chat, Ollama Chat, Lm Chat Deep Seek +12
AI & RAG

The Recap AI - Facebook UGC Video Ad Thief. Uses formTrigger, @mendable/n8n-nodes-firecrawl, chainLlm, httpRequest. Event-driven trigger; 38 nodes.

Form Trigger, @Mendable/N8N Nodes Firecrawl, Chain Llm +5
AI & RAG

The best content automation template in the market is now even better—with “deep research” on time-sensitive topics\! Unlike most n8n content automation templates that are mainly for “demo purposes,”

OpenAI, HTTP Request, XML +11