AutomationFlowsAI & RAG › Airtable Operations System: Leads, AI Agent, Outreach, Sync, Canva,…

Airtable Operations System: Leads, AI Agent, Outreach, Sync, Canva,…

Original n8n title: Airtable Operations System: Leads, AI Agent, Outreach, Sync, Canva, Make/zapier Bridge

Airtable Operations System: Leads, AI Agent, Outreach, Sync, Canva, Make/Zapier Bridge. Uses airtable, anthropic, slack, agent. Webhook trigger; 82 nodes.

Webhook trigger★★★★★ complexityAI-powered82 nodesAirtableAnthropicSlackAgentAnthropic ChatMemory Buffer WindowAirtable ToolGmail Tool
AI & RAG Trigger: Webhook Nodes: 82 Complexity: ★★★★★ AI nodes: yes Added:

This workflow follows the Agent → Airtable 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 →

Download .json
{
  "name": "Airtable Operations System: Leads, AI Agent, Outreach, Sync, Canva, Make/Zapier Bridge",
  "nodes": [
    {
      "id": "read-me-first-sticky",
      "name": "Read me first",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -760
      ],
      "parameters": {
        "width": 720,
        "height": 460,
        "color": 7,
        "content": "## Harbourline Group operations system\nSix flows in one import. Airtable is the one place the data lives, n8n is the layer that moves it.\n\n1. LinkedIn and website leads land in Airtable, scored and routed\n2. A Claude agent that runs the task, not just the trigger\n3. Outreach written off each record, spaced and capped\n4. One clean list across Airtable, Sheets and Excel every night\n5. Campaign copy in Airtable becomes finished Canva artwork\n6. Make scenarios and Zaps point here so you can retire them one at a time\n\nWhat sits outside n8n: designing the Airtable base itself, the fields and views inside it, the Make and Zapier scenarios while they are still running, the LinkedIn connector that posts to Flow 1, and the Canva brand template that Flow 5 fills. Those are set up once in their own tools. n8n is the glue between them.\n\nEvery value on this canvas is demo data for Harbourline Group, a made-up company. This is a demonstration build, not a copy of anyone's production file. Point the credentials at real accounts and swap the base and file ids."
      }
    },
    {
      "id": "flow1-sticky",
      "name": "Flow 1 note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -220
      ],
      "parameters": {
        "content": "## Flow 1: LinkedIn and website leads into Airtable\n\n- One webhook takes LinkedIn form leads, LinkedIn reply exports, and website form leads, and reads all three into the same shape.\n- Claude scores each lead, the row lands in the Airtable leads table next to its company, and the hot ones get a sales task plus a Slack post before the webhook answers.",
        "height": 200,
        "width": 460,
        "color": 4
      }
    },
    {
      "id": "flow1-webhook",
      "name": "New lead arrives",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        300
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "harbourline/new-lead",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "flow1-read-lead",
      "name": "Read the lead from any source",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        230,
        300
      ],
      "parameters": {
        "jsCode": "// ---- Settings you can change ----------------------------------\nconst SOURCE_LINKEDIN_FORM = 'linkedin-form';\nconst SOURCE_LINKEDIN_OUTREACH = 'linkedin-outreach';\nconst SOURCE_WEBSITE = 'website';\nconst DEFAULT_CAMPAIGN = 'always-on';\n\n// LinkedIn Lead Gen Forms name their answers. Map those names to ours.\nconst LINKEDIN_ANSWERS = {\n  emailAddress: 'email',\n  email: 'email',\n  firstName: 'first_name',\n  lastName: 'last_name',\n  company: 'company',\n  companyName: 'company',\n  jobTitle: 'job_title',\n  title: 'job_title',\n  phoneNumber: 'phone',\n  phone: 'phone',\n  linkedinProfile: 'linkedin_url',\n  message: 'message',\n};\n// ---------------------------------------------------------------\n\nfunction str(value) {\n  if (value === null || value === undefined) return '';\n  return String(value).trim();\n}\n\nfunction cleanEmail(value) {\n  return str(value).toLowerCase();\n}\n\nfunction cleanPhone(value) {\n  const digits = str(value).replace(/[^0-9]/g, '');\n  return digits ? '+' + digits : '';\n}\n\nfunction fullName(first, last, whole) {\n  const joined = [str(first), str(last)].filter(Boolean).join(' ');\n  return joined || str(whole);\n}\n\n// LinkedIn posts answers as [{ name: 'emailAddress', values: ['a@example.com'] }]\nfunction flattenLinkedInAnswers(answers) {\n  const flat = {};\n  for (const answer of answers) {\n    const key = LINKEDIN_ANSWERS[answer.name];\n    if (!key) continue;\n    const value = Array.isArray(answer.values) ? answer.values[0] : answer.value;\n    flat[key] = str(value);\n  }\n  return flat;\n}\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const raw = item.json || {};\n  const body = raw.body && typeof raw.body === 'object' ? raw.body : raw;\n  const answers = body.answers || body.formResponse?.answers;\n\n  let lead;\n\n  if (Array.isArray(answers)) {\n    // 1. LinkedIn Lead Gen Form\n    const flat = flattenLinkedInAnswers(answers);\n    lead = {\n      email: cleanEmail(flat.email),\n      full_name: fullName(flat.first_name, flat.last_name),\n      first_name: str(flat.first_name),\n      company: str(flat.company),\n      job_title: str(flat.job_title),\n      linkedin_url: str(flat.linkedin_url || body.leadProfileUrl),\n      phone: cleanPhone(flat.phone),\n      source: SOURCE_LINKEDIN_FORM,\n      campaign: str(body.campaignName || body.campaign) || DEFAULT_CAMPAIGN,\n      message: str(flat.message),\n    };\n  } else if (body.reply_text || body.connection_message || body.sales_nav_list) {\n    // 2. LinkedIn outreach reply exported from Sales Navigator\n    lead = {\n      email: cleanEmail(body.work_email || body.email),\n      full_name: fullName(body.first_name, body.last_name, body.name),\n      first_name: str(body.first_name) || str(body.name).split(' ')[0],\n      company: str(body.company_name || body.company),\n      job_title: str(body.headline || body.job_title),\n      linkedin_url: str(body.profile_url || body.linkedin_url),\n      phone: cleanPhone(body.phone),\n      source: SOURCE_LINKEDIN_OUTREACH,\n      campaign: str(body.sales_nav_list || body.campaign) || DEFAULT_CAMPAIGN,\n      message: str(body.reply_text || body.connection_message),\n    };\n  } else {\n    // 3. Website form\n    lead = {\n      email: cleanEmail(body.email),\n      full_name: fullName(body.first_name, body.last_name, body.name || body.full_name),\n      first_name: str(body.first_name) || str(body.name || body.full_name).split(' ')[0],\n      company: str(body.company),\n      job_title: str(body.job_title || body.role),\n      linkedin_url: str(body.linkedin_url),\n      phone: cleanPhone(body.phone),\n      source: SOURCE_WEBSITE,\n      campaign: str(body.utm_campaign || body.campaign) || DEFAULT_CAMPAIGN,\n      message: str(body.message || body.notes),\n    };\n  }\n\n  out.push({ json: lead });\n}\n\nreturn out;\n"
      }
    },
    {
      "id": "flow1-find-company",
      "name": "Find the company in Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        460,
        300
      ],
      "alwaysOutputData": true,
      "parameters": {
        "authentication": "airtableTokenApi",
        "resource": "record",
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblCompanies"
        },
        "filterByFormula": "=LOWER({Company Name}) = '{{ ($json.company || '').toLowerCase() }}'",
        "returnAll": false,
        "limit": 1,
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow1-score-lead",
      "name": "Have Claude score the lead",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        690,
        300
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "content": "=The lead:\n{{ JSON.stringify($('Read the lead from any source').first().json) }}\n\nThe matching company record from Airtable, empty if we have not worked with them before:\n{{ JSON.stringify($json) }}"
            }
          ]
        },
        "simplify": true,
        "options": {
          "system": "You score inbound leads for Harbourline Group, a B2B operations consultancy that builds Airtable and automation systems for operations teams.\n\nScore 0 to 100 on fit. A good fit runs its operations across several disconnected tools, has enough staff to feel that pain, and the person sits in a role that can approve a project. A poor fit is a student, a job seeker, a competing agency, or a company too small to have an operations problem worth paying to fix.\n\nReply with this JSON and nothing else:\n{\"score\": 0-100, \"tier\": \"hot|warm|cold\", \"segment\": \"...\", \"why\": \"one plain sentence\", \"opening_line\": \"one sentence a human could actually send\"}\n\ntier is hot at 70 and up, warm from 40 to 69, cold under 40.\nsegment is a short label for the kind of business, like \"logistics ops\" or \"agency ops\".\n\nHow to write the why and the opening_line:\n- Plain words, the way a person actually talks.\n- No emojis, no em dashes, no exclamation marks.\n- No corporate filler. Never write reaching out, circling back, leverage, solutions, excited to connect, or hope this finds you well.\n- Do not stack three things in a row for effect.\n- Do not end a sentence with an -ing clause that adds nothing.\n- The opening_line points at something specific about this person or their company. If you have nothing specific, write something plain and honest instead of guessing.",
          "maxTokens": 600
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow1-shape-lead",
      "name": "Get the lead ready for Airtable",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        300
      ],
      "parameters": {
        "jsCode": "// ---- Settings you can change ----------------------------------\nconst HOT_SCORE = 70;\nconst HOT_TIER = 'hot';\nconst SALES_TRACK = 'sales';\nconst NURTURE_TRACK = 'nurture';\nconst NEW_STATUS = 'New';\n// ---------------------------------------------------------------\n\nconst lead = $('Read the lead from any source').first().json;\nconst company = $('Find the company in Airtable').first().json || {};\n\n// Claude hands back JSON inside a text field. Pull the text out wherever it sits.\nfunction claudeText(json) {\n  if (typeof json.content === 'string') return json.content;\n  if (Array.isArray(json.content)) return json.content.map((part) => part.text || '').join('');\n  if (typeof json.text === 'string') return json.text;\n  return '';\n}\n\nfunction readScoring(json) {\n  const text = claudeText(json);\n  const start = text.indexOf('{');\n  const end = text.lastIndexOf('}');\n  if (start === -1 || end === -1) return {};\n  try {\n    return JSON.parse(text.slice(start, end + 1));\n  } catch (e) {\n    return {};\n  }\n}\n\nconst scoring = readScoring($input.first().json);\nconst score = Number(scoring.score) || 0;\nconst tier = scoring.tier || (score >= HOT_SCORE ? HOT_TIER : 'cold');\n\nreturn [\n  {\n    json: {\n      email: lead.email,\n      full_name: lead.full_name,\n      first_name: lead.first_name,\n      company: lead.company,\n      company_record_id: company.id || '',\n      job_title: lead.job_title,\n      linkedin_url: lead.linkedin_url,\n      phone: lead.phone,\n      source: lead.source,\n      campaign: lead.campaign,\n      score: score,\n      tier: tier,\n      segment: scoring.segment || '',\n      why: scoring.why || '',\n      opening_line: scoring.opening_line || '',\n      track: tier === HOT_TIER ? SALES_TRACK : NURTURE_TRACK,\n      status: NEW_STATUS,\n      created: new Date().toISOString(),\n    },\n  },\n];\n"
      }
    },
    {
      "id": "flow1-save-lead",
      "name": "Save the lead in Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        1150,
        300
      ],
      "parameters": {
        "authentication": "airtableTokenApi",
        "resource": "record",
        "operation": "upsert",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblLeads"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Email"
          ],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false,
          "value": {
            "Email": "={{ $json.email }}",
            "Full Name": "={{ $json.full_name }}",
            "Company": "={{ $json.company }}",
            "Job Title": "={{ $json.job_title }}",
            "LinkedIn URL": "={{ $json.linkedin_url }}",
            "Phone": "={{ $json.phone }}",
            "Source": "={{ $json.source }}",
            "Campaign": "={{ $json.campaign }}",
            "Score": "={{ $json.score }}",
            "Tier": "={{ $json.tier }}",
            "Segment": "={{ $json.segment }}",
            "Why": "={{ $json.why }}",
            "Opening Line": "={{ $json.opening_line }}",
            "Track": "={{ $json.track }}",
            "Status": "={{ $json.status }}",
            "Created": "={{ $json.created }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow1-is-hot",
      "name": "Is this one hot",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1380,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "flow1-cond-tier",
              "leftValue": "={{ $('Get the lead ready for Airtable').first().json.tier }}",
              "rightValue": "hot",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            },
            {
              "id": "flow1-cond-score",
              "leftValue": "={{ $('Get the lead ready for Airtable').first().json.score }}",
              "rightValue": 70,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "flow1-sales-task",
      "name": "Add a follow up task for sales",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        1610,
        140
      ],
      "parameters": {
        "authentication": "airtableTokenApi",
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblTasks"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false,
          "value": {
            "Task": "=Call {{ $('Get the lead ready for Airtable').first().json.full_name }} at {{ $('Get the lead ready for Airtable').first().json.company }}",
            "Owner Email": "sales@harbourline-group.example.com",
            "Due": "={{ $now.plus({ days: 1 }).toISO() }}",
            "Related Lead": "={{ $json.id }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow1-slack-sales",
      "name": "Tell sales in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1840,
        140
      ],
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#new-leads"
        },
        "text": "=New hot lead: {{ $('Get the lead ready for Airtable').first().json.full_name }} at {{ $('Get the lead ready for Airtable').first().json.company }}\n{{ $('Get the lead ready for Airtable').first().json.job_title }}, came in from {{ $('Get the lead ready for Airtable').first().json.source }}\nScore {{ $('Get the lead ready for Airtable').first().json.score }} ({{ $('Get the lead ready for Airtable').first().json.segment }})\nWhy: {{ $('Get the lead ready for Airtable').first().json.why }}\nSomething to open with: {{ $('Get the lead ready for Airtable').first().json.opening_line }}\nProfile: {{ $('Get the lead ready for Airtable').first().json.linkedin_url }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow1-respond",
      "name": "Tell the sender we have it",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        2070,
        300
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ saved: true, track: $('Get the lead ready for Airtable').first().json.track }) }}",
        "options": {}
      }
    },
    {
      "id": "f2-note",
      "name": "Flow 2 note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        880
      ],
      "parameters": {
        "width": 460,
        "height": 200,
        "color": 4,
        "content": "## Flow 2: The agent that runs the task, not just the trigger\n\n- Send a plain English request to the webhook and Claude does the work itself in Airtable, Gmail, Slack, and Harbourline's own API.\n- You get back a written account of what it changed, and the same account is filed in the sync log."
      }
    },
    {
      "id": "f2-webhook",
      "name": "Ask the operations agent",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        1400
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "harbourline/ops-agent",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "f2-read",
      "name": "Read the instruction",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        230,
        1400
      ],
      "parameters": {
        "jsCode": "const results = [];\n\nfor (const item of $input.all()) {\n  const data = item.json.body || item.json;\n  const slack = data.event || {};\n\n  const instruction = data.instruction || data.text || data.message || slack.text || '';\n  const requester = data.requester_email || data.requester || data.email || slack.user_email || 'ops@harbourline-group.example.com';\n  const sessionKey = data.session_key || data.thread_id || slack.thread_ts || requester;\n\n  results.push({\n    json: {\n      instruction: String(instruction).trim(),\n      requester: String(requester).trim(),\n      session_key: String(sessionKey).trim(),\n    },\n  });\n}\n\nreturn results;"
      }
    },
    {
      "id": "f2-agent",
      "name": "Run the task",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 3.1,
      "position": [
        460,
        1400
      ],
      "parameters": {
        "promptType": "define",
        "text": "={{ $json.instruction }}",
        "options": {
          "systemMessage": "You are the operations assistant for Harbourline Group, a B2B operations consultancy. Requests reach you from Slack, from Airtable buttons, and from other automations. Your job is to finish the request, not to explain how it could be finished.\n\nWhat you are allowed to do:\n- Read lead records in Airtable.\n- Update lead records in Airtable.\n- Open a task in Airtable.\n- Send an email from ops@harbourline-group.example.com.\n- Post a message in Slack.\n- Call the Harbourline internal API for anything the other tools do not cover.\n\nHow to work:\n- Check Airtable before you state anything about a lead, a company, or a task. Look it up. Do not guess.\n- Never invent a record id, a price, or a date. If you do not have the value, go and find it, or say you do not have it.\n- Do the task. If the request says update the record, update the record. A summary of what you would have done is not an answer.\n- Send an email only when the request asks for one.\n- Post in Slack only when the request asks for that, or when the request names a channel.\n- When you are done, report what you actually changed. Name the records you touched.\n- If any part of the request failed, or you could not do it, say so plainly and say why. Do not cover it up.\n\nVoice for anything a person will read, in email, in Slack, or in your final answer:\n- Plain words and short sentences.\n- No corporate phrases, no emojis, no em dashes.\n- Never write the phrase \"reaching out\".\n- Write the way a colleague writes a quick note."
        }
      }
    },
    {
      "id": "f2-shape",
      "name": "Write down what the agent did",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        690,
        1400
      ],
      "parameters": {
        "jsCode": "const asked = $('Read the instruction').all();\nconst results = [];\nconst items = $input.all();\n\nfor (let i = 0; i < items.length; i++) {\n  const agent = items[i].json;\n  const source = (asked[i] || asked[0] || { json: {} }).json;\n  const answer = agent.output || agent.text || 'The agent ran but sent back no text.';\n\n  results.push({\n    json: {\n      instruction: source.instruction || '',\n      requester: source.requester || '',\n      answer: String(answer).trim(),\n      finished_at: new Date().toISOString(),\n    },\n  });\n}\n\nreturn results;"
      }
    },
    {
      "id": "f2-log",
      "name": "Log the run in Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        920,
        1400
      ],
      "parameters": {
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblSyncLog"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Run Type": "ops-agent",
            "Instruction": "={{ $json.instruction }}",
            "Requester": "={{ $json.requester }}",
            "Result": "={{ $json.answer }}",
            "Finished": "={{ $json.finished_at }}"
          },
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-respond",
      "name": "Send the answer back",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1150,
        1400
      ],
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $('Write down what the agent did').item.json.answer }}",
        "options": {}
      }
    },
    {
      "id": "f2-claude",
      "name": "Claude runs the agent",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.5,
      "position": [
        120,
        1660
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-sonnet-5",
          "cachedResultName": "claude-sonnet-5"
        },
        "options": {
          "maxTokensToSample": 4096,
          "temperature": 0.2
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-memory",
      "name": "Remember the conversation",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "typeVersion": 1.4,
      "position": [
        290,
        1660
      ],
      "parameters": {
        "sessionIdType": "customKey",
        "sessionKey": "={{ $json.session_key }}",
        "contextWindowLength": 15
      }
    },
    {
      "id": "f2-at-search",
      "name": "Look up records in Airtable",
      "type": "n8n-nodes-base.airtableTool",
      "typeVersion": 2.2,
      "position": [
        460,
        1660
      ],
      "parameters": {
        "toolDescription": "Read lead records from the Harbourline leads table in Airtable. Use it to check what is already on file before you say anything about a lead. You can pass an Airtable filter formula to narrow the list, for example {Status} = 'New' or {Email} = 'jane@example.com'. Leave the formula empty to get the most recent leads.",
        "resource": "record",
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblLeads"
        },
        "filterByFormula": "={{ $fromAI('search_formula', \"an Airtable filter formula that narrows the leads, for example {Status} = 'New'. Send an empty string to list recent leads.\", 'string') }}",
        "limit": 20,
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-at-update",
      "name": "Update a record in Airtable",
      "type": "n8n-nodes-base.airtableTool",
      "typeVersion": 2.2,
      "position": [
        630,
        1660
      ],
      "parameters": {
        "toolDescription": "Change a lead record in the Harbourline leads table. You need the Airtable record id, so look the lead up first. Only send the fields you actually want to change.",
        "resource": "record",
        "operation": "update",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblLeads"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "id": "={{ $fromAI('record_id', 'the Airtable record id of the lead to change, taken from a lookup', 'string') }}",
            "Status": "={{ $fromAI('status', 'the new status for the lead, for example New, Working, Qualified, or Closed', 'string') }}",
            "Owner": "={{ $fromAI('owner', 'the email of the person who now owns the lead', 'string') }}",
            "Notes": "={{ $fromAI('notes', 'a short note about what changed and why', 'string') }}"
          },
          "matchingColumns": [
            "id"
          ],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-at-task",
      "name": "Add a task in Airtable",
      "type": "n8n-nodes-base.airtableTool",
      "typeVersion": 2.2,
      "position": [
        800,
        1660
      ],
      "parameters": {
        "toolDescription": "Open a task in the Harbourline tasks table so a person picks up the follow up work. Use it when the request asks for something a human has to do.",
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblTasks"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Task": "={{ $fromAI('task_name', 'a short title for the task, written plainly', 'string') }}",
            "Owner": "={{ $fromAI('task_owner', 'the email of the person who should do the task', 'string') }}",
            "Due": "={{ $fromAI('task_due', 'the due date in YYYY-MM-DD form, only if the request gives one', 'string') }}",
            "Notes": "={{ $fromAI('task_notes', 'what the person needs to know to do the task', 'string') }}"
          },
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-gmail",
      "name": "Send an email",
      "type": "n8n-nodes-base.gmailTool",
      "typeVersion": 2.2,
      "position": [
        970,
        1660
      ],
      "parameters": {
        "toolDescription": "Send an email from ops@harbourline-group.example.com. Only use this when the request asks for an email to go out. Never use it to confirm your own work.",
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $fromAI('email', 'the recipient email address', 'string') }}",
        "subject": "={{ $fromAI('subject', 'the subject line, plain and short', 'string') }}",
        "emailType": "text",
        "message": "={{ $fromAI('body', 'the body of the email in plain text, written the way a colleague writes', 'string') }}",
        "options": {
          "appendAttribution": false
        }
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-slack",
      "name": "Post in Slack",
      "type": "n8n-nodes-base.slackTool",
      "typeVersion": 2.3,
      "position": [
        1140,
        1660
      ],
      "parameters": {
        "toolDescription": "Post a message in a Harbourline Slack channel. The channels in use are #new-leads, #ops-daily, and #creative. Use it when the request asks for the team to be told something.",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $fromAI('channel', 'the Slack channel to post in, for example #ops-daily', 'string') }}"
        },
        "text": "={{ $fromAI('slack_message', 'the message text, plain and short', 'string') }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f2-api",
      "name": "Call the client's own API",
      "type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
      "typeVersion": 1.1,
      "position": [
        1310,
        1660
      ],
      "parameters": {
        "toolDescription": "Reach Harbourline's own internal systems at https://api.harbourline-group.example.com/v1. Use this for anything that has no ready made connector, such as the billing ledger, the project register, or the delivery tracker. Give it the path you want and the JSON body to send.",
        "method": "POST",
        "url": "https://api.harbourline-group.example.com/v1/{endpoint}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "model",
        "placeholderDefinitions": {
          "values": [
            {
              "name": "endpoint",
              "description": "the internal API path after /v1, for example projects, invoices/1042, or delivery/status",
              "type": "string"
            }
          ]
        },
        "optimizeResponse": false
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow3-sticky",
      "name": "Flow 3 note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        1980
      ],
      "parameters": {
        "content": "## Flow 3: Outreach that writes itself off the record\n\n- Every weekday morning this picks the leads who are actually due, has Claude write each email from that person's own record, and sends it.\n- Out comes a sent email, a stamped record so nobody gets the same touch twice, a call task once someone runs out of touches, and one Slack line in #ops-daily.",
        "height": 200,
        "width": 460,
        "color": 4
      }
    },
    {
      "id": "flow3-schedule",
      "name": "Every weekday at 8am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        2500
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * 1-5"
            }
          ]
        }
      }
    },
    {
      "id": "flow3-get-list",
      "name": "Get the outreach list from Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        230,
        2500
      ],
      "parameters": {
        "authentication": "airtableTokenApi",
        "resource": "record",
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblLeads"
        },
        "filterByFormula": "AND({Track} = 'outreach', {Status} != 'Unsubscribed', {Status} != 'Customer')",
        "returnAll": true,
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow3-pick-due",
      "name": "Pick who is due today",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        2500
      ],
      "parameters": {
        "jsCode": "// ---- Settings you can change ----------------------------------\nconst TOUCH_SPACING_DAYS = 3;\nconst MAX_TOUCHES = 4;\nconst DAILY_SEND_CAP = 40;\nconst STOP_STATUSES = ['Replied', 'Unsubscribed', 'Customer', 'Do not contact'];\n// ---------------------------------------------------------------\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst now = Date.now();\n\nfunction str(value) {\n  if (value === null || value === undefined) return '';\n  return String(value).trim();\n}\n\nfunction fieldsOf(item) {\n  // Airtable rows come back either flat or wrapped in a fields object.\n  const json = item.json || {};\n  return json.fields && typeof json.fields === 'object' ? json.fields : json;\n}\n\nfunction daysSince(value) {\n  const when = Date.parse(str(value));\n  if (!when) return null;\n  return (now - when) / DAY_MS;\n}\n\nconst due = [];\nlet held_back = 0;\nlet handed_over = 0;\n\nfor (const item of $input.all()) {\n  const row = fieldsOf(item);\n  const recordId = item.json.id || row.id || '';\n  const status = str(row.Status);\n  const touches = Number(row.Touches) || 0;\n  const waited = daysSince(row['Last Touch Sent']);\n\n  // Anyone who answered, opted out, bought, or asked us to stop is done.\n  if (STOP_STATUSES.includes(status)) {\n    held_back++;\n    continue;\n  }\n\n  // Too soon since the last one.\n  if (waited !== null && waited < TOUCH_SPACING_DAYS) {\n    held_back++;\n    continue;\n  }\n\n  // Out of touches. A person picks it up from here.\n  if (touches >= MAX_TOUCHES) {\n    handed_over++;\n    continue;\n  }\n\n  const email = str(row.Email).toLowerCase();\n  if (!email) {\n    held_back++;\n    continue;\n  }\n\n  due.push({\n    record_id: recordId,\n    email: email,\n    full_name: str(row['Full Name']),\n    first_name: str(row['Full Name']).split(' ')[0],\n    company: str(row.Company),\n    job_title: str(row['Job Title']),\n    source: str(row.Source),\n    segment: str(row.Segment),\n    why: str(row.Why),\n    touch_number: touches + 1,\n    last_subject: str(row['Last Subject']),\n    last_touch_sent: str(row['Last Touch Sent']),\n    waited_days: waited === null ? null : Math.round(waited),\n    handoff: touches + 1 >= MAX_TOUCHES,\n  });\n}\n\n// Oldest touched first, so nobody sits at the back of the line forever.\ndue.sort((a, b) => {\n  const left = Date.parse(a.last_touch_sent || '') || 0;\n  const right = Date.parse(b.last_touch_sent || '') || 0;\n  return left - right;\n});\n\nconst overflow = Math.max(0, due.length - DAILY_SEND_CAP);\nconst sending = due.slice(0, DAILY_SEND_CAP);\nheld_back += overflow;\n\nconst stats = {\n  considered: $input.all().length,\n  sending: sending.length,\n  held_back: held_back,\n  handed_over: handed_over,\n  final_touch_today: sending.filter((lead) => lead.touch_number >= MAX_TOUCHES).length,\n  max_touches: MAX_TOUCHES,\n  spacing_days: TOUCH_SPACING_DAYS,\n  daily_cap: DAILY_SEND_CAP,\n};\n\nreturn sending.map((lead) => ({ json: Object.assign({}, lead, { stats: stats }) }));\n"
      }
    },
    {
      "id": "flow3-write-email",
      "name": "Have Claude write each email",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        690,
        2500
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "content": "=Touch number: {{ $json.touch_number }} of {{ $json.stats.max_touches }}\nName: {{ $json.full_name }}\nFirst name: {{ $json.first_name }}\nCompany: {{ $json.company }}\nJob title: {{ $json.job_title }}\nHow we got them: {{ $json.source }}\nKind of business: {{ $json.segment }}\nWhy we think they fit: {{ $json.why }}\nThe last email we sent them was about: {{ $json.last_subject || 'nothing yet, this is the first one' }}"
            }
          ]
        },
        "simplify": true,
        "options": {
          "system": "You write one short outreach email for Harbourline Group, a B2B operations consultancy. Harbourline takes a company whose work is scattered across Airtable, spreadsheets, and a pile of half connected apps, and puts it back together as one Airtable centred system with the automation running off it.\n\nYou are given one person and the touch number. The touch number decides what the email does:\n1. Say why you are writing them specifically.\n2. Name one specific thing you noticed about how their setup runs.\n3. Give one short proof point, a real result at a similar company.\n4. Close plainly and offer to go away.\n\nRules for the email:\n- 90 words maximum. Shorter is better.\n- One question at most.\n- No attachments.\n- No links except the booking link https://harbourline-group.example.com/call, and only use it when it fits.\n\nVOICE RULES:\n- Write the way you would text someone you respect. Plain words, short sentences.\n- No corporate phrases. No emojis. No em dashes.\n- Never write I hope this finds you well, reaching out, circling back, touching base, leverage, solutions, synergy, or excited to connect.\n- Do not stack three things in a row for effect.\n- Do not end a sentence with an -ing clause that adds nothing.\n- Sign off as Dan at Harbourline.\n\nReply with this JSON and nothing else:\n{\"subject\": \"...\", \"body\": \"...\"}\nThe subject is lowercase, under 6 words, and reads like a person typed it, not a campaign.",
          "maxTokens": 600
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow3-ready-email",
      "name": "Get the email ready",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        2500
      ],
      "parameters": {
        "jsCode": "// ---- Settings you can change ----------------------------------\nconst FALLBACK_SUBJECT = 'quick question about your setup';\nconst SIGN_OFF = '\\n\\nDan\\nHarbourline Group';\nconst FALLBACK_SPACING_DAYS = 3;\n// ---------------------------------------------------------------\n\n// Claude hands back JSON inside a text field. Pull the text out wherever it sits.\nfunction claudeText(json) {\n  if (typeof json.content === 'string') return json.content;\n  if (Array.isArray(json.content)) return json.content.map((part) => part.text || '').join('');\n  if (typeof json.text === 'string') return json.text;\n  return '';\n}\n\nfunction readEmail(json) {\n  const text = claudeText(json);\n  const start = text.indexOf('{');\n  const end = text.lastIndexOf('}');\n  if (start !== -1 && end !== -1) {\n    try {\n      return JSON.parse(text.slice(start, end + 1));\n    } catch (e) {\n      // Fall through and use the raw text as the body.\n    }\n  }\n  return { subject: '', body: text };\n}\n\nconst leads = $('Pick who is due today').all();\nconst out = [];\n\n$input.all().forEach((item, index) => {\n  const lead = (leads[index] && leads[index].json) || {};\n  const written = readEmail(item.json || {});\n  const body = String(written.body || '').trim();\n  const spacing = (lead.stats && lead.stats.spacing_days) || FALLBACK_SPACING_DAYS;\n\n  out.push({\n    json: {\n      record_id: lead.record_id,\n      email: lead.email,\n      full_name: lead.full_name,\n      company: lead.company,\n      touch_number: lead.touch_number,\n      handoff: lead.handoff === true,\n      stats: lead.stats,\n      subject: String(written.subject || '').trim() || FALLBACK_SUBJECT,\n      body: body.endsWith('Harbourline Group') ? body : body + SIGN_OFF,\n      next_touch_due: new Date(Date.now() + (spacing * 24 * 60 * 60 * 1000)).toISOString(),\n    },\n  });\n});\n\nreturn out;\n"
      }
    },
    {
      "id": "flow3-send-email",
      "name": "Send the outreach email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1150,
        2500
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow3-stamp-touch",
      "name": "Stamp the touch on the record",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        1380,
        2500
      ],
      "parameters": {
        "authentication": "airtableTokenApi",
        "resource": "record",
        "operation": "update",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblLeads"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "id"
          ],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false,
          "value": {
            "id": "={{ $('Get the email ready').item.json.record_id }}",
            "Touches": "={{ $('Get the email ready').item.json.touch_number }}",
            "Last Touch Sent": "={{ $now.toISO() }}",
            "Last Subject": "={{ $('Get the email ready').item.json.subject }}",
            "Next Touch Due": "={{ $('Get the email ready').item.json.next_touch_due }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow3-last-touch",
      "name": "Was that the last touch",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1610,
        2500
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "flow3-cond-last-touch",
              "leftValue": "={{ $('Get the email ready').item.json.touch_number }}",
              "rightValue": "={{ $('Get the email ready').item.json.stats.max_touches }}",
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      }
    },
    {
      "id": "flow3-holdout-task",
      "name": "Add a call task for the holdouts",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        1840,
        2340
      ],
      "parameters": {
        "authentication": "airtableTokenApi",
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appHarbourOps01"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblTasks"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false,
          "value": {
            "Task": "=Emails are done, give {{ $('Get the email ready').item.json.full_name }} at {{ $('Get the email ready').item.json.company }} a call",
            "Owner Email": "sales@harbourline-group.example.com",
            "Due": "={{ $now.plus({ days: 2 }).toISO() }}",
            "Related Lead": "={{ $('Get the email ready').item.json.record_id }}"
          }
        },
        "options": {
          "typecast": true
        }
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "flow3-sum-morning",
      "name": "Sum up the morning",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2070,
        2500
      ],
      "parameters": {
        "jsCode": "// ---- Settings you can change ----------------------------------\nconst CHANNEL_LABEL = 'This morning';\n// ---------------------------------------------------------------\n\nconst written = $('Get the email ready').all().map((item) => item.json);\nconst stats = (written[0] && written[0].stats) || {\n  considered: 0,\n  sending: 0,\n  held_back: 0,\n  handed_over: 0,\n  final_touch_today: 0,\n};\n\nconst sent = written.length;\nconst handed_over = stats.handed_over + stats.final_touch_today;\nconst names = written.slice(0, 5).map((item) => item.full_name + ' at ' + item.company);\n\nconst lines = [\n  CHANNEL_LABEL + ': ' + sent + ' outreach emails went out.',\n  stats.held_back + ' were left alone, either too soon or already answered.',\n];\n\nif (handed_over > 0) {\n  lines.push(handed_over + ' ran out of touches and are now a call task for sales.');\n}\n\nif (names.length) {\n  lines.push('Went to: ' + names.join(', ') + (sent > names.length ? ' and ' + (sent - names.length) + ' more.' : '.'));\n}\n\nreturn [\n  {\n    json: {\n      sent: sent,\n      held_back: stats.held_back,\n      handed_over: handed_over,\n      considered: stats.considered,\n      digest: lines.join('\\n'),\n    },\n  },\n];\n"
      }
    },
    {
      "id": "flow3-post-digest",
      "name": "Post the morning digest",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2300,
        2500
      ],
      "parameters": {
        "authentication": "accessToken",
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#ops-daily"
        },
        "text": "={{ $json.digest }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-sticky",
      "name": "Flow 4 note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        2980
      ],
      "parameters": {
        "content": "## Flow 4: One clean list across Airtable, Sheets and Excel\n\n- Every night it pulls the Google Sheet and the Excel workbook into one clean set of rows in Airtable.\n- Both files get the agreed list back, and a short Doc records what changed.",
        "height": 200,
        "width": 460,
        "color": 4
      }
    },
    {
      "id": "f4-schedule",
      "name": "Every night at 2am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        3400
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "triggerAtHour": 2
            }
          ]
        }
      }
    },
    {
      "id": "f4-read-sheet",
      "name": "Read what people typed in the Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        230,
        3400
      ],
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "1HbLnMasterOpsSheetDemo0123456789abcdefg",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Intake",
          "mode": "name"
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-read-excel",
      "name": "Read the Excel workbook",
      "type": "n8n-nodes-base.microsoftExcel",
      "typeVersion": 2.2,
      "position": [
        460,
        3400
      ],
      "parameters": {
        "resource": "worksheet",
        "operation": "readRows",
        "workbook": {
          "__rl": true,
          "value": "01HARBOURLINEWORKBOOKDEMO7XK4",
          "mode": "id"
        },
        "worksheet": {
          "__rl": true,
          "value": "Leads",
          "mode": "id"
        },
        "options": {}
      },
      "credentials": {
        "microsoftExcelOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-clean",
      "name": "Clean both files into one list",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        690,
        3400
      ],
      "parameters": {
        "jsCode": "// ===== EDITABLE RULES =====\n// Left side is a spelling people actually type. Right side is the column it becomes.\nconst HEADER_ALIASES = {\n  'name': 'Name',\n  'full name': 'Name',\n  'contact name': 'Name',\n  'e-mail': 'Email',\n  'email': 'Email',\n  'email address': 'Email',\n  'co.': 'Company',\n  'company': 'Company',\n  'company name': 'Company',\n  'organisation': 'Company',\n  'organization': 'Company',\n  'phone': 'Phone',\n  'phone number': 'Phone',\n  'mobile': 'Phone',\n  'telephone': 'Phone',\n  'added': 'Added',\n  'date added': 'Added',\n  'created': 'Added',\n  'created at': 'Added'\n};\nconst DEFAULT_DIAL_CODE = '+1';\nconst KEEP_ROWS_WITHOUT_EMAIL = false;\n// ===== END EDITABLE RULES =====\n\nfunction canonicalKey(key) {\n  const flat = String(key).trim().toLowerCase().replace(/[_\\s]+/g, ' ');\n  return HEADER_ALIASES[flat] || String(key).trim();\n}\n\nfunction titleCase(value) {\n  return String(value === undefined || value === null ? '' : value)\n    .trim()\n    .replace(/\\s+/g, ' ')\n    .toLowerCase()\n    .replace(/(^|[\\s'-])([a-z])/g, (whole, sep, chr) => sep + chr.toUpperCase());\n}\n\nfunction cleanPhone(value) {\n  const original = String(value === undefined || value === null ? '' : value).trim();\n  const digits = original.replace(/[^0-9]/g, '');\n  if (!digits) return '';\n  if (original.startsWith('+')) return '+' + digits;\n  if (digits.length === 10) return DEFAULT_DIAL_CODE + digits;\n  return '+' + digits;\n}\n\nfunction cleanDate(value) {\n  if (!value) return '';\n  const parsed = new Date(value);\n  if (isNaN(parsed.getTime())) return '';\n  return parsed.toISOString();\n}\n\nfunction renameRow(row) {\n  const out = {};\n  for (const [key, value] of Object.entries(row)) {\n    out[canonicalKey(key)] = value;\n  }\n  return out;\n}\n\n// The Sheet rows are reached back through the node that read them, because this\n// step only has one input.\nconst fromSheet = $('Read what people typed in the Sheet').all().map(item => ({ row: renameRow(item.json), source: 'sheet' }));\nconst fromExcel = $input.all().map(item => ({ row: renameRow(item.json), source: 'excel' }));\n\nconst cleaned = [];\nfor (const entry of [...fromSheet, ...fromExcel]) {\n  const row = entry.row;\n  const email = String(row.Email === undefined || row.Email === null ? '' : row.Email).trim().toLowerCase();\n  if (!email && !KEEP_ROWS_WITHOUT_EMAIL) continue;\n  cleaned.push({\n    Name: titleCase(row.Name),\n    Email: email,\n    Phone: cleanPhone(row.Phone),\n    Company: titleCase(row.Company),\n    Added: cleanDate(row.Added),\n    Source: entry.source\n  });\n}\n\n// One row per email address. The newest wins.\nconst newest = new Map();\nfor (const row of cleaned) {\n  const seen = newest.get(row.Email);\n  if (!seen || (row.Added || '') >= (seen.Added || '')) newest.set(row.Email, row);\n}\n\nreturn [...newest.values()].map(row => ({ json: row }));\n"
      }
    },
    {
      "id": "f4-airtable-upsert",
      "name": "Write the clean rows into Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        920,
        3400
      ],
      "parameters": {
        "operation": "upsert",
        "base": {
          "__rl": true,
          "value": "appHarbourOps01",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "tblLeads",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "Email"
          ],
          "value": {},
          "schema": []
        },
        "options": {}
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-airtable-search",
      "name": "Read the agreed view back out",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.2,
      "position": [
        1150,
        3400
      ],
      "parameters": {
        "operation": "search",
        "base": {
          "__rl": true,
          "value": "appHarbourOps01",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "tblLeads",
          "mode": "id"
        },
        "returnAll": true,
        "options": {
          "fields": [
            "Name",
            "Email",
            "Phone",
            "Company",
            "Source",
            "Added"
          ]
        }
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-shape",
      "name": "Shape the rows for the two files",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1380,
        3400
      ],
      "parameters": {
        "jsCode": "// Columns the Sheet and the workbook are allowed to show, in this order.\nconst COLUMNS = ['Name', 'Email', 'Phone', 'Company', 'Source', 'Added'];\n\nconst rows = $input.all().map(item => item.json.fields || item.json);\n\nfunction shape(row) {\n  const out = {};\n  for (const column of COLUMNS) {\n    const value = row[column];\n    out[column] = value === undefined || value === null ? '' : value;\n  }\n  return out;\n}\n\n// Both files take the same shape. The Sheet step and the Excel step each match on\n// Email, so a flat row per record is all either one needs.\nconst forSheets = rows.map(shape);\nconst forExcel = rows.map(shape);\n\nreturn forSheets.map((row, index) => ({ json: { ...row, ...forExcel[index] } }));\n"
      }
    },
    {
      "id": "f4-push-sheet",
      "name": "Push the clean list back to the Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1610,
        3400
      ],
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "1HbLnMasterOpsSheetDemo0123456789abcdefg",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Master",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "Email"
          ],
          "value": {},
          "schema": []
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-push-excel",
      "name": "Push the clean list back to Excel",
      "type": "n8n-nodes-base.microsoftExcel",
      "typeVersion": 2.2,
      "position": [
        1840,
        3400
      ],
      "parameters": {
        "resource": "worksheet",
        "operation": "upsert",
        "workbook": {
          "__rl": true,
          "value": "01HARBOURLINEWORKBOOKDEMO7XK4",
          "mode": "id"
        },
        "worksheet": {
          "__rl": true,
          "value": "Leads",
          "mode": "id"
        },
        "dataMode": "autoMap",
        "columnToMatchOn": "Email",
        "options": {}
      },
      "credentials": {
        "microsoftExcelOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "f4-summary",
      "name": "Write up what changed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2070,
        3400
      ],
      "parameters": {
        "jsCode": "const EMAIL_KEYS = ['email', 'e-mail', 'email address'];\n\nfunction emailOf(row) {\n  for (const [key, value] of Object.entries(row)) {\n    if (EMAIL_KEYS.includes(String(key).trim().toLowerCase().replace(/[_\\s]+/g, ' '))) {\n      return String(value === undefined || value === null ? '' : value).trim().toLowerCase();\n    }\n  }\n  return '';\n}\n\nconst sheetRows = $('Read what people typed in the Sheet').all().map(item => item.json);\nconst excelRows = $('Read the Excel workbook').all().map(item => item.json);\nconst cleanRows = $('Clean both files into one list').all().map(item => item.json);\n\nconst rawEmails = [...sheetRows, ...excelRows].map(emailOf);\nconst withEmail = rawEmails.filter(value => value !== '');\nconst rowsWithNoEmail = rawEmails.length - withEmail.length;\nconst repeatsDropped = withEmail.length - new Set(withEmail).size;\n\nconst runDate = $now.toFormat('yyyy-LL-dd');\n\nconst summary = [\n  'Nightly data sync for ' + runDate + '.',\n  '',\n  'Rows read from t

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Airtable Operations System: Leads, AI Agent, Outreach, Sync, Canva, Make/Zapier Bridge. Uses airtable, anthropic, slack, agent. Webhook trigger; 82 nodes.

Source: https://github.com/mcruz1799/automation-examples/blob/main/n8n/02-airtable-ops-ai-agent/workflow.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

What if AI didn't just write content—but actually thought about how to write it? This n8n workflow revolutionizes content creation by deploying multiple specialized AI agents that handle every aspect

Tool Http Request, Anthropic Chat, Airtable +7
AI & RAG

Whatsapp Lead Agent. Uses httpRequest, hunter, @tavily/n8n-nodes-tavily, @mendable/n8n-nodes-firecrawl. Webhook trigger; 35 nodes.

HTTP Request, Hunter, @Tavily/N8N Nodes Tavily +11
AI & RAG

This workflow automates credit operations onboarding by running KYC verification, credit bureau checks, identity validation, and sanctions screening through a single AI-powered agent. Built for credit

Agent, OpenAI Chat, Output Parser Structured +8
AI & RAG

This workflow automates credit operations onboarding by running KYC verification, credit bureau checks, identity validation, and sanctions screening through a single AI-powered agent. Built for credit

Agent, OpenAI Chat, Output Parser Structured +8
AI & RAG

Avecta. Uses httpRequest, agent, lmChatAnthropic, memoryBufferWindow. Webhook trigger; 32 nodes.

HTTP Request, Agent, Anthropic Chat +7