This workflow corresponds to n8n.io template #7469 — we link there as the canonical source.
This workflow follows the Airtable → Google Drive 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 →
{
"id": "eRS0KpRKSwAZs7rW",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "ResumeRadar",
"tags": [],
"nodes": [
{
"id": "049242f2-4300-462a-9aa0-4b02dfde99c1",
"name": "Gmail Trigger",
"type": "n8n-nodes-base.gmailTrigger",
"position": [
220,
30
],
"parameters": {
"simple": false,
"filters": {
"q": "has:attachment"
},
"options": {
"downloadAttachments": true
},
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "c610f1ed-df34-4bec-a308-ffa68b1eda74",
"name": "Airtable",
"type": "n8n-nodes-base.airtable",
"position": [
440,
155
],
"parameters": {
"base": {
"__rl": true,
"mode": "list",
"value": "appzbBhAyKeU2danJ",
"cachedResultUrl": "https://airtable.com/appzbBhAyKeU2danJ",
"cachedResultName": "ResumeRadar"
},
"table": {
"__rl": true,
"mode": "list",
"value": "tblnk2uJSYm7R5ABB",
"cachedResultUrl": "https://airtable.com/appzbBhAyKeU2danJ/tblnk2uJSYm7R5ABB",
"cachedResultName": "Job Descriptions"
},
"options": {},
"operation": "search"
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"typeVersion": 2.1
},
{
"id": "2873ae38-0c44-4493-a604-1088e768ec63",
"name": "If",
"type": "n8n-nodes-base.if",
"position": [
880,
155
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "66b8762f-c4ee-46c4-b2e5-a9e78ffccebd",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.matchFound }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.2
},
{
"id": "424f557a-1bab-4116-ad34-2f03251f44d9",
"name": "EmailMatcher",
"type": "n8n-nodes-base.code",
"position": [
660,
155
],
"parameters": {
"jsCode": "// ---------- config ----------\nconst MIN_SCORE = 0.60; // raise/lower to be stricter/looser\n// ----------------------------\n\n// Build job list from Airtable items\nconst jobListings = [];\nif (Array.isArray(items)) {\n for (const item of items) {\n if (item?.json) {\n jobListings.push({\n id: item.json.id || '',\n title: item.json[\"Job Title\"] || '',\n jobCode: item.json[\"Job Code\"] || '',\n description: item.json[\"Description\"] || '',\n skills: item.json[\"Skills Required\"] || '',\n experience: item.json[\"Experience Required\"] || item.json[\"Experience Required (Years)\"] || '',\n location: item.json[\"Location\"] || ''\n });\n }\n }\n}\n\n// Email subject from Gmail Trigger (Option A: Simplify OFF)\nconst emailNode = $node[\"Gmail Trigger\"];\nconst email = emailNode?.json || {};\nlet subject = email.subject || email.Subject || '';\n\nif (!subject && items?.[0]?.json) {\n subject = items[0].json.subject || items[0].json.Subject || '';\n}\n\nfunction extractJobTitle(subj) {\n const lower = (subj || '').toLowerCase();\n const patterns = [\n /application (?:for|to) (.*?)(?:position|role|job)?$/i,\n /(?:applying|apply) (?:for|to) (?:the )?(.*?)(?:position|role|job)?$/i,\n /interested in (?:the )?(.*?)(?:position|role|job)/i,\n /regarding (?:the )?(.*?)(?:position|role|job)/i,\n /^(.*?)(?:position|job|role|application)$/i,\n ];\n for (const p of patterns) {\n const m = lower.match(p);\n if (m?.[1]?.trim()?.length > 3) return m[1].trim();\n }\n const capRe = /([A-Z][a-z]+(?: [A-Z][a-z]+)+)/g;\n let best = '', m;\n while ((m = capRe.exec(subj || '')) !== null) if (m[0].length > best.length) best = m[0];\n if (best.length > 5) return best.toLowerCase();\n return lower\n .replace(/application for|applying for|regarding|re:|job application|position|role|job|application|interest in/g, '')\n .replace(/^for the |for a |for |the |a /g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n// ---------- NEW similarity ----------\nconst STOP = new Set(['the','a','an','and','for','to','of','role','position','job','senior','jr','junior','lead','manager','developer','engineer']);\nconst PHRASE_REPL = [\n ['machine learning','ai'],\n ['artificial intelligence','ai'],\n ['quality assurance','qa'],\n ['full stack','fullstack'],\n ['front end','frontend'],\n ['back end','backend'],\n];\nconst TOKEN_MAP = { // single-token synonyms\n ml: 'ai',\n sdet: 'qa',\n testing: 'qa',\n test: 'qa',\n sre: 'devops',\n};\n\nfunction normalizeTitle(s = '') {\n s = s.toLowerCase();\n for (const [a,b] of PHRASE_REPL) s = s.replace(new RegExp(a, 'g'), b);\n s = s.replace(/[\\/,()\\-]/g, ' ');\n s = s.replace(/\\s+/g, ' ').trim();\n return s;\n}\n\nfunction canonicalTokens(s = '') {\n const t = normalizeTitle(s).split(' ').filter(Boolean);\n const out = [];\n for (const tok of t) {\n if (STOP.has(tok)) continue;\n out.push(TOKEN_MAP[tok] || tok);\n }\n // dedupe, preserve order\n return Array.from(new Set(out));\n}\n\nfunction jaccard(a, b) {\n const A = new Set(a), B = new Set(b);\n if (A.size === 0 && B.size === 0) return 1;\n const inter = new Set([...A].filter(x => B.has(x))).size;\n const uni = new Set([...A, ...B]).size;\n return uni ? inter / uni : 0;\n}\n\nfunction lev(a, b) {\n a = normalizeTitle(a); b = normalizeTitle(b);\n if (!a && !b) return 1;\n const T = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(null));\n for (let i = 0; i <= a.length; i++) T[0][i] = i;\n for (let j = 0; j <= b.length; j++) T[j][0] = j;\n for (let j = 1; j <= b.length; j++) {\n for (let i = 1; i <= a.length; i++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n T[j][i] = Math.min(T[j][i - 1] + 1, T[j - 1][i] + 1, T[j - 1][i - 1] + cost);\n }\n }\n const d = T[b.length][a.length];\n const maxLen = Math.max(a.length, b.length) || 1;\n return 1 - d / maxLen;\n}\n\n// final score = 70% token match + 30% string similarity\nfunction smartSim(s1, s2) {\n const jac = jaccard(canonicalTokens(s1), canonicalTokens(s2));\n const levSim = lev(s1, s2);\n return 0.7 * jac + 0.3 * levSim;\n}\n// ------------------------------------\n\nconst jobKeywords = extractJobTitle(subject);\n\n// guard: no keywords\nif (!jobKeywords) {\n return [{\n json: {\n subject,\n extractedJobTitle: jobKeywords,\n matchFound: false,\n reason: \"No extractable job title from subject\"\n },\n }];\n}\n\n// find best match using smartSim\nlet best = null, bestScore = 0;\nfor (const j of jobListings) {\n const s = smartSim(jobKeywords, j.title);\n if (s > bestScore) { best = j; bestScore = s; }\n}\n\n// Return the match\nif (best && bestScore >= MIN_SCORE) {\n return [{\n json: {\n subject,\n extractedJobTitle: jobKeywords,\n matchFound: true,\n jobMatch: {\n id: best.id,\n title: best.title,\n code: best.jobCode,\n description: best.description,\n skills: best.skills,\n experience: best.experience,\n location: best.location,\n confidence: bestScore\n }\n },\n }];\n}\n\nreturn [{\n json: {\n subject,\n extractedJobTitle: jobKeywords,\n matchFound: false,\n reason: best\n ? `Low confidence (${bestScore.toFixed(2)}) vs threshold ${MIN_SCORE}`\n : 'No jobs in list'\n },\n}];"
},
"typeVersion": 2
},
{
"id": "a528ce65-eeb4-49c4-8c8d-bf152ea6f52a",
"name": "PromptBuilder",
"type": "n8n-nodes-base.code",
"position": [
1300,
140
],
"parameters": {
"jsCode": "// PromptBuilder \u2014 builds the LLM prompt + carries email body & job meta\n\nconst m = items[0]?.json ?? {};\nconst job = m.jobMatch ?? {};\n\n// helpers\nfunction stripHtml(s = '') {\n return String(s)\n .replace(/<style[\\s\\S]*?<\\/style>/gi, '')\n .replace(/<script[\\s\\S]*?<\\/script>/gi, '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\nfunction firstStr(...vals) {\n for (const v of vals) if (typeof v === 'string' && v.trim()) return v.trim();\n return '';\n}\n\n// get Gmail payload (paired item first, then pinned/previous run as fallback)\nconst g1 = $item(0, 'Gmail Trigger')?.$json ?? {};\nconst g2 = (($items && $items('Gmail Trigger', 0)) || [])[0]?.json ?? {};\n\n// build email body: prefer any body already carried on the item, else read from Gmail\nlet emailBody = firstStr(\n m.emailBody,\n g1.text, g1.textPlain,\n g1.html ? stripHtml(g1.html) : '',\n g1.textHtml ? stripHtml(g1.textHtml) : '',\n g1.textAsHtml ? stripHtml(g1.textAsHtml) : '',\n g1.snippet,\n g2.text, g2.textPlain,\n g2.html ? stripHtml(g2.html) : '',\n g2.textHtml ? stripHtml(g2.textHtml) : '',\n g2.textAsHtml ? stripHtml(g2.textAsHtml) : '',\n g2.snippet\n);\nemailBody = emailBody.slice(0, 5000); // keep prompt compact\n\n// subject: prefer the one already on the item (from EmailMatcher), else Gmail\nconst subject = firstStr(m.subject, g1.subject, g2.subject);\n\n// prompt\nconst prompt = `\nYou are a senior technical recruiter.\n\nTASKS\n1) Read the ATTACHED resume (primary source of truth).\n2) Consider the email subject & body for extra context.\n3) Evaluate fit against the JOB description.\n\nExtract ONLY from the resume (do NOT infer from email headers):\n- candidateName: full legal name as printed in the resume. If missing, return \"\".\n- candidatePhone: best reachable phone (normalize to E.164 if possible; else raw). If missing, return \"\".\n\nScoring:\n- score: integer 1\u201310 (10=excellent, 7\u20139=strong, 4\u20136=partial, 1\u20133=poor).\n- explanation: one concise sentence under 40 words.\n\nEMAIL\n- Subject: ${subject}\n- Body: ${emailBody}\n\nJOB\n- Title: ${job.title || \"\"}\n- Code: ${job.code || \"\"}\n- Description: ${job.description || \"\"}\n- Skills Required: ${job.skills || \"\"}\n- Experience Required: ${job.experience || \"\"}\n- Location: ${job.location || \"\"}\n\nReturn STRICT JSON only (no prose, no markdown):\n{\n \"score\": <1-10>,\n \"explanation\": \"<under 40 words>\",\n \"candidateName\": \"<from resume or empty string>\",\n \"candidatePhone\": \"<E.164 if possible else raw or empty string>\"\n}\n`.trim();\n\nreturn [{ json: { prompt, jobMeta: job } }];\n"
},
"typeVersion": 2
},
{
"id": "39fc01a4-5a9b-438e-9932-e4b82f47bb4c",
"name": "ReadyAttachment",
"type": "n8n-nodes-base.code",
"position": [
1220,
-160
],
"parameters": {
"jsCode": "const matched = items.some(i => i.json?.matchFound === true || i.json?.jobMatch);\nif (!matched) return [];\n\n\n// Keep only real resume-ish attachments\nconst ALLOWED = new Set([\n 'application/pdf',\n]);\n\n \n// --- helpers ---\nfunction headerPick(g, key) {\n const v = g.headers?.[key] || g.headers?.[key.toLowerCase()];\n return v ? String(v).replace(/^[A-Za-z-]+:\\s*/, '') : '';\n}\nfunction pickEmail(str) {\n const m = String(str || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i);\n return m ? m[0].toLowerCase() : '';\n}\nfunction pickName(str) {\n // \"Display Name\" <email@domain>\n const m = String(str || '').match(/^\"?([^\"<]+?)\"?\\s*<[^>]+>/);\n return m ? m[1].trim() : '';\n}\nfunction extractSender(g) {\n // 1) Preferred: rich shape\n const v0 = g.from?.value?.[0];\n const addr = v0?.address || '';\n const nm = v0?.name || '';\n\n // 2) Text forms (rich shape has these too)\n const fromText = typeof g.from?.text === 'string' ? g.from.text : '';\n const replyTo = headerPick(g, 'reply-to');\n const fromHdr = headerPick(g, 'from') || fromText;\n const returnPath = headerPick(g, 'return-path');\n\n // Decide email\n const email = addr || pickEmail(fromHdr) || pickEmail(replyTo) || pickEmail(returnPath) || '';\n // Decide name\n let name = nm || pickName(fromHdr);\n if (!name && email) name = email.split('@')[0];\n\n return { email, name };\n}\n\nconst out = [];\nfor (const item of items) {\n const g = item.json || {};\n const { email: fromEmail, name: fromName } = extractSender(g);\n\n // Prefer internalDate (epoch); else parse ISO \"date\"; else now\n const when =\n (g.internalDate ? Number(g.internalDate) : 0) ||\n (g.date ? Date.parse(g.date) : 0) ||\n Date.now();\n\n if (!item.binary) continue;\n for (const key of Object.keys(item.binary)) {\n const bin = item.binary[key];\n if (!bin?.data) continue;\n if (ALLOWED.size && !ALLOWED.has(bin.mimeType)) continue;\n\n out.push({\n json: {\n fileName: bin.fileName || 'resume',\n mimeType: bin.mimeType || 'application/octet-stream',\n fromEmail,\n fromName,\n internalDate: when,\n subject: g.subject || headerPick(g, 'subject') || ''\n },\n binary: { data: bin } // Downstream nodes use Binary Property: \"data\"\n });\n }\n}\nreturn out;\n"
},
"typeVersion": 2,
"alwaysOutputData": false
},
{
"id": "ee2d7f60-58be-475c-8d54-502f0bbf0aea",
"name": "Merge",
"type": "n8n-nodes-base.merge",
"position": [
1640,
-20
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "08d15341-fad1-41b4-8dd1-45c19ef66e19",
"name": "PayloadBuilder",
"type": "n8n-nodes-base.code",
"position": [
1860,
-20
],
"parameters": {
"jsCode": "const inItems = items.map(i => i.json);\n\n// 1) file object from FileUpload\nconst fileBlock = inItems.find(o => o.file?.uri)?.file;\nif (!fileBlock?.uri) throw new Error('Missing Gemini file URI');\n\n// 2) prompt/meta from PromptBuilder\nconst metaBlock = inItems.find(o => typeof o.prompt === 'string') || {};\nconst jm = metaBlock.jobMeta || {};\n\n// 3) prefer the ORIGINAL attachment mimeType (from ReadyAttachment);\n// fallback to the FileUpload's mimeType; final fallback to octet-stream.\nconst realMime =\n (inItems.find(o => o.mimeType)?.mimeType) || // ReadyAttachment\n fileBlock.mimeType || // FileUpload (if correct)\n 'application/octet-stream';\n\nconst parts = [];\nif (metaBlock.prompt) parts.push({ text: metaBlock.prompt });\nif (metaBlock.emailBody) parts.push({ text: `\\nEMAIL BODY:\\n${metaBlock.emailBody}` });\n\nparts.push({\n text:\n`JOB META:\nTitle: ${jm.title ?? ''}\nCode: ${jm.code ?? ''}\nDescription: ${jm.description ?? ''}\nSkills: ${jm.skills ?? ''}\nExperience: ${jm.experience ?? ''}\nLocation: ${jm.location ?? ''}`\n});\n\n// \u2705 pass the real mime type here\nparts.push({\n fileData: {\n fileUri: fileBlock.uri,\n mimeType: realMime\n }\n});\n\nconst body = {\n contents: [{ role: 'user', parts }],\n generationConfig: {\n temperature: 0.1,\n responseMimeType: 'application/json',\n responseSchema: {\n type: 'object',\n properties: {\n score: { type: 'integer', minimum: 1, maximum: 10 },\n explanation: { type: 'string' },\n candidateName: { type: 'string' },\n candidatePhone: { type: 'string' }\n },\n required: ['score','explanation']\n },\n },\n};\n\nreturn [{ json: { body } }];\n"
},
"typeVersion": 2
},
{
"id": "c18f2954-dced-4342-a0aa-66a81545f5ff",
"name": "FileUpload",
"type": "n8n-nodes-base.httpRequest",
"position": [
1420,
-20
],
"parameters": {
"url": "https://generativelanguage.googleapis.com/upload/v1beta/files?uploadType=media",
"method": "POST",
"options": {},
"sendBody": true,
"contentType": "binaryData",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "x-goog-api-key",
"value": "={{$env.GEMINI_API_KEY}}"
},
{
"name": "Content-Type",
"value": "={{ $json.mimeType || $binary.data.mimeType || 'application/octet-stream' }}"
}
]
},
"inputDataFieldName": "data"
},
"typeVersion": 4.2
},
{
"id": "14be41de-3ef7-4258-952d-affe1c439341",
"name": "ResponseGenerator",
"type": "n8n-nodes-base.httpRequest",
"position": [
2080,
-20
],
"parameters": {
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent",
"method": "POST",
"options": {},
"jsonBody": "={{$json.body}}",
"sendBody": true,
"sendQuery": true,
"sendHeaders": true,
"specifyBody": "json",
"queryParameters": {
"parameters": [
{
"name": "key",
"value": "={{$env.GEMINI_API_KEY}}"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"typeVersion": 4.2
},
{
"id": "e0dd0699-059a-49ba-942d-58f10aaec4b7",
"name": "ParseResponse",
"type": "n8n-nodes-base.code",
"position": [
2400,
-100
],
"parameters": {
"jsCode": "// Input is the ONE merged item\nconst j = items[0].json;\n\n// Parse model JSON\nconst text = j?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';\nlet data = {};\ntry { data = JSON.parse(text); } catch {}\n\nconst normPhone = s => s ? s.toString().trim().replace(/[^\\d+]/g,'') : '';\n\n// Build the exact columns we want for Sheets\nreturn [{\n json: {\n JobTitle: j.jobMeta?.title || j.jobMatch?.title || j.subject || '',\n CandidateName: (data.candidateName || '').trim() || (j.fromName || ''),\n CandidateEmail: j.fromEmail || '',\n CandidatePhoneNumber: normPhone(data.candidatePhone || ''),\n // prefer open link; fallback to direct or ID\n ResumeLink: j.webViewLink || j.webContentLink || (j.id ? `https://drive.google.com/file/d/${j.id}/view?usp=drivesdk` : ''),\n AIScore: data.score ?? null,\n AIExplanation: data.explanation ?? ''\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "f0c3079a-8cda-4a2c-bfde-0fc3d61037bd",
"name": "Merge1",
"type": "n8n-nodes-base.merge",
"position": [
1020,
-160
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "03a33b24-68a2-4f5f-afc2-36ef1cbdb0f7",
"name": "Google Drive",
"type": "n8n-nodes-base.googleDrive",
"position": [
1900,
-240
],
"parameters": {
"name": "={{\n (function () {\n const email = ($json.fromEmail || 'unknown').toLowerCase();\n const user = email.split('@')[0].replace(/[^a-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'');\n const d = new Date(Number($json.internalDate) || Date.now());\n const yyyy=d.getUTCFullYear(), mm=String(d.getUTCMonth()+1).padStart(2,'0'),\n dd=String(d.getUTCDate()).padStart(2,'0'),\n hh=String(d.getUTCHours()).padStart(2,'0'),\n mi=String(d.getUTCMinutes()).padStart(2,'0'),\n ss=String(d.getUTCSeconds()).padStart(2,'0');\n const ext = (($json.fileName || '').split('.').pop()\n || ($json.mimeType==='application/pdf'?'pdf':'bin')).toLowerCase();\n return `${user || 'unknown'}__${yyyy}-${mm}-${dd}-${hh}${mi}${ss}.${ext}`;\n })()\n}}\n",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive"
},
"options": {},
"folderId": {
"__rl": true,
"mode": "list",
"value": "1nI3oQ_hfa8eEDJ2aThNT45T7SfJ5jGzd",
"cachedResultUrl": "https://drive.google.com/drive/folders/1nI3oQ_hfa8eEDJ2aThNT45T7SfJ5jGzd",
"cachedResultName": "ResumeRadar"
}
},
"credentials": {
"googleDriveOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 3
},
{
"id": "2ef593bc-bd85-451b-91a4-e93c32739659",
"name": "CombineData",
"type": "n8n-nodes-base.merge",
"position": [
2240,
-200
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition",
"numberInputs": 4
},
"typeVersion": 3.2
},
{
"id": "3cf0a49e-df9a-4cca-b245-a46838748e69",
"name": "Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
2580,
-100
],
"parameters": {
"columns": {
"value": {
"Email": "={{ $json.CandidateEmail }}",
"Resume": "={{ '=HYPERLINK(\"' + $json.ResumeLink + '\",\"Download\")' }}",
"Contact": "={{ $json.CandidatePhoneNumber }}",
"AI Score": "={{ $json.AIScore }}",
"Job Title": "={{ $json.JobTitle }}",
"AI Explanation": "={{ $json.AIExplanation }}",
"Candidate Name": "={{ $json.CandidateName }}"
},
"schema": [
{
"id": "Job Title",
"type": "string",
"display": true,
"required": false,
"displayName": "Job Title",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Candidate Name",
"type": "string",
"display": true,
"required": false,
"displayName": "Candidate Name",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Email",
"type": "string",
"display": true,
"required": false,
"displayName": "Email",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Contact",
"type": "string",
"display": true,
"required": false,
"displayName": "Contact",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Resume",
"type": "string",
"display": true,
"required": false,
"displayName": "Resume",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "AI Score",
"type": "string",
"display": true,
"required": false,
"displayName": "AI Score",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "AI Explanation",
"type": "string",
"display": true,
"required": false,
"displayName": "AI Explanation",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1OGWk5UJdb2XrTS-5k9R0Q44d8HpvhdQW3AtzqkdWffg/edit#gid=0",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1OGWk5UJdb2XrTS-5k9R0Q44d8HpvhdQW3AtzqkdWffg",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1OGWk5UJdb2XrTS-5k9R0Q44d8HpvhdQW3AtzqkdWffg/edit?usp=drivesdk",
"cachedResultName": "ResumeRadar - Applicants"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.6
},
{
"id": "da71f4e7-21d7-403b-8e0b-d0ce2e9ee7b1",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
240,
-520
],
"parameters": {
"width": 500,
"height": 380,
"content": "# ResumeRadar\n\nThis workflow automates the resume screening process:\n1. Monitors Gmail for emails with PDF attachments\n2. Extracts job titles from email subjects\n3. Matches against job listings in Airtable\n4. Uses Gemini AI to analyze the resume against job requirements\n5. Saves data to Google Sheets and archives resumes in Google Drive\n\n## Setup Requirements:\n* Gmail, Google Drive, Google Sheets credentials\n* Airtable with job listings\n* Gemini API key in environment variables\n* Google Sheet for results"
},
"typeVersion": 1
},
{
"id": "1e73497d-9df7-403c-a89a-4555a76681f6",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
540,
300
],
"parameters": {
"width": 380,
"height": 240,
"content": "## Job Matching Logic\n\nThis code extracts job titles from email subjects and matches against Airtable listings using:\n- Regular expressions for common application phrases\n- Smart similarity matching with synonyms handling\n\nAdjust MIN_SCORE (currently 0.60) to make matching stricter or more lenient."
},
"typeVersion": 1
},
{
"id": "37c8a308-2fdd-42f4-8b97-d9a25c8437dd",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
1040,
-480
],
"parameters": {
"width": 420,
"height": 280,
"content": "## Resume Processing\n\nCurrently configured to accept PDF resumes only.\n\nTo support other formats:\n1. Edit the ALLOWED set in this node\n2. Add formats like 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n\nThis node also extracts sender information from email headers."
},
"typeVersion": 1
},
{
"id": "2528ce2a-089a-49be-9674-393d73d28c7f",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1180,
280
],
"parameters": {
"width": 420,
"height": 220,
"content": "## AI Prompt\n\nThis node creates a structured prompt for Gemini to:\n- Extract candidate name and phone from resume\n- Score candidate fit on a scale of 1-10\n- Provide a concise explanation\n\nCustomize the prompt to change evaluation criteria or output format."
},
"typeVersion": 1
},
{
"id": "2161802f-3bd9-4366-926c-717b38ec5257",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1720,
180
],
"parameters": {
"width": 420,
"height": 260,
"content": "## Gemini API Request Builder\n\nThis node prepares the payload for Gemini AI by:\n1. Combining the resume file with job context\n2. Structuring the prompt for consistent analysis\n3. Setting response schema for structured JSON output\n\nThe temperature setting (0.1) ensures consistent evaluations.\nAdjust the schema properties if you need different data points."
},
"typeVersion": 1
}
],
"active": true,
"settings": {
"callerPolicy": "workflowsFromSameOwner",
"executionOrder": "v1",
"saveExecutionProgress": true
},
"versionId": "62965170-c2de-4fc3-bffd-d2596db843ec",
"connections": {
"If": {
"main": [
[
{
"node": "Merge1",
"type": "main",
"index": 1
},
{
"node": "PromptBuilder",
"type": "main",
"index": 0
}
],
[]
]
},
"Merge": {
"main": [
[
{
"node": "PayloadBuilder",
"type": "main",
"index": 0
}
]
]
},
"Merge1": {
"main": [
[
{
"node": "ReadyAttachment",
"type": "main",
"index": 0
}
]
]
},
"Airtable": {
"main": [
[
{
"node": "EmailMatcher",
"type": "main",
"index": 0
}
]
]
},
"FileUpload": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"CombineData": {
"main": [
[
{
"node": "ParseResponse",
"type": "main",
"index": 0
}
]
]
},
"EmailMatcher": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"Google Drive": {
"main": [
[
{
"node": "CombineData",
"type": "main",
"index": 0
}
]
]
},
"Gmail Trigger": {
"main": [
[
{
"node": "Airtable",
"type": "main",
"index": 0
},
{
"node": "Merge1",
"type": "main",
"index": 0
}
]
]
},
"ParseResponse": {
"main": [
[
{
"node": "Google Sheets",
"type": "main",
"index": 0
}
]
]
},
"PromptBuilder": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
},
{
"node": "CombineData",
"type": "main",
"index": 3
}
]
]
},
"PayloadBuilder": {
"main": [
[
{
"node": "ResponseGenerator",
"type": "main",
"index": 0
}
]
]
},
"ReadyAttachment": {
"main": [
[
{
"node": "FileUpload",
"type": "main",
"index": 0
},
{
"node": "Google Drive",
"type": "main",
"index": 0
},
{
"node": "CombineData",
"type": "main",
"index": 1
}
]
]
},
"ResponseGenerator": {
"main": [
[
{
"node": "CombineData",
"type": "main",
"index": 2
}
]
]
}
}
}
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.
airtableTokenApigmailOAuth2googleDriveOAuth2ApigoogleSheetsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow is perfect for recruiters, HR professionals, and startup founders who receive job applications by email and want to automate the process of parsing, matching, and evaluating resumes. If you want to save time by having candidate data and AI scores automatically…
Source: https://n8n.io/workflows/7469/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Automatically process invoices and receipts using Gemini OCR, extracting data directly into Google Sheets from multiple sources including Google Drive, Gmail, and Telegram. This powerful workflow ensu
This is an elite enterprise-grade solution for Accounts Payable and Finance Ops teams. It automates the high-volume extraction of unstructured data from PDF invoices using the HTML to PDF (Parse PDF t
"This workflow builds an AI-powered resume screening system inside n8n. It begins with Gmail and Form triggers that capture incoming resumes, then uploads each file to Google Drive for storage. The re
This template is ideal for solo store owners, eCommerce marketers, automation beginners, or anyone using Shopify and Gmail who wants to recover lost revenue without coding.
PCN. Uses googleSheets, httpRequest, @n-octo-n/n8n-nodes-json-database, itemLists. Event-driven trigger; 60 nodes.