{
  "name": "LGM 09 — Live-Call Human Handoff (SIMULATED)",
  "nodes": [
    {
      "parameters": {},
      "id": "f09-start",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        120,
        420
      ]
    },
    {
      "parameters": {
        "jsCode": "// Manual-run fixture. Edit branch_hint here to exercise different\n// downstream paths when you click Execute workflow:\n//   'warm-transfer' | 'take-email' | 'callback-slot' | 'frustrated' | 'auto'\nconst branchHint = 'warm-transfer';\nconst fixture = {\n  call_id: 'CALL-' + Math.floor(Math.random() * 90000 + 10000),\n  received_at: new Date().toISOString(),\n  caller: {\n    name: 'Helen Marsh',\n    phone: '+61400000333',\n    email: 'helen@example.invalid'\n  },\n  handoff: {\n    reason: 'speaks-to-human',\n    phrase_detected: 'can I talk to a person?',\n    sentiment: 'neutral'\n  },\n  on_call: { name: 'Sam', phone: '+61400000999', available: true },\n  call_summary: 'Asked about a quote for a full exterior repaint of a weatherboard house in Bentleigh.',\n  payload: { slot: { startsAt: 'Tomorrow 9:00 AM', durationMin: 10 } },\n  branch_hint: branchHint,\n  source: 'manual-test'\n};\nreturn [{ json: fixture }];"
      },
      "id": "f09-manual-fixture",
      "name": "Build Manual Fixture",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        420
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lgm-call-handoff",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "f09-webhook",
      "name": "Handoff Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        120,
        240
      ],
      "webhookId": "lgm-call-handoff"
    },
    {
      "parameters": {
        "jsCode": "// Detect + Gate — normalise the inbound handoff event and apply the gates.\n// Responsibilities:\n//   1. Blank-input guard: a handoff with no call_id and no caller is acked\n//      and dropped — nothing downstream fires.\n//   2. Consent stub: real deployments record caller consent for AI-handled\n//      calls in the opening message (AU telemarketing + consent guidance).\n//      Stubbed here — see docs/automation-runtime.md.\n//   3. Emergency fast-path: an emergency handoff reason is routed straight\n//      to the on-call line — never to the email-capture loop.\n//   4. Rota check: on_call.available decides warm-transfer vs email\n//      capture. If nobody is on, a warm transfer can't be honoured —\n//      fall back to email capture so the caller isn't stranded.\nconst items = $input.all();\nconst raw = items[0].json || {};\nconst caller = raw.caller || {};\nconst handoff = raw.handoff || {};\nconst onCall = raw.on_call || {};\n\nconst branchMap = {\n  'speaks-to-human': 'warm-transfer',\n  'complex-question': 'warm-transfer',\n  'frustrated': 'frustrated',\n  'complaint-or-dispute': 'frustrated',\n  'callback-requested': 'callback-slot',\n  'email-capture': 'take-email'\n};\nlet branch = raw.branch_hint && raw.branch_hint !== 'auto'\n  ? raw.branch_hint\n  : (branchMap[handoff.reason] || 'warm-transfer');\n\nconst isEmergency = /emergency/i.test(String(handoff.reason || ''));\nif (isEmergency) branch = 'frustrated';\n\nconst rotaAvailable = onCall.available === true;\nif (branch === 'warm-transfer' && !rotaAvailable && !isEmergency) {\n  branch = 'take-email';\n}\n\nconst blankInput = !raw.call_id || (!caller.phone && !caller.name);\nconst checks = {\n  blank_input: blankInput ? 'BLOCKED' : 'ok',\n  consent: 'stub-not-implemented',\n  emergency_fast_path: isEmergency ? 'routed' : 'n/a',\n  rota_available: rotaAvailable ? 'yes' : 'no'\n};\n\nif (blankInput) {\n  return [{\n    json: {\n      ...raw, caller, handoff, checks,\n      branch: 'unhandled',\n      dispatch_status: 'blocked-blank-input',\n      simulatedOutputs: [\n        { type: 'heading', label: 'Handoff — blank input' },\n        { type: 'note', label: 'Result',\n          text: 'Handoff event missing call_id or caller. Acked and dropped — nothing was dispatched.' },\n        { type: 'code', label: 'Raw event', language: 'json', body: JSON.stringify(raw, null, 2) }\n      ]\n    }\n  }];\n}\n\nreturn [{\n  json: { ...raw, caller, handoff, on_call: onCall, checks, branch, dispatch_status: 'ok' }\n}];"
      },
      "id": "f09-gate",
      "name": "Detect + Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        340
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "warm-transfer",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "warm-transfer",
              "renameOutput": true
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "take-email",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "take-email",
              "renameOutput": true
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "callback-slot",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "callback-slot",
              "renameOutput": true
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "frustrated",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "frustrated",
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "f09-switch",
      "name": "Route by Branch",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        780,
        420
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Warm-Transfer Output — the agent briefs the team member BEFORE\n// bridging, so the caller never repeats themselves. Replace the simulated\n// blocks with a real voice API transfer (<Dial>/conference) when wiring.\nconst items = $input.all();\nconst $json = items[0].json;\nconst caller = $json.caller || {};\nconst onCall = $json.on_call || {};\nconst summary = $json.call_summary || 'no summary captured';\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Warm transfer' },\n  { type: 'note', label: 'Pattern',\n    text: 'Agent briefs ' + (onCall.name || 'the on-call team member') + ' first (caller name, number, intent, what was already tried), then bridges the call. AI-generated interpretations are labelled, not presented as caller-confirmed facts.' },\n  { type: 'message', channel: 'internal',\n    body: 'Warm-transfer brief — caller: ' + (caller.name || '(unknown)') + '. Summary: ' + summary + ' Bridging now.' },\n  { type: 'code', label: 'Simulated dispatch', language: 'json',\n    body: JSON.stringify({\n      method: 'POST',\n      url: 'https://voice.example.invalid/v1/calls/' + ($json.call_id || 'unknown') + '/transfer',\n      to: onCall.phone || 'ON_CALL_NUMBER',\n      brief_attached: true,\n      idempotency_key: $json.call_id\n    }, null, 2) }\n];\nreturn [{ json: { ...$json, branch: 'warm-transfer', simulatedOutputs } }];"
      },
      "id": "f09-warm",
      "name": "Build Warm-Transfer Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        80
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Email-Capture Output — nobody available: the agent asks for the\n// caller's email, reads it back to confirm the spelling, then emails the\n// team a full brief. The caller is never stranded and never asked twice.\nconst items = $input.all();\nconst $json = items[0].json;\nconst caller = $json.caller || {};\nconst summary = $json.call_summary || 'no summary captured';\nconst email = caller.email || '';\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Email-capture fallback' },\n  { type: 'note', label: 'Pattern',\n    text: email\n      ? 'Email captured and read back to confirm: ' + email + '. Same-day contact was promised.'\n      : 'Caller has not given an email yet — the agent asks once and reads it back to confirm spelling. Never asks a second time.' },\n  { type: 'message', channel: 'email',\n    subject: 'Call handoff — ' + (caller.name || 'caller') + ' asked for a person',\n    body: 'Team,\\n\\n' + (caller.name || 'A caller') + ' asked to speak with a human. Nobody was on the rota.\\n\\nCall summary: ' + summary + '\\nEmail on file: ' + (email || '(not captured)') + '\\n\\nPlease reply today — same-day contact was promised.\\n\\n— Receptionist agent (automated handoff brief)' },\n  { type: 'task', title: 'Reply to caller — handoff requested', owner: 'On-call team', dueIn: 'Same business day', notes: summary }\n];\nreturn [{ json: { ...$json, branch: 'take-email', simulatedOutputs } }];"
      },
      "id": "f09-email",
      "name": "Build Email-Capture Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Callback Output — caller prefers a scheduled callback over staying\n// on the line. The slot comes from the booking tool; the agent never\n// invents times.\nconst items = $input.all();\nconst $json = items[0].json;\nconst caller = $json.caller || {};\nconst slot = ($json.payload && $json.payload.slot) || { startsAt: 'TBD — fetch from the booking tool', durationMin: 10 };\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Callback booked' },\n  { type: 'calendar',\n    summary: 'Callback — ' + (caller.name || 'caller') + ' (handoff follow-up)',\n    startsAt: slot.startsAt,\n    durationMin: slot.durationMin,\n    attendees: [caller.name || 'caller', 'On-call team'] },\n  { type: 'message', channel: 'sms',\n    body: 'Hi ' + (caller.name || 'there') + ', your callback is booked for ' + slot.startsAt + \". We'll call from the office number — the brief is on file, so you won't need to repeat yourself.\" },\n  { type: 'code', label: 'Simulated dispatch', language: 'json',\n    body: JSON.stringify({\n      method: 'POST',\n      url: 'https://booking.example.invalid/api/bookings',\n      body: { contact: caller, slot, source: 'call-handoff' },\n      idempotency_key: $json.call_id\n    }, null, 2) }\n];\nreturn [{ json: { ...$json, branch: 'callback-slot', simulatedOutputs } }];"
      },
      "id": "f09-callback",
      "name": "Build Callback Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Frustration Output — sentiment tripped on a live call: page a human\n// immediately. No email loop, no further automated questions, priority over\n// the standard queue.\nconst items = $input.all();\nconst $json = items[0].json;\nconst caller = $json.caller || {};\nconst handoff = $json.handoff || {};\nconst summary = $json.call_summary || 'no summary captured';\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Frustration escalation' },\n  { type: 'note', label: 'Pattern',\n    text: 'Sentiment tripped — a human is paged immediately. No email loop, no further automated questions. Priority over the standard queue.' },\n  { type: 'message', channel: 'internal',\n    body: 'Frustration escalation on a live call: ' + (caller.name || '(unknown)') + '. Reason: ' + (handoff.reason || 'sentiment') + '. Summary: ' + summary },\n  { type: 'task', title: 'Take over the call', owner: 'On-call team', dueIn: 'Immediately',\n    notes: 'Sentiment tripped — priority over the standard queue.' }\n];\nreturn [{ json: { ...$json, branch: 'frustrated', simulatedOutputs } }];"
      },
      "id": "f09-frustrated",
      "name": "Build Frustration Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "// Catch-all for unhandled branches (including the blank-input ack from\n// Detect + Gate, which arrives with branch 'unhandled').\nconst items = $input.all();\nconst $json = items[0].json;\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Handoff — unhandled branch' },\n  { type: 'note', label: 'Ack',\n    text: 'Unhandled branch \"' + $json.branch + '\"; logged for human review. Returning 200 to prevent retry storms.' },\n  { type: 'code', label: 'Raw event', language: 'json', body: JSON.stringify($json, null, 2) }\n];\nreturn [{ json: { ...$json, branch: 'unhandled', simulatedOutputs } }];"
      },
      "id": "f09-unhandled",
      "name": "Build Unhandled Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        720
      ]
    },
    {
      "parameters": {},
      "id": "f09-sink",
      "name": "Sink",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1300,
        420
      ]
    }
  ],
  "connections": {
    "Start": {
      "main": [
        [
          {
            "node": "Build Manual Fixture",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Manual Fixture": {
      "main": [
        [
          {
            "node": "Detect + Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handoff Webhook": {
      "main": [
        [
          {
            "node": "Detect + Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect + Gate": {
      "main": [
        [
          {
            "node": "Route by Branch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Branch": {
      "main": [
        [
          {
            "node": "Build Warm-Transfer Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Email-Capture Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Callback Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Frustration Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Unhandled Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Warm-Transfer Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Email-Capture Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Callback Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Frustration Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Unhandled Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner"
  },
  "versionId": "lgm-09-call-handoff",
  "id": "lgm-09-call-handoff",
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Live-call human handoff. Manual + Webhook entrypoints → blank/consent/emergency/rota gates → branch by handoff reason: warm transfer (brief before bridge), email-capture fallback (ask once, read back, email the team), callback slot, frustration escalation. SIMULATED, inactive — no live telephony wired."
  },
  "tags": [
    {
      "name": "lgm-09-call-handoff"
    }
  ]
}
