Skip to content

Exercise 1

  1. Add a Merge node Combine Results: Mode Append, Number of Inputs 5.
  2. Connect check_availability → input 1, book_appointment → 2, cancel_appointment → 3, lookup_customer → 4, Unknown Tool → 5.
  3. 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:

  1. 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.
  2. Code Free Slots: the same slot logic as availability.js, but taken = hours of the returned events:
    const taken = $('Busy Times').all().map(e => DateTime.fromISO(e.json.start.dateTime).toFormat('HH:mm'));
    
    Turn on Always Output Data on Busy Times so an empty day still flows.
  3. 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.