AutomationFlowsAI & RAG › Screen Linkedin Jobs and Generate Tailored Resume and Cover Pdfs with…

Screen Linkedin Jobs and Generate Tailored Resume and Cover Pdfs with…

Original n8n title: Screen Linkedin Jobs and Generate Tailored Resume and Cover Pdfs with Anthropic and Docraptor

Byshafeel @shafeel on n8n.io

This workflow collects your job preferences and resume via an n8n form, scrapes fresh LinkedIn job listings with Apify, screens and ranks matches using Anthropic Claude, generates tailored resume and cover letter PDFs via DocRaptor, then emails them to you and logs each match in…

Event trigger★★★★☆ complexityAI-powered25 nodesForm TriggerHTTP RequestAgentAnthropic ChatGmailGoogle Sheets
AI & RAG Trigger: Event Nodes: 25 Complexity: ★★★★☆ AI nodes: yes Added:

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

This workflow follows the Agent → 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": "BvhX0OiBaCyBEh2K",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "AI Job Application Engine (scrape, screen, tailor resume + cover, email)",
  "tags": [],
  "nodes": [
    {
      "id": "0a0909fd-837c-43b9-a2a0-4d6e2da74c52",
      "name": "overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        416,
        -208
      ],
      "parameters": {
        "width": 760,
        "height": 812,
        "content": "# AI Job Application Engine\n\nScrapes fresh LinkedIn jobs, screens them against your profile with AI, writes a tailored resume and cover letter for each real match, and emails them to you with a tracker row. Works for any user, nothing is hardcoded.\n\n### How it works\n1. Fill the form: email, target role, seniority, locations, skills, deal-breakers, and paste your resume.\n2. Apify scrapes LinkedIn, then a free pre-filter drops duplicates and obvious mismatches.\n3. An AI agent (Haiku) screens each job for relevance and eligibility and keeps the best 5.\n4. A second AI (Sonnet) writes a tailored resume and cover letter from your resume text, no fabrication, no em-dashes.\n5. DocRaptor turns them into PDFs, emails them to you, and logs each match to a Google Sheet.\n\n### Setup\n1. Add credentials: Anthropic (all three Chat Model nodes), DocRaptor (Basic Auth, API key as username, blank password), Google Sheets, Gmail.\n2. In Run Apify, replace YOUR_APIFY_TOKEN with your Apify token (LinkedIn Jobs Scraper actor).\n3. Make a Google Sheet with a Tracker tab (Date, Company, Role, Fit, Verdict, Why, ApplyURL, Status) and paste its ID.\n4. DocRaptor nodes are in test mode (free, watermarked); set test:false for clean PDFs.\n5. Open the form URL and submit.\n\n### Customization tips\nBroaden the target keywords for more matches, adjust the fit threshold in Rank + Keep Top 5, or swap models in the Chat Model nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "a75bd25d-60ef-4f0e-8e28-3ecb40a714dc",
      "name": "sec1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -464,
        576
      ],
      "parameters": {
        "color": 7,
        "width": 770,
        "height": 540,
        "content": "## 1. Collect and scrape\nForm captures the user prefs and resume, then Apify scrapes fresh LinkedIn jobs."
      },
      "typeVersion": 1
    },
    {
      "id": "774538e8-a249-4c53-a277-d1caf6d4615a",
      "name": "sec2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        336,
        640
      ],
      "parameters": {
        "color": 7,
        "width": 1050,
        "height": 540,
        "content": "## 2. Filter and AI screen\nFree pre-filter and dedup, then one AI agent scores relevance plus eligibility and keeps the top 5."
      },
      "typeVersion": 1
    },
    {
      "id": "1cb7f998-ce74-43cb-a216-66f18142a082",
      "name": "sec3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1232,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 1840,
        "height": 540,
        "content": "## 3. Tailor resume and cover\nBuild prompts from the resume, write both documents with AI, render HTML, and make PDFs with DocRaptor."
      },
      "typeVersion": 1
    },
    {
      "id": "d6761584-f125-413d-9a93-e6c01b3f7851",
      "name": "sec4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3104,
        80
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 540,
        "content": "## 4. Deliver\nBundle both PDFs, email them to the user, and log the match to the tracker sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "783329b4-3573-4be9-b45c-94ac7b54a779",
      "name": "Job preferences",
      "type": "n8n-nodes-base.formTrigger",
      "notes": "Collects everything from ANY user.",
      "position": [
        -384,
        816
      ],
      "parameters": {
        "options": {},
        "formTitle": "Find and tailor jobs for me",
        "formFields": {
          "values": [
            {
              "fieldType": "email",
              "fieldLabel": "Your email",
              "requiredField": true
            },
            {
              "fieldLabel": "Target role or keywords",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Seniority",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Fresher / Entry"
                  },
                  {
                    "option": "Associate"
                  },
                  {
                    "option": "Mid"
                  },
                  {
                    "option": "Senior"
                  }
                ]
              }
            },
            {
              "fieldLabel": "Preferred locations"
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Your top skills"
            },
            {
              "fieldLabel": "Deal-breakers (skip jobs needing these)"
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Your resume or profile",
              "requiredField": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "0efd2590-84dd-4894-bbab-41e31a10af15",
      "name": "Build Apify Input",
      "type": "n8n-nodes-base.code",
      "position": [
        -160,
        816
      ],
      "parameters": {
        "jsCode": "// Turn the user's form answers into a LinkedIn-scraper search input.\nconst f = $('Job preferences').first().json;\n\nconst keywords = String(f['Target role or keywords'] || '')\n  .split(',').map(s => s.trim()).filter(Boolean).slice(0, 3);\n\nconst location = (String(f['Preferred locations'] || '').split(',')[0] || '').trim() || 'India';\n\nconst senMap = {\n  'fresher / entry': 'entry-level', 'fresher': 'entry-level', 'entry': 'entry-level',\n  'associate': 'associate', 'mid': 'mid-senior', 'senior': 'mid-senior'\n};\nconst exp = senMap[String(f['Seniority'] || '').toLowerCase()];\n\nconst input = {\n  keyword: keywords.length ? keywords : ['AI Engineer'],\n  location: location,\n  publishedAt: 'r604800',        // last 7 days (fresh jobs)\n  saveOnlyUniqueItems: true,     // don't pay for duplicates\n  enrichCompanyData: false,      // faster + cheaper\n  maxItems: 150                  // actor enforces a 150 minimum\n};\nif (exp) input.experienceLevel = [exp];\n\nreturn [{ json: input }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "737dd892-d5dc-4035-9888-7fd11ebc0769",
      "name": "Run Apify",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Replace YOUR_APIFY_TOKEN.",
      "position": [
        64,
        816
      ],
      "parameters": {
        "url": "https://api.apify.com/v2/actors/cheap_scraper~linkedin-job-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN_HERE",
        "method": "POST",
        "options": {
          "timeout": 300000
        },
        "jsonBody": "={{ JSON.stringify($json) }}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "510ceecc-feac-4236-94eb-e6b6620a3d46",
      "name": "Pre-filter + Dedup",
      "type": "n8n-nodes-base.code",
      "position": [
        416,
        816
      ],
      "parameters": {
        "jsCode": "// CHEAP PRE-FILTER + DEDUP  (runs before the AI, so the AI only sees real candidates)\n// Uses the user's form answers. Free + instant. Cuts hundreds of rows to a handful.\nconst f = $('Job preferences').first().json;\nconst seniority = String(f['Seniority'] || '').toLowerCase();\nconst isJunior = /fresher|entry|associate/.test(seniority);\nconst wantLocs = String(f['Preferred locations'] || '')\n  .toLowerCase().split(/[,;/]| or /).map(s => s.trim()).filter(Boolean);\n\nconst seniorTitle = /(senior|sr\\.?|lead|principal|staff|architect|manager|director|head|vp|avp|\\biii\\b|sde-?3|sde-?ii)/i;\n\nconst seen = new Set();\nconst out = [];\nfor (const it of $input.all()) {\n  const r = it.json;\n  const title = String(r.jobTitle || '');\n  const loc = String(r.location || '').toLowerCase();\n\n  // 1) DEDUP within this batch (same job posted many times)\n  const key = (String(r.applyUrl || r.jobUrl || '') || (String(r.companyName || '') + '|' + title))\n    .toLowerCase().trim();\n  if (!key || seen.has(key)) continue;\n  seen.add(key);\n\n  // 2) Drop roles that are clearly too senior for a junior seeker\n  if (isJunior && seniorTitle.test(title)) continue;\n  if (/executive/i.test(String(r.experienceLevel || ''))) continue;\n\n  // 3) Drop roles demanding too many years (structured field first, then the JD text)\n  let maxY = -1;\n  for (let i = 0; i < 5; i++) {\n    const m = String(r['yearsOfExperience/' + i + '/years'] || '').match(/(\\d+)/);\n    if (m) maxY = Math.max(maxY, parseInt(m[1]));\n  }\n  // also scan the description text, e.g. \"5+ years\", \"5-10 years\", \"minimum 4 years\"\n  const desc = String(r.jobDescription || '');\n  const re = /(\\d+)\\s*(?:\\+|-\\s*\\d+)?\\s*(?:years|yrs)/gi;\n  let tm;\n  while ((tm = re.exec(desc)) !== null) {\n    const n = parseInt(tm[1]);\n    if (n <= 20) maxY = Math.max(maxY, n);\n  }\n  if (isJunior && maxY >= 4) continue;\n  if (maxY >= 8) continue;\n\n  // 4) Location: if the user named locations, keep only matches (or remote)\n  if (wantLocs.length) {\n    const ok = wantLocs.some(w => w && loc.includes(w)) || /remote|anywhere/.test(loc);\n    if (!ok) continue;\n  }\n\n  out.push({ json: r });\n}\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "5a78c1a6-155c-4a4a-a5c6-9a61dd73abe2",
      "name": "Build Match Prompt",
      "type": "n8n-nodes-base.code",
      "position": [
        640,
        816
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Build ONE screening prompt: relevance + eligibility in a single AI call.\nconst f = $('Job preferences').first().json;\nconst j = $json;\nconst jd = String(j.jobDescription || '').slice(0, 4500);\n\n$json.matchPrompt =\n'You are a strict job screener for a job seeker. They want -> role/keywords: ' + (f['Target role or keywords'] || '') +\n'. seniority: ' + (f['Seniority'] || '') +\n'. preferred locations: ' + (f['Preferred locations'] || '') +\n'. their top skills: ' + (f['Your top skills'] || '') +\n'. deal-breakers (reject if the job requires any of these): ' + (f['Deal-breakers (skip jobs needing these)'] || '') +\n'. Infer the candidate experience from their seniority (Fresher/Entry = 0 to 1 years). Judge THIS job on BOTH: ' +\n'(1) relevance to what they want, and (2) eligibility - read the description for required years like \"3+ years\" or \"5-10 years\" and for senior/lead/principal titles. ' +\n'Return ONLY JSON: {\"relevant\": true or false, \"fit\": 0 to 100, \"verdict\": \"APPLY or STRETCH or REJECT\", \"reason\": \"one short sentence naming years required vs candidate\"}. ' +\n'Rules: REJECT if the job needs clearly more years than the candidate has, is senior/lead/principal/manager level, requires a deal-breaker, or is a different field. APPLY if a genuine fit. STRETCH if close. ' +\n'JOB TITLE: ' + (j.jobTitle || '') + '. COMPANY: ' + (j.companyName || '') + '. LOCATION: ' + (j.location || '') + '. LEVEL: ' + (j.experienceLevel || '') + '. DESCRIPTION: ' + jd;\n\nreturn $json;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "f7d1cb65-1ed4-4ff1-9255-107fcc9c503e",
      "name": "AI Match (Agent)",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        864,
        816
      ],
      "parameters": {
        "text": "={{ $json.matchPrompt }}",
        "options": {
          "systemMessage": "You are a strict job screener. Reply with ONLY the JSON object asked for, nothing else."
        },
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "21dda721-7297-4dd4-a251-93a230a6c15f",
      "name": "Anthropic Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "notes": "Add your Anthropic credential here.",
      "position": [
        816,
        1040
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-haiku-4-5-20251001",
          "cachedResultName": "Claude Haiku 4.5"
        },
        "options": {
          "maxTokensToSample": 600
        }
      },
      "typeVersion": 1.5
    },
    {
      "id": "bac0b687-5895-4a00-9d7e-c94f4f2832fe",
      "name": "Rank + Keep Top 5",
      "type": "n8n-nodes-base.code",
      "position": [
        1200,
        816
      ],
      "parameters": {
        "jsCode": "// Parse the combined screen output, gate out REJECTs, keep the best 5.\nconst prompts = $('Build Match Prompt').all();\nconst inputs = $input.all();\nconst scored = [];\nfor (let i = 0; i < inputs.length; i++) {\n  let m = { relevant: false, fit: 0, verdict: 'REJECT', reason: '' };\n  try {\n    let t = inputs[i].json.output;\n    if (typeof t !== 'string') t = JSON.stringify(t || {});\n    t = t.replace(/\\x60\\x60\\x60json|\\x60\\x60\\x60/g, '').trim();\n    m = JSON.parse(t);\n  } catch (e) {}\n  const lead = (prompts[i] && prompts[i].json) || inputs[i].json;\n  scored.push({ json: {\n    ...lead,\n    _fit: Number(m.fit) || 0,\n    verdict: String(m.verdict || 'REJECT').toUpperCase(),\n    reason: m.reason || '',\n    _relevant: !!m.relevant\n  }});\n}\n// Keep anything not REJECTed with a decent score. If none, fall back to the best few.\nlet keep = scored.filter(r => r.json.verdict !== 'REJECT' && r.json._fit >= 40);\nif (keep.length === 0) keep = scored.filter(r => r.json._fit >= 30);\nkeep.sort((a, b) => b.json._fit - a.json._fit);\nreturn keep.slice(0, 5);\n"
      },
      "typeVersion": 2
    },
    {
      "id": "31e4e0d1-50ca-4ee0-a9e1-f95aabe5eecc",
      "name": "Build Prompts (user profile)",
      "type": "n8n-nodes-base.code",
      "position": [
        1376,
        272
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const f = $('Job preferences').first().json;\nconst profile = f['Your resume or profile'] || '';\nconst rules = 'Rules: Do not use em-dashes, use commas colons or periods instead. Avoid AI-slop words like thrilled, passionate about leveraging, fast-paced world, delve. Use ONLY facts present in the candidate profile text below, never invent skills, employers, dates, metrics or numbers. If the candidate lacks something the job wants, do not claim it, frame it honestly as eager to learn or ramping on. Write in plain, human language.';\nconst j = $json;\nconst jd = String(j.jobDescription||'').slice(0,6000);\nconst role = j.jobTitle||''; const company = (j.companyName||'').replace(/\"/g,'');\nconst PROFILE = 'CANDIDATE PROFILE (the only source of truth, do not invent beyond this): ' + profile;\n$json.verifyPrompt = 'You are a STRICT job-eligibility screener. Step 1: infer the candidate total years of experience from their profile (a final-year student or fresher counts as 0 to 1 years). Step 2: find the minimum experience the JD requires, reading the description text for phrases like \"3+ years\", \"5-10 years\", \"minimum 4 years\", \"senior\", \"lead\", \"principal\". Step 3: if the JD clearly requires more experience than the candidate has, OR the title is senior/lead/principal/staff/architect/manager level, set verdict REJECT. Use APPLY only if the candidate genuinely qualifies. Use STRETCH only if the experience gap is small (1 year or less). Return ONLY JSON: {\"real_min_years\":0,\"candidate_years\":0,\"verdict\":\"APPLY or STRETCH or REJECT\",\"reason\":\"one short sentence naming the years required vs the candidate\"}. ' + PROFILE + ' ROLE: ' + role + ' at ' + company + '. JD: ' + jd;\n$json.resumePrompt = 'Write a tailored resume as JSON for this candidate and job. ' + rules + ' Output ONLY JSON with this shape: {\"_company\":\"' + company + '\",\"basics\":{\"name\":\"\",\"title\":\"\",\"email\":\"\",\"phone\":\"\",\"location\":\"\",\"links\":[{\"label\":\"\",\"url\":\"\"}]},\"summary\":\"\",\"experience\":[],\"projects\":[{\"name\":\"\",\"url\":\"\",\"tech\":[],\"bullets\":[]}],\"skills\":[],\"education\":[{\"degree\":\"\",\"school\":\"\",\"dates\":\"\"}]}. Extract the name, email, phone and links from the profile text. Keep it to 3 sections (summary, projects, skills); reorder skills so the job-relevant true ones come first; mirror the job wording where it is genuinely true for the candidate. ' + PROFILE + ' ROLE: ' + role + ' at ' + company + '. JD: ' + jd;\n$json.coverPrompt = 'Write a humanized cover letter as JSON. ' + rules + ' 5 to 7 short paragraphs, open with the job and company (never \"I am writing to apply\"), include one honest line if the candidate lacks the required years or pedigree, and include one concrete achievement or number from the profile if one exists. Output ONLY JSON: {\"name\":\"\",\"contact\":\"\",\"links\":[{\"label\":\"\",\"url\":\"\"}],\"paragraphs\":[],\"filename\":\"\"}. Use the candidate name and contact from the profile. ' + PROFILE + ' ROLE: ' + role + ' at ' + company + '. JD: ' + jd;\nreturn $json;"
      },
      "typeVersion": 2
    },
    {
      "id": "67e11684-fd92-404b-a28d-21ed9fea9144",
      "name": "Generate Resume JSON (Claude)",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        1600,
        272
      ],
      "parameters": {
        "text": "={{ $json.resumePrompt }}",
        "options": {
          "systemMessage": "You are an expert resume writer. Return ONLY the JSON object requested. No preamble, no code fences."
        },
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "1e5190e1-89f0-47e4-9aff-e68e4ffcc70f",
      "name": "Anthropic Chat Model (Resume)",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "notes": "Add your Anthropic credential here.",
      "position": [
        1680,
        496
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-sonnet-4-6",
          "cachedResultName": "Claude Sonnet 4.6"
        },
        "options": {
          "maxTokensToSample": 2000
        }
      },
      "typeVersion": 1.5
    },
    {
      "id": "644f7acf-8e64-49f6-aba5-43a638816c52",
      "name": "Render Resume HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        1952,
        272
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ---- RENDER RESUME HTML (port of template.mjs, no em-dash) ----\nconst esc=s=>String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\nlet r={}; try { r = JSON.parse((typeof $json.output==='string'?$json.output:JSON.stringify($json.output||{})).replace(/```json|```/g,'').trim()); } catch(e){ throw new Error('resume JSON parse failed'); }\nconst b=r.basics||{};\nconst links=(b.links||[]).map(l=>`<a href=\"${esc(l.url)}\">${esc(l.label)}</a>`).join('<span class=\"dot\">\u00b7</span>');\nconst skills=(r.skills||[]).map(s=>`<span class=\"skill\">${esc(s)}</span>`).join('');\nconst projects=(r.projects||[]).map(p=>`<div class=\"entry\"><div class=\"entry-head\"><span class=\"role\">${esc(p.name)}</span>${p.url?`<span class=\\\"dates\\\"><a href=\\\"${esc(p.url)}\\\">${esc(String(p.url).replace(/^https?:\\/\\//,''))}</a></span>`:''}</div>${p.tech&&p.tech.length?`<div class=\\\"tech\\\">${esc(p.tech.join(' \u00b7 '))}</div>`:''}<ul>${(p.bullets||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div>`).join('');\nconst edu=(r.education||[]).map(e=>`<div class=\"edu-row\"><span>${esc(e.degree)}, ${esc(e.school)}</span><span class=\"dates\">${esc(e.dates)}</span></div>`).join('');\nconst html=`<!doctype html><html><head><meta charset=\"utf-8\"><style>*{box-sizing:border-box}@page{margin:14mm 16mm}body{font-family:Georgia,serif;color:#1a1a1a;font-size:10.5pt;line-height:1.4;margin:0}h1{font-family:Arial,sans-serif;font-size:20pt;letter-spacing:-.02em;margin:0}.title{font-family:Arial,sans-serif;color:#444;font-size:11pt;margin:2px 0 6px}.contact{font-family:Arial,sans-serif;font-size:9pt;color:#333}.contact a{color:#1a4f8b;text-decoration:none}.dot{margin:0 6px;color:#aaa}h2{font-family:Arial,sans-serif;font-size:10.5pt;text-transform:uppercase;letter-spacing:.08em;color:#1a4f8b;border-bottom:1.5px solid #1a4f8b;padding-bottom:2px;margin:16px 0 8px}.summary{margin:8px 0 4px}.skills{display:flex;flex-wrap:wrap;gap:5px}.skill{font-family:Arial,sans-serif;font-size:9pt;background:#eef2f7;border-radius:4px;padding:2px 7px}.entry{margin-bottom:10px}.entry-head{display:flex;align-items:baseline;gap:4px}.role{font-weight:bold}.dates{font-family:Arial,sans-serif;font-size:9pt;color:#666;margin-left:auto}.dates a{color:#666;text-decoration:none}.tech{font-family:Arial,sans-serif;font-size:9pt;color:#555;font-style:italic;margin:1px 0}ul{margin:4px 0 0;padding-left:16px}li{margin-bottom:2px}.edu-row{display:flex;justify-content:space-between}</style></head><body><header><h1>${esc(b.name)}</h1><div class=\"title\">${esc(b.title)}</div><div class=\"contact\">${esc(b.email)}<span class=\"dot\">\u00b7</span>${esc(b.phone)}<span class=\"dot\">\u00b7</span>${esc(b.location)}${links?`<span class=\\\"dot\\\">\u00b7</span>${links}`:''}</div></header>${r.summary?`<section><h2>Summary</h2><div class=\\\"summary\\\">${esc(r.summary)}</div></section>`:''}${projects?`<section><h2>Projects</h2>${projects}</section>`:''}${skills?`<section><h2>Skills</h2><div class=\\\"skills\\\">${skills}</div></section>`:''}${edu?`<section><h2>Education</h2>${edu}</section>`:''}</body></html>`;\nreturn { ...$('Build Prompts (user profile)').item.json, resumeHtml: html, resumeName: (r._company||'resume')+'-resume' };"
      },
      "typeVersion": 2
    },
    {
      "id": "35fa0ad5-073d-4eb1-8d61-08f0b1c4da28",
      "name": "Resume PDF (DocRaptor)",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "DocRaptor: Basic Auth, username = API key, password blank. test:true = free watermarked PDF; set false for live.",
      "position": [
        2176,
        272
      ],
      "parameters": {
        "url": "https://api.docraptor.com/docs",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "resumePdf"
            }
          }
        },
        "jsonBody": "={\n  \"type\": \"pdf\",\n  \"test\": true,\n  \"name\": {{ JSON.stringify($json.resumeName + '.pdf') }},\n  \"document_content\": {{ JSON.stringify($json.resumeHtml) }}\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth"
      },
      "typeVersion": 4.2
    },
    {
      "id": "23e541ed-a71c-493d-abf6-b86147c18173",
      "name": "Generate Cover JSON (Claude)",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2400,
        272
      ],
      "parameters": {
        "text": "={{ $json.coverPrompt }}",
        "options": {
          "systemMessage": "You are an expert cover-letter writer. Return ONLY the JSON object requested. No preamble, no code fences."
        },
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "80c16137-f19e-4001-9b17-ec6e2519a719",
      "name": "Anthropic Chat Model (Cover)",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "notes": "Add your Anthropic credential here.",
      "position": [
        2480,
        496
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-sonnet-4-6",
          "cachedResultName": "Claude Sonnet 4.6"
        },
        "options": {
          "maxTokensToSample": 2000
        }
      },
      "typeVersion": 1.5
    },
    {
      "id": "0ca26123-34f2-4d3d-b4f0-a141f80912da",
      "name": "Render Cover HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        2752,
        272
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ---- RENDER COVER HTML (port of cover-template.mjs, no em-dash) ----\nconst esc=s=>String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\nlet d={}; try { d = JSON.parse((typeof $json.output==='string'?$json.output:JSON.stringify($json.output||{})).replace(/```json|```/g,'').trim()); } catch(e){ throw new Error('cover JSON parse failed'); }\nconst links=(d.links||[]).map(l=>`<a href=\"${esc(l.url)}\">${esc(l.label)}</a>`).join('<span class=\"dot\"> \u00b7 </span>');\nconst paras=(d.paragraphs||[]).map(p=>`<p>${esc(p)}</p>`).join('');\nconst html=`<!doctype html><html><head><meta charset=\"utf-8\"><style>@page{margin:22mm 20mm}body{font-family:Georgia,serif;color:#1a1a1a;font-size:11pt;line-height:1.65;margin:0}h1{font-family:Arial,sans-serif;font-size:18pt;letter-spacing:-.02em;margin:0}.contact{font-family:Arial,sans-serif;font-size:9.5pt;color:#333;margin:5px 0 0}.contact a{color:#1a4f8b;text-decoration:none}.dot{color:#aaa}hr{border:none;border-top:1.5px solid #1a4f8b;margin:11px 0 18px}p{margin:0 0 12px}</style></head><body><h1>${esc(d.name)}</h1><div class=\"contact\">${esc(d.contact)}${links?`<span class=\\\"dot\\\"> \u00b7 </span>${links}`:''}</div><hr>${paras}</body></html>`;\nreturn { ...$('Render Resume HTML').item.json, coverHtml: html, coverName: (d.filename||'cover-letter') };"
      },
      "typeVersion": 2
    },
    {
      "id": "00b80bd2-40d8-4255-ad2a-27dd2053c5f4",
      "name": "Cover PDF (DocRaptor)",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "DocRaptor: Basic Auth, username = API key, password blank. test:true = free watermarked PDF; set false for live.",
      "position": [
        2928,
        272
      ],
      "parameters": {
        "url": "https://api.docraptor.com/docs",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file",
              "outputPropertyName": "coverPdf"
            }
          }
        },
        "jsonBody": "={\n  \"type\": \"pdf\",\n  \"test\": true,\n  \"name\": {{ JSON.stringify($json.coverName + '.pdf') }},\n  \"document_content\": {{ JSON.stringify($json.coverHtml) }}\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth"
      },
      "typeVersion": 4.2
    },
    {
      "id": "2bc46917-5d30-4f01-a363-e82df3ec0984",
      "name": "Assemble Email",
      "type": "n8n-nodes-base.code",
      "position": [
        3200,
        368
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Gather the job info + both PDFs into one item for the email.\nconst lead = $('Render Cover HTML').item.json;   // jobTitle, companyName, verdict, _fit, applyUrl, reason\nconst out = { json: lead, binary: {} };\ntry { out.binary.resumePdf = $('Resume PDF (DocRaptor)').item.binary.resumePdf; } catch (e) {}\ntry { out.binary.coverPdf  = $('Cover PDF (DocRaptor)').item.binary.coverPdf; } catch (e) {}\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "612f5b0f-85a1-4c73-9844-b5117adbb66a",
      "name": "Email Me (Gmail)",
      "type": "n8n-nodes-base.gmail",
      "notes": "Add your Gmail credential.",
      "position": [
        3408,
        288
      ],
      "parameters": {
        "sendTo": "={{ $('Job preferences').first().json['Your email'] }}",
        "message": "={{ '<h3>' + $json.jobTitle + ' at ' + $json.companyName + '</h3><p><b>' + $json.verdict + '</b> fit ' + $json._fit + '</p><p>' + ($json.reason||'') + '</p><p><a href=\"https://www.google.com/search?q=' + encodeURIComponent($json.companyName + ' careers ' + $json.jobTitle) + '\">Apply on company site</a></p><p>Resume and cover letter attached.</p>' }}",
        "options": {
          "attachmentsUi": {
            "attachmentsBinary": [
              {
                "property": "resumePdf"
              },
              {
                "property": "coverPdf"
              }
            ]
          }
        },
        "subject": "={{ 'Job match: ' + $json.jobTitle + ' at ' + $json.companyName + ' (' + $json.verdict + ')' }}"
      },
      "typeVersion": 2.1
    },
    {
      "id": "0e3570a0-9610-4e59-9ac0-5f10381ab37b",
      "name": "Tracker Row (Sheets)",
      "type": "n8n-nodes-base.googleSheets",
      "notes": "Add Google Sheets credential + your sheet ID. Tab \"Tracker\".",
      "position": [
        3456,
        480
      ],
      "parameters": {
        "columns": {
          "value": {
            "Fit": "={{ $json._fit }}",
            "Why": "={{ $json.reason }}",
            "Date": "={{ $now.format('yyyy-LL-dd') }}",
            "Role": "={{ $json.jobTitle }}",
            "Status": "To Apply",
            "Company": "={{ $json.companyName }}",
            "Verdict": "={{ $json.verdict }}",
            "ApplyURL": "={{ $json.applyUrl }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Tracker"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_TRACKER_SHEET_ID"
        }
      },
      "typeVersion": 4
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "eff8047b-aec0-4a94-ba48-f1f64eaafb42",
  "nodeGroups": [],
  "connections": {
    "Run Apify": {
      "main": [
        [
          {
            "node": "Pre-filter + Dedup",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Email": {
      "main": [
        [
          {
            "node": "Email Me (Gmail)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Tracker Row (Sheets)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Job preferences": {
      "main": [
        [
          {
            "node": "Build Apify Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Match (Agent)": {
      "main": [
        [
          {
            "node": "Rank + Keep Top 5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Apify Input": {
      "main": [
        [
          {
            "node": "Run Apify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank + Keep Top 5": {
      "main": [
        [
          {
            "node": "Build Prompts (user profile)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Cover HTML": {
      "main": [
        [
          {
            "node": "Cover PDF (DocRaptor)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Match Prompt": {
      "main": [
        [
          {
            "node": "AI Match (Agent)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pre-filter + Dedup": {
      "main": [
        [
          {
            "node": "Build Match Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Resume HTML": {
      "main": [
        [
          {
            "node": "Resume PDF (DocRaptor)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anthropic Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Match (Agent)",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Cover PDF (DocRaptor)": {
      "main": [
        [
          {
            "node": "Assemble Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resume PDF (DocRaptor)": {
      "main": [
        [
          {
            "node": "Generate Cover JSON (Claude)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anthropic Chat Model (Cover)": {
      "ai_languageModel": [
        [
          {
            "node": "Generate Cover JSON (Claude)",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Build Prompts (user profile)": {
      "main": [
        [
          {
            "node": "Generate Resume JSON (Claude)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Cover JSON (Claude)": {
      "main": [
        [
          {
            "node": "Render Cover HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anthropic Chat Model (Resume)": {
      "ai_languageModel": [
        [
          {
            "node": "Generate Resume JSON (Claude)",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Generate Resume JSON (Claude)": {
      "main": [
        [
          {
            "node": "Render Resume HTML",
            "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

This workflow collects your job preferences and resume via an n8n form, scrapes fresh LinkedIn job listings with Apify, screens and ranks matches using Anthropic Claude, generates tailored resume and cover letter PDFs via DocRaptor, then emails them to you and logs each match in…

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

Lead Workflow: Yelp & Trustpilot Scraping + OpenAI Analysis via BrightData. Uses formTrigger, agent, httpRequest, googleSheets. Event-driven trigger; 32 nodes.

Form Trigger, Agent, HTTP Request +4
AI & RAG

A complete n8n automation that discovers TikTok influencers using Bright Data, evaluates their fit using Claude AI, and sends personalized outreach emails. Designed for marketing teams and brands that

Anthropic Chat, Google Sheets, Gmail +3
AI & RAG

Automated TikTok Influencer Discovery & Analysis via Bright Data and Anthropic AI and Send Email Notification. Uses lmChatAnthropic, googleSheets, gmail, httpRequest. Event-driven trigger; 22 nodes.

Anthropic Chat, Google Sheets, Gmail +3
AI & RAG

This workflow contains community nodes that are only compatible with the self-hosted version of n8n.

Form Trigger, Google Sheets, HTTP Request +3
AI & RAG

The workflow runs every hour with a randomized delay of 5–20 minutes to help distribute load. It records the exact date and time a lead is emailed so you can track outreach. Follow-ups are automatical

Google Sheets, Agent, OpenAI Chat +5