Skip to content

In n8n, copy this file and paste it onto the canvas, or use Import from File.

{
  "name": "13 - AI Voice Agent Backend",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "voice/tools",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m13-tools-webhook",
      "name": "Voice Tools API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        200
      ],
      "webhookId": "course-13-voice-tools"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "b1",
              "name": "business_name",
              "value": "Bright Smile Dental",
              "type": "string"
            },
            {
              "id": "b2",
              "name": "open_hour",
              "value": 9,
              "type": "number"
            },
            {
              "id": "b3",
              "name": "close_hour",
              "value": 17,
              "type": "number"
            },
            {
              "id": "b4",
              "name": "open_days",
              "value": "={{ [1, 2, 3, 4, 5] }}",
              "type": "array"
            },
            {
              "id": "b5",
              "name": "default_service",
              "value": "a check-up",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m13-settings",
      "name": "Business Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "// Turn any voice platform's tool-call payload into one item per tool call:\n// { platform, tool, args, tool_call_id }\nconst out = [];\nfor (const item of $input.all()) {\n  const body = item.json.body ?? {};\n\n  if (body.message?.toolCallList) {\n    // Vapi: { message: { type: 'tool-calls', toolCallList: [{ id, function: { name, arguments } }] } }\n    for (const tc of body.message.toolCallList) {\n      const fn = tc.function ?? tc;\n      let args = fn.arguments ?? fn.parameters ?? {};\n      if (typeof args === 'string') {\n        try { args = JSON.parse(args); } catch (e) { args = {}; }\n      }\n      out.push({ json: { platform: 'vapi', tool: fn.name, args, tool_call_id: tc.id } });\n    }\n  } else if (body.name) {\n    // Retell: { name, args, call }\n    out.push({ json: { platform: 'retell', tool: body.name, args: body.args ?? {}, tool_call_id: null } });\n  } else {\n    // Retell \"args only\" mode or a plain test call: the tool name comes from ?tool=\n    out.push({ json: { platform: 'plain', tool: item.json.query?.tool ?? 'unknown', args: body, tool_call_id: null } });\n  }\n}\nreturn out;\n"
      },
      "id": "m13-normalize",
      "name": "Normalize Tool Calls",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r1",
                    "leftValue": "={{ $json.tool }}",
                    "rightValue": "check_availability",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "check_availability"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r2",
                    "leftValue": "={{ $json.tool }}",
                    "rightValue": "book_appointment",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "book_appointment"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r3",
                    "leftValue": "={{ $json.tool }}",
                    "rightValue": "cancel_appointment",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "cancel_appointment"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r4",
                    "leftValue": "={{ $json.tool }}",
                    "rightValue": "lookup_customer",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "lookup_customer"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "unknown"
        }
      },
      "id": "m13-route",
      "name": "Route Tool",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        660,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "// check_availability({ date: 'YYYY-MM-DD' }) → free slots that day\nconst s = $('Business Settings').first().json;\nconst db = $getWorkflowStaticData('global');\ndb.bookings = db.bookings ?? {};\n\nconst speak = (t) => DateTime.fromFormat(t, 'HH:mm').toFormat('h:mm a').replace(':00', '');\nconst speakList = (a) => (a.length <= 1 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a.at(-1));\n\nreturn $input.all().map(({ json }) => {\n  const date = String(json.args.date ?? '').trim();\n  const day = DateTime.fromISO(date);\n  let result;\n\n  if (!day.isValid) {\n    result = { ok: false, message: 'Which day would you like? Please tell me the date.' };\n  } else if (day.startOf('day') < DateTime.now().startOf('day')) {\n    result = { ok: false, message: 'That date is in the past. Which upcoming day works for you?' };\n  } else if (!s.open_days.includes(day.weekday)) {\n    result = { ok: false, date, available_slots: [], message: `We're closed on ${day.toFormat('cccc')}s. We're open Monday to Friday.` };\n  } else {\n    const all = [];\n    for (let h = s.open_hour; h < s.close_hour; h++) all.push(String(h).padStart(2, '0') + ':00');\n    const taken = Object.values(db.bookings).filter((b) => b.date === date).map((b) => b.time);\n    const free = all.filter((t) => !taken.includes(t));\n    result = free.length\n      ? { ok: true, date, available_slots: free, message: `On ${day.toFormat('cccc, LLLL d')} we have ${speakList(free.map(speak))} available.` }\n      : { ok: true, date, available_slots: [], message: `Sorry, ${day.toFormat('cccc, LLLL d')} is fully booked. Would another day work?` };\n  }\n  return { json: { ...json, result } };\n});\n"
      },
      "id": "m13-avail",
      "name": "check_availability",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        -40
      ]
    },
    {
      "parameters": {
        "jsCode": "// book_appointment({ name, phone, date: 'YYYY-MM-DD', time: 'HH:mm', service? }) → booking\nconst s = $('Business Settings').first().json;\nconst db = $getWorkflowStaticData('global');\ndb.bookings = db.bookings ?? {};\n\nconst speak = (t) => DateTime.fromFormat(t, 'HH:mm').toFormat('h:mm a').replace(':00', '');\n\nreturn $input.all().map(({ json }) => {\n  const a = json.args;\n  const name = String(a.name ?? '').trim();\n  const phone = String(a.phone ?? '').replace(/[^\\d+]/g, '');\n  const date = String(a.date ?? '').trim();\n  const time = String(a.time ?? '').trim().padStart(5, '0');\n  const service = String(a.service ?? s.default_service).trim();\n  const day = DateTime.fromISO(date);\n  const hour = Number(time.split(':')[0]);\n  let result;\n\n  const missing = [!name && 'name', phone.length < 7 && 'phone number', !day.isValid && 'date', !/^\\d{2}:00$/.test(time) && 'time'].filter(Boolean);\n  if (missing.length) {\n    result = { ok: false, message: `To book, I still need your ${missing.join(' and ')}.` };\n  } else if (day.startOf('day') < DateTime.now().startOf('day') || !s.open_days.includes(day.weekday) || hour < s.open_hour || hour >= s.close_hour) {\n    result = { ok: false, message: `We can't book ${day.toFormat('cccc')} at ${speak(time)}. We're open Monday to Friday, ${speak(String(s.open_hour).padStart(2, '0') + ':00')} to ${speak(String(s.close_hour).padStart(2, '0') + ':00')}.` };\n  } else if (Object.values(db.bookings).some((b) => b.date === date && b.time === time)) {\n    result = { ok: false, message: `Sorry, ${speak(time)} on ${day.toFormat('cccc')} was just taken. Shall I check other times that day?` };\n  } else {\n    let id;\n    do { id = 'BK-' + Math.floor(1000 + Math.random() * 9000); } while (db.bookings[id]);\n    db.bookings[id] = { id, name, phone, date, time, service, created_at: DateTime.now().toISO() };\n    result = {\n      ok: true,\n      booking_id: id,\n      message: `You're booked for ${service} on ${day.toFormat('cccc, LLLL d')} at ${speak(time)}, ${name.split(' ')[0]}. Your booking number is ${id.replace('-', '').split('').join(' ')}.`,\n    };\n  }\n  return { json: { ...json, result } };\n});\n"
      },
      "id": "m13-book",
      "name": "book_appointment",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "// cancel_appointment({ booking_id } or { phone, date }) → cancellation\nconst db = $getWorkflowStaticData('global');\ndb.bookings = db.bookings ?? {};\n\nreturn $input.all().map(({ json }) => {\n  const a = json.args;\n  const id = String(a.booking_id ?? '').replace(/\\s/g, '').toUpperCase();\n  const phone = String(a.phone ?? '').replace(/[^\\d+]/g, '');\n  const date = String(a.date ?? '').trim();\n\n  const match = db.bookings[id] ?? Object.values(db.bookings).find((b) => phone && b.phone === phone && b.date === date);\n  let result;\n  if (!match) {\n    result = { ok: false, message: \"I couldn't find that booking. Could you give me your booking number, or the phone number and date you booked?\" };\n  } else {\n    delete db.bookings[match.id];\n    result = { ok: true, booking_id: match.id, message: `Done. Your appointment on ${DateTime.fromISO(match.date).toFormat('cccc, LLLL d')} is cancelled.` };\n  }\n  return { json: { ...json, result } };\n});\n"
      },
      "id": "m13-cancel",
      "name": "cancel_appointment",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "// lookup_customer({ phone }) → known customer + upcoming bookings\n// Demo customer list; the Business version reads a CRM / Google Sheet.\nconst customers = {\n  '+15551230001': { name: 'Maria Santos', last_visit: '2026-08-12', notes: 'Prefers mornings' },\n  '+15551230002': { name: 'Sam Lee', last_visit: '2026-09-30', notes: 'Due for a cleaning in March' },\n};\nconst db = $getWorkflowStaticData('global');\ndb.bookings = db.bookings ?? {};\n\nreturn $input.all().map(({ json }) => {\n  let phone = String(json.args.phone ?? '').replace(/[^\\d+]/g, '');\n  if (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\n  const c = customers[phone];\n  const today = DateTime.now().toISODate();\n  const upcoming = Object.values(db.bookings).filter((b) => b.phone.replace(/^(\\d{10})$/, '+1$1') === phone && b.date >= today);\n  const result = c\n    ? {\n        ok: true, known: true, name: c.name, last_visit: c.last_visit, notes: c.notes, upcoming,\n        message: `Welcome back, ${c.name.split(' ')[0]}.` + (upcoming.length ? ` You have ${upcoming.length} upcoming appointment${upcoming.length > 1 ? 's' : ''}.` : ''),\n      }\n    : { ok: true, known: false, upcoming, message: \"I don't have that number on file yet, which is no problem. May I have your name?\" };\n  return { json: { ...json, result } };\n});\n"
      },
      "id": "m13-lookup",
      "name": "lookup_customer",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        380
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "u1",
              "name": "result",
              "value": "={{ { ok: false, error: 'unknown tool: ' + $json.tool, message: \"Sorry, I can't help with that over the phone. I can check availability, book, cancel, or look up your appointments.\" } }}",
              "type": "object"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m13-unknown",
      "name": "Unknown Tool",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        900,
        520
      ]
    },
    {
      "parameters": {
        "jsCode": "// Shape the reply the way each platform expects.\n// Vapi: { results: [{ toolCallId, result }] }  ·  Retell / plain: the result object itself\nconst items = $input.all().map((i) => i.json);\nif (items[0]?.platform === 'vapi') {\n  return [{ json: { results: items.map((i) => ({ toolCallId: i.tool_call_id, result: JSON.stringify(i.result) })) } }];\n}\nreturn [{ json: items.length === 1 ? items[0].result : { results: items.map((i) => i.result) } }];\n"
      },
      "id": "m13-format",
      "name": "Format for Platform",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1140,
        200
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m13-respond",
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1360,
        200
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "voice/call-ended",
        "options": {}
      },
      "id": "m13-call-webhook",
      "name": "Call Ended",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        760
      ],
      "webhookId": "course-13-call-ended"
    },
    {
      "parameters": {
        "jsCode": "// Normalize end-of-call webhooks. Ignore other events (call_started, status updates…).\nconst body = $input.first().json.body ?? {};\nlet call = null;\n\nif (body.event === 'call_ended' || body.event === 'call_analyzed') {\n  // Retell: { event, call: { call_id, transcript, from_number, start_timestamp, end_timestamp } }\n  const c = body.call ?? {};\n  call = {\n    platform: 'retell', event: body.event, call_id: c.call_id, from_number: c.from_number ?? '',\n    transcript: c.transcript ?? '',\n    duration_sec: c.end_timestamp && c.start_timestamp ? Math.round((c.end_timestamp - c.start_timestamp) / 1000) : null,\n  };\n} else if (body.message?.type === 'end-of-call-report') {\n  // Vapi: { message: { type: 'end-of-call-report', call: { id, customer: { number } }, artifact: { transcript }, durationSeconds } }\n  const m = body.message;\n  call = {\n    platform: 'vapi', event: m.type, call_id: m.call?.id, from_number: m.call?.customer?.number ?? m.customer?.number ?? '',\n    transcript: m.artifact?.transcript ?? m.transcript ?? '', duration_sec: m.durationSeconds ?? null,\n  };\n}\n\n// Retell sends both call_ended and call_analyzed; summarize once\nif (!call || call.event === 'call_analyzed' || !call.transcript) return [];\nreturn [{ json: { ...call, transcript: String(call.transcript).slice(0, 12000), received_at: DateTime.now().toISO() } }];\n"
      },
      "id": "m13-norm-call",
      "name": "Normalize Call",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        760
      ]
    },
    {
      "parameters": {
        "text": "=Caller: {{ $json.from_number || 'unknown' }}\nDuration: {{ $json.duration_sec ?? '?' }} seconds\n\nTRANSCRIPT:\n{{ $json.transcript }}",
        "attributes": {
          "attributes": [
            {
              "name": "summary",
              "description": "Two sentences: who called and what happened (booked, cancelled, question answered…)",
              "required": true
            },
            {
              "name": "caller_intent",
              "description": "Exactly one of: booking, reschedule, cancellation, question, complaint, other",
              "required": true
            },
            {
              "name": "follow_up_needed",
              "type": "boolean",
              "description": "true if a human must call back or act (unresolved question, complaint, emergency, request for a person)",
              "required": true
            },
            {
              "name": "follow_up_reason",
              "description": "If follow_up_needed, what the human must do in one sentence; otherwise empty",
              "required": false
            }
          ]
        },
        "options": {
          "systemPromptTemplate": "You summarize phone calls handled by an AI receptionist for a small business. Be factual; only use what is in the transcript."
        }
      },
      "id": "m13-summarize",
      "name": "Summarize Call",
      "type": "@n8n/n8n-nodes-langchain.informationExtractor",
      "typeVersion": 1.2,
      "position": [
        440,
        760
      ]
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini",
          "cachedResultName": "gpt-5-mini"
        },
        "options": {}
      },
      "id": "m13-model",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.3,
      "position": [
        440,
        980
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "call_id",
              "value": "={{ $('Normalize Call').item.json.call_id }}",
              "type": "string"
            },
            {
              "id": "c2",
              "name": "from_number",
              "value": "={{ $('Normalize Call').item.json.from_number }}",
              "type": "string"
            },
            {
              "id": "c3",
              "name": "summary",
              "value": "={{ $json.output.summary }}",
              "type": "string"
            },
            {
              "id": "c4",
              "name": "caller_intent",
              "value": "={{ $json.output.caller_intent }}",
              "type": "string"
            },
            {
              "id": "c5",
              "name": "follow_up_needed",
              "value": "={{ $json.output.follow_up_needed === true || $json.output.follow_up_needed === 'true' }}",
              "type": "boolean"
            },
            {
              "id": "c6",
              "name": "follow_up_reason",
              "value": "={{ $json.output.follow_up_reason ?? '' }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "m13-record",
      "name": "Call Record",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        660,
        760
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "f1",
              "leftValue": "={{ $json.follow_up_needed }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m13-followup",
      "name": "Needs Follow-up?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        760
      ]
    },
    {
      "parameters": {},
      "id": "m13-notify",
      "name": "Notify Owner (Exercise 2)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1100,
        680
      ]
    },
    {
      "parameters": {},
      "id": "m13-log",
      "name": "Log Call (Exercise 3)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1100,
        860
      ]
    }
  ],
  "connections": {
    "Voice Tools API": {
      "main": [
        [
          {
            "node": "Business Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Business Settings": {
      "main": [
        [
          {
            "node": "Normalize Tool Calls",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Tool Calls": {
      "main": [
        [
          {
            "node": "Route Tool",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Tool": {
      "main": [
        [
          {
            "node": "check_availability",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "book_appointment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "cancel_appointment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "lookup_customer",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Unknown Tool",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "check_availability": {
      "main": [
        [
          {
            "node": "Format for Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "book_appointment": {
      "main": [
        [
          {
            "node": "Format for Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "cancel_appointment": {
      "main": [
        [
          {
            "node": "Format for Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "lookup_customer": {
      "main": [
        [
          {
            "node": "Format for Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unknown Tool": {
      "main": [
        [
          {
            "node": "Format for Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format for Platform": {
      "main": [
        [
          {
            "node": "Respond",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Ended": {
      "main": [
        [
          {
            "node": "Normalize Call",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Call": {
      "main": [
        [
          {
            "node": "Summarize Call",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Summarize Call",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Summarize Call": {
      "main": [
        [
          {
            "node": "Call Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Record": {
      "main": [
        [
          {
            "node": "Needs Follow-up?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs Follow-up?": {
      "main": [
        [
          {
            "node": "Notify Owner (Exercise 2)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Call (Exercise 3)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  }
}