This workflow follows the Airtable → Gmail 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 →
{
"name": "Recruitment Ops Automation Suite (EU Placements)",
"nodes": [
{
"id": "read-me-first",
"name": "Read me first",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
-900
],
"parameters": {
"content": "## Read me first\n- 7 flows, top to bottom, all in this one import: 1 candidate applications from the careers form and the CV inbox, 2 new vacancies from employers, 3 half-hourly screening and matching, 4 interview booking plus next-day reminders, 5 daily document chase and quiet-candidate digest, 6 Monday operations report, 7 monthly GDPR retention sweep.\n- Create these credentials by name before you activate anything: \"Airtable Personal Access Token\", \"Gmail account\", \"Google Calendar (OAuth2)\", \"Slack account\", \"Anthropic API\".\n- Your Airtable base needs two tables. Candidates: Name, Email, Phone, Trade, Country, Languages, Years Experience, Availability, CV Link, CV Summary, Status, Source, Applied On, Last Activity, Match Score, Matched Vacancy, Match Notes, Documents Received, Documents Missing, Reminders Sent, Last Reminder, Interview Date, Recruiter, Recruiter Email, Consent Date, Notes. Vacancies: Vacancy Key, Role, Employer, Employer Contact, Employer Email, Country, City, Trade, Headcount, Languages Required, Certifications Required, Pay Rate, Start Date, Status, Notes, Created On. Status on Candidates: New, Screened, Shortlisted, Interview booked, Placed, Not a fit, Archived. Status on Vacancies: Open, On hold, Filled.\n- Then fill every REPLACE_WITH placeholder: AIRTABLE_BASE_ID, CANDIDATES_TABLE_ID, VACANCIES_TABLE_ID, RECRUITING_SLACK_CHANNEL, COMPANY_NAME, TEAM_EMAIL, APPLICATIONS_EMAIL, INTERVIEW_CALENDAR_ID, DATA_LEAD_EMAIL. That is nine values in total. Three webhook URLs come out of this: /candidate-application for the careers form, /new-vacancy for the employer form, /book-interview for the recruiter booking action.\n- The rules you will want to change sit in commented blocks at the top of the code nodes: match threshold in flow 3, reminder spacing and escalation count in flow 5, report window in flow 6, retention months in flow 7.\n- What the file cannot decide for you: which of the seven flows to switch on first. That comes from looking at how the desk actually runs today. Turn one on, let it work against real records for a week, then add the next.\n- This is a demonstration build. The company, the candidates and the vacancies in it are made up, and nothing here is a copy of anyone's production file.",
"height": 620,
"width": 900
}
},
{
"id": "sticky-1",
"name": "Flow 1: New candidate applications",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
-180
],
"parameters": {
"content": "## Flow 1: New candidate applications\n- The careers form and the applications inbox both land as one candidate record.\n- Claude reads the CV and fills in trade, years, languages, certificates, summary.\n- Credentials: Gmail account, Anthropic API, Airtable Personal Access Token.\n- Fill in REPLACE_WITH_APPLICATIONS_EMAIL, REPLACE_WITH_COMPANY_NAME, REPLACE_WITH_AIRTABLE_BASE_ID, REPLACE_WITH_CANDIDATES_TABLE_ID.\n- Writes Candidates: Name, Email, Phone, Trade, Country, Languages, Years Experience, Availability, CV Link, CV Summary, Status New, Source, Applied On, Last Activity, Consent Date. Tune APPLICANT_WINS at the top of Turn the CV read into candidate fields to decide whether the form or the CV wins.",
"height": 300,
"width": 700
}
},
{
"id": "candidate-applies-website",
"name": "A candidate applies on the website",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
0
],
"parameters": {
"httpMethod": "POST",
"path": "candidate-application",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "tidy-website-application",
"name": "Tidy up the website application",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"parameters": {
"jsCode": "// ---- Tunable ----\n// Set to false if you would rather trust the CV read over what the form says.\nconst CONSENT_DEFAULT = true;\nconst SOURCE_LABEL = 'Careers page';\n// -----------------\n\nconst out = [];\n\nconst str = (v) => (v === undefined || v === null ? '' : String(v).trim());\n\nfor (const item of $input.all()) {\n const raw = item.json || {};\n const first = raw.body || raw.data || raw;\n const body = first.body || first.data || first;\n\n const pick = (...keys) => {\n for (const k of keys) {\n const v = body[k];\n if (v !== undefined && v !== null && str(v) !== '') return str(v);\n }\n return '';\n };\n\n const name = pick('name', 'Name', 'fullName', 'full_name', 'firstName') +\n (pick('lastName', 'last_name') && !pick('name', 'Name', 'fullName', 'full_name')\n ? ' ' + pick('lastName', 'last_name')\n : '');\n\n const email = pick('email', 'Email', 'emailAddress', 'email_address').toLowerCase();\n\n const rawPhone = pick('phone', 'Phone', 'phoneNumber', 'phone_number', 'mobile', 'tel');\n const digits = rawPhone.replace(/[^0-9]/g, '');\n const phone = digits ? '+' + digits : '';\n\n let languages = body.languages || body.Languages || body.language || '';\n if (Array.isArray(languages)) {\n languages = languages.map(str).filter(Boolean).join(', ');\n } else {\n languages = str(languages);\n }\n\n let consent = body.consent;\n if (consent === undefined) consent = body.consentGiven;\n if (consent === undefined) consent = body.consent_given;\n if (consent === undefined) consent = body.gdprConsent;\n if (consent === undefined) consent = body.marketingConsent;\n\n let consentGiven = CONSENT_DEFAULT;\n if (consent !== undefined && consent !== null && str(consent) !== '') {\n const c = str(consent).toLowerCase();\n consentGiven = !(c === 'false' || c === 'no' || c === '0' || c === 'off' || c === 'unchecked');\n }\n\n out.push({\n json: {\n name: name.trim(),\n email: email,\n phone: phone,\n trade: pick('trade', 'Trade', 'role', 'job', 'jobTitle', 'profession'),\n country: pick('country', 'Country', 'location', 'basedIn'),\n languages: languages,\n yearsExperience: pick('yearsExperience', 'years_experience', 'years', 'experience'),\n availability: pick('availability', 'Availability', 'startDate', 'available', 'availableFrom'),\n cvLink: pick('cvLink', 'cv_link', 'cvUrl', 'cv_url', 'resumeLink', 'fileUrl', 'attachmentUrl'),\n cvText: pick('cvText', 'cv_text', 'cv', 'coverLetter', 'cover_letter', 'coverText', 'message', 'about', 'notes'),\n source: SOURCE_LABEL,\n consentGiven: consentGiven\n }\n });\n}\n\nreturn out;\n"
}
},
{
"id": "reply-careers-form",
"name": "Reply to the careers form",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
440,
0
],
"parameters": {
"respondWith": "json",
"responseBody": "={\"received\": true, \"message\": \"Thanks, we have your application. A recruiter will be in touch.\"}",
"options": {}
}
},
{
"id": "watch-applications-inbox",
"name": "Watch the applications inbox",
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1.4,
"position": [
0,
320
],
"parameters": {
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"simple": true,
"filters": {
"q": "has:attachment in:inbox to:REPLACE_WITH_APPLICATIONS_EMAIL"
}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "tidy-emailed-application",
"name": "Tidy up the emailed application",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
320
],
"parameters": {
"jsCode": "// ---- Tunable ----\n// Label written to the Source field for CVs that arrive by email.\nconst SOURCE_LABEL = 'Email';\n// -----------------\n\nconst out = [];\n\nconst str = (v) => (v === undefined || v === null ? '' : String(v).trim());\n\n// Handles \"Ana Silva <ana@example.com>\" as well as a bare address.\nconst splitFrom = (text) => {\n const t = str(text);\n const m = t.match(/^(.*)<([^>]+)>\\s*$/);\n if (m) {\n return { name: m[1].replace(/[\"']/g, '').trim(), email: m[2].trim() };\n }\n return { name: '', email: t };\n};\n\nfor (const item of $input.all()) {\n const mail = item.json || {};\n const from = mail.from;\n\n let name = '';\n let email = '';\n\n if (from && typeof from === 'object' && Array.isArray(from.value) && from.value.length) {\n name = str(from.value[0].name);\n email = str(from.value[0].address);\n } else if (from && typeof from === 'object' && from.text) {\n const parts = splitFrom(from.text);\n name = parts.name;\n email = parts.email;\n } else if (typeof from === 'string') {\n const parts = splitFrom(from);\n name = parts.name;\n email = parts.email;\n } else if (mail.headers && mail.headers.from) {\n const parts = splitFrom(mail.headers.from);\n name = parts.name;\n email = parts.email;\n }\n\n email = email.toLowerCase();\n\n // Fall back to the address local part so the record is never nameless.\n if (!name && email) {\n name = email.split('@')[0].replace(/[._-]+/g, ' ').trim();\n }\n\n const subject = str(mail.subject);\n const bodyText = str(mail.text) || str(mail.snippet) || str(mail.textPlain) || str(mail.textAsHtml);\n\n const cvText = [subject, bodyText].filter(Boolean).join('\\n\\n');\n\n out.push({\n json: {\n name: name,\n email: email,\n phone: '',\n trade: '',\n country: '',\n languages: '',\n yearsExperience: '',\n availability: '',\n cvLink: '',\n cvText: cvText,\n source: SOURCE_LABEL,\n consentGiven: true\n }\n });\n}\n\nreturn out;\n"
}
},
{
"id": "claude-reads-cv",
"name": "Have Claude read the CV",
"type": "@n8n/n8n-nodes-langchain.anthropic",
"typeVersion": 1,
"position": [
660,
160
],
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"mode": "id",
"value": "claude-sonnet-5"
},
"messages": {
"values": [
{
"role": "user",
"content": "=You are reading a job application for REPLACE_WITH_COMPANY_NAME, a recruitment company that places skilled workers with employers across Europe.\n\nApplicant text:\n{{ $json.cvText }}\n\nWhat the applicant already told us on the form:\nName: {{ $json.name }}\nTrade: {{ $json.trade }}\nCountry: {{ $json.country }}\nLanguages: {{ $json.languages }}\nYears of experience: {{ $json.yearsExperience }}\nAvailability: {{ $json.availability }}\n\nGive back these keys:\n- trade: their main trade or job title\n- yearsExperience: a number\n- languages: array of language names\n- certifications: array of certificate or licence names\n- country: where they live now\n- availability: when they can start\n- summary: two sentences about this person, written for a recruiter\n\nShape:\n{ \"trade\": \"\", \"yearsExperience\": 0, \"languages\": [], \"certifications\": [], \"country\": \"\", \"availability\": \"\", \"summary\": \"\" }\n\nRules:\n- Use only what the text says. Never guess.\n- If the text does not say, use an empty string, an empty array, or 0.\n- Plain register. Short sentences. Say the thing.\n- No corporate words such as streamline, leverage, seamless, robust, reach out.\n- No emojis. No em dashes. No exclamation marks.\n- Do not write lists of three for rhythm.\n- Do not tack \"-ing\" tails onto sentences.\n\nReply with JSON only. No preamble, no code fence."
}
]
},
"options": {}
},
"credentials": {
"anthropicApi": {
"name": "<your credential>"
}
}
},
{
"id": "cv-read-to-fields",
"name": "Turn the CV read into candidate fields",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
160
],
"parameters": {
"jsCode": "// ---- Tunable ----\n// When true, anything the applicant typed on the form beats what Claude read off the CV.\nconst APPLICANT_WINS = true;\n// Every new candidate lands on this status.\nconst NEW_STATUS = 'New';\n// -----------------\n\nconst str = (v) => (v === undefined || v === null ? '' : String(v).trim());\n\nconst toList = (v) => {\n if (Array.isArray(v)) return v.map(str).filter(Boolean);\n if (str(v)) return str(v).split(',').map((s) => s.trim()).filter(Boolean);\n return [];\n};\n\nconst out = [];\nconst items = $input.all();\n\nfor (let i = 0; i < items.length; i++) {\n const item = items[i];\n\n let read = {};\n try {\n const content = item.json && item.json.content ? item.json.content : [];\n const text = content && content[0] ? content[0].text : '';\n let cleaned = String(text || '').replace(/^[^{]*/, '');\n const end = cleaned.lastIndexOf('}');\n if (end > -1) cleaned = cleaned.slice(0, end + 1);\n read = JSON.parse(cleaned);\n } catch (e) {\n read = {};\n }\n if (!read || typeof read !== 'object' || Array.isArray(read)) read = {};\n\n // The item came in from one of the two entry paths. Try the form path first.\n let applicant = {};\n try {\n applicant = $('Tidy up the website application').all()[i].json || {};\n } catch (e) {\n applicant = {};\n }\n if (!applicant || !str(applicant.email)) {\n try {\n applicant = $('Tidy up the emailed application').all()[i].json || {};\n } catch (e) {\n applicant = applicant || {};\n }\n }\n if (!applicant || typeof applicant !== 'object') applicant = {};\n\n const prefer = (fromForm, fromCv) => {\n const a = str(fromForm);\n const b = str(fromCv);\n if (APPLICANT_WINS) return a || b;\n return b || a;\n };\n\n const formLanguages = toList(applicant.languages);\n const cvLanguages = toList(read.languages);\n const languages = APPLICANT_WINS\n ? (formLanguages.length ? formLanguages : cvLanguages)\n : (cvLanguages.length ? cvLanguages : formLanguages);\n\n let years = Number(str(applicant.yearsExperience).replace(/[^0-9.]/g, ''));\n if (!years || isNaN(years)) years = Number(read.yearsExperience);\n if (!years || isNaN(years)) years = 0;\n\n const now = new Date();\n const nowIso = now.toISOString();\n\n const consentGiven = applicant.consentGiven === undefined ? true : Boolean(applicant.consentGiven);\n\n out.push({\n json: {\n name: str(applicant.name),\n email: str(applicant.email).toLowerCase(),\n phone: str(applicant.phone),\n trade: prefer(applicant.trade, read.trade),\n country: prefer(applicant.country, read.country),\n languages: languages,\n certifications: toList(read.certifications),\n yearsExperience: years,\n availability: prefer(applicant.availability, read.availability),\n cvLink: str(applicant.cvLink),\n cvSummary: str(read.summary),\n status: NEW_STATUS,\n source: str(applicant.source) || 'Careers page',\n appliedOn: nowIso,\n lastActivity: nowIso,\n consentDate: consentGiven ? nowIso : ''\n }\n });\n}\n\nreturn out;\n"
}
},
{
"id": "save-the-candidate",
"name": "Save the candidate",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
1100,
160
],
"parameters": {
"resource": "record",
"operation": "upsert",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_CANDIDATES_TABLE_ID"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Name": "={{ $json.name }}",
"Email": "={{ $json.email }}",
"Phone": "={{ $json.phone }}",
"Trade": "={{ $json.trade }}",
"Country": "={{ $json.country }}",
"Languages": "={{ ($json.languages || []).join(', ') }}",
"Years Experience": "={{ $json.yearsExperience }}",
"Availability": "={{ $json.availability }}",
"CV Link": "={{ $json.cvLink }}",
"CV Summary": "={{ $json.cvSummary }}",
"Status": "New",
"Source": "={{ $json.source }}",
"Applied On": "={{ $json.appliedOn }}",
"Last Activity": "={{ $json.lastActivity }}",
"Consent Date": "={{ $json.consentDate }}"
},
"matchingColumns": [
"Email"
],
"schema": []
},
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "send-candidate-confirmation",
"name": "Send the candidate a confirmation",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
1320,
160
],
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "={{ $('Turn the CV read into candidate fields').item.json.email }}",
"subject": "=Your application to REPLACE_WITH_COMPANY_NAME",
"emailType": "text",
"message": "=Hi {{ $('Turn the CV read into candidate fields').item.json.name || 'there' }},\n\nThanks for applying. Your details and your CV are with us.\n\nA recruiter reads every application. If your background fits one of the roles we are working on, we will call you to talk it through. If nothing fits right now, we will still write back and let you know.\n\nNothing for you to do yet.\n\nREPLACE_WITH_COMPANY_NAME",
"options": {}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "sticky-2",
"name": "Flow 2: New vacancy from an employer",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
920
],
"parameters": {
"content": "## Flow 2: New vacancy from an employer\n- Employer sends a role through the vacancy form. It is saved, the employer gets a confirmation, and the recruiting channel is told.\n- Needs the Airtable, Gmail and Slack credentials.\n- Fill in: REPLACE_WITH_AIRTABLE_BASE_ID, REPLACE_WITH_VACANCIES_TABLE_ID, REPLACE_WITH_RECRUITING_SLACK_CHANNEL, REPLACE_WITH_COMPANY_NAME.\n- Writes to Vacancies: Vacancy Key, Role, Employer, Employer Contact, Employer Email, Country, City, Trade, Headcount, Languages Required, Certifications Required, Pay Rate, Start Date, Status, Notes, Created On.\n- Tune in the tidy step: the default headcount of 1 and the field names the form can send.",
"height": 300,
"width": 700
}
},
{
"id": "vacancy-form-trigger",
"name": "An employer sends a new vacancy",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
1100
],
"parameters": {
"httpMethod": "POST",
"path": "new-vacancy",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "tidy-vacancy-details",
"name": "Tidy up the vacancy details",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
1100
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// vacancyKey is what stops the same role being created twice.\n// One employer + role + city always makes the same key, so a repeat send updates\n// the existing vacancy instead of adding a duplicate.\n\n// ---- Tunable business rules ----\nconst DEFAULT_HEADCOUNT = 1; // used when the form does not say how many workers\n// --------------------------------\n\nconst out = [];\n\nfor (const item of $input.all()) {\n const raw = item.json || {};\n const src = raw.body || raw.data || raw;\n\n const text = (v) => {\n if (v === undefined || v === null) return '';\n return String(v).trim();\n };\n\n const pick = (...keys) => {\n for (const k of keys) {\n const v = src[k];\n if (v !== undefined && v !== null && v !== '') return v;\n }\n return '';\n };\n\n const list = (v) => {\n if (Array.isArray(v)) {\n return v.map((x) => text(x)).filter(Boolean).join(', ');\n }\n return text(v);\n };\n\n const role = text(pick('role', 'jobTitle', 'job_title', 'position'));\n const employer = text(pick('employer', 'company', 'companyName', 'company_name'));\n const employerContact = text(pick('employerContact', 'employer_contact', 'contactName', 'contact_name', 'contact'));\n const employerEmail = text(pick('employerEmail', 'employer_email', 'contactEmail', 'contact_email', 'email')).toLowerCase();\n const country = text(pick('country'));\n const city = text(pick('city', 'town'));\n const trade = text(pick('trade', 'discipline', 'skill'));\n\n const headcountDigits = text(pick('headcount', 'numberOfWorkers', 'number_of_workers', 'workers', 'quantity')).replace(/[^0-9]/g, '');\n const headcountNumber = parseInt(headcountDigits, 10);\n const headcount = Number.isFinite(headcountNumber) && headcountNumber > 0 ? headcountNumber : DEFAULT_HEADCOUNT;\n\n const languagesRequired = list(pick('languagesRequired', 'languages_required', 'languages'));\n const certificationsRequired = list(pick('certificationsRequired', 'certifications_required', 'certifications', 'certs'));\n const payRate = text(pick('payRate', 'pay_rate', 'rate', 'salary'));\n const startDate = text(pick('startDate', 'start_date', 'start'));\n const notes = text(pick('notes', 'message', 'details', 'comments'));\n const createdOn = new Date().toISOString().slice(0, 10);\n\n const vacancyKey = [employer, role, city]\n .filter(Boolean)\n .join(' ')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+/, '')\n .replace(/-+$/, '');\n\n out.push({\n json: {\n vacancyKey,\n role,\n employer,\n employerContact,\n employerEmail,\n country,\n city,\n trade,\n headcount,\n languagesRequired,\n certificationsRequired,\n payRate,\n startDate,\n notes,\n createdOn\n }\n });\n}\n\nreturn out;"
}
},
{
"id": "save-the-vacancy",
"name": "Save the vacancy",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
440,
1100
],
"parameters": {
"resource": "record",
"operation": "upsert",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_VACANCIES_TABLE_ID"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Vacancy Key": "={{ $json.vacancyKey }}",
"Role": "={{ $json.role }}",
"Employer": "={{ $json.employer }}",
"Employer Contact": "={{ $json.employerContact }}",
"Employer Email": "={{ $json.employerEmail }}",
"Country": "={{ $json.country }}",
"City": "={{ $json.city }}",
"Trade": "={{ $json.trade }}",
"Headcount": "={{ $json.headcount }}",
"Languages Required": "={{ $json.languagesRequired }}",
"Certifications Required": "={{ $json.certificationsRequired }}",
"Pay Rate": "={{ $json.payRate }}",
"Start Date": "={{ $json.startDate }}",
"Status": "Open",
"Notes": "={{ $json.notes }}",
"Created On": "={{ $json.createdOn }}"
},
"matchingColumns": [
"Vacancy Key"
],
"schema": []
},
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "reply-to-vacancy-form",
"name": "Reply to the vacancy form",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
660,
1100
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ status: 'received', message: 'Got it. Your ' + $('Tidy up the vacancy details').item.json.role + ' role for ' + $('Tidy up the vacancy details').item.json.employer + ' is saved. A recruiter will come back to you with candidates.' }) }}",
"options": {}
}
},
{
"id": "confirm-vacancy-with-employer",
"name": "Confirm the vacancy with the employer",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
880,
1100
],
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "={{ $('Tidy up the vacancy details').item.json.employerEmail }}",
"subject": "=Your {{ $('Tidy up the vacancy details').item.json.role }} vacancy is with us",
"emailType": "text",
"message": "=Hi {{ ($('Tidy up the vacancy details').item.json.employerContact || 'there').split(' ')[0] }},\n\nYour role is saved. Here is what we have written down:\n\nRole: {{ $('Tidy up the vacancy details').item.json.role }}\nWorkers needed: {{ $('Tidy up the vacancy details').item.json.headcount }}\nLocation: {{ $('Tidy up the vacancy details').item.json.city }}, {{ $('Tidy up the vacancy details').item.json.country }}\nStart date: {{ $('Tidy up the vacancy details').item.json.startDate || 'not set yet' }}\n\nHave a quick read. If any of that is wrong, reply to this email and we will fix it.\n\nA recruiter will come back to you with candidates for the role.\n\nREPLACE_WITH_COMPANY_NAME",
"options": {}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "tell-team-new-vacancy",
"name": "Tell the team about the new vacancy",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [
1100,
1100
],
"parameters": {
"resource": "message",
"operation": "post",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "REPLACE_WITH_RECRUITING_SLACK_CHANNEL"
},
"text": "=New vacancy in from an employer.\nRole: {{ $('Tidy up the vacancy details').item.json.role }}\nEmployer: {{ $('Tidy up the vacancy details').item.json.employer }}\nLocation: {{ $('Tidy up the vacancy details').item.json.city }}, {{ $('Tidy up the vacancy details').item.json.country }}\nWorkers needed: {{ $('Tidy up the vacancy details').item.json.headcount }}\nTrade: {{ $('Tidy up the vacancy details').item.json.trade || 'not given' }}\nLanguages required: {{ $('Tidy up the vacancy details').item.json.languagesRequired || 'none listed' }}\nStart date: {{ $('Tidy up the vacancy details').item.json.startDate || 'not set yet' }}\nPay rate: {{ $('Tidy up the vacancy details').item.json.payRate || 'not given' }}\n\nSomeone pick this up and start matching candidates.",
"otherOptions": {}
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
}
},
{
"id": "sticky-3",
"name": "Flow 3: Screening and matching",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
2020
],
"parameters": {
"content": "## Flow 3: Screening and matching\n- Every 30 minutes it takes candidates at Status New with a CV summary and scores them against the open vacancies.\n- Needs the Airtable, Anthropic and Slack credentials.\n- Fill REPLACE_WITH_AIRTABLE_BASE_ID, REPLACE_WITH_CANDIDATES_TABLE_ID, REPLACE_WITH_VACANCIES_TABLE_ID and REPLACE_WITH_RECRUITING_SLACK_CHANNEL.\n- Writes Status, Match Score, Matched Vacancy, Match Notes and Last Activity back on the candidate.\n- Tune STRONG_MATCH_SCORE at the top of Read the match result. That one number decides both the Shortlisted status and the Slack ping, so nothing else needs changing.",
"height": 300,
"width": 700
}
},
{
"id": "match-schedule-trigger",
"name": "Match new candidates every 30 minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
2200
],
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 30
}
]
}
}
},
{
"id": "get-open-roles",
"name": "Get the open roles",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
220,
2200
],
"parameters": {
"resource": "record",
"operation": "search",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_VACANCIES_TABLE_ID"
},
"filterByFormula": "{Status}='Open'",
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "hold-open-roles",
"name": "Hold the open roles in one list",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
2200
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Airtable hands back one item per open vacancy.\n// Collapse them into a single list so the next nodes can share it.\n\nconst readField = (json, key) => {\n const source = json.fields || json;\n const value = source[key];\n if (value === undefined || value === null) return '';\n return typeof value === 'string' ? value.trim() : value;\n};\n\nconst roles = items.map((item) => ({\n recordId: item.json.id || readField(item.json, 'Vacancy Key'),\n role: readField(item.json, 'Role'),\n employer: readField(item.json, 'Employer'),\n country: readField(item.json, 'Country'),\n city: readField(item.json, 'City'),\n trade: readField(item.json, 'Trade'),\n languagesRequired: readField(item.json, 'Languages Required'),\n certificationsRequired: readField(item.json, 'Certifications Required'),\n headcount: readField(item.json, 'Headcount'),\n startDate: readField(item.json, 'Start Date'),\n payRate: readField(item.json, 'Pay Rate'),\n}));\n\n// Always emit exactly one item, even when nothing is open.\nreturn [{ json: { roles } }];\n"
}
},
{
"id": "find-new-candidates",
"name": "Find candidates waiting to be screened",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
660,
2200
],
"parameters": {
"resource": "record",
"operation": "search",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_CANDIDATES_TABLE_ID"
},
"filterByFormula": "AND({Status}='New', {CV Summary}!='')",
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "pair-candidate-with-roles",
"name": "Pair each candidate with the open roles",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
2200
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// One item per candidate, each carrying the same shared list of open roles.\n\nconst roles = $('Hold the open roles in one list').first().json.roles || [];\n\n// Nothing open means there is nothing to score. Stop here quietly.\nif (!roles.length) {\n return [];\n}\n\nconst readField = (json, key) => {\n const source = json.fields || json;\n const value = source[key];\n if (value === undefined || value === null) return '';\n return typeof value === 'string' ? value.trim() : value;\n};\n\nreturn items.map((item) => {\n const email = String(readField(item.json, 'Email') || '').trim().toLowerCase();\n return {\n json: {\n recordId: item.json.id || '',\n name: readField(item.json, 'Name'),\n email,\n trade: readField(item.json, 'Trade'),\n country: readField(item.json, 'Country'),\n languages: readField(item.json, 'Languages'),\n yearsExperience: readField(item.json, 'Years Experience'),\n availability: readField(item.json, 'Availability'),\n cvSummary: readField(item.json, 'CV Summary'),\n roles,\n },\n };\n});\n"
}
},
{
"id": "claude-score-match",
"name": "Have Claude score the match",
"type": "@n8n/n8n-nodes-langchain.anthropic",
"typeVersion": 1,
"position": [
1100,
2200
],
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"mode": "id",
"value": "claude-sonnet-5"
},
"messages": {
"values": [
{
"role": "user",
"content": "=You are screening one candidate for a recruitment company that places skilled workers with employers across Europe.\n\nCandidate\nName: {{ $json.name }}\nTrade: {{ $json.trade }}\nCountry: {{ $json.country }}\nLanguages: {{ $json.languages }}\nYears of experience: {{ $json.yearsExperience }}\nAvailability: {{ $json.availability }}\nCV summary: {{ $json.cvSummary }}\n\nOpen roles, as JSON:\n{{ JSON.stringify($json.roles) }}\n\nPick the single open role that fits this candidate best and score the fit from 0 to 100.\nWeigh trade match first. Then the work country and the language. Then certifications. Then years of experience.\nScore conservatively. A high score means you would put this person in front of the employer today.\nIf no open role matches the candidate's trade, score 0 and leave bestVacancyKey and bestVacancyLabel as empty strings.\nUse the recordId of the chosen role for bestVacancyKey. Write bestVacancyLabel as the role at the employer, for example Welder at Nord Fabrication.\n\nVoice rules for reasoning and blockers:\nPlain texting register. Short sentences. Say the thing.\nNo corporate phrases. No reach out, touch base, circle back, streamline, leverage, seamless, robust.\nNo emojis. No em dashes. No exclamation marks.\nNo lists of three written for rhythm.\nNo -ing tails tacked onto the end of sentences.\n\nReply with JSON only. No code fence, no notes around it. Use exactly these keys:\n{\"bestVacancyKey\": \"\", \"bestVacancyLabel\": \"\", \"score\": 0, \"reasoning\": \"two sentences saying what fits and what does not\", \"blockers\": [\"short strings, for example missing certification or no German\"]}\n"
}
]
},
"options": {}
},
"credentials": {
"anthropicApi": {
"name": "<your credential>"
}
}
},
{
"id": "read-match-result",
"name": "Read the match result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
2200
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// ---------------------------------------------------------------\n// TUNABLE BUSINESS RULE\n// A candidate scoring this or higher counts as a strong match:\n// their status becomes \"Shortlisted\" and the recruiting channel is told.\n// Anything below becomes \"Screened\" and just sits in Airtable.\n// Change this one number to widen or tighten the shortlist. Nothing else needs\n// changing: the \"Is this a strong match?\" node reads the isStrongMatch flag set here.\nconst STRONG_MATCH_SCORE = 75;\n// ---------------------------------------------------------------\n\nconst candidates = $('Pair each candidate with the open roles').all();\nconst nowIso = new Date().toISOString();\n\nreturn items.map((item, index) => {\n const source = candidates[index] ? candidates[index].json : {};\n\n let result = {};\n try {\n const raw = String(item.json.content[0].text || '')\n .replace(/^```(?:json)?/i, '')\n .replace(/```$/, '')\n .trim();\n result = JSON.parse(raw) || {};\n } catch (error) {\n result = {};\n }\n\n let score = Number(result.score);\n if (!Number.isFinite(score)) score = 0;\n score = Math.max(0, Math.min(100, Math.round(score)));\n\n const blockers = Array.isArray(result.blockers)\n ? result.blockers.map((entry) => String(entry).trim()).filter(Boolean)\n : [];\n\n const isStrongMatch = score >= STRONG_MATCH_SCORE;\n\n return {\n json: {\n recordId: source.recordId || '',\n name: source.name || '',\n email: source.email || '',\n bestVacancyKey: typeof result.bestVacancyKey === 'string' ? result.bestVacancyKey.trim() : '',\n bestVacancyLabel: typeof result.bestVacancyLabel === 'string' ? result.bestVacancyLabel.trim() : '',\n score,\n reasoning: typeof result.reasoning === 'string' ? result.reasoning.trim() : '',\n blockers,\n isStrongMatch,\n newStatus: isStrongMatch ? 'Shortlisted' : 'Screened',\n lastActivity: nowIso,\n strongMatchScore: STRONG_MATCH_SCORE,\n },\n };\n});\n"
}
},
{
"id": "update-candidate-match",
"name": "Update the candidate with the match",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
1540,
2200
],
"parameters": {
"resource": "record",
"operation": "update",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_CANDIDATES_TABLE_ID"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"id": "={{ $json.recordId }}",
"Status": "={{ $json.newStatus }}",
"Match Score": "={{ $json.score }}",
"Matched Vacancy": "={{ $json.bestVacancyLabel }}",
"Match Notes": "={{ $json.reasoning }}{{ $json.blockers.length ? ' Blockers: ' + $json.blockers.join('; ') : '' }}",
"Last Activity": "={{ $json.lastActivity }}"
},
"matchingColumns": [
"id"
],
"schema": []
},
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "is-strong-match",
"name": "Is this a strong match?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1760,
2200
],
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "strong-match-score",
"leftValue": "={{ $('Read the match result').item.json.isStrongMatch }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
]
},
"options": {}
}
},
{
"id": "slack-strong-match",
"name": "Tell the team about a strong match",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.5,
"position": [
1980,
2100
],
"parameters": {
"resource": "message",
"operation": "post",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "name",
"value": "REPLACE_WITH_RECRUITING_SLACK_CHANNEL"
},
"text": "=Strong match on a new candidate.\nCandidate: {{ $('Read the match result').item.json.name }}\nRole: {{ $('Read the match result').item.json.bestVacancyLabel }}\nScore: {{ $('Read the match result').item.json.score }} out of 100\n{{ $('Read the match result').item.json.reasoning }}{{ $('Read the match result').item.json.blockers.length ? '\\nWatch out for: ' + $('Read the match result').item.json.blockers.join('; ') : '' }}\nThey are marked Shortlisted in Airtable. Whoever owns this role should call them today.",
"otherOptions": {}
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
}
},
{
"id": "sticky-4",
"name": "Flow 4: Interview booking and reminders",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
3120
],
"parameters": {
"content": "## Flow 4: Interview booking and reminders\n- A recruiter posts to the book-interview link. The interview goes on the calendar and both sides get the details by email.\n- Every morning at 8 it reads tomorrow's interviews and emails each candidate a reminder.\n- Needs the Google Calendar, Gmail and Airtable credentials.\n- Fill REPLACE_WITH_INTERVIEW_CALENDAR_ID, REPLACE_WITH_AIRTABLE_BASE_ID, REPLACE_WITH_CANDIDATES_TABLE_ID and REPLACE_WITH_COMPANY_NAME.\n- Writes Status, Interview Date, Recruiter, Recruiter Email and Last Activity on the candidate. Interview length, time zone and reminder wording are at the top of the two Code nodes.",
"height": 300,
"width": 700
}
},
{
"id": "book-interview-webhook",
"name": "A recruiter books an interview",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
3300
],
"parameters": {
"httpMethod": "POST",
"path": "book-interview",
"responseMode": "responseNode",
"options": {}
}
},
{
"id": "tidy-interview-details",
"name": "Tidy up the interview details",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
3300
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// ---- Tunable business rules ----\n// How long an interview runs when only a start time is sent, in minutes.\nconst DEFAULT_INTERVIEW_MINUTES = 60;\n// What we put on the invite when nobody sends a location.\nconst DEFAULT_LOCATION = 'Video call';\n// --------------------------------\n\nconst clean = (v) => (v === undefined || v === null ? '' : String(v).trim());\nconst cleanEmail = (v) => clean(v).toLowerCase();\n\nconst out = [];\n\nfor (const item of $input.all()) {\n const raw = item.json || {};\n const src = raw.body || raw.data || raw;\n\n const startRaw = clean(src.startTime || src.start_time || src.start || src.interviewStart);\n const endRaw = clean(src.endTime || src.end_time || src.end || src.interviewEnd);\n\n let start = startRaw ? new Date(startRaw) : new Date();\n if (isNaN(start.getTime())) {\n start = new Date();\n }\n\n let end = endRaw ? new Date(endRaw) : null;\n if (!end || isNaN(end.getTime())) {\n end = new Date(start.getTime() + DEFAULT_INTERVIEW_MINUTES * 60 * 1000);\n }\n\n out.push({\n json: {\n candidateRecordId: clean(src.candidateRecordId || src.candidate_record_id || src.recordId || src.record_id),\n candidateName: clean(src.candidateName || src.candidate_name || src.name),\n candidateEmail: cleanEmail(src.candidateEmail || src.candidate_email || src.email),\n employerName: clean(src.employerName || src.employer_name || src.employer),\n employerEmail: cleanEmail(src.employerEmail || src.employer_email || src.employerContactEmail),\n role: clean(src.role || src.position || src.jobTitle),\n trade: clean(src.trade),\n startTime: start.toISOString(),\n endTime: end.toISOString(),\n meetingLink: clean(src.meetingLink || src.meeting_link || src.link),\n location: clean(src.location) || DEFAULT_LOCATION,\n recruiterName: clean(src.recruiterName || src.recruiter_name || src.recruiter),\n recruiterEmail: cleanEmail(src.recruiterEmail || src.recruiter_email),\n lastActivity: new Date().toISOString(),\n },\n });\n}\n\nreturn out;\n"
}
},
{
"id": "create-interview-event",
"name": "Put the interview on the calendar",
"type": "n8n-nodes-base.googleCalendar",
"typeVersion": 1.3,
"position": [
440,
3300
],
"parameters": {
"resource": "event",
"operation": "create",
"calendar": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_INTERVIEW_CALENDAR_ID"
},
"start": "={{ $json.startTime }}",
"end": "={{ $json.endTime }}",
"additionalFields": {
"summary": "=Interview: {{ $json.candidateName }} for {{ $json.role }} at {{ $json.employerName }}",
"description": "=Candidate: {{ $('Tidy up the interview details').item.json.candidateName }} ({{ $('Tidy up the interview details').item.json.candidateEmail }})\nEmployer contact: {{ $('Tidy up the interview details').item.json.employerName }} ({{ $('Tidy up the interview details').item.json.employerEmail }})\nRecruiter: {{ $('Tidy up the interview details').item.json.recruiterName }} ({{ $('Tidy up the interview details').item.json.recruiterEmail }})\nRole: {{ $('Tidy up the interview details').item.json.role }}\nHow to join: {{ $('Tidy up the interview details').item.json.meetingLink || $('Tidy up the interview details').item.json.location }}",
"location": "={{ $json.location }}"
}
},
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"id": "respond-booking",
"name": "Reply to the booking request",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
660,
3300
],
"parameters": {
"respondWith": "json",
"responseBody": "={\"status\": \"booked\", \"message\": \"Interview booked for {{ $('Tidy up the interview details').item.json.candidateName }} at {{ $('Tidy up the interview details').item.json.startTime }}\"}"
}
},
{
"id": "email-candidate-interview",
"name": "Send the candidate the interview details",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
880,
3300
],
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "={{ $('Tidy up the interview details').item.json.candidateEmail }}",
"subject": "=Your interview for {{ $('Tidy up the interview details').item.json.role }} on {{ DateTime.fromISO($('Tidy up the interview details').item.json.startTime).toFormat('cccc d LLLL yyyy, HH:mm') }}",
"emailType": "text",
"message": "=Hi {{ $('Tidy up the interview details').item.json.candidateName }},\n\nYour interview is booked.\n\nWhen: {{ DateTime.fromISO($('Tidy up the interview details').item.json.startTime).toFormat('cccc d LLLL yyyy, HH:mm') }}\nWho you are meeting: {{ $('Tidy up the interview details').item.json.employerName }}\nRole: {{ $('Tidy up the interview details').item.json.role }}\nHow to join: {{ $('Tidy up the interview details').item.json.meetingLink || $('Tidy up the interview details').item.json.location }}\n\nIf that time does not work, reply to this email and we will move it.\n\n{{ $('Tidy up the interview details').item.json.recruiterName }}\nREPLACE_WITH_COMPANY_NAME",
"options": {}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "email-employer-interview",
"name": "Send the employer the interview details",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
1100,
3300
],
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "={{ $('Tidy up the interview details').item.json.employerEmail }}",
"subject": "=Interview with {{ $('Tidy up the interview details').item.json.candidateName }} on {{ DateTime.fromISO($('Tidy up the interview details').item.json.startTime).toFormat('cccc d LLLL yyyy, HH:mm') }}",
"emailType": "text",
"message": "=Hi {{ $('Tidy up the interview details').item.json.employerName }},\n\nYour interview is booked.\n\nWhen: {{ DateTime.fromISO($('Tidy up the interview details').item.json.startTime).toFormat('cccc d LLLL yyyy, HH:mm') }}\nCandidate: {{ $('Tidy up the interview details').item.json.candidateName }}\nTrade: {{ $('Tidy up the interview details').item.json.trade }}\nRole: {{ $('Tidy up the interview details').item.json.role }}\nHow to join: {{ $('Tidy up the interview details').item.json.meetingLink || $('Tidy up the interview details').item.json.location }}\n\nYour contact for this one is {{ $('Tidy up the interview details').item.json.recruiterName }} on {{ $('Tidy up the interview details').item.json.recruiterEmail }}.\n\nREPLACE_WITH_COMPANY_NAME",
"options": {}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "mark-candidate-interview-booked",
"name": "Mark the candidate as interview booked",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
1320,
3300
],
"parameters": {
"resource": "record",
"operation": "update",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_CANDIDATES_TABLE_ID"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"id": "={{ $('Tidy up the interview details').item.json.candidateRecordId }}",
"Status": "Interview booked",
"Interview Date": "={{ $('Tidy up the interview details').item.json.startTime }}",
"Recruiter": "={{ $('Tidy up the interview details').item.json.recruiterName }}",
"Recruiter Email": "={{ $('Tidy up the interview details').item.json.recruiterEmail }}",
"Last Activity": "={{ $('Tidy up the interview details').item.json.lastActivity }}"
},
"matchingColumns": [
"id"
],
"schema": []
},
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "reminder-schedule",
"name": "Send interview reminders every morning at 8",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
3620
],
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 8
}
]
}
}
},
{
"id": "get-tomorrow-interviews",
"name": "Get tomorrow's interviews",
"type": "n8n-nodes-base.googleCalendar",
"typeVersion": 1.3,
"position": [
220,
3620
],
"parameters": {
"resource": "event",
"operation": "getAll",
"calendar": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_INTERVIEW_CALENDAR_ID"
},
"returnAll": true,
"timeMin": "={{ $now.plus({ days: 1 }).startOf('day').toISO() }}",
"timeMax": "={{ $now.plus({ days: 2 }).startOf('day').toISO() }}",
"options": {
"singleEvents": true,
"orderBy": "startTime"
}
},
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"id": "write-interview-reminders",
"name": "Write the reminder for each interview",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
3620
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// ---- Tunable business rules ----\n// Time zone the reminder prints the interview time in.\nconst TIME_ZONE = 'Europe/Amsterdam';\n// Locale used for the readable date.\nconst LOCALE = 'en-GB';\n// What we tell people when the event has no location and no link.\nconst FALLBACK_WHERE = 'Check the calendar invite';\n// --------------------------------\n\nconst EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/;\nconst LINK_PATTERN = /https?:\\/\\/\\S+/;\n\nconst out = [];\n\nfor (const item of $input.all()) {\n const event = item.json || {};\n const description = String(event.description || '');\n\n const foundEmail = description.match(EMAIL_PATTERN);\n if (!foundEmail) {\n // No candidate email on the event, so there is nobody to remind.\n continue;\n }\n\n const start = (event.start && (event.start.dateTime || event.start.date)) || '';\n const startDate = start ? new Date(start) : null;\n let whenText = start;\n if (startDate && !isNaN(startDate.getTime())) {\n whenText = startDate.toLocaleString(LOCALE, {\n timeZone: TIME_ZONE,\n weekday: 'long',\n day: 'numeric',\n month: 'long',\n hour: '2-digit',\n minute: '2-digit',\n });\n }\n\n const foundLink = description.match(LINK_PATTERN);\n const where = String(event.location || '').trim() || (foundLink ? foundLink[0] : '') || FALLBACK_WHERE;\n\n out.push({\n json: {\n email: foundEmail[0].toLowerCase(),\n title: String(event.summary || 'Interview').trim(),\n whenText,\n where,\n },\n });\n}\n\nreturn out;\n"
}
},
{
"id": "email-interview-reminder",
"name": "Remind the candidate about tomorrow",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
660,
3620
],
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "={{ $json.email }}",
"subject": "=Interview tomorrow: {{ $json.title }}",
"emailType": "text",
"message": "=Hi,\n\nYou have an interview tomorrow.\n\nWhat: {{ $json.title }}\nWhen: {{ $json.whenText }}\nWhere: {{ $json.where }}\n\nReply to this email if anything has changed on your side.\n\nREPLACE_WITH_COMPANY_NAME",
"options": {}
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "sticky-5",
"name": "Flow 5: Document chase and quiet candidates",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-40,
4420
],
"parameters": {
"content": "## Flow 5: Document chase and quiet candidates\n- Runs every morning at 9. Branch A chases candidates who still owe documents. Branch B tells each recruiter who has gone quiet.\n- Needs the Airtable, Gmail and Slack credentials.\n- Fill REPLACE_WITH_AIRTABLE_BASE_ID, REPLACE_WITH_CANDIDATES_TABLE_ID, REPLACE_WITH_RECRUITING_SLACK_CHANNEL, REPLACE_WITH_TEAM_EMAIL, REPLACE_WITH_COMPANY_NAME.\n- Writes Reminders Sent, Last Reminder and Last Activity back onto the candidate.\n- Tune DAYS_BETWEEN_REMINDERS and REMINDERS_BEFORE_A_RECRUITER_CALLS at the top of \"Work out who to chase today\".",
"height": 300,
"width": 700
}
},
{
"id": "schedule-paperwork-morning",
"name": "Check paperwork every morning at 9",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
4900
],
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 9
}
]
}
}
},
{
"id": "airtable-find-missing-documents",
"name": "Find candidates missing documents",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.2,
"position": [
240,
4700
],
"parameters": {
"resource": "record",
"operation": "search",
"base": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_AIRTABLE_BASE_ID"
},
"table": {
"__rl": true,
"mode": "id",
"value": "REPLACE_WITH_CANDIDATES_TABLE_ID"
},
"filterByFormula": "AND({Documents Missing}!='', OR({Status}='Shortlisted', {Status}='Interview booked', {Status}='Placed'))",
"options": {}
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"id": "code-work-out-who-to-chase",
"name": "Work out who to chase today",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
4700
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// ---------------------------------------------\n// Tunable rules - edit these two numbers\n// ---------------------------------------------\nconst DAYS_BETWEEN_REMINDERS = 3;\nconst REMINDERS_BEFORE_A_RECRUITER_CALLS = 3;\n// ---------------------------------------------\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst now = new Date();\nconst nowIso = now.toISOString();\n\nconst out = [];\n\nfor (const item of $input.all()) {\n const row = item.json.fields || item.json || {};\n\n // Documents Missing is a comma separated list on the candidate record.\n const missingList = String(row['Documents Missing'] || '')\n .split(',')\n .map((doc) => doc.trim())\n .filter((doc) => doc.length > 0);\n\n if (missingList.length === 0) {\n continue;\n }\n\n // Skip anyone already reminded inside the quiet window.\n const lastReminderRaw = String(row['Last Reminder'] || '').trim();\n if (lastReminderRaw) {\n const lastReminder = new Date(lastReminderRaw);\n if (!isNaN(lastReminder.getTime())) {\n const daysSince = (now.getTime() - lastReminder.getTime()) / DAY_MS;\n if (daysSince < DAYS_BETWEEN_REMINDERS) {\n continue;\n }\n }\n }\n\n const priorReminders = Number(row['Reminders Sent']) || 0;\n const remindersSent = priorReminders + 1;\n\n out.push({\n json: {\n recordId: item.json.id,\n name: String(row['Name'] || '').trim(),\n email: String(row['Email'] || '').trim().toLowerCase(),\n missingList: missingList,\n missingText: missingList.map((doc) => '- ' + doc).join('\\n'),\n remindersSent: remindersSent,\n needsRecruiterCall: remindersSent >= REMINDERS_BEFORE_A_RECRUITER_CALLS,\n r
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.
airtableTokenApianthropicApigmailOAuth2googleCalendarOAuth2ApislackApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Recruitment Ops Automation Suite (EU Placements). Uses gmailTrigger, anthropic, airtable, gmail. Webhook trigger; 71 nodes.
Source: https://github.com/mcruz1799/automation-examples/blob/main/n8n/03-recruitment-ops-suite/workflow.json — 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.
Complaints arrive via Gmail or a web form webhook Claude AI classifies each complaint: fault category, priority (P1/P2/P3), tenant tone, and drafts an acknowledgement email The right technician is loo
Imagine your recruitment process transformed into a sleek, efficient, AI-powered assembly line for talent. That's exactly what this system creates. It automates the heavy lifting, allowing your human
This workflow automates the end-to-end process of scheduling technical or behavioral interviews. It captures interview data via Webhook, creates a Google Calendar event with an integrated Google Meet
Reply Handling (Optional Extension)
Lead-Scoring-Routing. Uses anthropic, httpRequest, slack, twilio. Webhook trigger; 15 nodes.