{
  "id": "ea2871756065da23",
  "name": "01 Missed Call Rescue",
  "nodes": [
    {
      "parameters": {
        "content": "## WHAT THIS LOOP CLOSES\n\nA missed inbound call becomes a lost job.\n\nSomeone calls, nobody picks up, and within minutes they call the next business on the list. This loop texts them back before that happens: a short, personal SMS that names your business, apologizes for missing them, and offers to help or book.\n\nEvery rescue is logged to a sheet and you get a Slack ping, so nothing disappears into voicemail again.",
        "height": 320,
        "width": 380
      },
      "id": "1f0a7c2e-91b4-4d6a-8f3c-0a1b2c3d4e5f",
      "name": "Sticky: What This Loop Closes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -40
      ]
    },
    {
      "parameters": {
        "content": "## 60-SECOND SETUP\n\n1. Open the \"Settings (Edit Me)\" node and fill in: business name, your mobile, your Twilio number, your booking link, and paste your CONTEXT block from your business brain.\n2. Attach your Twilio credential to the \"Send SMS (Twilio)\" node. One credential, one phone number.\n3. Attach your OpenAI credential to the \"Draft Text-Back (AI)\" node.\n4. Activate the workflow, copy the webhook URL from \"Missed Call Webhook\", and paste it into your phone system (Twilio status callback, CallRail webhook, or 3CX HTTP hook) so it fires on missed or unanswered calls.\n\nGoogle Sheets and Slack nodes are optional: attach credentials if you want logging and alerts, or delete them.",
        "height": 320,
        "width": 420
      },
      "id": "2a8b9d0e-3c4f-4a5b-9c6d-7e8f9a0b1c2d",
      "name": "Sticky: 60-Second Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        340,
        -40
      ]
    },
    {
      "parameters": {
        "content": "## SAFETY: DRAFTS NOT SENDS\n\nThe AI only drafts. It never sends anything itself.\n\nOut of the box, sendMode is \"review\": the drafted SMS goes to YOUR mobile, prefixed with the caller's number, so you can read it and forward it yourself.\n\nOnly after you trust the drafts, change sendMode to \"live\" in the Settings node and the text-back goes straight to the caller.",
        "height": 280,
        "width": 380
      },
      "id": "3b9c0e1f-4d5a-4b6c-8d7e-9f0a1b2c3d4e",
      "name": "Sticky: Safety Drafts Not Sends",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        800,
        540
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "missed-call-rescue",
        "options": {},
        "authentication": "headerAuth"
      },
      "id": "4c0d1e2f-5a6b-4c7d-9e8f-0a1b2c3d4e5a",
      "name": "Missed Call Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        320
      ],
      "webhookId": "9d8c7b6a-5e4f-4d3c-2b1a-0f9e8d7c6b5a"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "5d1e2f3a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
              "name": "businessName",
              "value": "YOUR_BUSINESS_NAME",
              "type": "string"
            },
            {
              "id": "6e2f3a4b-7c8d-4e9f-0a1b-2c3d4e5f6a7b",
              "name": "ownerMobile",
              "value": "YOUR_MOBILE_NUMBER_E164",
              "type": "string"
            },
            {
              "id": "7f3a4b5c-8d9e-4f0a-1b2c-3d4e5f6a7b8c",
              "name": "twilioNumber",
              "value": "YOUR_TWILIO_NUMBER_E164",
              "type": "string"
            },
            {
              "id": "8a4b5c6d-9e0f-4a1b-2c3d-4e5f6a7b8c9d",
              "name": "bookingLink",
              "value": "YOUR_BOOKING_LINK",
              "type": "string"
            },
            {
              "id": "9b5c6d7e-0f1a-4b2c-3d4e-5f6a7b8c9d0e",
              "name": "sendMode",
              "value": "review",
              "type": "string"
            },
            {
              "id": "0c6d7e8f-1a2b-4c3d-4e5f-6a7b8c9d0e1f",
              "name": "businessContext",
              "value": "PASTE_YOUR_CONTEXT_BLOCK_HERE: 3 to 6 lines from your business brain. What you do, who you serve, service area, opening hours, how customers usually book, and your tone of voice.",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "1d7e8f9a-2b3c-4d4e-5f6a-7b8c9d0e1f2a",
      "name": "Settings (Edit Me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        320
      ]
    },
    {
      "parameters": {
        "jsCode": "// Normalize missed-call payloads from Twilio, CallRail, or 3CX into one shape.\nconst raw = $('Missed Call Webhook').first().json;\nconst b = raw.body ?? raw;\n\nconst caller =\n  b.From || b.from || b.caller || b.caller_number ||\n  b.customer_phone_number || b.callernum || b.CallerNumber || '';\n\nconst businessLine =\n  b.To || b.to || b.called || b.called_number ||\n  b.business_phone_number || b.tracking_number || b.CalledNumber || '';\n\nconst callStatus =\n  b.CallStatus || b.call_status || b.status ||\n  (b.answered === false ? 'unanswered' : 'missed');\n\n// Twilio status callbacks fire on every status change. Only rescue calls that were not answered.\nconst answeredStatuses = ['completed', 'answered', 'in-progress', 'ringing', 'initiated', 'queued'];\nif (answeredStatuses.includes(String(callStatus).toLowerCase())) {\n  return [];\n}\n\nif (!caller) {\n  throw new Error('No caller number found in the webhook payload. Check which field your phone system uses and add it to this node.');\n}\n\nreturn [{\n  json: {\n    callerNumber: String(caller).trim(),\n    businessLine: String(businessLine).trim(),\n    callStatus: String(callStatus || 'missed'),\n    receivedAt: new Date().toISOString()\n  }\n}];"
      },
      "id": "2e8f9a0b-3c4d-4e5f-6a7b-8c9d0e1f2a3b",
      "name": "Normalize Call Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        320
      ]
    },
    {
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "value": "gpt-4o-mini",
          "mode": "list",
          "cachedResultName": "gpt-4o-mini"
        },
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "You draft text-back SMS messages for a small business that just missed an inbound call. You only DRAFT. You never send anything, you never claim a message was sent, and you never contact anyone. A human or a downstream system decides what happens with your draft. Output the SMS body only: no quotes around it, no preamble, no explanations, no signature block, no emojis. Maximum 300 characters. Warm, plain, human. At most one question. Never invent prices, availability, discounts, or promises that are not stated in the CONTEXT block."
            },
            {
              "content": "=A customer just called {{ $('Settings (Edit Me)').item.json.businessName }} and nobody picked up.\n\nCONTEXT (pasted by the owner from their business brain):\n{{ $('Settings (Edit Me)').item.json.businessContext }}\n\nCaller number: {{ $json.callerNumber }}\nCall status reported by the phone system: {{ $json.callStatus }}\n\nDraft a short personal text-back that:\n1. Briefly apologizes for missing the call.\n2. Mentions the business by name.\n3. Offers to help right here over text, or to book a time: {{ $('Settings (Edit Me)').item.json.bookingLink }}\n\nUse only facts from the CONTEXT block. This is a draft, not a sent message."
            }
          ]
        },
        "options": {}
      },
      "id": "3f9a0b1c-4d5e-4f6a-7b8c-9d0e1f2a3b4c",
      "name": "Draft Text-Back (AI)",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        660,
        320
      ]
    },
    {
      "parameters": {
        "jsCode": "// Decide where the draft goes. Review mode = your phone. Live mode = the caller.\nconst settings = $('Settings (Edit Me)').first().json;\nconst call = $('Normalize Call Payload').first().json;\nconst ai = $input.first().json;\n\nconst draft = (ai.message && ai.message.content) || (ai.choices && ai.choices[0] && ai.choices[0].message && ai.choices[0].message.content) || ai.output || ai.text || '';\nif (!draft) {\n  throw new Error('The AI returned an empty draft. Check the Draft Text-Back (AI) node output.');\n}\n\nconst review = String(settings.sendMode || 'review').toLowerCase() !== 'live';\n\nconst smsTo = review ? settings.ownerMobile : call.callerNumber;\nconst smsBody = review\n  ? `[DRAFT for missed call from ${call.callerNumber}. Not sent to the caller. Forward it if you like it.]\\n\\n${draft}`\n  : draft;\n\nreturn [{\n  json: {\n    smsTo,\n    smsBody,\n    draft,\n    mode: review ? 'review' : 'live',\n    callerNumber: call.callerNumber,\n    callStatus: call.callStatus,\n    receivedAt: call.receivedAt,\n    businessName: settings.businessName\n  }\n}];"
      },
      "id": "4a0b1c2d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "name": "Prepare SMS (Draft Mode)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        320
      ]
    },
    {
      "parameters": {
        "from": "={{ $('Settings (Edit Me)').item.json.twilioNumber }}",
        "to": "={{ $json.smsTo }}",
        "message": "={{ $json.smsBody }}"
      },
      "id": "5b1c2d3e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
      "name": "Send SMS (Twilio)",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        1100,
        320
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "YOUR_GOOGLE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Sheet1",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Timestamp": "={{ $('Prepare SMS (Draft Mode)').item.json.receivedAt }}",
            "Caller": "={{ $('Prepare SMS (Draft Mode)').item.json.callerNumber }}",
            "Call Status": "={{ $('Prepare SMS (Draft Mode)').item.json.callStatus }}",
            "Draft SMS": "={{ $('Prepare SMS (Draft Mode)').item.json.draft }}",
            "Mode": "={{ $('Prepare SMS (Draft Mode)').item.json.mode }}"
          },
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "6c2d3e4f-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
      "name": "Log to Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1320,
        320
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "YOUR_SLACK_CHANNEL",
          "mode": "name"
        },
        "text": "=Missed call rescued at {{ $('Prepare SMS (Draft Mode)').item.json.businessName }}.\nCaller: {{ $('Prepare SMS (Draft Mode)').item.json.callerNumber }}\nMode: {{ $('Prepare SMS (Draft Mode)').item.json.mode }} ({{ $('Prepare SMS (Draft Mode)').item.json.mode === 'review' ? 'draft texted to the owner for review' : 'text-back sent to the caller' }})\nDraft: {{ $('Prepare SMS (Draft Mode)').item.json.draft }}",
        "otherOptions": {}
      },
      "id": "7d3e4f5a-8b9c-4d0e-1f2a-3b4c5d6e7f8a",
      "name": "Alert Owner (Slack)",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1540,
        320
      ]
    },
    {
      "parameters": {
        "content": "## WEBHOOK AUTH (do not skip)\n\nThis workflow's trigger is a public URL. It ships with **Header Auth** switched on so a stranger who guesses the URL cannot fire your loop, spend your API credits, or send on your behalf.\n\nSetup, once:\n1. In the webhook node, open the Credential field and create a new **Header Auth** credential.\n2. Name: `X-Kratt-Token`. Value: any long random string you invent (30+ characters).\n3. In whatever system calls this webhook, add that same header and value to the request.\n\nIf your caller genuinely cannot send custom headers, switch Authentication back to None, but then treat the URL itself as a secret and never paste it anywhere public.",
        "height": 360,
        "width": 400
      },
      "id": "auth-note-01",
      "name": "Sticky: Webhook Auth",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -540,
        -40
      ]
    }
  ],
  "connections": {
    "Missed Call Webhook": {
      "main": [
        [
          {
            "node": "Settings (Edit Me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (Edit Me)": {
      "main": [
        [
          {
            "node": "Normalize Call Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Call Payload": {
      "main": [
        [
          {
            "node": "Draft Text-Back (AI)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft Text-Back (AI)": {
      "main": [
        [
          {
            "node": "Prepare SMS (Draft Mode)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare SMS (Draft Mode)": {
      "main": [
        [
          {
            "node": "Send SMS (Twilio)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send SMS (Twilio)": {
      "main": [
        [
          {
            "node": "Log to Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log to Sheet": {
      "main": [
        [
          {
            "node": "Alert Owner (Slack)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}