Module 13 · ai · intermediate
Solutions 13 · AI Voice Agent Backend
Exercise 1
- Add a Merge node
Combine Results: Mode Append, Number of Inputs5. - Connect
check_availability→ input 1,book_appointment→ 2,cancel_appointment→ 3,lookup_customer→ 4,Unknown Tool→ 5. - Connect
Combine Results→Format for Platform(remove the five direct connections).
Format for Platform already maps every item into results[] with its own tool_call_id, so no code changes are needed. Test:
curl -s -X POST http://localhost:5678/webhook/voice/tools -H 'Content-Type: application/json' -d '{
"message": {"type": "tool-calls", "toolCallList": [
{"id": "a", "function": {"name": "lookup_customer", "arguments": {"phone": "5551230001"}}},
{"id": "b", "function": {"name": "check_availability", "arguments": {"date": "2026-12-01"}}}
]}}' | jq '.results | map(.toolCallId)'
Expected: ["b", "a"]. Tested on n8n 2.43.6: results come out in Merge-input order (availability is input 1), not request order. That's fine, because Vapi matches each result by toolCallId. Single-call requests still work through the Merge (inputs that received no data are skipped). Without the Merge, the same request returns only one of the two results; that's the bug this exercise fixes.
Exercise 2
Telegram example (replace the No Operation node):
📞 Callback needed: {{ $json.from_number || 'unknown number' }}
Intent: {{ $json.caller_intent }}
{{ $json.follow_up_reason }}
Summary: {{ $json.summary }}
Twilio Send SMS: To = owner's number, From = your Twilio number, Message = the same text (keep it under ~300 characters).
Exercise 3
check_availability becomes three nodes:
- Google Calendar
Busy Times: Event → Get Many, Calendar = clinic calendar, After{{ $json.args.date }}T00:00:00, Before{{ $json.args.date }}T23:59:59, Single Events on. - Code
Free Slots: the same slot logic asavailability.js, buttaken= hours of the returned events:
Turn on Always Output Data onconst taken = $('Busy Times').all().map(e => DateTime.fromISO(e.json.start.dateTime).toFormat('HH:mm'));Busy Timesso an empty day still flows. - Keep returning
{ ...json, result }.
book_appointment: after validation, Google Calendar Create Event (start {{ date }}T{{ time }}, end +1 hour, summary {{ service }} – {{ name }}, description with the phone), then return the confirmation with the event id as the booking id. Re-check availability inside the booking flow to avoid double-booking from two simultaneous calls.
Stretch
At the start of the tools flow, add a Code node:
const crypto = require('crypto');
const sig = $input.first().json.headers['x-retell-signature'] ?? '';
const body = JSON.stringify($input.first().json.body);
const expected = crypto.createHmac('sha256', $env.RETELL_API_KEY).update(body).digest('hex');
if (sig !== expected) throw new Error('invalid signature');
return $input.all();
Allowing crypto in the Code node requires NODE_FUNCTION_ALLOW_BUILTIN=crypto in the n8n environment, and $env access requires N8N_BLOCK_ENV_ACCESS_IN_NODE=false. Re-serializing the JSON may not byte-match Retell's original body; check Retell's docs for its exact signature scheme (some versions include a timestamp) and test against a real call. Use the node's error output to return 401.