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 (starter)",
  "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": {
        "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": "// 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": {
        "content": "## Your task\n1. Read **Normalize Tool Calls**: one item per tool call, whatever the platform (Retell, Vapi, args-only).\n2. Add a **Switch** on `{{ $json.tool }}` with outputs for check_availability, book_appointment, cancel_appointment, lookup_customer + a fallback.\n3. Write **book_appointment** and **cancel_appointment** Code nodes that store bookings in `$getWorkflowStaticData('global')`.\n4. Add **lookup_customer** and an Unknown Tool reply. Every tool returns `{ ok, message }`, where message is a sentence the agent can say out loud.\n5. Second flow: **Call Ended** webhook → normalize → AI summary (Information Extractor) → follow-up IF.\n\nTest: `./test/test.sh` (workflow must be published)",
        "height": 260,
        "width": 420
      },
      "id": "starter-task-note",
      "name": "Your Task",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -340
      ]
    }
  ],
  "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": "check_availability",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "check_availability": {
      "main": [
        [
          {
            "node": "Format for Platform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format for Platform": {
      "main": [
        [
          {
            "node": "Respond",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  }
}