This workflow corresponds to n8n.io template #16549 — we link there as the canonical source.
This workflow follows the Agent → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "zThgJDkFr6kU9SI2",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Talent Atlas AI",
"tags": [],
"nodes": [
{
"id": "2da6e92f-cbd8-4ada-8bb0-8d90dfc31263",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
14144,
5776
],
"parameters": {
"width": 480,
"height": 896,
"content": "## Talent Atlas Final\n\n### How it works\n\n1. The workflow receives a candidate search request via a webhook, validates and extracts job requirements using an AI agent.\n2. It builds a search brief and queries multiple sources (GitHub, LinkedIn via Apify, Stack Overflow, Hacker News) in parallel.\n3. Results from each source are normalized, merged, sanitized and deduplicated into a candidate pool.\n4. If candidates exist, the workflow scores evidence, builds a recruiter-facing shortlist, optionally saves candidates to Supabase, and returns a summarized result; if no candidates are found or the request is invalid it responds accordingly.\n5. AI is used twice: to extract JD requirements from the request and to summarize the final shortlist for recruiters.\n\n### Setup steps\n\n- [ ] Deploy the webhook endpoint and point your client to the Candidate Search Webhook URL.\n- [ ] Configure AI credentials (Groq/OpenAI) for the agent nodes (Extract JD Requirements, Summarize Shortlist).\n- [ ] Add API credentials: Apify key for LinkedIn scraping, GitHub token for user/repos endpoints, and Stack Exchange/HN access if applicable.\n- [ ] Configure Supabase credentials and database/table details if you want Save Candidate to Supabase enabled.\n- [ ] Ensure environment variables used by custom code nodes (rate limits, default search parameters) are set in n8n.\n\n### Customization\n\nAdjust search brief construction and normalization code to tune which candidate attributes are returned; change the summarization prompt or model settings to alter recruiter-facing output; enable/disable Supabase save with the Save Results Enabled? conditional."
},
"typeVersion": 1
},
{
"id": "5533cdbe-8469-4ca5-b44e-b56d909c5e1d",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
14704,
6496
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Receive and normalize request\n\nWebhook trigger and initial input normalization that prepares the incoming request payload for validation and downstream processing."
},
"typeVersion": 1
},
{
"id": "e237fada-b7d0-4b68-9015-fdf945117cb4",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
15152,
6528
],
"parameters": {
"color": 7,
"width": 768,
"height": 464,
"content": "## Validate request and errors\n\nChecks whether the incoming request is valid and, on failure, builds and returns a 400 Invalid Request response."
},
"typeVersion": 1
},
{
"id": "37640bf9-0fc0-435a-959b-b1dcf9b82cad",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
15376,
5984
],
"parameters": {
"color": 7,
"width": 544,
"height": 512,
"content": "## Extract JD and brief\n\nAI extraction of job description requirements and construction of the search brief used to query external sources."
},
"typeVersion": 1
},
{
"id": "d281563a-a37a-45b9-937b-c3f0d6e55f31",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
15952,
6240
],
"parameters": {
"color": 7,
"width": 1536,
"height": 336,
"content": "## Search GitHub profiles\n\nQuery GitHub for users by role/location, split results, fetch repositories and profiles, group by owner, and normalize GitHub evidence items."
},
"typeVersion": 1
},
{
"id": "ce973fa9-9b26-443f-9768-1862ce08d313",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
17072,
5888
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Search LinkedIn via Apify\n\nCall Apify act to scrape LinkedIn results for the search brief and normalize the LinkedIn output into the pipeline format."
},
"typeVersion": 1
},
{
"id": "f758a294-27f9-4a26-b4b5-f02003eca3ff",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
17520,
6016
],
"parameters": {
"color": 7,
"width": 240,
"height": 384,
"content": "## Merge LinkedIn and GitHub\n\nAppend LinkedIn and GitHub normalized outputs together as a single stream for downstream merging."
},
"typeVersion": 1
},
{
"id": "38429245-be94-4ba6-8ffb-185971e98426",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
16400,
6608
],
"parameters": {
"color": 7,
"width": 640,
"height": 304,
"content": "## Search Stack Overflow\n\nQuery Stack Exchange for expert users, split results and normalize them into the pipeline format."
},
"typeVersion": 1
},
{
"id": "7fe8eb17-271e-4405-8783-ae7cf154c480",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
15952,
6944
],
"parameters": {
"color": 7,
"width": 1088,
"height": 304,
"content": "## Search Hacker News\n\nFind the latest hiring thread on Hacker News, select the exact thread, search candidate posts, split results and normalize items."
},
"typeVersion": 1
},
{
"id": "b8077ff6-d254-430b-aaaf-2a7f65230b76",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"position": [
17072,
6608
],
"parameters": {
"color": 7,
"width": 240,
"height": 384,
"content": "## Merge Stack Overflow and HN\n\nAppend and unify the normalized Stack Overflow and Hacker News outputs into a combined stream."
},
"typeVersion": 1
},
{
"id": "b436c045-5bd1-4d93-bc49-ca8669b18f7b",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"position": [
17792,
6208
],
"parameters": {
"color": 7,
"width": 240,
"height": 368,
"content": "## Merge all sources\n\nCombine the LinkedIn/GitHub stream and the StackOverflow/HN stream into a single candidate pool for sanitation."
},
"typeVersion": 1
},
{
"id": "f3d24fc9-1c25-4cda-880e-fe242c55aac1",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"position": [
18064,
6208
],
"parameters": {
"color": 7,
"width": 240,
"height": 368,
"content": "## Sanitize candidate pool\n\nRun deduplication, filtering and normalization across the combined candidate list to prepare for scoring."
},
"typeVersion": 1
},
{
"id": "7f7ce570-963a-4afd-9261-f97e88b67f99",
"name": "Sticky Note12",
"type": "n8n-nodes-base.stickyNote",
"position": [
18336,
6288
],
"parameters": {
"color": 7,
"width": 240,
"height": 496,
"content": "## Handle no results\n\nIf no candidates remain after sanitation, build the no-results response and return a 200 with an empty result set."
},
"typeVersion": 1
},
{
"id": "ae4f0f63-4441-42e8-a1e6-661085869fdc",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"position": [
18416,
5776
],
"parameters": {
"color": 7,
"width": 1440,
"height": 480,
"content": "## Score and summarize shortlist\n\nCalculate evidence scores for candidates, build the recruiter response, generate a summarized shortlist via AI, attach it and respond with results."
},
"typeVersion": 1
},
{
"id": "5fc775f7-49d6-4dad-b7c9-bb7fb2302c75",
"name": "Sticky Note14",
"type": "n8n-nodes-base.stickyNote",
"position": [
18640,
6288
],
"parameters": {
"color": 7,
"width": 416,
"height": 320,
"content": "## Save results to Supabase\n\nConditional branch that optionally persists candidate records to Supabase if saving is enabled."
},
"typeVersion": 1
},
{
"id": "ce431579-8f65-4546-ba81-77ea1e3ab81f",
"name": "Candidate Search Webhook",
"type": "n8n-nodes-base.webhook",
"position": [
14752,
6656
],
"parameters": {
"path": "talent-atlas-candidate-search",
"options": {},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 2.1
},
{
"id": "2700e68c-6b05-444a-b013-c402626b4c6e",
"name": "Normalize Webhook Input",
"type": "n8n-nodes-base.code",
"position": [
14976,
6656
],
"parameters": {
"jsCode": "const incoming = $input.first().json;\nlet payload = incoming.body ?? incoming;\n\nif (typeof payload === 'string') {\n try {\n payload = JSON.parse(payload);\n } catch {\n payload = { job_description: payload };\n }\n}\n\npayload = payload && typeof payload === 'object' ? payload : {};\n\nconst firstText = (...values) => {\n for (const value of values) {\n if (typeof value === 'string' && value.trim()) return value.trim();\n }\n return '';\n};\n\nconst toArray = (value) => {\n if (Array.isArray(value)) {\n return [...new Set(value.map((item) => String(item).trim()).filter(Boolean))];\n }\n if (typeof value !== 'string') return [];\n return [...new Set(value.split(/[,\\n;|]/).map((item) => item.trim()).filter(Boolean))];\n};\n\nconst clamp = (value, fallback, min, max) => {\n const number = Number(value);\n return Number.isFinite(number) ? Math.min(max, Math.max(min, Math.round(number))) : fallback;\n};\n\nconst toBoolean = (value, fallback = false) => {\n if (typeof value === 'boolean') return value;\n if (typeof value === 'number') return value !== 0;\n if (typeof value === 'string') {\n const normalized = value.trim().toLowerCase();\n if (['true', '1', 'yes', 'y'].includes(normalized)) return true;\n if (['false', '0', 'no', 'n'].includes(normalized)) return false;\n }\n return fallback;\n};\n\nconst jobDescription = firstText(\n payload.job_description,\n payload.jobDescription,\n payload.jd,\n payload.description,\n);\n\nconst explicit = {\n job_title: firstText(payload.job_title, payload.jobTitle, payload.title),\n skills: toArray(payload.skills ?? payload.required_skills ?? payload.keywords),\n location: firstText(payload.location, payload.job_location),\n experience: firstText(payload.experience, payload.years_of_experience),\n seniority: firstText(payload.seniority, payload.level),\n employment_type: firstText(payload.employment_type, payload.employmentType),\n};\n\nconst valid = Boolean(jobDescription || explicit.job_title || explicit.skills.length);\nconst synthesizedDescription = [\n explicit.job_title && 'Job title: ' + explicit.job_title,\n explicit.skills.length && 'Required skills: ' + explicit.skills.join(', '),\n explicit.location && 'Location: ' + explicit.location,\n explicit.experience && 'Experience: ' + explicit.experience,\n explicit.seniority && 'Seniority: ' + explicit.seniority,\n explicit.employment_type && 'Employment type: ' + explicit.employment_type,\n].filter(Boolean).join('\\n');\n\nreturn [{\n json: {\n valid,\n error: valid ? null : 'Provide job_description (or jd), or provide job_title/skills.',\n request_id: firstText(payload.request_id, payload.requestId) ||\n 'req_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),\n job_description: jobDescription || synthesizedDescription,\n explicit,\n max_candidates_per_source: clamp(\n payload.max_candidates_per_source ?? payload.max_candidates,\n 5,\n 1,\n 10,\n ),\n max_results: clamp(payload.max_results, 20, 1, 50),\n score_threshold: clamp(payload.score_threshold, 65, 0, 100),\n save_results: toBoolean(payload.save_results, false),\n },\n}];"
},
"typeVersion": 2
},
{
"id": "509cb265-318e-45c2-9271-7147dfe2649f",
"name": "Request Is Valid?",
"type": "n8n-nodes-base.if",
"position": [
15200,
6656
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "f2f677a7-338d-4547-ae12-c96541484a76",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.valid }}"
}
]
}
},
"typeVersion": 2.2
},
{
"id": "2c61d6f0-48a1-4250-94d7-6d07847e1529",
"name": "Build Invalid Request Response",
"type": "n8n-nodes-base.code",
"position": [
15488,
6816
],
"parameters": {
"jsCode": "const input = $input.first().json;\nreturn [{\n json: {\n ok: false,\n error: input.error,\n accepted_input: {\n raw: ['job_description', 'jd', 'description'],\n structured: ['job_title', 'skills', 'location', 'experience', 'seniority', 'employment_type'],\n options: ['max_candidates_per_source', 'max_results', 'score_threshold', 'save_results'],\n },\n },\n}];"
},
"typeVersion": 2
},
{
"id": "df12ff4a-ebcb-4483-8148-189334cda892",
"name": "Respond 400 - Invalid Request",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
15776,
6816
],
"parameters": {
"options": {
"responseCode": 400
},
"respondWith": "json",
"responseBody": "={{ $json }}"
},
"typeVersion": 1.5
},
{
"id": "95227dfe-aecd-4a78-a74f-40376aaeb360",
"name": "Extract JD Requirements",
"type": "@n8n/n8n-nodes-langchain.agent",
"maxTries": 2,
"position": [
15424,
6112
],
"parameters": {
"text": "={{ 'Extract a structured hiring brief from this job description. Preserve explicit requirements and do not invent mandatory qualifications.\\n\\nJOB DESCRIPTION:\\n' + $json.job_description }}",
"options": {
"systemMessage": "You are a precise job-description parsing engine. Return only the structured object required by the output parser. Separate must-have skills from nice-to-have skills. Use empty strings or empty arrays when the JD does not provide a value."
},
"promptType": "define",
"hasOutputParser": true
},
"retryOnFail": true,
"typeVersion": 3.1,
"waitBetweenTries": 1000
},
{
"id": "32aadb45-a42f-49ff-80fe-f91a48ef265d",
"name": "Model - Groq JD Extraction",
"type": "@n8n/n8n-nodes-langchain.lmChatGroq",
"position": [
15440,
6336
],
"parameters": {
"model": "llama-3.3-70b-versatile",
"options": {
"temperature": 0.1,
"maxTokensToSample": 1000
}
},
"typeVersion": 1
},
{
"id": "788ff33d-95c2-4cc6-a9f4-f9812dfb87d3",
"name": "JD Requirements Parser",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"position": [
15568,
6336
],
"parameters": {
"jsonSchemaExample": "{\"job_title\":\"Senior Backend Engineer\",\"location\":\"Remote\",\"experience\":\"5+ years\",\"seniority\":\"Senior\",\"employment_type\":\"Full-time\",\"skills\":[\"Node.js\",\"PostgreSQL\",\"AWS\"],\"must_have_skills\":[\"Node.js\",\"PostgreSQL\"],\"nice_to_have_skills\":[\"Kubernetes\"],\"responsibilities\":[\"Design and operate backend services\"],\"education\":\"\",\"keywords\":[\"backend\",\"distributed systems\",\"REST APIs\"]}"
},
"typeVersion": 1.3
},
{
"id": "4d9b09b6-23a8-452f-8a36-285a7adf166f",
"name": "Build Search Brief",
"type": "n8n-nodes-base.code",
"position": [
15776,
6208
],
"parameters": {
"jsCode": "const normalized = $('Normalize Webhook Input').first().json;\nconst input = $input.first().json;\nconst extracted = input.output && typeof input.output === 'object' ? input.output : {};\nconst explicit = normalized.explicit || {};\n\nconst toArray = (value) => {\n if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);\n if (typeof value === 'string') return value.split(/[,\\n;|]/).map((item) => item.trim()).filter(Boolean);\n return [];\n};\n\nconst unique = (values) => [...new Set(values.filter(Boolean))];\nconst skills = explicit.skills?.length\n ? explicit.skills\n : unique([\n ...toArray(extracted.must_have_skills),\n ...toArray(extracted.skills),\n ...toArray(extracted.nice_to_have_skills),\n ]);\n\nconst primarySkill = skills[0] || 'javascript';\nconst normalizedSkill = primarySkill.toLowerCase().replace(/\\s+/g, '');\n\nconst githubLanguageMap = {\n '.net': 'C#',\n '.netcore': 'C#',\n 'asp.net': 'C#',\n 'asp.netcore': 'C#',\n 'c#': 'C#',\n 'react': 'JavaScript',\n 'react.js': 'JavaScript',\n 'reactjs': 'JavaScript',\n 'node': 'JavaScript',\n 'node.js': 'JavaScript',\n 'nodejs': 'JavaScript',\n 'typescript': 'TypeScript',\n 'python': 'Python',\n 'java': 'Java',\n 'golang': 'Go',\n 'go': 'Go',\n 'ruby': 'Ruby',\n 'php': 'PHP',\n 'rust': 'Rust',\n};\n\nconst stackOverflowTagMap = {\n '.net': '.net',\n '.netcore': 'asp.net-core',\n 'asp.net': 'asp.net',\n 'asp.netcore': 'asp.net-core',\n 'c#': 'c#',\n 'react': 'reactjs',\n 'react.js': 'reactjs',\n 'reactjs': 'reactjs',\n 'node': 'node.js',\n 'node.js': 'node.js',\n 'nodejs': 'node.js',\n};\n\nconst githubTermMap = {\n '.net': 'dotnet',\n '.net core': 'asp.net-core',\n '.netcore': 'asp.net-core',\n 'asp.net': 'asp.net-core',\n 'asp.net core': 'asp.net-core',\n 'asp.netcore': 'asp.net-core',\n 'c#': 'csharp',\n 'react': 'react',\n 'react.js': 'react',\n 'reactjs': 'react',\n 'typescript': 'typescript',\n 'rest api': 'rest-api',\n 'rest apis': 'rest-api',\n 'sql server': 'sql-server',\n 'azure': 'azure',\n 'docker': 'docker',\n 'microservices': 'microservices',\n 'kubernetes': 'kubernetes',\n 'node': 'nodejs',\n 'node.js': 'nodejs',\n 'nodejs': 'nodejs',\n};\n\nconst githubLanguage = githubLanguageMap[normalizedSkill] || primarySkill;\nconst githubTerms = unique(\n skills.map((skill) =>\n githubTermMap[String(skill).trim().toLowerCase()] ||\n String(skill).trim().toLowerCase().replace(/\\s+/g, '-')\n ),\n).filter((term) =>\n !(githubLanguage.toLowerCase() === 'c#' && term === 'csharp')\n);\nconst repositoryTerms = githubTerms.slice(0, 2);\nconst pushedAfterYear = new Date().getUTCFullYear() - 2;\nconst resolvedJobTitle = explicit.job_title || extracted.job_title || 'Software Engineer';\nconst linkedinTitleVariants = unique([\n resolvedJobTitle,\n resolvedJobTitle.replace(/^(senior|lead|principal)\\s+/i, '').trim(),\n resolvedJobTitle.replace(/\\bdeveloper\\b/i, 'Engineer'),\n resolvedJobTitle.replace(/\\bengineer\\b/i, 'Developer'),\n]).filter(Boolean);\nconst githubRoleTerms = resolvedJobTitle\n .toLowerCase()\n .split(/[^a-z0-9+#.]+/)\n .filter((term) =>\n term.length >= 4 &&\n !['senior', 'junior', 'lead', 'principal', 'developer', 'engineer'].includes(term)\n )\n .slice(0, 3)\n .join(' ');\nconst githubLocationQualifier = normalized.explicit?.location || extracted.location\n ? 'location:\"' + (normalized.explicit.location || extracted.location) + '\"'\n : '';\n\nconst brief = {\n request_id: normalized.request_id,\n job_title: resolvedJobTitle,\n location: explicit.location || extracted.location || 'Remote',\n experience: explicit.experience || extracted.experience || '',\n seniority: explicit.seniority || extracted.seniority || '',\n employment_type: explicit.employment_type || extracted.employment_type || '',\n skills: unique(skills),\n skills_str: unique(skills).join(', '),\n must_have_skills: unique(toArray(extracted.must_have_skills).length ? toArray(extracted.must_have_skills) : skills),\n nice_to_have_skills: unique(toArray(extracted.nice_to_have_skills)),\n responsibilities: unique(toArray(extracted.responsibilities)),\n education: extracted.education || '',\n keywords: unique([...toArray(extracted.keywords), ...skills]),\n primary_skill: primarySkill,\n linkedin_title_variants: linkedinTitleVariants,\n linkedin_search_query: [\n resolvedJobTitle,\n ...unique(toArray(extracted.must_have_skills).length ? toArray(extracted.must_have_skills) : skills).slice(0, 4),\n ].join(' '),\n github_language: githubLanguage,\n github_repository_terms: repositoryTerms,\n github_user_query: [\n githubRoleTerms,\n 'language:' + githubLanguage,\n githubLocationQualifier,\n 'repos:>3',\n 'type:user',\n ].filter(Boolean).join(' '),\n github_repository_query: [\n ...repositoryTerms,\n 'in:name,description,readme',\n 'language:' + githubLanguage,\n 'fork:false',\n 'archived:false',\n 'pushed:>' + pushedAfterYear + '-01-01',\n ].join(' '),\n stackoverflow_tag: stackOverflowTagMap[normalizedSkill] || primarySkill.toLowerCase().replace(/\\s+/g, '-'),\n max_candidates_per_source: normalized.max_candidates_per_source,\n max_results: normalized.max_results,\n score_threshold: normalized.score_threshold,\n save_results: normalized.save_results,\n};\n\nreturn [{ json: brief }];"
},
"typeVersion": 2
},
{
"id": "197b82fd-4975-4a6f-a9d3-e121fe7771a9",
"name": "Search LinkedIn via Apify",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
17120,
6048
],
"parameters": {
"url": "https://api.apify.com/v2/acts/harvestapi~linkedin-profile-search/run-sync-get-dataset-items",
"method": "POST",
"options": {
"timeout": 120000
},
"jsonBody": "={{ ({ profileScraperMode: 'Full', searchQuery: $json.linkedin_search_query, currentJobTitles: $json.linkedin_title_variants, locations: [$json.location], maxItems: $json.max_candidates_per_source, takePages: 1 }) }}",
"sendBody": true,
"specifyBody": "json",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth"
},
"executeOnce": true,
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 2000
},
{
"id": "df942017-0b85-42d2-98d7-2c5a32008740",
"name": "Normalize LinkedIn",
"type": "n8n-nodes-base.code",
"position": [
17344,
6048
],
"parameters": {
"jsCode": "const scalarText = (value, preferredKeys = []) => {\n if (typeof value === 'string') return value.trim();\n if (typeof value === 'number' || typeof value === 'boolean') return String(value);\n if (!value || typeof value !== 'object') return '';\n\n if (Array.isArray(value)) {\n return value\n .map((item) => scalarText(item, preferredKeys))\n .filter(Boolean)\n .join(', ');\n }\n\n const commonKeys = [\n ...preferredKeys,\n 'name',\n 'title',\n 'label',\n 'text',\n 'value',\n 'description',\n 'linkedinText',\n ];\n\n for (const key of commonKeys) {\n const text = scalarText(value[key], preferredKeys);\n if (text) return text;\n }\n\n return '';\n};\n\nconst skillsText = (value) => {\n const values = Array.isArray(value) ? value : [value];\n return [...new Set(\n values\n .map((item) => scalarText(item, ['skillName', 'skill', 'name', 'title']))\n .filter(Boolean),\n )].join(', ');\n};\n\nconst records = $input.all().map((item) => item.json);\nconst candidates = records\n .filter((record) => record && !record.error && (record.profileUrl || record.linkedinUrl || record.url))\n .map((record) => ({\n name: [record.firstName, record.lastName].filter(Boolean).join(' ').trim() ||\n record.fullName || 'LinkedIn profile',\n title: scalarText(record.headline || record.jobTitle, ['headline', 'jobTitle']),\n location: scalarText(\n record.location || record.geoLocation,\n ['linkedinText', 'full', 'city', 'region', 'country'],\n ),\n skills: skillsText(record.skills),\n profile_url: record.profileUrl || record.linkedinUrl || record.url,\n source: 'linkedin',\n email: scalarText(record.email),\n bio_snippet: scalarText(record.about || record.summary),\n source_metrics: {},\n }));\n\nreturn candidates.length\n ? candidates.map((candidate) => ({ json: candidate }))\n : [{ json: { _source_empty: true, source: 'linkedin' } }];"
},
"typeVersion": 2
},
{
"id": "b79d1168-654b-4acf-a070-8ecafb94770f",
"name": "Search GitHub Profiles by Role and Location",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
16000,
6416
],
"parameters": {
"url": "https://api.github.com/search/users",
"options": {
"timeout": 15000
},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $json.github_user_query }}"
},
{
"name": "per_page",
"value": "={{ Math.min(10, Math.max(5, $json.max_candidates_per_source * 2)) }}"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/vnd.github+json"
},
{
"name": "X-GitHub-Api-Version",
"value": "2026-03-10"
},
{
"name": "User-Agent",
"value": "talent-atlas-n8n-workflow"
}
]
}
},
"executeOnce": true,
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 1000
},
{
"id": "a44310a5-ce23-463c-9089-f99fbb2304dc",
"name": "Split GitHub Results",
"type": "n8n-nodes-base.splitOut",
"position": [
16224,
6416
],
"parameters": {
"options": {},
"fieldToSplitOut": "items"
},
"typeVersion": 1,
"alwaysOutputData": true
},
{
"id": "b4ec643a-23ca-4ee1-8f55-08e3bc402081",
"name": "Search Each GitHub User Repositories",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
16448,
6416
],
"parameters": {
"url": "=https://api.github.com/users/{{ $json.login }}/repos",
"options": {
"timeout": 15000
},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "type",
"value": "owner"
},
{
"name": "sort",
"value": "pushed"
},
{
"name": "direction",
"value": "desc"
},
{
"name": "per_page",
"value": "30"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/vnd.github+json"
},
{
"name": "X-GitHub-Api-Version",
"value": "2026-03-10"
},
{
"name": "User-Agent",
"value": "talent-atlas-n8n-workflow"
}
]
}
},
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 750
},
{
"id": "ed593b5d-f4e2-475d-a811-a0011d33461b",
"name": "Group GitHub Repositories by Owner",
"type": "n8n-nodes-base.code",
"position": [
16672,
6416
],
"parameters": {
"jsCode": "const brief = $('Build Search Brief').first().json;\nconst owners = new Map();\n\nfor (const item of $input.all()) {\n const repo = item.json || {};\n const owner = repo.owner || {};\n if (owner.type !== 'User' || !owner.login || repo.fork || repo.archived) continue;\n\n const login = owner.login;\n if (!owners.has(login)) {\n owners.set(login, {\n github_login: login,\n profile_api_url: owner.url || 'https://api.github.com/users/' + encodeURIComponent(login),\n repositories: [],\n });\n }\n\n const record = owners.get(login);\n record.repositories.push(repo);\n}\n\nconst candidates = [...owners.values()]\n .map((owner) => {\n const terms = [\n ...(brief.github_repository_terms || []),\n String(brief.github_language || '').toLowerCase(),\n ];\n const rankedRepositories = owner.repositories\n .map((repo) => {\n const evidence = [\n repo.name,\n repo.description,\n repo.language,\n ...(Array.isArray(repo.topics) ? repo.topics : []),\n ].filter(Boolean).join(' ').toLowerCase();\n const termMatches = terms.filter((term) =>\n evidence.includes(String(term).toLowerCase())\n ).length;\n const pushedAt = Date.parse(repo.pushed_at || '');\n const recent = Number.isFinite(pushedAt) &&\n Date.now() - pushedAt <= 1000 * 60 * 60 * 24 * 365 * 2;\n return {\n repo,\n relevance: termMatches * 10 +\n (recent ? 3 : 0) +\n Math.min(5, Math.log10(Number(repo.stargazers_count || 0) + 1) * 2),\n termMatches,\n };\n })\n .sort((a, b) => b.relevance - a.relevance)\n .slice(0, 5);\n\n const selected = rankedRepositories.map((entry) => entry.repo);\n const repositoryNames = selected.map((repo) => repo.full_name || repo.name || '');\n const repositoryDescriptions = selected.map((repo) => repo.description).filter(Boolean);\n const repositoryLanguages = [...new Set(selected.map((repo) => repo.language).filter(Boolean))];\n const repositoryTopics = [...new Set(selected.flatMap((repo) => Array.isArray(repo.topics) ? repo.topics : []))];\n const relevantRepositoryCount = rankedRepositories.filter((entry) => entry.termMatches > 0).length;\n\n return {\n github_login: owner.github_login,\n profile_api_url: owner.profile_api_url,\n repository_names: repositoryNames,\n repository_urls: selected.map((repo) => repo.html_url || ''),\n repository_descriptions: repositoryDescriptions,\n repository_languages: repositoryLanguages,\n repository_topics: repositoryTopics,\n repository_stars: selected.reduce((sum, repo) => sum + Number(repo.stargazers_count || 0), 0),\n repository_forks: selected.reduce((sum, repo) => sum + Number(repo.forks_count || 0), 0),\n latest_repo_push: selected.map((repo) => repo.pushed_at || '').sort().at(-1) || '',\n relevant_repository_count: relevantRepositoryCount,\n repository_evidence: [\n repositoryNames.length && 'Repositories: ' + repositoryNames.join(', '),\n repositoryDescriptions.length && 'Descriptions: ' + repositoryDescriptions.join(' | '),\n repositoryLanguages.length && 'Languages: ' + repositoryLanguages.join(', '),\n repositoryTopics.length && 'Topics: ' + repositoryTopics.join(', '),\n ].filter(Boolean).join('. '),\n };\n })\n .sort((a, b) =>\n b.relevant_repository_count - a.relevant_repository_count ||\n b.repository_stars - a.repository_stars\n )\n .slice(0, Math.min(10, Math.max(5, brief.max_candidates_per_source * 2)));\n\nreturn candidates.length\n ? candidates.map((candidate) => ({ json: candidate }))\n : [{ json: { _source_empty: true, source: 'github' } }];"
},
"typeVersion": 2
},
{
"id": "bf97a24a-d6a9-4e4d-a986-3b1a35067d25",
"name": "Fetch GitHub Profiles",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
16896,
6352
],
"parameters": {
"url": "={{ $json.profile_api_url }}",
"options": {
"timeout": 10000
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/vnd.github+json"
},
{
"name": "X-GitHub-Api-Version",
"value": "2026-03-10"
},
{
"name": "User-Agent",
"value": "talent-atlas-n8n-workflow"
}
]
}
},
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 500
},
{
"id": "cde40777-e0b5-43da-b2b4-ffd560d70678",
"name": "Merge GitHub Profile and Repository Evidence",
"type": "n8n-nodes-base.merge",
"position": [
17120,
6416
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineByPosition"
},
"typeVersion": 3.2
},
{
"id": "74790fb9-b2c9-4741-93a5-ec8f4bfda4d9",
"name": "Normalize GitHub",
"type": "n8n-nodes-base.code",
"position": [
17344,
6416
],
"parameters": {
"jsCode": "const records = $input.all().map((item) => item.json);\nconst candidates = records\n .filter((record) =>\n record &&\n !record.error &&\n record.html_url &&\n record.github_login &&\n !record._source_empty\n )\n .map((record) => ({\n name: record.name || record.login || 'GitHub profile',\n title: record.bio || 'Active open-source developer',\n location: record.location || '',\n skills: [\n ...(record.repository_languages || []),\n ...(record.repository_topics || []),\n ].filter(Boolean).join(', '),\n profile_url: record.html_url,\n source: 'github',\n email: record.email || '',\n bio_snippet: [\n record.bio,\n record.repository_evidence,\n ].filter(Boolean).join('. ').slice(0, 1400),\n source_metrics: {\n followers: Number(record.followers || 0),\n public_repositories: Number(record.public_repos || 0),\n relevant_repositories: Number(record.relevant_repository_count || 0),\n repository_stars: Number(record.repository_stars || 0),\n repository_forks: Number(record.repository_forks || 0),\n latest_repo_push: record.latest_repo_push || '',\n },\n }));\n\nreturn candidates.length\n ? candidates.map((candidate) => ({ json: candidate }))\n : [{ json: { _source_empty: true, source: 'github' } }];"
},
"typeVersion": 2
},
{
"id": "5c32138b-7ded-4b2e-bda7-bfc29e50bb01",
"name": "Search Stack Overflow Experts",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
16448,
6736
],
"parameters": {
"url": "={{ 'https://api.stackexchange.com/2.3/tags/' + encodeURIComponent($json.stackoverflow_tag) + '/top-answerers/all_time' }}",
"options": {
"timeout": 15000
},
"sendQuery": true,
"sendHeaders": true,
"queryParameters": {
"parameters": [
{
"name": "site",
"value": "stackoverflow"
},
{
"name": "pagesize",
"value": "={{ $json.max_candidates_per_source }}"
},
{
"name": "order",
"value": "desc"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "User-Agent",
"value": "talent-atlas-n8n-workflow"
}
]
}
},
"executeOnce": true,
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 1000
},
{
"id": "0628c77a-df1c-4bfe-a90e-8be2f2837a4d",
"name": "Split Stack Overflow Results",
"type": "n8n-nodes-base.splitOut",
"position": [
16672,
6736
],
"parameters": {
"options": {},
"fieldToSplitOut": "items"
},
"typeVersion": 1,
"alwaysOutputData": true
},
{
"id": "7d863d77-4212-431a-b7de-261adcd12245",
"name": "Normalize Stack Overflow",
"type": "n8n-nodes-base.code",
"position": [
16896,
6736
],
"parameters": {
"jsCode": "const brief = $('Build Search Brief').first().json;\nconst records = $input.all().map((item) => item.json);\nconst candidates = records\n .filter((record) => record?.user?.link)\n .map((record) => ({\n name: record.user.display_name || 'Stack Overflow profile',\n title: 'Top ' + brief.stackoverflow_tag + ' answerer',\n location: brief.location,\n skills: brief.stackoverflow_tag,\n profile_url: record.user.link,\n source: 'stackoverflow',\n email: '',\n bio_snippet:\n 'Top-answerer evidence for ' + brief.stackoverflow_tag +\n '; answer score ' + Number(record.score || 0) +\n '; post count ' + Number(record.post_count || 0) + '.',\n source_metrics: {\n answer_score: Number(record.score || 0),\n post_count: Number(record.post_count || 0),\n reputation: Number(record.user.reputation || 0),\n },\n }));\n\nreturn candidates.length\n ? candidates.map((candidate) => ({ json: candidate }))\n : [{ json: { _source_empty: true, source: 'stackoverflow' } }];"
},
"typeVersion": 2
},
{
"id": "6ff587e5-175f-42d7-96ae-10a74dd3aeeb",
"name": "Find Latest HN Hiring Thread",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
16000,
7072
],
"parameters": {
"url": "https://hn.algolia.com/api/v1/search_by_date",
"options": {
"timeout": 10000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "query",
"value": "Who wants to be hired"
},
{
"name": "tags",
"value": "story"
},
{
"name": "hitsPerPage",
"value": "50"
}
]
}
},
"executeOnce": true,
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 1000
},
{
"id": "292f5c85-a19b-4512-acbb-76544f1e5760",
"name": "Select Exact HN Hiring Thread",
"type": "n8n-nodes-base.code",
"position": [
16224,
7072
],
"parameters": {
"jsCode": "const input = $input.first().json;\nconst hits = Array.isArray(input.hits) ? input.hits : [];\nconst exactTitle = /^Ask HN:\\s*Who wants to be hired\\?\\s*(?:\\(|$)/i;\nconst selected = hits.find((hit) => exactTitle.test(String(hit.title || '')));\n\nreturn [{\n json: selected\n ? {\n story_id: selected.objectID,\n story_title: selected.title,\n story_created_at: selected.created_at,\n }\n : {\n story_id: 'missing',\n story_title: '',\n story_created_at: '',\n },\n}];"
},
"typeVersion": 2
},
{
"id": "7392f29c-b503-48c6-8cc1-20d428e9cf5d",
"name": "Search HN Candidates",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
16448,
7072
],
"parameters": {
"url": "https://hn.algolia.com/api/v1/search",
"options": {
"timeout": 15000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "query",
"value": "={{ $('Build Search Brief').first().json.primary_skill }}"
},
{
"name": "tags",
"value": "={{ 'comment,story_' + $json.story_id }}"
},
{
"name": "hitsPerPage",
"value": "={{ $('Build Search Brief').first().json.max_candidates_per_source }}"
},
{
"name": "restrictSearchableAttributes",
"value": "comment_text"
}
]
}
},
"executeOnce": true,
"retryOnFail": true,
"typeVersion": 4.4,
"alwaysOutputData": true,
"waitBetweenTries": 1000
},
{
"id": "4af25e1b-5b56-4567-aec4-c2b59867c515",
"name": "Split HN Results",
"type": "n8n-nodes-base.splitOut",
"position": [
16672,
7072
],
"parameters": {
"options": {},
"fieldToSplitOut": "hits"
},
"typeVersion": 1,
"alwaysOutputData": true
},
{
"id": "3782dac7-70e3-47b8-9eaf-cecdb41ffe9c",
"name": "Normalize Hacker News",
"type": "n8n-nodes-base.code",
"position": [
16896,
7072
],
"parameters": {
"jsCode": "const decodeText = (value) => String(value || '')\n .replace(/<p>/gi, '\\n')\n .replace(/<[^>]+>/g, ' ')\n .replace(/>/g, '>')\n .replace(/</g, '<')\n .replace(/&/g, '&')\n .replace(/'/g, \"'\")\n .replace(/"/g, '\"')\n .replace(/\\s+/g, ' ')\n .trim();\n\nconst records = $input.all().map((item) => item.json);\nconst candidates = records\n .filter((record) => record?.author && record?.comment_text)\n .map((record) => {\n const text = decodeText(record.comment_text);\n const location = text.match(/\\b(?:location|based in)\\s*[:\\-]\\s*([^|;]{2,80})/i)?.[1]?.trim() || '';\n const email = text.match(/[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/)?.[0] || '';\n return {\n name: record.author,\n title: text.slice(0, 140),\n location,\n skills: text.slice(0, 500),\n profile_url: 'https://news.ycombinator.com/user?id=' + encodeURIComponent(record.author),\n source: 'hackernews',\n email,\n bio_snippet: text.slice(0, 700),\n source_metrics: {},\n };\n });\n\nreturn candidates.length\n ? candidates.map((candidate) => ({ json: candidate }))\n : [{ json: { _source_empty: true, source: 'hackernews' } }];"
},
"typeVersion": 2
},
{
"id": "da2e27f5-65fc-4e1c-b930-9bb65a348107",
"name": "Merge LinkedIn and GitHub",
"type": "n8n-nodes-base.merge",
"position": [
17568,
6224
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "45bd53f5-eb73-4a10-9120-1822c3583c1b",
"name": "Merge Stack Overflow and HN",
"type": "n8n-nodes-base.merge",
"position": [
17120,
6816
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "1b8ca81f-eb62-49dc-a021-e210b18fefe5",
"name": "Merge All Sources",
"type": "n8n-nodes-base.merge",
"position": [
17840,
6416
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "b038f93a-c82f-4877-862a-1fc72f89cbf4",
"name": "Sanitize Candidate Pool",
"type": "n8n-nodes-base.code",
"position": [
18112,
6416
],
"parameters": {
"jsCode": "const brief = $('Build Search Brief').first().json;\nconst seen = new Set();\nconst candidates = [];\n\nconst skillAliases = {\n '.net core': [\n '.net core',\n 'asp.net core',\n 'aspnet core',\n 'aspnetcore',\n 'aspnet-core',\n 'dotnet core',\n 'dotnet-core',\n 'net core',\n ],\n '.net': ['.net', 'dotnet', 'asp.net'],\n 'c#': ['c#', 'c sharp', 'csharp'],\n 'react.js': ['react.js', 'reactjs', 'react ', 'react-'],\n 'node.js': ['node.js', 'nodejs'],\n 'rest apis': ['rest api', 'rest-api', 'restful api', 'web api', 'webapi'],\n 'sql server': ['sql server', 'sql-server', 'mssql'],\n 'microservices': ['microservice'],\n 'ci/cd': ['ci/cd', 'ci cd', 'continuous integration', 'continuous delivery'],\n 'kubernetes': ['kubernetes', 'k8s'],\n};\n\nconst containsSkill = (evidence, skill) => {\n const key = String(skill || '').trim().toLowerCase();\n const aliases = skillAliases[key] || [key];\n return aliases.some((alias) =>\n (alias === 'c#' || alias.length >= 3) && evidence.includes(alias)\n );\n};\n\nconst titleTerms = String(brief.job_title || '')\n .toLowerCase()\n .split(/[^a-z0-9+#.]+/)\n .filter((term) => term.length >= 4 && !['senior', 'developer', 'engineer'].includes(term));\n\nfor (const item of $input.all()) {\n const candidate = item.json || {};\n if (candidate._source_empty || candidate.error || !candidate.profile_url) continue;\n\n const key = String(candidate.profile_url).trim().toLowerCase() ||\n [candidate.source, candidate.name].join(':').toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n\n const normalized = {\n name: String(candidate.name || 'Unknown candidate').trim(),\n title: String(candidate.title || '').trim(),\n location: String(candidate.location || '').trim(),\n skills: String(candidate.skills || '').trim(),\n profile_url: String(candidate.profile_url || '').trim(),\n source: String(candidate.source || '').trim(),\n email: String(candidate.email || '').trim(),\n bio_snippet: String(candidate.bio_snippet || '').trim().slice(0, 1200),\n source_metrics: candidate.source_metrics || {},\n };\n\n const evidence = [\n normalized.title,\n normalized.skills,\n normalized.bio_snippet,\n ].join(' ').toLowerCase();\n const prefilterMatchedSkills = (brief.must_have_skills || [])\n .filter((skill) => containsSkill(evidence, skill));\n const coverage = brief.must_have_skills?.length\n ? prefilterMatchedSkills.length / brief.must_have_skills.length\n : 0;\n const titleMatches = titleTerms.filter((term) =>\n normalized.title.toLowerCase().includes(term)\n ).length;\n const requestedLocation = String(brief.location || '').toLowerCase();\n const locationMatch = !requestedLocation ||\n requestedLocation === 'remote' ||\n normalized.location.toLowerCase().includes(requestedLocation) ||\n normalized.location.toLowerCase().includes('remote');\n\n candidates.push({\n ...normalized,\n prefilter_matched_skills: prefilterMatchedSkills,\n prefilter_score: Math.round(\n coverage * 80 +\n Math.min(15, titleMatches * 5) +\n (locationMatch ? 5 : 0),\n ),\n });\n}\n\n// Keep the shortlist compact for fast deterministic evaluation and summary generation.\nconst cap = Math.min(\n 10,\n Math.max(1, brief.max_results, brief.max_candidates_per_source * 2),\n);\nconst limited = candidates\n .sort((a, b) => b.prefilter_score - a.prefilter_score)\n .slice(0, cap);\n\nreturn limited.length\n ? limited.map((candidate) => ({ json: { ...candidate, _empty: false } }))\n : [{ json: { _empty: true } }];"
},
"typeVersion": 2
},
{
"id": "e4c60b21-f758-42ac-8614-26ef6545456b",
"name": "Candidates Found?",
"type": "n8n-nodes-base.if",
"position": [
18384,
6416
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "27f171a1-2a16-4441-b244-dbee93f19bf5",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json._empty }}",
"rightValue": false
}
]
}
},
"typeVersion": 2.2
},
{
"id": "932a21d9-2e2d-47b1-a4c9-fabbfebdc0de",
"name": "Calculate Evidence Scores",
"type": "n8n-nodes-base.code",
"position": [
18464,
5984
],
"parameters": {
"jsCode": "const brief = $('Build Search Brief').first().json;\nconst mustHave = Array.isArray(brief.must_have_skills) ? brief.must_have_skills : [];\nconst niceToHave = Array.isArray(brief.nice_to_have_skills) ? brief.nice_to_have_skills : [];\n\nconst skillAliases = {\n '.net core': ['.net core', 'asp.net core', 'aspnet core', 'aspnetcore', 'aspnet-core', 'dotnet core', 'dotnet-core'],\n '.net': ['.net', 'dotnet', 'asp.net'],\n 'c#': ['c#', 'c sharp', 'csharp'],\n 'react.js': ['react.js', 'reactjs', 'react ', 'react-'],\n 'node.js': ['node.js', 'nodejs'],\n 'rest apis': ['rest api', 'rest-api', 'restful api', 'web api', 'webapi'],\n 'sql server': ['sql server', 'sql-server', 'mssql'],\n 'microservices': ['microservice'],\n 'ci/cd': ['ci/cd', 'ci cd', 'continuous integration', 'continuous delivery'],\n 'kubernetes': ['kubernetes', 'k8s'],\n};\n\nconst containsSkill = (evidence, skill) => {\n const key = String(skill || '').trim().toLowerCase();\n const aliases = skillAliases[key] || [key];\n return aliases.some((alias) =>\n (alias === 'c#' || alias.length >= 3) && evidence.includes(alias)\n );\n};\n\nconst jobTitleTerms = String(brief.job_title || '')\n .toLowerCase()\n .split(/[^a-z0-9+#.]+/)\n .filter((term) =>\n term.length >= 4 &&\n !['senior', 'junior', 'lead', 'developer', 'engineer'].includes(term)\n );\nconst requestedLocation = String(brief.location || '').trim().toLowerCase();\nconst requiredYears = Number(String(brief.experience || '').match(/\\d+/)?.[0] || 0);\n\nconst evidenceQualityScore = (candidate) => {\n const metrics = candidate.source_metrics || {};\n if (candidate.source === 'linkedin') {\n return Math.min(\n 10,\n (String(candidate.skills || '').length >= 80 ? 5 : 2) +\n (String(candidate.bio_snippet || '').length >= 300 ? 5 : 2),\n );\n }\n if (candidate.source === 'github') {\n const latestPush = Date.parse(metrics.latest_repo_push || '');\n const recent = Number.isFinite(latestPush) &&\n Date.now() - latestPush <= 1000 * 60 * 60 * 24 * 365 * 2;\n return Math.min(\n 10,\n Math.min(6, Number(metrics.relevant_repositories || 0) * 3) +\n (recent ? 2 : 0) +\n (Number(metrics.repository_stars || 0) >= 5 ? 2 : 0),\n );\n }\n if (candidate.source === 'stackoverflow') {\n return Math.min(\n 8,\n (Number(metrics.answer_score || 0) >= 1000 ? 5 : 2) +\n (Number(metrics.post_count || 0) >= 100 ? 3 : 1),\n );\n }\n if (candidate.source === 'hackernews') {\n return Math.min(\n 8,\n (String(candidate.bio_snippet || '').length >= 500 ? 6 : 3) +\n (candidate.email ? 2 : 0),\n );\n }\n return 4;\n};\n\nreturn $input.all().map((item) => {\n const candidate = item.json;\n const evidence = [\n candidate.title,\n candidate.skills,\n candidate.bio_snippet,\n ].join(' ').toLowerCase();\n\n const matchedSkills = mustHave.filter((skill) => containsSkill(evidence, skill));\n const missingSkills = mustHave.filter((skill) => !containsSkill(evidence, skill));\n const niceMatches = niceToHave.filter((skill) => containsSkill(evidence, skill));\n const coverage = mustHave.length ? matchedSkills.length / mustHave.length : 0;\n const mustHaveScore = Math.round(coverage * 65);\n\n const title = String(candidate.title || '').toLowerCase();\n const titleMatches = jobTitleTerms.filter((term) => title.includes(term)).length;\n const titleScore = jobTitleTerms.length\n ? Math.round((titleMatches / jobTitleTerms.length) * 10)\n : 5;\n\n const location = String(candidate.location || '').trim().toLowerCase();\n let locationScore = 0;\n let locationStatus = 'unknown';\n if (!requestedLocation || requestedLocation === 'remote') {\n locationScore = 5;\n locationStatus = 'not constrained';\n } else if (location.includes(requestedLocation)) {\n locationScore = 5;\n locationStatus = 'matched';\n } else if (location.includes('remote')) {\n locationScore = 3;\n locationStatus = 'remote';\n } else if (location) {\n locationStatus = 'different';\n }\n\n const years = [...evidence.matchAll(/(\\d{1,2})\\+?\\s*(?:years|yrs)/g)]\n .map((match) => Number(match[1]))\n .filter(Number.isFinite);\n const maxYears = years.length ? Math.max(...years) : 0;\n const seniorityEvidence = /\\b(senior|lead|principal|architect|staff)\\b/i.test(evidence);\n const experienceScore = !requiredYears\n ? 3\n : maxYears >= requiredYears\n ? 5\n : seniorityEvidence\n ? 3\n : 0;\n\n const sourceEvidenceScore = evidenceQualityScore(candidate);\n const niceToHaveScore = niceToHave.length\n ? Math.round((niceMatches.length / niceToHave.length) * 5)\n : 0;\n const score = Math.min(\n 100,\n mustHaveScore +\n titleScore +\n locationScore +\n experienceScore +\n sourceEvidenceScore +\n niceToHaveScore,\n );\n\n const concerns = [];\n if (coverage < 0.5) concerns.push('Evidence covers fewer than half of the must-have skills');\n if (locationStatus === 'unknown') concerns.push('Location is not available from this source');\n if (locationStatus === 'different') concerns.push('Candidate location does not match the requested location');\n if (requiredYears && !maxYears && !seniorityEvidence) concerns.push('Required experience duration is not verified');\n if (candidate.source === 'github') concerns.push('Repository ownership is evidence of work, but contribution depth should be reviewed');\n if (candidate.source === 'stackoverflow') concerns.push('Strong specialist evidence does not verify full employment history');\n if (candidate.source === 'hackernews') concerns.push('Profile details are self-reported');\n\n const recommendation = score >= 85\n ? 'Strong evidence match'\n : score >= 70\n ? 'Good evidence match'\n : score >= 55\n ? 'Potential match - review'\n : 'Low evidence match';\n const summary = [\n matchedSkills.length + '/' + mustHave.length + ' must-have skills evidenced',\n titleScore >= 7 ? 'title aligned' : 'title only partially aligned',\n locationStatus === 'matched' ? 'location matched' : 'location ' + locationStatus,\n candidate.source + ' evidence quality ' + sourceEvidenceScore + '/10',\n ].join('; ');\n\n return {\n json: {\n ...candidate,\n score,\n score_method: 'deterministic-evidence-v1',\n score_breakdown: {\n must_have_skills: mustHaveScore,\n title_alignment: titleScore,\n location: locationScore,\n experience: experienceScore,\n source_evidence: sourceEvidenceScore,\n nice_to_have: niceToHaveScore,\n },\n recommendation,\n summary,\n matched_skills: matchedSkills,\n missing_skills: missingSkills,\n nice_to_have_matches: niceMatches,\n concerns,\n request_id: brief.request_id,\n job_title: brief.job_title,\n jd_keywords: brief.skills_str,\n created_at: new Date().toISOString(),\n },\n };\n});"
},
"typeVersion": 2
},
{
"id": "e75b5e6c-6119-4e4c-9b01-4b100cf19dcc",
"name": "Build Recruiter Response",
"type": "n8n-nodes-base.code",
"position": [
18688,
5888
],
"parameters": {
"jsCode": "const brief = $('Build Search Brief').first().json;\nconst scored = $input.all()\n .map((item) => item.json)\n .sort((a, b) => Number(b.score || 0) - Number(a.score || 0));\n\nconst qualified = scored\n .filter((candidate) => Number(candidate.score || 0) >= brief.score_threshold)\n .slice(0, brief.max_results);\n\nconst bySource = {};\nfor (const candidate of scored) {\n bySource[candidate.source] = (bySource[candidate.source] || 0) + 1;\n}\n\nreturn [{\n json: {\n ok: true,\n workflow: 'Talent Atlas - Multi-Source Candidate Discovery and Evidence Shortlisting',\n version: '1.1.0',\n request_id: brief.request_id,\n generated_at: new Date().toISOString(),\n search_criteria: {\n job_title: brief.job_title,\n location: brief.location,\n experience: brief.experience,\n seniority: brief.seniority,\n employment_type: brief.employment_type,\n skills: brief.skills,\n must_have_skills: brief.must_have_skills,\n nice_to_have_skills: brief.nice_to_have_skills,\n score_threshold: brief.score_threshold,\n },\n stats: {\n candidates_evaluated: scored.length,\n candidates_qualified: qualified.length,\n candidates_returned: qualified.length,\n by_source: bySource,\n },\n candidates: qualified,\n human_review_required: true,\n notice:\n 'Candidate scores use a transparent evidence formula, not AI judgment. Verify candidate data and use a qualified human reviewer for all hiring decisions.',\n },\n}];"
},
"typeVersion": 2
},
{
"id": "f04d3bdc-8cb5-4017-8c50-05ac628929b9",
"name": "Build Shortlist Summary Input",
"type": "n8n-nodes-base.code",
"position": [
18912,
5888
],
"parameters": {
"jsCode": "const report = $input.first().json;\nreturn [{\n json: {\n request_id: report.request_id,\n search_criteria: report.search_criteria,\n stats: report.stats,\n candidates: (report.candidates || []).slice(0, 10).map((candidate) => ({\n name: candidate.name,\n source: candidate.source,\n score: candidate.score,\n recommendation: candidate.recommendation,\n summary: candidate.summary,\n matched_skills: candidate.matched_skills,\n missing_skills: candidate.missing_skills,\n concerns: candidate.concerns,\n })),\n },\n}];"
},
"typeVersion": 2
},
{
"id": "e9bfa5bd-931d-4cc8-9fb4-9b3acb6111f2",
"name": "Summarize Shortlist",
"type": "@n8n/n8n-nodes-langchain.agent",
"onError": "continueRegularOutput",
"maxTries": 2,
"position": [
19136,
5888
],
"parameters": {
"text": "={{ 'Summarize this evidence-ranked shortlist for a recruiter. Do not change scores, invent facts, or make hiring decisions. Identify the strongest evidence, common gaps, source limitations, and useful verification steps.\\n\\nSHORTLIST:\\n' + JSON.stringify($json) }}",
"options": {
"systemMessage": "You are a recruiting research assistant. The ranking and scores are already calculated by deterministic rules. Explain the shortlist concisely without rescoring candidates. Do not infer protected characteristics. Return only the structured object required by the output parser."
},
"promptType": "def
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow exposes a POST webhook that turns a job description into a structured hiring brief using Groq, searches candidates across GitHub, LinkedIn (via Apify), Stack Overflow, and Hacker News, scores and summarizes the shortlist, optionally saves results to Supabase, and…
Source: https://n8n.io/workflows/16549/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
⏺ 🚀 How it works
leads. Uses supabase, gmail, formTrigger, httpRequest. Webhook trigger; 62 nodes.
Fully automated blog creation system using n8n + AI Agents + Image Generation
Webhooks. Uses httpRequest, lmChatOpenAi, supabase, outputParserStructured. Webhook trigger; 28 nodes.
Content Creator. Uses lmChatOpenAi, httpRequest, supabase, outputParserStructured. Webhook trigger; 22 nodes.