{
  "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      recruiterName: String(row['Recruiter'] || '').trim(),\n      recruiterEmail: String(row['Recruiter Email'] || '').trim().toLowerCase(),\n      lastReminder: nowIso,\n      lastActivity: nowIso\n    }\n  });\n}\n\nreturn out;"
      }
    },
    {
      "id": "if-chased-enough",
      "name": "Has this one been chased enough?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        680,
        4700
      ],
      "parameters": {
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "needs-recruiter-call",
              "leftValue": "={{ $json.needsRecruiterCall }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "slack-ask-recruiter-to-phone",
      "name": "Ask a recruiter to phone about documents",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        900,
        4600
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "REPLACE_WITH_RECRUITING_SLACK_CHANNEL"
        },
        "text": "=Time for a phone call: {{ $json.name }}\n\nStill missing:\n{{ $json.missingText }}\n\nWe have emailed them {{ $json.remindersSent }} times and nothing has come back. {{ $json.recruiterName }}, please call them today and get the documents moving.",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "gmail-candidate-document-checklist",
      "name": "Email the candidate their document checklist",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        900,
        4800
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $json.email }}",
        "subject": "=Documents we still need from you",
        "emailType": "text",
        "message": "=Hi {{ ($json.name || '').split(' ')[0] }},\n\nWe still need these from you:\n\n{{ $json.missingText }}\n\nSend them to REPLACE_WITH_TEAM_EMAIL. Photos or scans are fine as long as everything on the page is readable.\n\nYour placement cannot move forward until these are in. If something is hard to get hold of, tell us which one and we will work out what to do.\n\nThanks,\nREPLACE_WITH_COMPANY_NAME",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "airtable-note-document-reminder",
      "name": "Note the document reminder on the candidate",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        1120,
        4700
      ],
      "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": "={{ $('Work out who to chase today').item.json.recordId }}",
            "Reminders Sent": "={{ $('Work out who to chase today').item.json.remindersSent }}",
            "Last Reminder": "={{ $('Work out who to chase today').item.json.lastReminder }}",
            "Last Activity": "={{ $('Work out who to chase today').item.json.lastActivity }}"
          },
          "matchingColumns": [
            "id"
          ],
          "schema": []
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "airtable-find-quiet-candidates",
      "name": "Find candidates nobody has touched in a week",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        240,
        5080
      ],
      "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(IS_BEFORE({Last Activity}, DATEADD(NOW(), -7, 'days')), NOT(OR({Status}='Placed', {Status}='Not a fit', {Status}='Archived')))",
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "code-group-quiet-by-recruiter",
      "name": "Group the quiet candidates by recruiter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        5080
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ---------------------------------------------\n// Tunable rules - where unassigned candidates go\n// ---------------------------------------------\nconst FALLBACK_RECRUITER_EMAIL = 'REPLACE_WITH_TEAM_EMAIL';\nconst FALLBACK_RECRUITER_NAME = 'team';\n// ---------------------------------------------\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst now = new Date();\n\nconst buckets = {};\n\nfor (const item of $input.all()) {\n  const row = item.json.fields || item.json || {};\n\n  const recruiterEmail = String(row['Recruiter Email'] || '').trim().toLowerCase() || FALLBACK_RECRUITER_EMAIL;\n  const recruiterName = String(row['Recruiter'] || '').trim() || FALLBACK_RECRUITER_NAME;\n\n  const name = String(row['Name'] || '').trim() || 'Unnamed candidate';\n  const trade = String(row['Trade'] || '').trim() || 'trade not set';\n  const status = String(row['Status'] || '').trim() || 'status not set';\n\n  let daysQuiet = 'unknown';\n  const lastActivityRaw = String(row['Last Activity'] || '').trim();\n  if (lastActivityRaw) {\n    const lastActivity = new Date(lastActivityRaw);\n    if (!isNaN(lastActivity.getTime())) {\n      daysQuiet = String(Math.floor((now.getTime() - lastActivity.getTime()) / DAY_MS));\n    }\n  }\n\n  if (!buckets[recruiterEmail]) {\n    buckets[recruiterEmail] = {\n      recruiterEmail: recruiterEmail,\n      recruiterName: recruiterName,\n      lines: []\n    };\n  }\n\n  buckets[recruiterEmail].lines.push(name + ' (' + trade + ') - ' + status + ' - quiet for ' + daysQuiet + ' days');\n}\n\nconst out = [];\n\nfor (const key of Object.keys(buckets)) {\n  const bucket = buckets[key];\n  out.push({\n    json: {\n      recruiterEmail: bucket.recruiterEmail,\n      recruiterName: bucket.recruiterName,\n      count: bucket.lines.length,\n      listText: bucket.lines.join('\\n')\n    }\n  });\n}\n\nreturn out;"
      }
    },
    {
      "id": "gmail-recruiter-quiet-list",
      "name": "Send each recruiter their quiet list",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        680,
        5080
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $json.recruiterEmail }}",
        "subject": "={{ $json.count }} of your candidates have gone quiet",
        "emailType": "text",
        "message": "=Hi {{ ($json.recruiterName || '').split(' ')[0] }},\n\nNobody has touched these candidates for over a week:\n\n{{ $json.listText }}\n\nMove each one on today or close it out so the list stays honest.\n\nREPLACE_WITH_COMPANY_NAME",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "sticky-6",
      "name": "Flow 6: Monday operations report",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        5720
      ],
      "parameters": {
        "content": "## Flow 6: Monday operations report\n- Every Monday at 8 it counts the week in code, has Claude write the narrative, then emails the team and posts to Slack.\n- Needs the Airtable, Anthropic, Gmail, and Slack credentials.\n- Fill REPLACE_WITH_VACANCIES_TABLE_ID, REPLACE_WITH_CANDIDATES_TABLE_ID, REPLACE_WITH_TEAM_EMAIL, REPLACE_WITH_RECRUITING_SLACK_CHANNEL, REPLACE_WITH_COMPANY_NAME.\n- Read only. This flow writes nothing back to Airtable.\n- Tune REPORT_WINDOW_DAYS at the top of \"Count what happened last week\" to change the window.",
        "height": 300,
        "width": 700
      }
    },
    {
      "id": "weekly-report-schedule",
      "name": "Build the weekly report every Monday at 8",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        5900
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      }
    },
    {
      "id": "read-vacancies-for-report",
      "name": "Read every vacancy for the report",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        220,
        5900
      ],
      "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"
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "count-vacancy-numbers",
      "name": "Count the vacancy numbers",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        5900
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ---- Tunable settings ----\n// How many open roles get listed by name in the report.\nconst OPEN_ROLES_LIST_CAP = 15;\n// --------------------------\n\nconst rows = items.map(function (i) { return i.json || {}; });\n\nfunction pick(record, key) {\n  const source = record && record.fields ? record.fields : record;\n  const value = source ? source[key] : undefined;\n  if (value === undefined || value === null) return '';\n  return value;\n}\n\nfunction text(value) {\n  return String(value).trim();\n}\n\nfunction toDate(value) {\n  if (!value) return null;\n  const d = new Date(value);\n  if (isNaN(d.getTime())) return null;\n  return d;\n}\n\nconst now = Date.now();\n\nlet openCount = 0;\nlet onHoldCount = 0;\nlet filledCount = 0;\nlet openHeadcount = 0;\nlet oldestOpenDays = 0;\nconst openRolesList = [];\nconst byCountry = {};\n\nfor (const row of rows) {\n  const status = text(pick(row, 'Status'));\n\n  if (status === 'On hold') { onHoldCount++; continue; }\n  if (status === 'Filled') { filledCount++; continue; }\n  if (status !== 'Open') { continue; }\n\n  openCount++;\n\n  const headcount = Number(pick(row, 'Headcount'));\n  openHeadcount += isNaN(headcount) ? 0 : headcount;\n\n  const country = text(pick(row, 'Country')) || 'Country not set';\n  byCountry[country] = (byCountry[country] || 0) + 1;\n\n  const created = toDate(pick(row, 'Created On'));\n  if (created) {\n    const days = Math.floor((now - created.getTime()) / 86400000);\n    if (days > oldestOpenDays) oldestOpenDays = days;\n  }\n\n  if (openRolesList.length < OPEN_ROLES_LIST_CAP) {\n    const role = text(pick(row, 'Role')) || 'Role not set';\n    const employer = text(pick(row, 'Employer')) || 'Employer not set';\n    const city = text(pick(row, 'City')) || country;\n    openRolesList.push(role + ' at ' + employer + ', ' + city);\n  }\n}\n\nif (oldestOpenDays < 0) oldestOpenDays = 0;\n\nreturn [{\n  json: {\n    openCount: openCount,\n    onHoldCount: onHoldCount,\n    filledCount: filledCount,\n    openHeadcount: openHeadcount,\n    oldestOpenDays: oldestOpenDays,\n    openRolesList: openRolesList,\n    byCountry: byCountry\n  }\n}];"
      }
    },
    {
      "id": "read-candidates-for-report",
      "name": "Read every candidate for the report",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        660,
        5900
      ],
      "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"
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "count-the-week",
      "name": "Count what happened last week",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        5900
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ---- Tunable settings ----\n// How far back the report looks. Change this one number to widen the window.\nconst REPORT_WINDOW_DAYS = 7;\n// Statuses that mean the candidate is finished, so they never count as stuck.\nconst CLOSED_STATUSES = ['Placed', 'Not a fit', 'Archived'];\n// How many trades show up in the breakdown.\nconst TOP_TRADES = 8;\n// --------------------------\n\nconst vacancies = $('Count the vacancy numbers').first().json || {};\n\nconst rows = items.map(function (i) { return i.json || {}; });\n\nfunction pick(record, key) {\n  const source = record && record.fields ? record.fields : record;\n  const value = source ? source[key] : undefined;\n  if (value === undefined || value === null) return '';\n  return value;\n}\n\nfunction text(value) {\n  return String(value).trim();\n}\n\nfunction toDate(value) {\n  if (!value) return null;\n  const d = new Date(value);\n  if (isNaN(d.getTime())) return null;\n  return d;\n}\n\nconst now = Date.now();\nconst windowMs = REPORT_WINDOW_DAYS * 86400000;\n\nfunction insideWindow(value) {\n  const d = toDate(value);\n  if (!d) return false;\n  return (now - d.getTime()) <= windowMs;\n}\n\nlet totalCandidates = 0;\nlet newThisWeek = 0;\nlet placedThisWeek = 0;\nlet shortlistedThisWeek = 0;\nlet interviewsBooked = 0;\nlet stuckCount = 0;\nlet missingDocsCount = 0;\nconst byStatus = {};\nconst tradeCounts = {};\n\nfor (const row of rows) {\n  totalCandidates++;\n\n  const status = text(pick(row, 'Status')) || 'Status not set';\n  const appliedOn = pick(row, 'Applied On');\n  const lastActivity = pick(row, 'Last Activity');\n  const missingDocs = text(pick(row, 'Documents Missing'));\n  const trade = text(pick(row, 'Trade')) || 'Trade not set';\n\n  byStatus[status] = (byStatus[status] || 0) + 1;\n  tradeCounts[trade] = (tradeCounts[trade] || 0) + 1;\n\n  if (insideWindow(appliedOn)) newThisWeek++;\n  if (status === 'Placed' && insideWindow(lastActivity)) placedThisWeek++;\n  if (status === 'Shortlisted' && insideWindow(lastActivity)) shortlistedThisWeek++;\n  if (status === 'Interview booked') interviewsBooked++;\n  if (missingDocs) missingDocsCount++;\n\n  const isClosed = CLOSED_STATUSES.indexOf(status) !== -1;\n  if (!isClosed) {\n    const activity = toDate(lastActivity) || toDate(appliedOn);\n    if (activity && (now - activity.getTime()) > windowMs) stuckCount++;\n  }\n}\n\nconst byTrade = {};\nObject.keys(tradeCounts)\n  .sort(function (a, b) { return tradeCounts[b] - tradeCounts[a]; })\n  .slice(0, TOP_TRADES)\n  .forEach(function (key) { byTrade[key] = tradeCounts[key]; });\n\nfunction breakdown(obj) {\n  const keys = Object.keys(obj || {});\n  if (!keys.length) return 'none';\n  return keys.map(function (k) { return k + ': ' + obj[k]; }).join(', ');\n}\n\nconst openCount = vacancies.openCount || 0;\nconst onHoldCount = vacancies.onHoldCount || 0;\nconst filledCount = vacancies.filledCount || 0;\nconst openHeadcount = vacancies.openHeadcount || 0;\nconst oldestOpenDays = vacancies.oldestOpenDays || 0;\nconst openRolesList = vacancies.openRolesList || [];\nconst byCountry = vacancies.byCountry || {};\n\nconst factsBlock = [\n  'Report window: last ' + REPORT_WINDOW_DAYS + ' days',\n  'New candidates this week: ' + newThisWeek,\n  'Placed this week: ' + placedThisWeek,\n  'Shortlisted this week: ' + shortlistedThisWeek,\n  'Interviews booked right now: ' + interviewsBooked,\n  'Candidates with no activity in the window: ' + stuckCount,\n  'Candidates missing documents: ' + missingDocsCount,\n  'Total candidates on file: ' + totalCandidates,\n  'Candidates by status: ' + breakdown(byStatus),\n  'Candidates by trade: ' + breakdown(byTrade),\n  'Open roles: ' + openCount,\n  'Roles on hold: ' + onHoldCount,\n  'Roles filled to date: ' + filledCount,\n  'Headcount still needed across open roles: ' + openHeadcount,\n  'Days the oldest open role has been open: ' + oldestOpenDays,\n  'Open roles by country: ' + breakdown(byCountry)\n].join('\\n');\n\nreturn [{\n  json: {\n    reportWindowDays: REPORT_WINDOW_DAYS,\n    totalCandidates: totalCandidates,\n    newThisWeek: newThisWeek,\n    placedThisWeek: placedThisWeek,\n    shortlistedThisWeek: shortlistedThisWeek,\n    interviewsBooked: interviewsBooked,\n    stuckCount: stuckCount,\n    missingDocsCount: missingDocsCount,\n    byStatus: byStatus,\n    byTrade: byTrade,\n    openCount: openCount,\n    onHoldCount: onHoldCount,\n    filledCount: filledCount,\n    openHeadcount: openHeadcount,\n    oldestOpenDays: oldestOpenDays,\n    openRolesList: openRolesList,\n    byCountry: byCountry,\n    factsBlock: factsBlock\n  }\n}];"
      }
    },
    {
      "id": "write-weekly-report",
      "name": "Have Claude write the weekly report",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        1100,
        5900
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=You write the Monday morning report for REPLACE_WITH_COMPANY_NAME, a recruitment company that places skilled workers with employers across Europe.\n\nHere are this week's numbers. They are already counted for you.\n\n{{ $json.factsBlock }}\n\nOpen roles right now:\n{{ ($json.openRolesList || []).join('\\n') }}\n\nRules for the numbers:\n- Use only the numbers above. Never invent a figure, a percentage, or a trend you cannot see here.\n- If a number looks bad, say so plainly. Do not soften it.\n- If a number is zero, say it is zero.\n- Do not guess at causes. Report what the numbers show.\n\nHow to write:\n- Plain texting register. Short sentences. Say the thing.\n- No corporate phrases. Never write \"reach out\", \"touch base\", \"circle back\", \"streamline\", \"leverage\", \"seamless\", or \"robust\".\n- No emojis. No em dashes. No exclamation marks.\n- No three item lists written for rhythm.\n- Do not tack \"-ing\" clauses onto the end of sentences.\n\nThe body runs 200 to 300 words as plain text with four short labelled sections, in this order: What came in, What moved, What is stuck, Where the gaps are. Put a blank line between sections.\n\nwatchItems holds up to 4 short lines a manager should act on this week. One action each.\n\nReply with JSON only. No other text, no code fence. Use this exact shape:\n{\"headline\": \"one sentence on the week\", \"body\": \"the report as plain text\", \"watchItems\": [\"short action\", \"short action\"]}"
            }
          ]
        },
        "options": {}
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "read-written-report",
      "name": "Read the written report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        5900
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const facts = $('Count what happened last week').first().json || {};\n\nlet headline = '';\nlet body = '';\nlet watchItems = [];\nlet writeUpFailed = false;\n\ntry {\n  const raw = $json.content[0].text;\n  const parsed = JSON.parse(raw);\n\n  headline = String(parsed.headline || '').trim();\n  body = String(parsed.body || '').trim();\n  watchItems = Array.isArray(parsed.watchItems) ? parsed.watchItems : [];\n\n  if (!headline || !body) throw new Error('The write-up came back empty');\n} catch (error) {\n  writeUpFailed = true;\n\n  headline = 'The write-up did not come back, so here are the raw numbers for the week.';\n  body = 'The weekly write-up failed, so this report is the numbers straight from Airtable.\\n\\n' + (facts.factsBlock || 'No numbers were available either.');\n  watchItems = [\n    'The weekly write-up failed. Read the numbers above and check the report run in n8n.'\n  ];\n}\n\nwatchItems = watchItems\n  .map(function (item) { return String(item).trim(); })\n  .filter(function (item) { return item.length > 0; })\n  .slice(0, 4);\n\nconst watchText = watchItems.length\n  ? watchItems.map(function (item) { return '- ' + item; }).join('\\n')\n  : '- Nothing needs a decision this week.';\n\nreturn [{\n  json: {\n    headline: headline,\n    body: body,\n    watchItems: watchItems,\n    watchText: watchText,\n    writeUpFailed: writeUpFailed,\n    reportWindowDays: facts.reportWindowDays || 7,\n    newThisWeek: facts.newThisWeek || 0,\n    placedThisWeek: facts.placedThisWeek || 0,\n    shortlistedThisWeek: facts.shortlistedThisWeek || 0,\n    interviewsBooked: facts.interviewsBooked || 0,\n    stuckCount: facts.stuckCount || 0,\n    missingDocsCount: facts.missingDocsCount || 0,\n    openCount: facts.openCount || 0,\n    openHeadcount: facts.openHeadcount || 0,\n    oldestOpenDays: facts.oldestOpenDays || 0\n  }\n}];"
      }
    },
    {
      "id": "email-weekly-report",
      "name": "Email the weekly report",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1540,
        5900
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "REPLACE_WITH_TEAM_EMAIL",
        "subject": "=Weekly report: {{ $json.newThisWeek }} new candidates, {{ $json.placedThisWeek }} placed, {{ $json.openCount }} roles open",
        "emailType": "text",
        "message": "={{ $json.headline }}\n\n{{ $json.body }}\n\nWatch this week:\n{{ $json.watchText }}\n\nNumbers cover the last {{ $json.reportWindowDays }} days and come straight from Airtable.",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "post-weekly-report",
      "name": "Post the weekly report",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        1760,
        5900
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "REPLACE_WITH_RECRUITING_SLACK_CHANNEL"
        },
        "text": "=*Monday report* {{ $json.headline }}\n\n{{ $json.newThisWeek }} new candidates, {{ $json.placedThisWeek }} placed, {{ $json.openCount }} roles open, {{ $json.stuckCount }} candidates gone quiet.\n\nWatch this week:\n{{ $json.watchText }}",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "sticky-7",
      "name": "Flow 7: GDPR retention sweep",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        6820
      ],
      "parameters": {
        "content": "## Flow 7: GDPR retention sweep\n- Runs on the 1st of each month. Emails candidates whose consent is close to the limit and asks if they want to stay on file, and archives the ones already past it. The data lead gets a summary of what changed.\n- The two month settings sit at the top of the \"Decide who to ask and who to archive\" step: ASK_AFTER_MONTHS (23) and ARCHIVE_AFTER_MONTHS (24). Change them there, nowhere else.\n- Fill REPLACE_WITH_DATA_LEAD_EMAIL so the summary goes to the right person. Needs the Airtable and Gmail credentials. Writes Status, Notes and Last Activity on the Candidates table.\n- Every candidate record needs a Consent Date. Records with a blank or unreadable date are skipped, so nothing is archived by accident.\n- 23 and 24 months is a starting point, not legal advice. Use the window your own lawyer gives you.",
        "height": 360,
        "width": 760
      }
    },
    {
      "id": "retention-schedule",
      "name": "Check data retention on the 1st of each month",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        7300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "months",
              "triggerAtDayOfMonth": 1,
              "triggerAtHour": 6
            }
          ]
        }
      }
    },
    {
      "id": "retention-find-records",
      "name": "Find candidate records nearing the retention limit",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        220,
        7300
      ],
      "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({Consent Date}!='', IS_BEFORE({Consent Date}, DATEADD(NOW(), -23, 'months')), {Status}!='Placed', {Status}!='Archived')",
        "returnAll": true,
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "retention-decide",
      "name": "Decide who to ask and who to archive",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        7300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ---- Tunable rules: edit these two numbers and nothing else ----\nconst ASK_AFTER_MONTHS = 23;      // months on file before we email and ask if they want to stay\nconst ARCHIVE_AFTER_MONTHS = 24;  // months on file before the record gets archived\n// ----------------------------------------------------------------\n\nconst now = new Date();\nconst out = [];\n\nfor (const item of $input.all()) {\n  const row = item.json || {};\n  const fields = row.fields || row;\n\n  const consentRaw = fields['Consent Date'] || '';\n  if (!consentRaw) {\n    continue; // no consent date on the record, leave it alone\n  }\n\n  const consent = new Date(consentRaw);\n  if (isNaN(consent.getTime())) {\n    continue; // date we cannot read, leave it alone\n  }\n\n  let monthsOnFile =\n    (now.getFullYear() - consent.getFullYear()) * 12 +\n    (now.getMonth() - consent.getMonth());\n  if (now.getDate() < consent.getDate()) {\n    monthsOnFile = monthsOnFile - 1;\n  }\n\n  if (monthsOnFile < ASK_AFTER_MONTHS) {\n    continue; // not old enough to do anything yet\n  }\n\n  const name = String(fields['Name'] || '').trim();\n  const email = String(fields['Email'] || '').trim().toLowerCase();\n  const action = monthsOnFile >= ARCHIVE_AFTER_MONTHS ? 'archive' : 'ask';\n\n  out.push({\n    json: {\n      recordId: row.id,\n      name: name,\n      email: email,\n      monthsOnFile: monthsOnFile,\n      action: action,\n    },\n  });\n}\n\nreturn out;\n"
      }
    },
    {
      "id": "retention-past-limit",
      "name": "Past the limit already?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        660,
        7300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.action }}",
              "rightValue": "archive",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "retention-archive-record",
      "name": "Archive the candidate record",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        880,
        7180
      ],
      "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": "Archived",
            "Notes": "=Archived on {{ $now.toFormat('yyyy-MM-dd') }} because the consent on this record was over the retention window.",
            "Last Activity": "={{ $now.toFormat('yyyy-MM-dd') }}"
          },
          "matchingColumns": [
            "id"
          ],
          "schema": []
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "retention-sum-up",
      "name": "Sum up what changed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        7180
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ---- Tunable rule: how many names to list in the summary email ----\nconst MAX_LINES = 50;\n// -------------------------------------------------------------------\n\nconst archived = $input.all();\n\nconst now = new Date();\nconst runDate = now.toLocaleDateString('en-GB', {\n  day: 'numeric',\n  month: 'long',\n  year: 'numeric',\n});\nconst runMonth = now.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });\n\n// months on file came from the decide step, match it back by record id\nconst monthsById = {};\nfor (const decided of $('Decide who to ask and who to archive').all()) {\n  monthsById[decided.json.recordId] = decided.json;\n}\n\nconst lines = [];\nfor (const item of archived) {\n  const row = item.json || {};\n  const fields = row.fields || row;\n  const recordId = row.id || row.recordId || '';\n  const matched = monthsById[recordId] || {};\n\n  const name = String(fields['Name'] || matched.name || 'Unnamed candidate').trim();\n  const months = matched.monthsOnFile;\n  const monthText = typeof months === 'number' ? months + ' months on file' : 'months on file unknown';\n\n  lines.push(name + ' - ' + monthText);\n}\n\nconst shown = lines.slice(0, MAX_LINES);\nif (lines.length > MAX_LINES) {\n  shown.push('and ' + (lines.length - MAX_LINES) + ' more');\n}\n\nconst archivedList = shown.length\n  ? shown.join('\\n')\n  : 'Nothing was archived this month.';\n\nreturn [\n  {\n    json: {\n      archivedCount: lines.length,\n      archivedList: archivedList,\n      runDate: runDate,\n      runMonth: runMonth,\n    },\n  },\n];\n"
      }
    },
    {
      "id": "retention-tell-data-lead",
      "name": "Tell the data lead what was archived",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1320,
        7180
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "REPLACE_WITH_DATA_LEAD_EMAIL",
        "subject": "=Retention sweep for {{ $json.runMonth }}: {{ $json.archivedCount }} candidate records archived",
        "emailType": "text",
        "message": "={{ $json.archivedCount }} candidate records were archived on {{ $json.runDate }}. Their consent was older than the retention window.\n\n{{ $json.archivedList }}\n\nCandidates who are one month short of the limit were emailed today and can reply to stay on file. If they reply, take them out of next month's sweep by updating their consent date.\n\nRecords with a missing or unreadable consent date were skipped, not archived.",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "retention-ask-candidate",
      "name": "Ask the candidate if they want to stay on file",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        880,
        7440
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $json.email }}",
        "subject": "=Do you want to stay on our candidate list?",
        "emailType": "text",
        "message": "=Hi {{ $json.name }},\n\nWe have had your details on file for {{ $json.monthsOnFile }} months. Our rule is to take a candidate record off the list once the consent behind it gets old, so yours is due to come off next month.\n\nIf you want to stay on the list, reply to this email and say so. That is all it takes. We keep your details and carry on sending you roles that fit.\n\nIf you would rather we did not keep your details, do nothing. Your record comes off the list next month.\n\nThanks,\nREPLACE_WITH_COMPANY_NAME",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "A candidate applies on the website": {
      "main": [
        [
          {
            "node": "Tidy up the website application",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tidy up the website application": {
      "main": [
        [
          {
            "node": "Reply to the careers form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply to the careers form": {
      "main": [
        [
          {
            "node": "Have Claude read the CV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Watch the applications inbox": {
      "main": [
        [
          {
            "node": "Tidy up the emailed application",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tidy up the emailed application": {
      "main": [
        [
          {
            "node": "Have Claude read the CV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Have Claude read the CV": {
      "main": [
        [
          {
            "node": "Turn the CV read into candidate fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Turn the CV read into candidate fields": {
      "main": [
        [
          {
            "node": "Save the candidate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save the candidate": {
      "main": [
        [
          {
            "node": "Send the candidate a confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "An employer sends a new vacancy": {
      "main": [
        [
          {
            "node": "Tidy up the vacancy details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tidy up the vacancy details": {
      "main": [
        [
          {
            "node": "Save the vacancy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save the vacancy": {
      "main": [
        [
          {
            "node": "Reply to the vacancy form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply to the vacancy form": {
      "main": [
        [
          {
            "node": "Confirm the vacancy with the employer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confirm the vacancy with the employer": {
      "main": [
        [
          {
            "node": "Tell the team about the new vacancy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match new candidates every 30 minutes": {
      "main": [
        [
          {
            "node": "Get the open roles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get the open roles": {
      "main": [
        [
          {
            "node": "Hold the open roles in one list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hold the open roles in one list": {
      "main": [
        [
          {
            "node": "Find candidates waiting to be screened",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find candidates waiting to be screened": {
      "main": [
        [
          {
            "node": "Pair each candidate with the open roles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pair each candidate with the open roles": {
      "main": [
        [
          {
            "node": "Have Claude score the match",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Have Claude score the match": {
      "main": [
        [
          {
            "node": "Read the match result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the match result": {
      "main": [
        [
          {
            "node": "Update the candidate with the match",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update the candidate with the match": {
      "main": [
        [
          {
            "node": "Is this a strong match?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is this a strong match?": {
      "main": [
        [
          {
            "node": "Tell the team about a strong match",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "A recruiter books an interview": {
      "main": [
        [
          {
            "node": "Tidy up the interview details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tidy up the interview details": {
      "main": [
        [
          {
            "node": "Put the interview on the calendar",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Put the interview on the calendar": {
      "main": [
        [
          {
            "node": "Reply to the booking request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply to the booking request": {
      "main": [
        [
          {
            "node": "Send the candidate the interview details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the candidate the interview details": {
      "main": [
        [
          {
            "node": "Send the employer the interview details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the employer the interview details": {
      "main": [
        [
          {
            "node": "Mark the candidate as interview booked",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send interview reminders every morning at 8": {
      "main": [
        [
          {
            "node": "Get tomorrow's interviews",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get tomorrow's interviews": {
      "main": [
        [
          {
            "node": "Write the reminder for each interview",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the reminder for each interview": {
      "main": [
        [
          {
            "node": "Remind the candidate about tomorrow",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check paperwork every morning at 9": {
      "main": [
        [
          {
            "node": "Find candidates missing documents",
            "type": "main",
            "index": 0
          },
          {
            "node": "Find candidates nobody has touched in a week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find candidates missing documents": {
      "main": [
        [
          {
            "node": "Work out who to chase today",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Work out who to chase today": {
      "main": [
        [
          {
            "node": "Has this one been chased enough?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has this one been chased enough?": {
      "main": [
        [
          {
            "node": "Ask a recruiter to phone about documents",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Email the candidate their document checklist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ask a recruiter to phone about documents": {
      "main": [
        [
          {
            "node": "Note the document reminder on the candidate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email the candidate their document checklist": {
      "main": [
        [
          {
            "node": "Note the document reminder on the candidate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find candidates nobody has touched in a week": {
      "main": [
        [
          {
            "node": "Group the quiet candidates by recruiter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Group the quiet candidates by recruiter": {
      "main": [
        [
          {
            "node": "Send each recruiter their quiet list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the weekly report every Monday at 8": {
      "main": [
        [
          {
            "node": "Read every vacancy for the report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read every vacancy for the report": {
      "main": [
        [
          {
            "node": "Count the vacancy numbers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count the vacancy numbers": {
      "main": [
        [
          {
            "node": "Read every candidate for the report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read every candidate for the report": {
      "main": [
        [
          {
            "node": "Count what happened last week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count what happened last week": {
      "main": [
        [
          {
            "node": "Have Claude write the weekly report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Have Claude write the weekly report": {
      "main": [
        [
          {
            "node": "Read the written report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the written report": {
      "main": [
        [
          {
            "node": "Email the weekly report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email the weekly report": {
      "main": [
        [
          {
            "node": "Post the weekly report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check data retention on the 1st of each month": {
      "main": [
        [
          {
            "node": "Find candidate records nearing the retention limit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find candidate records nearing the retention limit": {
      "main": [
        [
          {
            "node": "Decide who to ask and who to archive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide who to ask and who to archive": {
      "main": [
        [
          {
            "node": "Past the limit already?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Past the limit already?": {
      "main": [
        [
          {
            "node": "Archive the candidate record",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ask the candidate if they want to stay on file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Archive the candidate record": {
      "main": [
        [
          {
            "node": "Sum up what changed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sum up what changed": {
      "main": [
        [
          {
            "node": "Tell the data lead what was archived",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}