{
"name": "Candidate engagement sequence",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9 * * 1-5"
}
]
}
},
"id": "1c1c1c1c-0001-0000-0000-000000000001",
"name": "Daily Cron — 9am Mon-Fri",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [240, 300],
"notesInFlow": true,
"notes": "Set the timezone explicitly in workflow Settings — default is UTC."
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT\n candidate_id,\n sequence_name,\n current_step,\n candidate_email,\n recruiter_owner,\n context_payload\nFROM candidate_sequence_state\nWHERE next_due_at <= now()\n AND status = 'active'\n AND opted_out_at IS NULL\nLIMIT 100;",
"options": {}
},
"id": "1c1c1c1c-0001-0000-0000-000000000002",
"name": "Pull Due Candidates",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 300],
"credentials": {
"postgres": {
"id": "PLACEHOLDER_POSTGRES_CRED_ID",
"name": "Postgres — sequence-state"
}
}
},
{
"parameters": {
"method": "GET",
"url": "=https://api.ashbyhq.com/candidate.info?id={{ $json.candidate_id }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpBasicAuth",
"sendQuery": false,
"options": {
"response": {
"response": {
"fullResponse": false
}
}
}
},
"id": "1c1c1c1c-0001-0000-0000-000000000003",
"name": "Ashby — Candidate Snapshot",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [680, 300],
"credentials": {
"httpBasicAuth": {
"id": "PLACEHOLDER_ASHBY_CRED_ID",
"name": "Ashby — API"
}
},
"notesInFlow": true,
"notes": "Returns latest candidate state, recent activity, applicationCount."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "skip-recent-reply",
"leftValue": "={{ $json.results.recentReplyAt }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notExists"
}
},
{
"id": "skip-recent-app",
"leftValue": "={{ $json.results.lastApplicationAt }}",
"rightValue": "={{ $now.minus({days: 30}).toISO() }}",
"operator": {
"type": "dateTime",
"operation": "before"
}
},
{
"id": "skip-opted-out",
"leftValue": "={{ $json.results.optedOut }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "equal"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "1c1c1c1c-0001-0000-0000-000000000004",
"name": "Skip-Condition Check",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [900, 300]
},
{
"parameters": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "anthropic-version", "value": "2023-06-01" },
{ "name": "content-type", "value": "application/json" }
]
},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"claude-sonnet-4-6\",\n \"max_tokens\": 1024,\n \"system\": \"You are writing the next outreach message in a multi-touch recruiting sequence. The candidate has been previously contacted and has not opted out. Your message must reference the candidate's actual context (recent role change, public talk, mentioned project) — never generic 'I came across your profile'. Length: 80-120 words. Tone: peer-to-peer, no marketing voice. End with a single specific question. Never claim a personal connection that does not exist.\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Sequence: {{ $('Pull Due Candidates').item.json.sequence_name }}. Step: {{ $('Pull Due Candidates').item.json.current_step }}. Candidate snapshot: {{ JSON.stringify($json.results) }}. Context payload: {{ $('Pull Due Candidates').item.json.context_payload }}.\"\n }\n ]\n}",
"options": {}
},
"id": "1c1c1c1c-0001-0000-0000-000000000005",
"name": "Claude — Personalize Message",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1120, 200],
"credentials": {
"httpHeaderAuth": {
"id": "PLACEHOLDER_ANTHROPIC_CRED_ID",
"name": "Anthropic — x-api-key"
}
}
},
{
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "={{ $('Pull Due Candidates').item.json.candidate_email }}",
"subject": "=Quick thought — {{ $('Pull Due Candidates').item.json.sequence_name }}",
"emailType": "text",
"message": "={{ $json.content[0].text }}\n\n—\nReply STOP to opt out: https://example.com/optout?token={{ $('Pull Due Candidates').item.json.candidate_id }}",
"options": {}
},
"id": "1c1c1c1c-0001-0000-0000-000000000006",
"name": "Gmail — Send",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [1340, 200],
"credentials": {
"gmailOAuth2": {
"id": "PLACEHOLDER_GMAIL_CRED_ID",
"name": "Gmail — sender mailbox"
}
},
"notesInFlow": true,
"notes": "For volume above ~50/day per mailbox, swap for a dedicated outreach platform (Smartlead/Instantly) with proper deliverability."
},
{
"parameters": {
"operation": "executeQuery",
"query": "UPDATE candidate_sequence_state\nSET\n current_step = current_step + 1,\n last_touched_at = now(),\n next_due_at = CASE\n WHEN current_step + 1 >= total_steps THEN NULL\n ELSE now() + interval_per_step\n END,\n status = CASE\n WHEN current_step + 1 >= total_steps THEN 'completed'\n ELSE 'active'\n END\nWHERE candidate_id = $1\nRETURNING current_step, status, next_due_at;",
"options": {
"queryReplacement": "={{ $('Pull Due Candidates').item.json.candidate_id }}"
}
},
"id": "1c1c1c1c-0001-0000-0000-000000000007",
"name": "Update Sequence State",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [1560, 200],
"credentials": {
"postgres": {
"id": "PLACEHOLDER_POSTGRES_CRED_ID",
"name": "Postgres — sequence-state"
}
}
},
{
"parameters": {
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"filters": {
"labelIds": ["INBOX"],
"q": "in:inbox -from:me -label:sequence-replied newer_than:1d"
},
"options": {
"downloadAttachments": false
}
},
"id": "1c1c1c1c-0001-0000-0000-000000000008",
"name": "Reply Trigger — Gmail Inbox Poll",
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1.2,
"position": [240, 600],
"credentials": {
"gmailOAuth2": {
"id": "PLACEHOLDER_GMAIL_CRED_ID",
"name": "Gmail — sender mailbox"
}
},
"notesInFlow": true,
"notes": "Independent trigger. Fires on every new inbound email and routes to Slack."
},
{
"parameters": {
"jsCode": "// Match inbound message to a candidate by email address.\nconst sender = ($json.from || '').match(/<([^>]+)>/)?.[1]\n || ($json.from || '').trim();\nconst subject = $json.subject || '';\nconst snippet = $json.snippet || '';\n\nreturn [{\n json: {\n candidate_email: sender,\n subject,\n snippet,\n received_at: $json.internalDate ? new Date(Number($json.internalDate)).toISOString() : new Date().toISOString(),\n gmail_thread_id: $json.threadId,\n }\n}];"
},
"id": "1c1c1c1c-0001-0000-0000-000000000009",
"name": "Normalize Reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [460, 600]
},
{
"parameters": {
"operation": "executeQuery",
"query": "UPDATE candidate_sequence_state\nSET status = 'replied', replied_at = now(), next_due_at = NULL\nWHERE candidate_email = $1 AND status = 'active'\nRETURNING candidate_id, recruiter_owner, sequence_name, current_step;",
"options": {
"queryReplacement": "={{ $json.candidate_email }}"
}
},
"id": "1c1c1c1c-0001-0000-0000-00000000000a",
"name": "Mark Replied + Lookup Owner",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [680, 600],
"credentials": {
"postgres": {
"id": "PLACEHOLDER_POSTGRES_CRED_ID",
"name": "Postgres — sequence-state"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://slack.com/api/chat.postMessage",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "content-type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"channel\": \"@{{ $json.recruiter_owner }}\",\n \"text\": \"Reply from {{ $('Normalize Reply').item.json.candidate_email }} on sequence *{{ $json.sequence_name }}* (step {{ $json.current_step }}).\\n> {{ $('Normalize Reply').item.json.snippet }}\"\n}",
"options": {}
},
"id": "1c1c1c1c-0001-0000-0000-00000000000b",
"name": "Slack — Notify Recruiter",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [900, 600],
"credentials": {
"httpHeaderAuth": {
"id": "PLACEHOLDER_SLACK_CRED_ID",
"name": "Slack — bot token"
}
}
}
],
"connections": {
"Daily Cron — 9am Mon-Fri": {
"main": [
[{ "node": "Pull Due Candidates", "type": "main", "index": 0 }]
]
},
"Pull Due Candidates": {
"main": [
[{ "node": "Ashby — Candidate Snapshot", "type": "main", "index": 0 }]
]
},
"Ashby — Candidate Snapshot": {
"main": [
[{ "node": "Skip-Condition Check", "type": "main", "index": 0 }]
]
},
"Skip-Condition Check": {
"main": [
[{ "node": "Claude — Personalize Message", "type": "main", "index": 0 }],
[]
]
},
"Claude — Personalize Message": {
"main": [
[{ "node": "Gmail — Send", "type": "main", "index": 0 }]
]
},
"Gmail — Send": {
"main": [
[{ "node": "Update Sequence State", "type": "main", "index": 0 }]
]
},
"Reply Trigger — Gmail Inbox Poll": {
"main": [
[{ "node": "Normalize Reply", "type": "main", "index": 0 }]
]
},
"Normalize Reply": {
"main": [
[{ "node": "Mark Replied + Lookup Owner", "type": "main", "index": 0 }]
]
},
"Mark Replied + Lookup Owner": {
"main": [
[{ "node": "Slack — Notify Recruiter", "type": "main", "index": 0 }]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"timezone": "America/New_York"
},
"versionId": "1c1c1c1c-0001-0000-0000-0000000000ff",
"meta": {
"templateCreatedBy": "ooligo",
"instanceId": "ooligo-pilot"
},
"id": "candidate-engagement-sequence",
"tags": [
{ "name": "recruiting" },
{ "name": "outbound" }
]
}