{
  "meta": {
    "templateId": "ai-job-matcher-single-role"
  },
  "name": "Aggregate jobs and AI-score them against a candidate profile",
  "tags": [],
  "nodes": [
    {
      "id": "3eb5dd82-ee2b-4d65-8fda-1c5d1aedd957",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -768,
        0
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## Aggregate jobs and AI-score them against a candidate profile (single role)\n\n### How it works\n\nThis workflow continuously builds a job pool and sends a daily digest of the best matches. Every three hours it gathers jobs from curated ATS boards, Adzuna, and RemoteOK, normalizes and deduplicates them, then upserts fresh records to Supabase. Each morning it pulls fresh jobs, removes already-matched items, scores candidates with Claude, saves qualifying matches, emails a digest, and sends a separate alert if the workflow fails.\n\n### Setup steps\n\n- Configure the schedule trigger time zones for the 3-hour crawler and daily 08:00 digest.\n- Set the target role details in the Role node, including role ID, destination email, function, preferred locations, and minimum score if used in the code.\n- Add Adzuna API credentials or query parameters required by the Adzuna search request.\n- Configure Supabase credentials, project URL, REST API headers, and ensure the jobs and user_job_matches tables exist with the expected columns and conflict keys.\n- Add an Anthropic API key for the Claude score batch HTTP request.\n- Configure Resend credentials or API headers, verified sender domain, recipient email, and alert recipient for digest and error notifications.\n\n### Customization\n\nAdjust the Search terms node, ATS board list, freshness window, scoring prompt, batch size, and score threshold to target different roles, locations, or job quality criteria."
      },
      "typeVersion": 1
    },
    {
      "id": "abd333cd-1465-4490-93ed-3a3b5c47e02b",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 336,
        "content": "## Start search cycle\n\nRuns the job discovery process every three hours and defines the search queries used by the upstream job source branches."
      },
      "typeVersion": 1
    },
    {
      "id": "8ebdc458-1b55-412a-9430-45750f3c434a",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        272,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 304,
        "content": "## Crawl ATS boards\n\nBuilds a curated list of no-key ATS company boards, fetches each board, and parses returned postings into a common job format."
      },
      "typeVersion": 1
    },
    {
      "id": "1bb497f4-1094-4e99-b1e9-41c42fa91be6",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        272,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 304,
        "content": "## Search Adzuna jobs\n\nCreates Adzuna API requests for each query, fetches matching jobs, and parses the results into normalized source records."
      },
      "typeVersion": 1
    },
    {
      "id": "d4d0a55b-b48d-46e9-a02e-e827da0cb895",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        496,
        672
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Fetch RemoteOK jobs\n\nFetches RemoteOK listings and parses them into the same job structure as the other source branches."
      },
      "typeVersion": 1
    },
    {
      "id": "35cb154f-f5fb-4769-b731-6e0d0c33b04e",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        960,
        192
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 336,
        "content": "## Normalize job pool\n\nCombines all discovered jobs, removes stale or duplicate postings, and upserts fresh records into the Supabase jobs pool."
      },
      "typeVersion": 1
    },
    {
      "id": "41cd5e5e-93b7-4eef-82bf-43005988c46c",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -208,
        944
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 336,
        "content": "## Start daily matching\n\nRuns the matching workflow each morning and sets the single target role profile, including role ID, recipient email, function, and locations."
      },
      "typeVersion": 1
    },
    {
      "id": "c9a0a4da-881c-428e-8850-b6b2e8cecb77",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        496,
        1088
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Prepare scoring candidates\n\nRetrieves fresh jobs from the pool, checks which jobs were already matched, removes repeats, and chunks remaining jobs into Claude scoring batches."
      },
      "typeVersion": 1
    },
    {
      "id": "a3fa602a-fb41-48f8-9ef1-35ab0f78e2ab",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1184,
        1056
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 336,
        "content": "## Score and filter jobs\n\nSends each batch to Claude for fit scoring, attaches returned scores to the original jobs, and keeps only matches above the configured threshold."
      },
      "typeVersion": 1
    },
    {
      "id": "46729105-3838-4b73-b744-758c0aaa9e45",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1632,
        1104
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Save and email matches\n\nStores qualified job matches in Supabase and sends the daily match digest through Resend."
      },
      "typeVersion": 1
    },
    {
      "id": "13eaa0d1-cc39-4d04-9c30-bd18d9d9ade9",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        1840
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 304,
        "content": "## Handle workflow errors\n\nCatches workflow failures, formats an alert message, and sends the error notification via Resend."
      },
      "typeVersion": 1
    },
    {
      "id": "cd37764d-f9cf-4189-bd28-1a27dd5f9b4e",
      "name": "Every 3 Hours Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -140,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 3
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "b3e4cd21-e43b-42a8-96e9-5ec27a7b5ad8",
      "name": "Set Search Queries",
      "type": "n8n-nodes-base.set",
      "position": [
        80,
        300
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "927f9a91-e41d-44af-919e-d5f2338b7648",
              "name": "queries",
              "type": "array",
              "value": "={{ ['YOUR ROLE KEYWORD'] }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "cfcf57c5-f9e3-47c0-a2c4-ce4808d8e908",
      "name": "Curate ATS Board List",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        140
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Curated no-key ATS boards. Extend freely \u2014 this is the shared crawl.\nconst B = [\n  ['greenhouse','stripe'],['greenhouse','hubspot'],['greenhouse','klaviyo'],['greenhouse','airbnb'],\n  ['greenhouse','databricks'],['lever','meesho'],['lever','gopuff'],['ashby','notion'],['ashby','openai'],\n];\nreturn B.map(([ats,slug]) => ({ json: { ats, slug } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "e9adebfa-9b63-4e8a-994a-7f73685c5727",
      "name": "Fetch ATS Board API Data",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        540,
        140
      ],
      "parameters": {
        "url": "={{ $json.ats==='greenhouse' ? 'https://boards-api.greenhouse.io/v1/boards/'+$json.slug+'/jobs?content=true' : $json.ats==='lever' ? 'https://api.lever.co/v0/postings/'+$json.slug+'?mode=json' : 'https://api.ashbyhq.com/posting-api/job-board/'+$json.slug+'?includeCompensation=false' }}",
        "method": "GET",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "continueOnFail": true,
      "waitBetweenTries": 2000
    },
    {
      "id": "e5b05d63-fc76-4117-b3a3-60b7ba0b3a3e",
      "name": "Parse ATS Response",
      "type": "n8n-nodes-base.code",
      "position": [
        760,
        140
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const strip=s=>(s||'').replace(/<[^>]+>/g,' ').replace(/&[a-z]+;/gi,' ').replace(/\\s+/g,' ').trim();\nconst out=[];\nfor(const it of $input.all()){const b=it.json;let rows=[];\n  if(Array.isArray(b))rows=b; else if(b&&Array.isArray(b.jobs))rows=b.jobs;\n  for(const j of rows){const title=j.title||j.text||'';const url=j.absolute_url||j.hostedUrl||j.applyUrl||'';\n    if(!title)continue;\n    out.push({json:{id:'ats:'+(j.id||url),source:'ats',title,\n      company:(j.categories&&j.categories.team)||'',\n      location:(j.location&&(j.location.name||j.location))||(j.categories&&j.categories.location)||'',\n      url,description:strip(j.content||j.descriptionPlain||'').slice(0,3000),\n      postedAt:j.updated_at||j.createdAt||null}});}}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "7a5e6b91-6bfa-4993-b4d1-9ee4fe1bbd91",
      "name": "Build Adzuna Query",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        464
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const q=$('Set Search Queries').first().json.queries||[];\nreturn q.map(w=>({json:{what:w}}));"
      },
      "typeVersion": 2
    },
    {
      "id": "003e3eac-d1fa-4655-8ba4-2d4e07eb6b57",
      "name": "Fetch Adzuna Job Listings",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        544,
        464
      ],
      "parameters": {
        "url": "https://api.adzuna.com/v1/api/jobs/in/search/1",
        "method": "GET",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "app_id",
              "value": "YOUR_ADZUNA_APP_ID"
            },
            {
              "name": "app_key",
              "value": "YOUR_ADZUNA_APP_KEY"
            },
            {
              "name": "results_per_page",
              "value": "50"
            },
            {
              "name": "what",
              "value": "={{ $json.what }}"
            },
            {
              "name": "max_days_old",
              "value": "30"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "continueOnFail": true,
      "waitBetweenTries": 3000
    },
    {
      "id": "a14abc35-c970-4078-aeb1-ebc873847e00",
      "name": "Parse Adzuna Response",
      "type": "n8n-nodes-base.code",
      "position": [
        768,
        464
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const strip=s=>(s||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();const out=[];\nfor(const it of $input.all())for(const j of ((it.json&&it.json.results)||[]))\n  out.push({json:{id:'adzuna:'+j.id,source:'adzuna',title:j.title||'',\n    company:(j.company&&j.company.display_name)||'',location:(j.location&&j.location.display_name)||'',\n    url:j.redirect_url||'',description:strip(j.description).slice(0,3000),postedAt:j.created||null}});\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "ecd27a55-b712-4a98-884b-1dcae0911c71",
      "name": "Fetch RemoteOK Jobs",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 2,
      "position": [
        544,
        832
      ],
      "parameters": {
        "url": "https://remoteok.com/api",
        "method": "GET",
        "options": {}
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "b86da118-ef32-4f47-b70f-e80780438f49",
      "name": "Parse RemoteOK Data",
      "type": "n8n-nodes-base.code",
      "position": [
        768,
        832
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const strip=s=>(s||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim();const out=[];\nfor(const it of $input.all()){const j=it.json; if(!j||!j.position)continue;\n  out.push({json:{id:'remoteok:'+j.id,source:'remoteok',title:j.position,company:j.company||'',\n    location:'Remote',url:j.url||('https://remoteok.com/l/'+j.id),\n    description:strip(j.description).slice(0,3000),postedAt:j.date||null}});}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "a6c515fc-2253-4301-b230-9148788e2859",
      "name": "Merge Job Sources",
      "type": "n8n-nodes-base.merge",
      "position": [
        1000,
        320
      ],
      "parameters": {
        "options": {},
        "numberInputs": 3
      },
      "typeVersion": 3
    },
    {
      "id": "784f6954-e2f8-499c-aa31-8e691887c465",
      "name": "Normalize and Deduplicate Jobs",
      "type": "n8n-nodes-base.code",
      "position": [
        1220,
        320
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Drop stale (>90d), keep newest per id. Emit rows for the pool upsert.\nconst now=Date.now(), DAY=86400000, seen=new Set(), rows=[];\nfor(const it of $input.all()){const j=it.json; if(!j||!j.id||seen.has(j.id))continue; seen.add(j.id);\n  let ageDays=null; if(j.postedAt){const t=Date.parse(j.postedAt); if(!isNaN(t)) ageDays=Math.floor((now-t)/DAY);}\n  if(ageDays!==null && ageDays>90) continue;\n  rows.push({id:j.id,source:j.source,title:j.title,company:j.company,location:j.location,\n    url:j.url,description:(j.description||'').slice(0,2000),posted_at:j.postedAt,\n    fresh: ageDays===null? true : ageDays<=60});}\nreturn [{json:{rows}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "8ae3c0a2-ce0e-4bdb-b7ea-2d7205d2fdae",
      "name": "Upsert Jobs to Pool",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1440,
        320
      ],
      "parameters": {
        "url": "YOUR_SUPABASE_URL/rest/v1/jobs?on_conflict=id",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ $json.rows }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates,return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "006925e5-98d9-42eb-8a5d-225429c6f8d4",
      "name": "Daily Morning Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -160,
        1120
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "2f991f28-4cea-400e-b1bd-16f6eb4a7af7",
      "name": "Fetch Pool Shortlist",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        540,
        1240
      ],
      "parameters": {
        "url": "=YOUR_SUPABASE_URL/rest/v1/jobs?fresh=eq.true&select=*&order=posted_at.desc&limit=15&title=ilike.*{{ encodeURIComponent($('Set Role Configuration').first().json.function || '') }}*",
        "method": "GET",
        "options": {},
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            }
          ]
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "83916b77-c188-451e-9260-4704d10a872d",
      "name": "Get Already Matched IDs",
      "type": "n8n-nodes-base.supabase",
      "position": [
        760,
        1240
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "user_id",
              "keyValue": "={{ $('Set Role Configuration').first().json.id }}",
              "condition": "eq"
            }
          ]
        },
        "tableId": "user_job_matches",
        "resource": "row",
        "operation": "getAll",
        "returnAll": true
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "41cd45ea-5c4c-49dd-b9f5-50d5f1ad39b2",
      "name": "Create Scoring Batches",
      "type": "n8n-nodes-base.code",
      "position": [
        980,
        1240
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Drop already-matched, chunk into batches of 20, prebuild each Claude request.\nconst user = $('Set Role Configuration').first().json;\nconst jobs = $('Fetch Pool Shortlist').all().map(i=>i.json).filter(j=>j&&j.id);\nconst sent = new Set($('Get Already Matched IDs').all().map(i=>i.json.job_id));\nconst fresh = jobs.filter(j=>!sent.has(j.id));\nconst system = [\n 'You are a sharp recruiter screening roles for one candidate. Score each job 0-100 for fit.',\n 'Reward the candidate\\'s strengths and correct seniority; penalise domain/seniority mismatch and bad location/remote fit.',\n '=== ACCEPTABLE LOCATIONS (remote or any of these) ===', (Array.isArray(user.locations)? user.locations.join(', ') : ''), 'A role tied to a location outside this list is a mismatch \u2014 lower the score and flag it.', '=== CANDIDATE PROFILE ===', user.profile || ''].join('\\n');\nconst tool={name:'report_scores',description:'Score each job.',input_schema:{type:'object',properties:{scores:{type:'array',\n  items:{type:'object',properties:{i:{type:'number'},score:{type:'number'},fit:{type:'string'},concerns:{type:'string'}},\n  required:['i','score','fit','concerns']}}},required:['scores']}};\nconst out=[]; const B=20;\nfor(let s=0;s<fresh.length;s+=B){const batch=fresh.slice(s,s+B);\n  const rendered=batch.map((j,i)=>`[${i}] ${j.title} @ ${j.company||j.source} | ${j.location||'?'}\\n${(j.description||'').slice(0,350)}`).join('\\n\\n');\n  out.push({json:{batch,body:{model:'claude-haiku-4-5',max_tokens:3000,system,tools:[tool],\n    tool_choice:{type:'tool',name:'report_scores'},messages:[{role:'user',content:`Score these ${batch.length} jobs.\\n\\n${rendered}`}]}}});}\nreturn out.length?out:[{json:{batch:[],body:null,__empty:true}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "c33eb093-9a2b-4c04-80b3-19d7920bb798",
      "name": "Score with Claude API",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        1232,
        1248
      ],
      "parameters": {
        "url": "https://api.anthropic.com/v1/messages",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ $json.body }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "x-api-key",
              "value": "={{ $env.ANTHROPIC_API_KEY }}"
            },
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "continueOnFail": true,
      "waitBetweenTries": 5000
    },
    {
      "id": "27aa2cf7-181d-45d5-9c4a-a023a85d6863",
      "name": "Filter Scored Jobs",
      "type": "n8n-nodes-base.code",
      "position": [
        1456,
        1248
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Zip each Claude response back to its batch; keep >= user's min_score.\nconst batches=$('Create Scoring Batches').all().map(i=>i.json);\nconst resps=$input.all().map(i=>i.json);\nconst _mn=Number($('Set Role Configuration').first().json.min_score);const min=Number.isFinite(_mn)?_mn:70;\nconst out=[];\nbatches.forEach((b,bi)=>{ if(!b.batch||!b.batch.length)return; const r=resps[bi]||{};\n  let scores=[]; try{const blk=(r.content||[]).find(c=>c.type==='tool_use'); scores=(blk&&blk.input&&blk.input.scores)||[];}catch(e){}\n  const by={}; for(const s of scores)by[s.i]=s;\n  b.batch.forEach((j,i)=>{const s=by[i]||{};const sc=s.score??0; if(sc>=min)\n    out.push({json:{...j,score:sc,fit:s.fit||'',concerns:s.concerns||''}});});});\nout.sort((a,b)=>b.json.score-a.json.score);\nreturn out.length?out:[{json:{__none:true}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "7d75fd8e-ef55-4662-9c3b-ad03fa4ed085",
      "name": "Save Job Matches",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1680,
        1248
      ],
      "parameters": {
        "url": "=YOUR_SUPABASE_URL/rest/v1/user_job_matches?on_conflict=user_id,job_id",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ $items().filter(x=>!x.json.__none).map(x=>({user_id: $('Set Role Configuration').first().json.id, job_id: x.json.id, score: x.json.score})) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "resolution=ignore-duplicates,return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "028e3f51-0a8d-4931-b0e2-1773188a0f07",
      "name": "Send Email Digest",
      "type": "n8n-nodes-base.resend",
      "position": [
        1888,
        1248
      ],
      "parameters": {
        "html": "={{ '<h2>New matches</h2><ul>' + $('Filter Scored Jobs').all().filter(i=>!i.json.__none).map(i=>i.json).map(j=>`<li><b>${j.score}</b> ${j.title} \u2014 ${j.company} (${j.location||'remote'})<br><a href=\"${j.url}\">apply</a><br><i>${j.fit}</i></li>`).join('') + '</ul>' }}",
        "subject": "={{ $('Filter Scored Jobs').all().filter(i => !i.json.__none).length + ' new job matches' }}",
        "toEmail": "={{ $('Set Role Configuration').first().json.email }}",
        "resource": "email",
        "fromEmail": "Job Quest <user@example.com>",
        "operation": "send",
        "additionalFields": {}
      },
      "credentials": {
        "resendApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "continueOnFail": true
    },
    {
      "id": "f31caff4-3689-4f99-b013-5f93c72ba9f1",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "position": [
        -140,
        1980
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "080f21ec-80f8-4088-a153-b6b29d01d3af",
      "name": "Format Error Alert",
      "type": "n8n-nodes-base.code",
      "position": [
        120,
        1980
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const e=$json; const wf=(e.workflow&&e.workflow.name)||'?';\nconst node=(e.execution&&e.execution.lastNodeExecuted)||'?';\nconst msg=(e.execution&&e.execution.error&&e.execution.error.message)||'(no message)';\nconst url=(e.execution&&e.execution.url)||'';\nreturn [{json:{email:{from:'Job Quest Alerts <user@example.com>',to:['user@example.com'],\n  subject:`Job Quest FAILED: ${wf} @ ${node}`,\n  html:`<b>${wf}</b> failed at node <b>${node}</b>.<br><pre>${msg}</pre><a href=\"${url}\">open execution</a>`}}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "26d0e0a2-e325-419d-b9f7-2b7e64d7a567",
      "name": "Send Alert Notification",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 2,
      "position": [
        380,
        1980
      ],
      "parameters": {
        "url": "https://api.resend.com/emails",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ $json.email }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "role-cfg",
      "name": "Set Role Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        60,
        1120
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "id",
              "type": "string",
              "value": "role-1"
            },
            {
              "id": "r2",
              "name": "email",
              "type": "string",
              "value": "user@example.com"
            },
            {
              "id": "r3",
              "name": "function",
              "type": "string",
              "value": "your-role-keyword"
            },
            {
              "id": "r6",
              "name": "locations",
              "type": "array",
              "value": "={{ ['Remote','Worldwide','India','United States','United Kingdom','Europe'] }}"
            },
            {
              "id": "r4",
              "name": "min_score",
              "type": "number",
              "value": 75
            },
            {
              "id": "r5",
              "name": "profile",
              "type": "string",
              "value": "Describe your candidate here: seniority, domain, key skills, and location preferences. Example: Senior <role> with ~N years in <industry>; strong on <core skills>; comfortable with <tools>. Open to remote or <preferred locations>. Not a fit for <roles to exclude>."
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "api-keys-note-0001",
      "name": "Sticky Note Keys",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -768,
        944
      ],
      "parameters": {
        "color": 4,
        "width": 480,
        "height": 560,
        "content": "## API keys \u2014 how to get each one\n\n**Anthropic (Claude)** \u2014 scores the jobs\nconsole.anthropic.com -> Settings -> API Keys -> Create Key (add billing credit).\nIn n8n: Settings -> Variables/Env `ANTHROPIC_API_KEY`, or swap the header to an HTTP-header credential.\n\n**Supabase** \u2014 the job pool DB (free tier)\nsupabase.com -> New project. Then Project Settings -> API:\n- Project URL  ->  YOUR_SUPABASE_URL\n- `service_role` secret  ->  YOUR_SUPABASE_SERVICE_ROLE_KEY  (server-side only, bypasses RLS)\n\n**Adzuna** \u2014 a job source (free, no card)\ndeveloper.adzuna.com -> Sign up -> gives an Application ID + Application Key.\n\n**Resend** \u2014 sends the digest + alert emails\nresend.com -> API Keys -> Create (`re_...`). To send from your own address, verify a domain\n(Domains -> add the DNS records); for a quick test, send from onboarding@resend.dev.\n\n**No key needed:** Greenhouse / Lever / Ashby ATS boards and RemoteOK are open JSON APIs."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Format Error Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Job Matches": {
      "main": [
        [
          {
            "node": "Send Email Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Job Sources": {
      "main": [
        [
          {
            "node": "Normalize and Deduplicate Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Adzuna Query": {
      "main": [
        [
          {
            "node": "Fetch Adzuna Job Listings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Scored Jobs": {
      "main": [
        [
          {
            "node": "Save Job Matches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Error Alert": {
      "main": [
        [
          {
            "node": "Send Alert Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse ATS Response": {
      "main": [
        [
          {
            "node": "Merge Job Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Search Queries": {
      "main": [
        [
          {
            "node": "Curate ATS Board List",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Adzuna Query",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch RemoteOK Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch RemoteOK Jobs": {
      "main": [
        [
          {
            "node": "Parse RemoteOK Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse RemoteOK Data": {
      "main": [
        [
          {
            "node": "Merge Job Sources",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Fetch Pool Shortlist": {
      "main": [
        [
          {
            "node": "Get Already Matched IDs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Curate ATS Board List": {
      "main": [
        [
          {
            "node": "Fetch ATS Board API Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily Morning Trigger": {
      "main": [
        [
          {
            "node": "Set Role Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 3 Hours Trigger": {
      "main": [
        [
          {
            "node": "Set Search Queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Adzuna Response": {
      "main": [
        [
          {
            "node": "Merge Job Sources",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Score with Claude API": {
      "main": [
        [
          {
            "node": "Filter Scored Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Scoring Batches": {
      "main": [
        [
          {
            "node": "Score with Claude API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Role Configuration": {
      "main": [
        [
          {
            "node": "Fetch Pool Shortlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Already Matched IDs": {
      "main": [
        [
          {
            "node": "Create Scoring Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch ATS Board API Data": {
      "main": [
        [
          {
            "node": "Parse ATS Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Adzuna Job Listings": {
      "main": [
        [
          {
            "node": "Parse Adzuna Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize and Deduplicate Jobs": {
      "main": [
        [
          {
            "node": "Upsert Jobs to Pool",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}