{
  "name": "Lead Workflow A - Intake Qualification and First Reply",
  "nodes": [
    {
      "id": "34ddb264-2cc4-4646-a08b-f8443bac85a2",
      "name": "Tally Lead Intake Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -1100,
        -160
      ],
      "parameters": {
        "path": "leads/tally-intake",
        "httpMethod": "POST",
        "responseMode": "onReceived",
        "options": {}
      }
    },
    {
      "id": "f067b934-0570-4d6d-ae4e-a67bbe5447ed",
      "name": "Prepare Tally Lead Qualification Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -840,
        -160
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const config = {\\n  businessName:       \"Prime Properties Agency\",\\n  vertical:           \"real estate agency\",\\n  ownerName:          \"Marco\",\\n  calendarLink:       \"https://calendly.com/prime-properties/intro\",\\n  hotThreshold:       7,\\n  warmThreshold:      4,\\n\\n  qualificationFields: [\\n    \"budget\",\\n    \"buy_or_rent\",\\n    \"location_preference\",\\n    \"bedrooms\",\\n    \"timeline_months\",\\n    \"currently_renting_or_owning\"\\n  ],\\n\\n  scoringRules: `\\n    Score higher if: budget is confirmed, timeline is under 3 months,\\n    location is specific, they mention a reason for moving.\\n    Score lower if: no budget mentioned, timeline is vague or over 12 months,\\n    message is very short with no specifics.\\n  `,\\n\\n  followupAngles: [\\n    \"gentle check-in, ask if they had a chance to think it over, mention 2 new listings\",\\n    \"add value \\u2014 share one market insight relevant to their search criteria\",\\n    \"soft close \\u2014 this is the last message, leave the door open with no pressure\"\\n  ],\\n\\n  proofElement: \"We helped 14 families find their home in Tirana this quarter.\",\\n};\n\n// This Code node normalizes the Tally webhook payload, validates the email, and builds the Groq qualification prompt.\nconst env = (typeof $env !== \"undefined\") ? $env : ((typeof process !== \"undefined\" && process.env) ? process.env : {});\nconst item = $json;\n\nfunction readField(payload, names) {\n  const roots = [payload, payload.body, payload.data, payload.body?.data].filter(Boolean);\n  for (const root of roots) {\n    for (const name of names) {\n      if (root[name] !== undefined && root[name] !== null && String(root[name]).trim() !== \"\") return root[name];\n    }\n  }\n  const arrays = [payload.fields, payload.answers, payload.body?.fields, payload.body?.answers, payload.data?.fields, payload.data?.answers, payload.body?.data?.fields, payload.body?.data?.answers].filter(Array.isArray).flat();\n  const wanted = names.map((name) => String(name).toLowerCase().replace(/[^a-z0-9]/g, \"\"));\n  for (const field of arrays) {\n    const identifiers = [field.key, field.id, field.name, field.label, field.title, field.field?.key, field.field?.id, field.field?.label, field.field?.title].map((value) => String(value ?? \"\").toLowerCase().replace(/[^a-z0-9]/g, \"\"));\n    if (identifiers.some((identifier) => wanted.includes(identifier))) {\n      return field.value ?? field.answer ?? field.text ?? field.email ?? field.phone_number ?? \"\";\n    }\n  }\n  return \"\";\n}\n\nconst name = String(readField(item, [\"name\", \"full_name\", \"fullName\"]) || \"there\").trim();\nconst email = String(readField(item, [\"email\", \"email_address\", \"emailAddress\"])).trim().toLowerCase();\nconst phone = String(readField(item, [\"phone\", \"phone_number\", \"phoneNumber\"]) || \"\").trim();\nconst rawMessage = String(readField(item, [\"message\", \"raw_message\", \"inquiry\", \"details\"]) || \"\").trim();\n\nif (!email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\n  throw new Error(\"A valid lead email is required.\");\n}\n\nconst source = \"tally\";\nconst airtableBaseUrl = \"https://api.airtable.com/v0/\" + encodeURIComponent(env.AIRTABLE_BASE_ID || \"REPLACE_AIRTABLE_BASE_ID\") + \"/\" + encodeURIComponent(env.AIRTABLE_TABLE_NAME || \"Leads\");\n\nconst qualificationPrompt = `\nYou are a lead qualification AI for ${config.businessName}, a ${config.vertical}.\n\nAnalyze this inquiry and extract structured data.\nSet any field to null if not mentioned.\n\nInquiry: \"${rawMessage}\"\n\nReturn ONLY valid JSON with these fields:\n${config.qualificationFields.map(f => `\"${f}\"`).join(\", \")},\n\"lead_quality\" (one of: hot, warm, cold),\n\"summary\" (one sentence describing what this lead needs),\n\"score\" (integer 1-10 based on these rules: ${config.scoringRules})\n\nReturn JSON only. No explanation. No markdown.\n`;\n\nreturn {\n  name,\n  email,\n  phone,\n  rawMessage,\n  source,\n  prompt: qualificationPrompt,\n  airtableBaseUrl,\n};"
      }
    },
    {
      "id": "6903548b-a3d6-4948-800b-b464a391e72f",
      "name": "Groq Qualify and Score Lead",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -580,
        -160
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: \"llama-3.3-70b-versatile\", temperature: 0.3, max_tokens: 600, messages: [{ role: \"system\", content: \"You are a lead qualification and email writing AI. Follow instructions exactly. Return only what is asked.\" }, { role: \"user\", content: $json.prompt }] }) }}",
        "options": {}
      }
    },
    {
      "id": "6bb552d6-5c93-496e-9030-21c35f76fd95",
      "name": "Parse Qualification and Score Lead",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -320,
        -160
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const config = {\\n  businessName:       \"Prime Properties Agency\",\\n  vertical:           \"real estate agency\",\\n  ownerName:          \"Marco\",\\n  calendarLink:       \"https://calendly.com/prime-properties/intro\",\\n  hotThreshold:       7,\\n  warmThreshold:      4,\\n\\n  qualificationFields: [\\n    \"budget\",\\n    \"buy_or_rent\",\\n    \"location_preference\",\\n    \"bedrooms\",\\n    \"timeline_months\",\\n    \"currently_renting_or_owning\"\\n  ],\\n\\n  scoringRules: `\\n    Score higher if: budget is confirmed, timeline is under 3 months,\\n    location is specific, they mention a reason for moving.\\n    Score lower if: no budget mentioned, timeline is vague or over 12 months,\\n    message is very short with no specifics.\\n  `,\\n\\n  followupAngles: [\\n    \"gentle check-in, ask if they had a chance to think it over, mention 2 new listings\",\\n    \"add value \\u2014 share one market insight relevant to their search criteria\",\\n    \"soft close \\u2014 this is the last message, leave the door open with no pressure\"\\n  ],\\n\\n  proofElement: \"We helped 14 families find their home in Tirana this quarter.\",\\n};\n\n// This Code node parses Groq's structured qualification JSON and applies the configured scoring thresholds.\nconst source = $(\"Prepare Tally Lead Qualification Prompt\").first().json;\nconst raw = $json.choices[0].message.content;\nlet qualifiedData;\ntry {\n  const clean = raw.replace(/```json|```/gi, \"\").trim();\n  qualifiedData = JSON.parse(clean);\n} catch (e) {\n  qualifiedData = {\n    summary: \"Lead submitted an inquiry. Manual review needed.\",\n    lead_quality: \"warm\",\n    score: 5\n  };\n}\n\nconst groqScore  = qualifiedData.score || 5;\nconst baseScore  = groqScore;\n\n// Boost signals\nlet finalScore = baseScore;\nif (qualifiedData.lead_quality === \"hot\")  finalScore = Math.min(10, finalScore + 2);\nif (qualifiedData.timeline_months && qualifiedData.timeline_months <= 3) finalScore += 1;\nif (qualifiedData.budget !== null)         finalScore += 1;\n\nfinalScore = Math.min(10, finalScore);\n\nconst classification =\n  finalScore >= config.hotThreshold  ? \"hot\"  :\n  finalScore >= config.warmThreshold ? \"warm\" : \"cold\";\n\nconst score = finalScore;\nconst qualified_data = qualifiedData;\nconst name = source.name;\nconst firstReplyPrompt = `\nWrite a first reply email from ${config.ownerName} at ${config.businessName}\nto a new lead named ${name}.\n\nTheir situation: ${qualified_data.summary}\nTheir score: ${score}/10\n\nRules:\n- Under 120 words\n- Sound like a person, not a company\n- Reference one specific detail from their inquiry\n- End with a question or a soft CTA linking to: ${config.calendarLink}\n- Do not mention AI, automation, or templates\nReturn plain text only. No subject line. No sign-off.\n`;\n\nconst gotifyPayload = {\n  title: \"Hot lead: \" + source.name,\n  message: [\n    \"Name: \" + source.name,\n    \"Email: \" + source.email,\n    \"Score: \" + finalScore + \"/10\",\n    \"Summary: \" + (qualifiedData.summary || \"No summary returned\"),\n  ].join(\"\\n\"),\n  priority: 8,\n};\n\nconst airtableFields = {\n  Name: source.name,\n  Email: source.email,\n  Phone: source.phone,\n  \"Raw Message\": source.rawMessage,\n  Source: source.source,\n  Score: finalScore,\n  Classification: classification,\n  Status: \"new\",\n  Summary: qualifiedData.summary || \"\",\n  \"Follow-up Count\": 0,\n  \"Meeting Booked\": false,\n};\n\nreturn {\n  name: source.name,\n  email: source.email,\n  phone: source.phone,\n  raw_message: source.rawMessage,\n  source: source.source,\n  qualifiedData,\n  finalScore,\n  classification,\n  prompt: firstReplyPrompt,\n  gotifyPayload,\n  airtableBaseUrl: source.airtableBaseUrl,\n  airtableCreateBody: { records: [{ fields: airtableFields }] },\n  pgParams: [\n    source.name,\n    source.email,\n    source.phone,\n    source.rawMessage,\n    source.source,\n    JSON.stringify(qualifiedData),\n    finalScore,\n    classification,\n    firstReplyPrompt,\n    JSON.stringify(gotifyPayload),\n    source.airtableBaseUrl,\n    JSON.stringify({ records: [{ fields: airtableFields }] }),\n  ],\n};"
      }
    },
    {
      "id": "47596784-1e41-4752-89d6-1ef9ad272a7f",
      "name": "Insert Qualified Lead in Postgres",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -60,
        -160
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO leads (\n  name,\n  email,\n  phone,\n  raw_message,\n  source,\n  qualified_data,\n  score,\n  classification,\n  status\n) VALUES (\n  $1,\n  $2,\n  $3,\n  $4,\n  $5,\n  $6::jsonb,\n  $7,\n  $8,\n  'new'\n)\nRETURNING\n  id,\n  name,\n  email,\n  phone,\n  raw_message,\n  source,\n  qualified_data,\n  score,\n  classification,\n  status,\n  followup_count,\n  last_followup_at,\n  meeting_booked,\n  created_at,\n  $9::text AS prompt,\n  $10::jsonb AS gotify_payload,\n  $11::text AS airtable_base_url,\n  $12::jsonb AS airtable_create_body;",
        "options": {
          "queryReplacement": "={{ $json.pgParams }}"
        }
      }
    },
    {
      "id": "92620c94-d569-4322-a34b-bd8918984232",
      "name": "Lead Is Hot",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        200,
        -160
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "cabdde44-7c9b-4815-8345-adab1a7f06dd",
              "leftValue": "={{ $json.classification }}",
              "rightValue": "hot",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "d563b095-2ec0-4ab4-bc0c-c84f78fff761",
      "name": "Notify Gotify About Hot Lead",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        460,
        -300
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ (($env.GOTIFY_URL || \"http://gotify:80\").replace(/\\/$/, \"\")) + \"/message\" }}",
        "authentication": "none",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "X-Gotify-Key",
              "value": "={{ $env.GOTIFY_TOKEN }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.gotify_payload) }}",
        "options": {}
      }
    },
    {
      "id": "e3cf12da-cacb-40f5-91fa-4bcb482f78d8",
      "name": "Groq Generate First Reply Email",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        460,
        -40
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: \"llama-3.3-70b-versatile\", temperature: 0.3, max_tokens: 600, messages: [{ role: \"system\", content: \"You are a lead qualification and email writing AI. Follow instructions exactly. Return only what is asked.\" }, { role: \"user\", content: $json.prompt }] }) }}",
        "options": {}
      }
    },
    {
      "id": "138bd013-7425-4414-8458-cc6891c154db",
      "name": "Parse First Reply Email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        -40
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const config = {\\n  businessName:       \"Prime Properties Agency\",\\n  vertical:           \"real estate agency\",\\n  ownerName:          \"Marco\",\\n  calendarLink:       \"https://calendly.com/prime-properties/intro\",\\n  hotThreshold:       7,\\n  warmThreshold:      4,\\n\\n  qualificationFields: [\\n    \"budget\",\\n    \"buy_or_rent\",\\n    \"location_preference\",\\n    \"bedrooms\",\\n    \"timeline_months\",\\n    \"currently_renting_or_owning\"\\n  ],\\n\\n  scoringRules: `\\n    Score higher if: budget is confirmed, timeline is under 3 months,\\n    location is specific, they mention a reason for moving.\\n    Score lower if: no budget mentioned, timeline is vague or over 12 months,\\n    message is very short with no specifics.\\n  `,\\n\\n  followupAngles: [\\n    \"gentle check-in, ask if they had a chance to think it over, mention 2 new listings\",\\n    \"add value \\u2014 share one market insight relevant to their search criteria\",\\n    \"soft close \\u2014 this is the last message, leave the door open with no pressure\"\\n  ],\\n\\n  proofElement: \"We helped 14 families find their home in Tirana this quarter.\",\\n};\n\n// This Code node parses Groq's first reply text and restores the lead context for Gmail, Airtable, and status updates.\nconst source = $(\"Insert Qualified Lead in Postgres\").first().json;\nconst raw = $json.choices[0].message.content;\nconst emailBody = raw.replace(/```[a-z]*\\n?/gi, \"\").trim();\nreturn {\n  ...source,\n  emailBody,\n  gmailSubject: \"Re: your inquiry\",\n};"
      }
    },
    {
      "id": "38a12c34-738e-4b2b-90a9-f69ebc99130d",
      "name": "Send First Reply via Gmail OAuth",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        980,
        -40
      ],
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.gmailSubject }}",
        "message": "={{ $json.emailBody }}",
        "emailType": "text",
        "options": {
          "appendAttribution": false
        }
      }
    },
    {
      "id": "c7578889-813d-4329-9a25-f8df03469510",
      "name": "Create Airtable Lead Record",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1240,
        -40
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $(\"Parse First Reply Email\").first().json.airtable_base_url }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($(\"Parse First Reply Email\").first().json.airtable_create_body) }}",
        "options": {}
      }
    },
    {
      "id": "55a8feed-ad8e-4aee-aabc-1fc01704b855",
      "name": "Mark Lead Contacted in Postgres",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1500,
        -40
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE leads\nSET status = 'contacted',\n    last_followup_at = NOW()\nWHERE id = $1\nRETURNING id, status, last_followup_at;",
        "options": {
          "queryReplacement": "={{ [$(\"Parse First Reply Email\").first().json.id] }}"
        }
      }
    }
  ],
  "connections": {
    "Tally Lead Intake Webhook": {
      "main": [
        [
          {
            "node": "Prepare Tally Lead Qualification Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Tally Lead Qualification Prompt": {
      "main": [
        [
          {
            "node": "Groq Qualify and Score Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Qualify and Score Lead": {
      "main": [
        [
          {
            "node": "Parse Qualification and Score Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Qualification and Score Lead": {
      "main": [
        [
          {
            "node": "Insert Qualified Lead in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Qualified Lead in Postgres": {
      "main": [
        [
          {
            "node": "Lead Is Hot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Is Hot": {
      "main": [
        [
          {
            "node": "Notify Gotify About Hot Lead",
            "type": "main",
            "index": 0
          },
          {
            "node": "Groq Generate First Reply Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Groq Generate First Reply Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Generate First Reply Email": {
      "main": [
        [
          {
            "node": "Parse First Reply Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse First Reply Email": {
      "main": [
        [
          {
            "node": "Send First Reply via Gmail OAuth",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send First Reply via Gmail OAuth": {
      "main": [
        [
          {
            "node": "Create Airtable Lead Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Airtable Lead Record": {
      "main": [
        [
          {
            "node": "Mark Lead Contacted in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Tirane"
  }
}
