{
  "id": "wIirge1rt4E3EquqWx9sA",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "CV screening",
  "tags": [],
  "nodes": [
    {
      "id": "f2d12e48-2668-4535-acc3-128f3bd7d1c3",
      "name": "Sticky Note \u2014 Header",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3344,
        976
      ],
      "parameters": {
        "width": 656,
        "height": 1212,
        "content": "## \ud83d\udcc4 CV & Resume Screener\n### Powered by Gmail \u00b7 easybits.tech \u00b7 Airtable \u00b7 Slack\n\nAutomatically reads CVs from Gmail, extracts structured candidate data using AI, scores each applicant against your open job requirements, saves results to Airtable, and notifies your recruiter on Slack \u2014 all without manual review.\n\n---\n\n### \u2705 Prerequisites\nBefore activating this workflow, complete the following setup steps:\n\n**1. Gmail**\n- Connect your Gmail account via OAuth2\n- Applied to: Gmail Trigger node + Download Email node\n\n**2. easybits.tech API Key**\n- Sign up at [easybits.tech](https://easybits.tech) to get your free API key\n- Add the key to the `httpCustomAuth` credential in n8n\n- Update the pipeline ID in the `easybits: Extract CV Data` node\n\n**3. Airtable**\n- Create a base with two tables: **Jobs** and **Candidates**\n- Run the companion **Job Description Parser** workflow first to populate the Jobs table\n- Update the Base ID and Table IDs in all Airtable nodes\n\n**4. Slack**\n- Create two Slack Incoming Webhooks (one per channel or status)\n- Paste each webhook URL into the respective `Slack: Notify` nodes\n\n---\n\n### \u2699\ufe0f How It Works\n1. **Gmail Trigger** polls for unread emails with CV attachments\n2. **Download & Prepare** fetches the email, extracts the PDF, and base64-encodes it\n3. **easybits Extract** sends the PDF to the AI pipeline and returns clean JSON\n4. **Fetch Open Job** pulls the first open role from your Airtable Jobs table\n5. **Score CV** compares candidate skills against the job's tech stack requirements\n6. **Route by Score** splits candidates: \u2265 60% \u2192 Shortlisted \u00b7 < 60% \u2192 In Review\n7. **Save & Notify** creates the candidate record in Airtable and posts a Slack summary\n\n---\n\n### \ud83d\udd27 Customisation\n- **Score threshold**: Change the value `60` in the IF node (Step 6)\n- **Email filter**: Edit the Gmail search query in the trigger node\n- **Scoring logic**: Adjust the Code node in Step 5 to weight skills differently\n- **Fields**: Add or remove fields in the Airtable nodes to match your Candidates table schema"
      },
      "typeVersion": 1
    },
    {
      "id": "3a1f6b36-74eb-493e-a748-71dc842f2f8f",
      "name": "Sticky Note \u2014 Step 1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4021,
        1396
      ],
      "parameters": {
        "color": 7,
        "width": 406,
        "height": 428,
        "content": "### Step 1 \u00b7 Get Email & Attachment\n\n\ud83d\udce7 **Gmail Trigger** polls every minute for unread emails matching:\n`has:attachment subject:(Application OR CV OR Resume OR Bewerbung)`\n\n**Setup required:**\n- Connect Gmail via OAuth2\n- Adjust the `after:` date and subject keywords to match your inbox"
      },
      "typeVersion": 1
    },
    {
      "id": "f3118ccb-bfd0-43d4-8388-f7ce02dfa5a0",
      "name": "Sticky Note \u2014 Step 2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4477,
        1396
      ],
      "parameters": {
        "color": 7,
        "width": 390,
        "height": 428,
        "content": "### Step 2 \u00b7 Download & Prepare PDF\n\n\ud83d\udce5 Downloads the full email via Gmail API, extracts the PDF attachment, and base64-encodes it.\n\n\u26a0\ufe0f **File size limit:** PDFs over ~600 KB will be rejected by the Code node to stay within API limits.\n\n**Setup required:**\n- Same Gmail OAuth2 credential as Step 1\n- Attachment index `parts[1]` assumes the PDF is the second part \u2014 adjust if needed"
      },
      "typeVersion": 1
    },
    {
      "id": "32fa7ad3-fbc7-4d06-bbd9-5a74fbbdb07e",
      "name": "Sticky Note \u2014 Step 3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4891,
        1268
      ],
      "parameters": {
        "color": 7,
        "width": 234,
        "height": 556,
        "content": "### Step 3 \u00b7 Extract CV with easybits\n\n\ud83e\udd16 Sends the base64 PDF to the easybits.tech AI pipeline and returns structured JSON (name, email, skills, experience, education, etc.)\n\n**Setup required:**\n- Add your API key to the `httpCustomAuth` credential\n- Replace the pipeline ID in the URL with your own CV extraction pipeline ID\n- Get your free key at [easybits.tech](https://easybits.tech)"
      },
      "typeVersion": 1
    },
    {
      "id": "6607e358-4b04-4712-aef0-0f3bd26d7f8b",
      "name": "Sticky Note \u2014 Step 4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5119,
        1220
      ],
      "parameters": {
        "color": 7,
        "width": 226,
        "height": 604,
        "content": "### Step 4 \u00b7 Fetch Open Job\n\n\ud83d\udccb Queries Airtable for the first record where `status = \"Open\"` to get the active job's requirements.\n\n**Setup required:**\n- Update the **Airtable Base ID** and **Jobs table ID**\n- Ensure the Jobs table has a `status` field and a `tech_stack` field (comma-separated or array)\n- Run the **Job Description Parser** workflow first to populate this table"
      },
      "typeVersion": 1
    },
    {
      "id": "1f738c86-57e3-4625-8c3c-569f0bafbc15",
      "name": "Sticky Note \u2014 Step 5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5348,
        1220
      ],
      "parameters": {
        "color": 7,
        "width": 216,
        "height": 604,
        "content": "### Step 5 \u00b7 Score CV vs Job\n\n\ud83e\uddee Compares the candidate's extracted CV text against each item in the job's `tech_stack` field.\n\n**Scoring formula:**\n`score = matched skills \u00f7 total required skills \u00d7 100`\n\n**Customise:**\n- Edit this Code node to add weighted scoring (e.g. must-have vs nice-to-have)\n- Add extra matching fields beyond tech_stack"
      },
      "typeVersion": 1
    },
    {
      "id": "172948e1-bb9f-4419-9edd-c764b667fc78",
      "name": "Sticky Note \u2014 Step 6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5581,
        1220
      ],
      "parameters": {
        "color": 7,
        "width": 198,
        "height": 604,
        "content": "### Step 6 \u00b7 Route by Score\n\n\ud83d\udd00 Routes candidates based on their match score:\n\n- \u2705 **\u2265 60%** \u2192 `true` branch \u2192 **Shortlisted**\n- \ud83d\udd0e **< 60%** \u2192 `false` branch \u2192 **In Review**\n\n**Customise:**\n- Change the threshold value `60` in this IF node to raise or lower the bar\n- Default was 80% \u2014 lowered to 60% to cast a wider net"
      },
      "typeVersion": 1
    },
    {
      "id": "7f530826-8eae-4ed3-8a2a-f559c8629759",
      "name": "Sticky Note \u2014 Step 7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5788,
        1188
      ],
      "parameters": {
        "color": 7,
        "width": 456,
        "height": 732,
        "content": "### Step 7 \u00b7 Save to Airtable & Notify on Slack\n\n\ud83d\udcbe Creates a candidate record in your Airtable **Candidates** table with status set to either `Shortlisted` or `In Review`.\n\n\ud83d\udce3 Posts a formatted summary card to Slack with the candidate name, match score, job title, matched skills, and LinkedIn URL.\n\n**Setup required:**\n- Update the **Airtable Base ID** and **Candidates table ID** in both Airtable nodes\n- Paste your **Slack webhook URLs** into both HTTP Request (Slack) nodes\n- Ensure your Candidates table fields match the column names mapped in the Airtable nodes"
      },
      "typeVersion": 1
    },
    {
      "id": "88dce86a-3ee8-46ec-8e93-62ae9ca6dd26",
      "name": "Gmail: Watch CV Emails",
      "type": "n8n-nodes-base.gmailTrigger",
      "position": [
        4064,
        1664
      ],
      "parameters": {
        "simple": false,
        "filters": {
          "q": "has:attachment subject:(Application OR CV OR Bewerbung OR Resume) after:2026/03/12",
          "readStatus": "unread"
        },
        "options": {},
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        }
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "80d51d67-5511-406a-9f09-35125123c900",
      "name": "Download Email & Attachment",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4288,
        1664
      ],
      "parameters": {
        "url": "=https://gmail.googleapis.com/gmail/v1/users/me/messages/{{ $json.id }}?format=full",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "gmailOAuth2"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "ff0c2db5-ea81-4070-bf93-d82d2a131937",
      "name": "Convert PDF to Base64",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4512,
        1664
      ],
      "parameters": {
        "url": "=https://gmail.googleapis.com/gmail/v1/users/me/messages/{{ $json.id }}/attachments/{{ $json.payload.parts[1].body.attachmentId }}",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "gmailOAuth2"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "b7020e7c-6334-4dd0-a669-6699b5eb65fe",
      "name": "Prepare PDF Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        4736,
        1664
      ],
      "parameters": {
        "jsCode": "const base64String = $input.first().json.data;\nconst sizeKB = Math.round(base64String.length / 1024);\n\nif (base64String.length > 800000) {\n  throw new Error(`CV too large: ${sizeKB}KB. Please use a PDF under 600KB.`);\n}\n\nreturn [{\n  json: {\n    base64: base64String,\n    sizeKB: sizeKB\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "aaf48e8a-6c2f-4a08-9d63-463602e13a30",
      "name": "easybits: Extract CV Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4960,
        1664
      ],
      "parameters": {
        "url": "https://extractor.easybits.tech/api/pipelines/8ppfebcZMIysqGMaUzli",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"files\": [\"data:application/pdf;base64,{{ $json.base64 }}\"]\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpCustomAuth"
      },
      "credentials": {
        "httpCustomAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "3e929414-7fe0-47f8-bc5c-68cd6b2c11f3",
      "name": "Airtable - Fetch Open Job",
      "type": "n8n-nodes-base.airtable",
      "position": [
        5184,
        1664
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appN5gwc0tof1nc0Y"
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "tblgftBvUd3PgfgKM",
          "cachedResultUrl": "https://airtable.com/appN5gwc0tof1nc0Y/tblgftBvUd3PgfgKM",
          "cachedResultName": "Jobs"
        },
        "options": {},
        "operation": "search",
        "filterByFormula": "status = \"Open\""
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "3a4f786f-7ad4-4934-b02a-ce28e77a1a92",
      "name": "Score CV vs Job Description ",
      "type": "n8n-nodes-base.code",
      "position": [
        5408,
        1664
      ],
      "parameters": {
        "jsCode": "// 1. Get data from the previous nodes\nconst extraction = $node[\"easybits: Extract CV Data\"].json.data;\n// We use the first item from Airtable (index 0)\nconst job = $node[\"Airtable - Fetch Open Job\"].json; \n\n// Combine everything the CV parser found into one text block\nconst cvText = JSON.stringify(extraction).toLowerCase();\n\n// 2. Score against the TECH STACK instead of the long phrases\n// This list has \"Python\", \"Figma\", \"Clay\", etc.\nconst techRequirements = job.tech_stack || [];\n\nlet matches = 0;\nlet foundItems = [];\n\ntechRequirements.forEach(skill => {\n  // Clean up the skill name and check if it's in the CV\n  const cleanSkill = skill.toLowerCase().trim();\n  if (cvText.includes(cleanSkill)) {\n    matches++;\n    foundItems.push(skill);\n  }\n});\n\n// 3. Calculate Score\nconst total = techRequirements.length || 1;\nconst finalScore = Math.round((matches / total) * 100);\n\nreturn {\n  match_score: finalScore,\n  matched_count: matches,\n  total_possible: total,\n  details: foundItems.join(\", \"),\n  name: extraction.candidate_name || \"Jordan Reed\",\n  email: extraction.contact_email || \"N/A\",\n  linkedin: extraction.linkedin_url || \"N/A\"\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "0ae553ee-ca8d-44f0-bb71-8a38be3e34f1",
      "name": "Route by Score ",
      "type": "n8n-nodes-base.if",
      "position": [
        5632,
        1664
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "d89ea650-898a-46ad-8782-a1f8d305ada0",
              "operator": {
                "type": "number",
                "operation": "gte"
              },
              "leftValue": "={{ $json.matchScore }}",
              "rightValue": 60
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "f9c742df-dd7c-4a27-a223-868a59333725",
      "name": "Airtable: Save Reviewed Candidate",
      "type": "n8n-nodes-base.airtable",
      "position": [
        5856,
        1568
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appN5gwc0tof1nc0Y"
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "tblTYVUhoy9QKNnmT",
          "cachedResultUrl": "https://airtable.com/appN5gwc0tof1nc0Y/tblTYVUhoy9QKNnmT",
          "cachedResultName": "Candidates"
        },
        "columns": {
          "value": {
            "skills": "={{ $json.skills.join(', ') }}",
            "status": "Shortlisted",
            "languages": "={{ $json.languages.join(', ') }}",
            "match_score": "={{ $json.matchScore }}",
            "availability": "={{ $json.availability }}",
            "linkedin_url": "={{ $json.linkedin_url }}",
            "contact_email": "={{ $json.contact_email }}",
            "contact_phone": "={{ $json.contact_phone }}",
            "candidate_name": "={{ $json.candidate_name }}",
            "highest_degree": "={{ $json.highest_degree }}",
            "contact_address": "={{ $json.contact_address }}",
            "current_job_title": "={{ $json.current_job_title }}",
            "years_of_experience": "={{ $json.years_of_experience }}",
            "current_company_name": "={{ $json.current_company_name }}",
            "current_employment_start_date": "={{ $json.current_employment_start_date }}",
            "highest_education_institution": "={{ $json.highest_education_institution }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "typecast": true
        },
        "operation": "create"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "e9ffc87f-0677-44b5-8b55-a4184a1b70b8",
      "name": "Slack: Notify For Review",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6080,
        1568
      ],
      "parameters": {
        "url": "https://hooks.slack.com/services/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",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"text\": \"\ud83d\udc40 *Review Needed* \u2014 New Candidate!\",\n  \"blocks\": [\n    {\n      \"type\": \"section\",\n      \"fields\": [\n        { \"type\": \"mrkdwn\", \"text\": \"*Name:*\\n{{ $('Score CV vs Job Description ').item.json.candidate_name }}\" },\n        { \"type\": \"mrkdwn\", \"text\": \"*Score:*\\n{{ $('Score CV vs Job Description ').item.json.matchScore }}%\" },\n        { \"type\": \"mrkdwn\", \"text\": \"*Title:*\\n{{ $('Score CV vs Job Description ').item.json.current_job_title }}\" },\n        { \"type\": \"mrkdwn\", \"text\": \"*Company:*\\n{{ $('Score CV vs Job Description ').item.json.current_company_name }}\" },\n        { \"type\": \"mrkdwn\", \"text\": \"*Email:*\\n{{ $('Score CV vs Job Description ').item.json.contact_email }}\" },\n        { \"type\": \"mrkdwn\", \"text\": \"*Matched Job:*\\n{{ $('Score CV vs Job Description ').item.json.jobTitle }}\" }\n      ]\n    }\n  ]\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "e8e35dcf-a86d-479d-84d9-8cbabc8d1801",
      "name": "Slack: Notify For Review1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6080,
        1760
      ],
      "parameters": {
        "url": "https://hooks.slack.com/services/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",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"text\": \"\ud83d\ude80 *New Candidate Match: Easybits.tech*\\n*Name:* {{ $node['Score CV vs Job Description '].json.name }}\\n*Score:* {{ $node['Score CV vs Job Description '].json.match_score }}%\\n*Role:* {{ $node['Airtable - Fetch Open Job'].json.job_title }}\\n*Matched:* {{ $node['Score CV vs Job Description '].json.details }}\\n*LinkedIn:* {{ $node['Score CV vs Job Description '].json.linkedin }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "00384117-dbd3-44df-8db5-aec96644c251",
      "name": "Airtable: Save Review Candidate",
      "type": "n8n-nodes-base.airtable",
      "position": [
        5856,
        1760
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appN5gwc0tof1nc0Y"
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": "tblTYVUhoy9QKNnmT",
          "cachedResultUrl": "https://airtable.com/appN5gwc0tof1nc0Y/tblTYVUhoy9QKNnmT",
          "cachedResultName": "Candidates"
        },
        "columns": {
          "value": {
            "skills": "={{ $json.skills.join(', ') }}",
            "status": "In Review",
            "languages": "={{ $json.languages.join(', ') }}",
            "match_score": "={{ $json.matchScore }}",
            "availability": "={{ $json.availability }}",
            "linkedin_url": "={{ $json.linkedin_url }}",
            "contact_email": "={{ $json.contact_email }}",
            "contact_phone": "={{ $json.contact_phone }}",
            "candidate_name": "={{ $json.candidate_name }}",
            "highest_degree": "={{ $json.highest_degree }}",
            "contact_address": "={{ $json.contact_address }}",
            "current_job_title": "={{ $json.current_job_title }}",
            "years_of_experience": "={{ $json.years_of_experience }}",
            "current_company_name": "={{ $json.current_company_name }}",
            "current_employment_start_date": "={{ $json.current_employment_start_date }}",
            "highest_education_institution": "={{ $json.highest_education_institution }}"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "typecast": true
        },
        "operation": "create"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    }
  ],
  "active": true,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "ccaf6584-bb29-4051-ae84-79f28761c385",
  "connections": {
    "Route by Score ": {
      "main": [
        [
          {
            "node": "Airtable: Save Reviewed Candidate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Airtable: Save Review Candidate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare PDF Payload": {
      "main": [
        [
          {
            "node": "easybits: Extract CV Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert PDF to Base64": {
      "main": [
        [
          {
            "node": "Prepare PDF Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail: Watch CV Emails": {
      "main": [
        [
          {
            "node": "Download Email & Attachment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Fetch Open Job": {
      "main": [
        [
          {
            "node": "Score CV vs Job Description ",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "easybits: Extract CV Data": {
      "main": [
        [
          {
            "node": "Airtable - Fetch Open Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Email & Attachment": {
      "main": [
        [
          {
            "node": "Convert PDF to Base64",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score CV vs Job Description ": {
      "main": [
        [
          {
            "node": "Route by Score ",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable: Save Review Candidate": {
      "main": [
        [
          {
            "node": "Slack: Notify For Review1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable: Save Reviewed Candidate": {
      "main": [
        [
          {
            "node": "Slack: Notify For Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}