AI Voice Agent Backend
Module 13 · ai · intermediate
learner.json
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"
}
}