Build an AI Appointment Voice Agent

Build a voice receptionist that confirms appointments through a Node.js webhook.

Introduction

30 Second Summary

A missed phone call can become a missed appointment. Callers expect a clear answer before they hang up.

In this project, you will build a browser-based clinic receptionist with Vapi that speaks with callers. A Node.js webhook will save each appointment before the agent confirms it.

What You'll Build

Call your finished receptionist through your browser to turn a spoken request into a confirmed appointment record you can inspect immediately.

By the end of this project, you'll have:

  • A natural voice conversation that collects the caller's booking details one at a time.
  • A tested confirmation guardrail that asks for missing details before any booking attempt. The agent announces success only after the backend returns confirmed.
  • An observable booking trail showing the saved appointment in your browser beside its webhook activity in Visual Studio Code.
  • Secret Mission: Add live availability checks with backend enforcement against unavailable slots or double-bookings.

Are there any prerequisites?

You need a Windows PC with internet access plus a working microphone. You should also have Chrome or Microsoft Edge installed.

Comfort with building web apps helps you focus on the voice workflow.

Before We Start

This step defines the Bright Smile Clinic receptionist's job: gather a caller's name, service, date, and time. The receptionist confirms the appointment only after the backend reports success.

Prepare the Voice Agent Workspace

Your receptionist eventually passes a caller's details from Vapi to code running on your computer. That bridge needs a supported Node.js runtime.

Ngrok later provides the public HTTPS route into your local server. This step verifies those dependencies before the voice workflow reaches them.

In this step, get ready to:
  • Prepare an empty local workspace with the required Node.js runtime.
  • Reach the Vapi Dashboard with your new account.
  • Prepare an authenticated ngrok Agent CLI.
Prepare the local workspace

Visual Studio Code keeps the project folder beside its integrated PowerShell terminal. This gives every later command a predictable starting location.

  • Press the Windows key to open the Windows search bar.

You'll see the search bar ready for an app name.

  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.

You'll see Visual Studio Code open on your desktop.

  • Click File in the top menu bar.
  • Select Open Folder....

You'll see the Windows folder selection dialog.

  • Create a folder named ai-voice-receptionist in the folder selection dialog.

You'll see the empty ai-voice-receptionist folder in the dialog.

  • Select the ai-voice-receptionist folder.
  • Confirm the folder selection in the dialog.

You'll see ai-voice-receptionist at the top of the Explorer sidebar. The folder is empty because the project code comes later.

  • Click View in the top menu bar.
  • Select Terminal.

You'll see a PowerShell terminal open at the bottom of Visual Studio Code. Its current location is the ai-voice-receptionist folder.

  • Check the installed Node.js version by running this command:
node --version

What does this command do?

The command asks Node.js to print its installed version. This project uses v24.21.0 so the booking server behaves consistently.

✔️ I see the required version

Your terminal shows v24.21.0. Your Node.js runtime is ready for the booking API.

ⓧ I see an older version

The installed runtime is older than this project requires. Upgrade it with the official v24.21.0 LTS Windows installer.

  • Download the official Node.js Windows installer.
  • Run the downloaded installer.
  • Complete the installer with its default options.
  • Close the current terminal session using the bin icon in the Terminal panel.
  • Click View in the top menu bar.
  • Select Terminal.
  • Check the upgraded runtime by running this command:
node --version

The new terminal should print v24.21.0.

Still seeing the older version?

  • Confirm that the installer finished before you restarted the terminal.
  • Close Visual Studio Code completely before opening it again if the old terminal environment remains active.
  • Still stuck? Help me update Node.js on Windows.

ⓧ Command not found

PowerShell cannot find Node.js yet. Install the required runtime before continuing.

  • Download the official Node.js Windows installer.
  • Run the downloaded installer.
  • Complete the installer with its default options.
  • Close the current terminal session using the bin icon in the Terminal panel.
  • Click View in the top menu bar.
  • Select Terminal.
  • Check the installed runtime by running this command:
node --version

The new terminal should print v24.21.0.

Node.js still not found?

  • Confirm that the Node.js installer completed successfully.
  • Restart Visual Studio Code so its terminal receives the updated command path.
  • Still stuck? Help me make Node.js available in PowerShell.
Create the Vapi account

The Vapi Dashboard is where you configure the receptionist later. This project uses its browser microphone test, so no phone number is needed.

  • Press the Windows key to open the Windows search bar.

You'll see the search bar ready for a browser name.

  • Type Chrome into the search bar.
  • Press Enter to open Chrome.

You'll see a Chrome window ready for the Vapi account setup.

You'll reach Vapi's website with access to its account flow.

  • Create your Vapi account by completing the on-screen account flow.
  • Open the Dashboard from your account.

You'll see the Vapi Dashboard open in Chrome.

  • Skip any phone-number setup.

Good progress: your Vapi account is ready for the browser voice test.

Install and authenticate ngrok

Your later booking API runs only on your computer. Ngrok creates the public HTTPS address that lets Vapi reach it.

  • Open a new browser tab in Chrome.
  • Open the ngrok website in the new tab.

You'll reach the ngrok website with access to its account flow.

  • Create your free ngrok account by completing the on-screen account flow.
  • Keep the ngrok Dashboard tab open.

Your auth token links the ngrok Agent CLI to your account. This value is a credential, so keep it out of screenshots and shared messages.

  • Copy the auth token from the ngrok Dashboard.
  • Return to the Visual Studio Code terminal from earlier.
  • Check whether ngrok is already available by running this command:
ngrok help

What does this command do?

The command asks ngrok to display its help text. The result tells you whether PowerShell can find the Agent CLI.

✔️ I see ngrok help text

The help text proves that the ngrok Agent CLI is installed. You can connect it to your account now.

  • Authenticate ngrok by replacing <YOUR_AUTHTOKEN> with the token you copied before running this command:
ngrok config add-authtoken "<YOUR_AUTHTOKEN>"

What does this command do?

The command adds your auth token to ngrok's local configuration. Future ngrok endpoints can then use your account.

You should see confirmation that ngrok updated its configuration.

Authentication not completing?

  • Confirm that you replaced the entire placeholder with the token from your ngrok Dashboard.
  • Keep the double quotation marks around the token.
  • Still stuck? Help me authenticate the ngrok Agent CLI.

ⓧ Command not found

PowerShell cannot find ngrok yet. Install the Agent CLI before connecting it to your account.

  • Install ngrok from the Microsoft Store source by running this command:
winget install ngrok -s msstore

What does this command do?

The command asks Windows Package Manager to install ngrok from the Microsoft Store source. This makes the ngrok command available in PowerShell.

When the installation finishes, you'll see that the ngrok package was installed.

Installation not completing?

  • Confirm that your internet connection is active.
  • Allow the Microsoft Store source if Windows asks for permission.
  • Still stuck? Help me install ngrok with WinGet.
  • Authenticate ngrok by replacing <YOUR_AUTHTOKEN> with the token you copied before running this command:
ngrok config add-authtoken "<YOUR_AUTHTOKEN>"

What does this command do?

The command adds your auth token to ngrok's local configuration. Future ngrok endpoints can then use your account.

You should see confirmation that ngrok updated its configuration.

Authentication not completing?

  • Confirm that the ngrok installation completed before you ran the authentication command.
  • Confirm that you replaced the entire placeholder with the token from your ngrok Dashboard.
  • Still stuck? Help me connect ngrok to my account.

Before you run the final check, do you expect PowerShell to recognize ngrok now?

  • Verify the ngrok Agent CLI by running this command:
ngrok help

You'll see ngrok's help text. This confirms that PowerShell can run the authenticated Agent CLI.

  • Return to the Vapi Dashboard tab from earlier to confirm your account remains accessible.

You'll see the Vapi Dashboard ready in your browser. Your earlier Node.js check should show v24.21.0 in the Visual Studio Code terminal.

Your workspace is ready: the local runtime is verified, both accounts are active, and ngrok can connect future requests to your computer. Next up, you'll give the clinic receptionist its voice.

Create the Voice Receptionist

Your workspace is ready for the part callers can hear. In the Vapi Dashboard from earlier, the browser gives you the quickest way to test a real-time voice interaction.

You will create a clinic receptionist that gathers appointment details. A system prompt keeps each response focused.

A useful receptionist needs more than a natural conversation. Its booking claim needs proof from a backend.

In this step, get ready to:
  • Create a blank Vapi assistant.
  • Shape the receptionist's booking conversation.
  • Test the published assistant through your browser microphone.
Create the assistant

An assistant holds the voice configuration that drives a conversation. The default Balanced preset supplies the voice pipeline for this first test.

  • Return to the Vapi Dashboard from earlier.
  • Select Assistants from the navigation.
  • Select Create Assistant.
  • Select the blank assistant option.
  • Enter Clinic Receptionist as the assistant name.
  • Keep the default Balanced preset.

You should see Clinic Receptionist as the assistant name. The voice pipeline should still show the Balanced preset.

Configure the conversation

The greeting establishes the clinic context as soon as the call starts. The prompt controls what the receptionist collects before it can confirm a booking.

  • Find the First Message field in the assistant editor.
  • Replace its current content with Thanks for calling Bright Smile Clinic. How can I help you today?.
  • Find the System Prompt field.
  • Replace its current content by pasting this prompt:
You are the voice receptionist for Bright Smile Clinic.
Keep each spoken response under 25 words.
Collect the caller's name, requested service, appointment date, and appointment time.
Use YYYY-MM-DD for dates and HH:MM in 24-hour time when calling tools.
Never say an appointment is booked unless the book_appointment tool returns a result with status confirmed.
If book_appointment is unavailable, explain that you can collect the details but cannot finalize the booking.
Ask for one missing detail at a time.
Read the confirmed date and time back to the caller before ending.

How Does This Prompt Guide the Call?

  • The response limit keeps each spoken turn short.
  • The collection rule gathers the caller's name, requested service, appointment date, and appointment time.
  • The YYYY-MM-DD date format makes tool input consistent.
  • The HH:MM time format gives the tool a predictable 24-hour value.
  • The book_appointment rule creates a confirmation boundary around the future function tool.
  • The unavailable-tool rule gives the receptionist an honest fallback.
  • The missing-detail rule keeps the conversation focused on one question at a time.
  • The final readback lets the caller hear the confirmed date and time.
  • Compare the First Message field with the greeting above.
  • Confirm every line of the prompt appears in the System Prompt field.

Prompt Missing a Rule?

  • Check whether the final lines were cut off when you pasted the prompt.
  • Restore any missing line by copying it from the prompt above.
  • Still stuck? Help me compare my Vapi system prompt with the required receptionist prompt.
Publish and test the receptionist

Publishing makes the current assistant configuration available to the browser call. The microphone test reveals whether the receptionist follows its booking boundary.

  • Select Publish.
  • Select Quick Publish.

Your receptionist is published with its greeting and conversation rules. That completes the voice configuration for the first call.

  • Select Talk.
  • Allow the Vapi Dashboard to use your microphone when your browser asks.

You should hear the Bright Smile Clinic greeting. This confirms the published first message is active.

Before you request an appointment, do you think the receptionist can turn the collected details into a saved booking yet?

  • Ask the receptionist to book a cleaning.
  • Provide Jamie Lee as the fictional caller name.
  • Provide a future date in YYYY-MM-DD format.
  • Provide 10:00 as the appointment time.

The receptionist should ask for one missing detail at a time. After collecting all four details, it should explain that it cannot finalize the booking.

That first voice loop is working. The assistant gathered structured details while keeping the confirmation boundary honest.

Talk Test Not Behaving as Expected?

  • If the call does not start, confirm that your browser granted microphone access to Vapi.
  • If the assistant claims the appointment is booked, return to the System Prompt field.
  • Restore the line that begins with Never say an appointment is booked from the prompt above.
  • Still stuck? Help me troubleshoot my Vapi Talk test.

Your published receptionist can now hold a focused booking conversation without making an unsupported promise. Next, you'll give that conversation a trustworthy booking API.

Build the Booking API

Your Vapi receptionist now gathers every appointment detail from a caller. The conversation still ends without a saved booking.

A local Node.js HTTP server creates the trustworthy system of record that the voice assistant was missing. It stores confirmed appointments in memory for you to inspect.

In this step, get ready to:
  • Build a dependency-free Node.js booking API.
  • Handle validated Vapi tool calls.
  • Verify the health response plus the empty appointments list.
Create the request helpers

Incoming tool requests arrive as JSON. Small helpers keep request parsing separate from appointment logic.

  • In the Explorer sidebar from earlier, select the new-file icon beside ai-voice-receptionist.
  • Name the file server.js.
  • Add the server settings plus JSON helpers by pasting this code into server.js:
const http = require("node:http");

const PORT = 3000;
const appointments = [];

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, { "Content-Type": "application/json" });
  response.end(JSON.stringify(payload));
}

function readJson(request) {
  return new Promise((resolve, reject) => {
    let body = "";

    request.on("data", (chunk) => {
      body += chunk.toString();
    });

    request.on("end", () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch {
        reject(new Error("Invalid JSON body"));
      }
    });

    request.on("error", reject);
  });
}

What Do These Helpers Do?

  • The PORT value tells the server to listen on port 3000.
  • The appointments array holds confirmed records for the current server session.
  • The sendJson() helper sends a status code plus a JSON response.
  • The readJson() helper collects an incoming request body before parsing it.
  • Continue directly below readJson() by pasting the tool-call discovery helpers:
function getToolCalls(payload) {
  const directCalls = payload?.message?.toolCallList;
  if (Array.isArray(directCalls) && directCalls.length > 0) {
    return directCalls;
  }

  const wrappedCalls = payload?.message?.toolWithToolCallList;
  if (Array.isArray(wrappedCalls)) {
    return wrappedCalls.map((item) => item.toolCall ?? item);
  }

  return [];
}

function getToolName(toolCall) {
  return toolCall.name ?? toolCall.function?.name ?? "unknown";
}

Why Check Multiple Tool-Call Shapes?

Vapi can place calls in a direct list or a wrapped list. The getToolCalls() helper normalizes both shapes into one array.

The getToolName() helper reads the function name from either supported location. The remaining booking logic can now use one consistent value.

  • Save server.js by pressing Ctrl+S.
  • Check the file syntax in the Visual Studio Code terminal by running this command:
node --check server.js

What Does This Check Prove?

The --check option asks Node.js to validate the file without executing it. This catches missing brackets or invalid punctuation before more logic is added.

PowerShell returns to a fresh prompt without displaying a syntax error. Your request helpers are valid JavaScript.

Seeing a Syntax Problem?

  • Check that every opening brace in readJson() has a matching closing brace.
  • Compare the quotation marks around node:http with the code above.
  • Still stuck? Help me find the syntax problem in my server helpers.
Handle booking tool calls

A booking can only be confirmed when all four required values reach the backend. The next helpers normalize those values before creating a record.

  • Add the argument parser directly below getToolName() by pasting this code:
function getToolArguments(toolCall) {
  const rawArguments =
    toolCall.arguments ??
    toolCall.parameters ??
    toolCall.function?.arguments ??
    toolCall.function?.parameters ??
    {};

  if (typeof rawArguments === "string") {
    try {
      return JSON.parse(rawArguments);
    } catch {
      return {};
    }
  }

  return rawArguments && typeof rawArguments === "object" ? rawArguments : {};
}

How Are Arguments Normalized?

The getToolArguments() helper checks each documented argument location. It also parses arguments that arrive as a JSON string.

An invalid or missing value becomes an empty object. The validation function can then report missing appointment fields.

  • Save server.js.
  • Confirm the new helper has valid syntax by running:
node --check server.js

What Does This Check Cover?

Node.js now checks the argument parser alongside the earlier helpers. A fresh PowerShell prompt confirms that the complete file remains syntactically valid.

The parser is ready to feed clean values into the booking function.

  • Start the bookAppointment() function below getToolArguments() by pasting this first section:
function bookAppointment(argumentsObject) {
  const requiredFields = ["customer_name", "service", "date", "time"];
  const missingFields = requiredFields.filter(
    (field) => !String(argumentsObject[field] ?? "").trim(),
  );

  if (missingFields.length > 0) {
    return {
      error: `Missing required fields: ${missingFields.join(", ")}`,
    };
  }

How Does Validation Work?

The requiredFields list names the four values needed for a booking. Empty values are collected in missingFields.

This first section leaves bookAppointment() open. The next code block completes the same function before you save it.

  • Continue inside bookAppointment() by pasting the record-creation section directly below the validation code:
  const appointment = {
    id: appointments.length + 1,
    customer_name: String(argumentsObject.customer_name).trim(),
    service: String(argumentsObject.service).trim(),
    date: String(argumentsObject.date).trim(),
    time: String(argumentsObject.time).trim(),
    status: "confirmed",
    created_at: new Date().toISOString(),
  };

  appointments.push(appointment);
  console.log("Booked appointment:", appointment);

  return {
    result: JSON.stringify({
      status: "confirmed",
      appointment_id: appointment.id,
      date: appointment.date,
      time: appointment.time,
    }),
  };
}

What Makes a Booking Confirmed?

A valid request becomes an appointment with an ID plus a creation timestamp. Its status is set to confirmed before the record enters the in-memory array.

The returned result contains the appointment ID plus the saved date and time. This gives the voice assistant concrete backend confirmation.

  • Save server.js.
  • Check the completed booking function by running:
node --check server.js

What Does This Check Confirm?

This syntax check covers both halves of bookAppointment() as one complete function. PowerShell returns to a fresh prompt when its braces and template strings are valid.

  • Add the tool dispatcher below bookAppointment() by pasting this code:
function processToolCall(toolCall) {
  const toolCallId = toolCall.id;
  const toolName = getToolName(toolCall);
  const argumentsObject = getToolArguments(toolCall);

  if (toolName !== "book_appointment") {
    return {
      toolCallId,
      error: `Unsupported tool: ${toolName}`,
    };
  }

  const outcome = bookAppointment(argumentsObject);
  return { toolCallId, ...outcome };
}

How Is the Tool Dispatched?

The processToolCall() function links each response to the incoming tool-call ID. It only allows book_appointment to reach the booking function.

Unsupported tool names return an error result. This keeps the booking endpoint limited to its intended action.

  • Save server.js.
  • Verify the complete tool-processing layer by running:
node --check server.js

What Does This Check Confirm?

Node.js validates every helper plus the tool dispatcher as one file. A fresh prompt means the booking logic is ready to connect to HTTP routes.

🙋‍♀️ Is the Tool Logic Failing Its Check?

  • Check that bookAppointment() closes before processToolCall() begins.
  • Confirm that each required field uses the exact spelling shown in the code.
  • Still stuck? Help me debug the booking tool functions in server.js.
Serve and verify the API

The helper functions now need routes that expose their results. The health route proves the server is running before any voice tool reaches it.

  • Start the server definition below processToolCall() by pasting these read-only routes:
const server = http.createServer(async (request, response) => {
  if (request.method === "GET" && request.url === "/health") {
    sendJson(response, 200, { status: "ok" });
    return;
  }

  if (request.method === "GET" && request.url === "/appointments") {
    sendJson(response, 200, {
      count: appointments.length,
      appointments,
    });
    return;
  }

What Do the Read-Only Routes Show?

The /health route returns a small success response when the server is reachable. The /appointments route exposes the current appointment count plus every in-memory record.

This code opens the server callback. The next block adds the tool route before closing it.

  • Complete the server callback directly below the appointments route by pasting this code:
  if (request.method === "POST" && request.url === "/tools/appointments") {
    try {
      const payload = await readJson(request);
      console.log("Received tool request:", JSON.stringify(payload));
      const results = getToolCalls(payload).map(processToolCall);
      sendJson(response, 200, { results });
    } catch (error) {
      console.error("Tool request failed:", error.message);
      sendJson(response, 200, { results: [] });
    }
    return;
  }

  sendJson(response, 404, { error: "Not found" });
});

server.listen(PORT, () => {
  console.log(`Booking API listening at http://localhost:${PORT}`);
});

How Does the Server Handle Tool Requests?

The POST /tools/appointments route parses the Vapi payload. Each discovered call passes through processToolCall().

Unknown routes receive a JSON error. The final listener starts the server on the configured port.

  • Save server.js.

Before you start the server, which two responses do you expect the health and appointments pages to show?

  • Start the booking API in the Visual Studio Code terminal by running this command:
node server.js

What Does This Command Do?

Node.js executes server.js and starts listening on port 3000. The command keeps this terminal occupied while the API is running.

You'll see Booking API listening at http://localhost:3000 in the terminal. That's the backend running and ready for browser requests.

  • Switch back to Chrome or Microsoft Edge from earlier.
  • Open a new browser tab.
  • Enter http://localhost:3000/health in the address bar.

You'll see {"status":"ok"}. This proves the local server is accepting requests.

  • Open another browser tab.
  • Enter http://localhost:3000/appointments in the address bar.

You'll see {"count":0,"appointments":[]}. The empty list confirms that no appointment has been created before a backend tool call succeeds.

Can’t Reach the Local API?

  • Return to the Visual Studio Code terminal from earlier to confirm the server process is still running.
  • Check that the terminal shows the listening address with port 3000.
  • Still stuck? Help me troubleshoot why my local booking API is not loading in the browser.

✔️ Awesome, I've got everything!

Great work. Your saved server.js file now serves a healthy API plus an observable appointments list.

ⓧ I'd like to double check the full code

Compare your saved server.js file with this complete version:

const http = require("node:http");

const PORT = 3000;
const appointments = [];

function sendJson(response, statusCode, payload) {
  response.writeHead(statusCode, { "Content-Type": "application/json" });
  response.end(JSON.stringify(payload));
}

function readJson(request) {
  return new Promise((resolve, reject) => {
    let body = "";

    request.on("data", (chunk) => {
      body += chunk.toString();
    });

    request.on("end", () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch {
        reject(new Error("Invalid JSON body"));
      }
    });

    request.on("error", reject);
  });
}

function getToolCalls(payload) {
  const directCalls = payload?.message?.toolCallList;
  if (Array.isArray(directCalls) && directCalls.length > 0) {
    return directCalls;
  }

  const wrappedCalls = payload?.message?.toolWithToolCallList;
  if (Array.isArray(wrappedCalls)) {
    return wrappedCalls.map((item) => item.toolCall ?? item);
  }

  return [];
}

function getToolName(toolCall) {
  return toolCall.name ?? toolCall.function?.name ?? "unknown";
}

function getToolArguments(toolCall) {
  const rawArguments =
    toolCall.arguments ??
    toolCall.parameters ??
    toolCall.function?.arguments ??
    toolCall.function?.parameters ??
    {};

  if (typeof rawArguments === "string") {
    try {
      return JSON.parse(rawArguments);
    } catch {
      return {};
    }
  }

  return rawArguments && typeof rawArguments === "object" ? rawArguments : {};
}

function bookAppointment(argumentsObject) {
  const requiredFields = ["customer_name", "service", "date", "time"];
  const missingFields = requiredFields.filter(
    (field) => !String(argumentsObject[field] ?? "").trim(),
  );

  if (missingFields.length > 0) {
    return {
      error: `Missing required fields: ${missingFields.join(", ")}`,
    };
  }

  const appointment = {
    id: appointments.length + 1,
    customer_name: String(argumentsObject.customer_name).trim(),
    service: String(argumentsObject.service).trim(),
    date: String(argumentsObject.date).trim(),
    time: String(argumentsObject.time).trim(),
    status: "confirmed",
    created_at: new Date().toISOString(),
  };

  appointments.push(appointment);
  console.log("Booked appointment:", appointment);

  return {
    result: JSON.stringify({
      status: "confirmed",
      appointment_id: appointment.id,
      date: appointment.date,
      time: appointment.time,
    }),
  };
}

function processToolCall(toolCall) {
  const toolCallId = toolCall.id;
  const toolName = getToolName(toolCall);
  const argumentsObject = getToolArguments(toolCall);

  if (toolName !== "book_appointment") {
    return {
      toolCallId,
      error: `Unsupported tool: ${toolName}`,
    };
  }

  const outcome = bookAppointment(argumentsObject);
  return { toolCallId, ...outcome };
}

const server = http.createServer(async (request, response) => {
  if (request.method === "GET" && request.url === "/health") {
    sendJson(response, 200, { status: "ok" });
    return;
  }

  if (request.method === "GET" && request.url === "/appointments") {
    sendJson(response, 200, {
      count: appointments.length,
      appointments,
    });
    return;
  }

  if (request.method === "POST" && request.url === "/tools/appointments") {
    try {
      const payload = await readJson(request);
      console.log("Received tool request:", JSON.stringify(payload));
      const results = getToolCalls(payload).map(processToolCall);
      sendJson(response, 200, { results });
    } catch (error) {
      console.error("Tool request failed:", error.message);
      sendJson(response, 200, { results: [] });
    }
    return;
  }

  sendJson(response, 404, { error: "Not found" });
});

server.listen(PORT, () => {
  console.log(`Booking API listening at http://localhost:${PORT}`);
});
  • Leave the node server.js terminal running for the next step.

Your receptionist now has a trustworthy place to save appointments. Next up, you'll expose this local API through ngrok and connect it to the voice assistant.

Connect Speech to a Real Booking

Your local Node.js booking API already validates appointment details. That gives the receptionist a trustworthy system of record.

Vapi cannot reach an API that only listens on your computer. ngrok solves this problem with a public HTTPS address.

A synchronous function tool connects that public address to the receptionist. The assistant can then wait for backend success before confirming a booking.

In this step, get ready to:
  • Expose the local booking API through a public HTTPS address.
  • Connect Vapi to the booking endpoint with a Custom Tool.
  • Prove a voice booking with a saved record and terminal log.
Expose the booking API

Your booking API currently listens only on your computer. An ngrok tunnel maps a public HTTPS address to port 3000.

  • In the Visual Studio Code terminal panel from earlier, select the plus button to create a second terminal.
  • Keep the booking API terminal from earlier running.
  • Start the public tunnel in the second terminal by running this command:
ngrok http 3000

What does this command do?

The command creates a public HTTPS route to port 3000 on your computer. Requests sent to that route reach the booking API.

The tunnel stays active while this terminal process runs. Keep this terminal running during the voice test.

You should see a public HTTPS forwarding URL in the ngrok output. This is the address Vapi can reach.

  • Record the public HTTPS forwarding URL here: <your-ngrok-https-url>.

No forwarding URL?

  • Check that the ngrok account connection from the setup step completed successfully.
  • Confirm that the booking API terminal still shows the server running on port 3000.
  • Still stuck? Help me troubleshoot my ngrok tunnel.
Create the booking tool

A function tool turns the appointment details from the conversation into a structured request. Its webhook response tells the assistant whether the booking succeeded.

  • Return to the Vapi Dashboard from earlier.
  • Select Tools.
  • Select Create Tool.
  • Select Custom Tool.

You should see the configuration form for a new Custom Tool.

  • Enter book_appointment in the Tool Name field.
  • Configure the tool as a synchronous function.

Why use a synchronous tool?

A synchronous tool waits for the booking API response before resolving. That response enforces the confirmation boundary in your system prompt.

  • Enter <your-ngrok-https-url> followed by /tools/appointments in the Server URL field.

The Server URL should now point directly to the booking endpoint exposed by your tunnel.

  • Add customer_name as a required string parameter.
  • Add service as a required string parameter.

The first two parameters now capture who is calling and which appointment they want.

  • Add date as a required string parameter.
  • Enter Appointment date in YYYY-MM-DD format in the date parameter's Description field.

The date parameter now tells the assistant which format the backend expects.

  • Add time as a required string parameter.
  • Enter Appointment time in HH:MM 24-hour format in the time parameter's Description field.

The tool now requires every value that the booking API validates.

  • Save the Custom Tool configuration.

You should see book_appointment in the Vapi Tools list.

  • Select Assistants in the Vapi Dashboard.
  • Select Clinic Receptionist.
  • Select the Tools tab.
  • Select Add Tool.

You should see your saved tools as options for the assistant.

  • Select book_appointment.
  • Save the assistant configuration.

You should now see book_appointment attached to Clinic Receptionist.

  • Select Publish to publish the updated assistant.

The published receptionist can now send structured appointment details to your booking API.

Tool missing from the assistant?

  • Return to the Vapi Tools list and confirm that book_appointment was saved.
  • Check that all four parameters are marked as required strings.
  • Still stuck? Help me attach the booking tool.
Complete a voice booking

This call tests the complete path from spoken details to a confirmed backend record. Your browser may request microphone access as soon as you select Talk.

Before you start the call, do you think the receptionist can now confirm a booking after the backend responds?

  • Select Talk for Clinic Receptionist.
  • Approve microphone access if your browser asks.
  • Ask the receptionist to book an appointment.
  • Provide a caller name when asked.
  • Provide a service when asked.
  • Provide a future date in YYYY-MM-DD format when asked.
  • Provide a time in HH:MM format when asked.
  • Listen for the receptionist's confirmation.

You should hear the receptionist confirm the appointment after the tool returns success.

  • Return to http://localhost:3000/appointments from earlier.
  • Refresh the appointments page.

You should see the caller's details in the appointments array. The record should contain "status":"confirmed".

  • Switch back to the booking API terminal from earlier.

You should see a Received tool request: log followed by a Booked appointment: log.

You have closed the loop: a spoken request now creates a backend record your browser can prove.

No confirmed appointment?

  • Confirm that the booking API terminal and ngrok terminal are both still running.
  • Check that the tool's Server URL uses the current ngrok HTTPS forwarding URL followed by /tools/appointments.
  • Confirm that book_appointment is attached to the published Clinic Receptionist assistant.
  • Still stuck? Help me trace the booking request.

Your receptionist now turns speech into a verified business action. Next, you'll test whether it protects that result when a caller leaves out required information.

Test the Agent Like a Real Caller

Your receptionist can now turn a spoken request into a confirmed record. One successful demo still leaves its most important guardrail unproven.

This step tests complete input against incomplete input. You will compare each conversation with the browser record plus the server log.

In this step, get ready to:
  • Complete a booking conversation with every required detail.
  • Test how the assistant handles a request without a time.
  • Compare the browser records with the server logs.
Verify a complete booking

A trustworthy voice workflow leaves matching evidence across several surfaces. Vapi speaks the result while the browser record proves what the backend saved.

The Node.js log provides the final piece of evidence. It shows the tool request that reached your server.

  • Return to the Clinic Receptionist assistant in the Vapi Dashboard.
  • Select Talk to start a new browser call.
  • Allow microphone access if your browser asks again.
  • Request a cleaning appointment.
  • Give a fictional caller name when asked.
  • Provide a future date when asked.
  • Provide a future time when asked.

You should hear the receptionist confirm the booking. The confirmation should follow the backend tool result.

  • Switch back to the browser tab showing http://localhost:3000/appointments.
  • Refresh the appointments page.
  • Locate the newest appointment record.

What should I see?

The newest appointment should show your fictional caller name beside the requested service.

The record should contain the requested date beside the requested time.

Its status should be confirmed.

  • Switch back to the Node.js terminal from earlier.
  • Locate the latest tool activity in the terminal output.

You should see a new Received tool request: entry. A matching Booked appointment: entry should follow it.

No New Appointment Record?

  • Confirm the Node.js terminal still shows that the booking API is listening on port 3000.
  • Confirm the ngrok terminal still shows an active public HTTPS forwarding URL.
  • Compare the Custom Tool's Server URL with the active forwarding URL plus /tools/appointments.
  • Still stuck? Help me trace why my Vapi booking reached the conversation but did not appear in the appointments page or Node.js log.
Test the missing-time guardrail

The confirmation boundary matters most when a required detail is missing. This test deliberately leaves the appointment time out of the request.

  • Switch back to the appointments page.
  • Refresh the page.
  • Record the current count here: current appointment count.

Before you start this call, do you think the count changes before you provide a time? The answer comes from the next test.

  • Return to the Clinic Receptionist assistant.
  • Select Talk to start a separate conversation.
  • Request a cleaning on a future date without giving a time.
  • Give a fictional caller name if asked.
  • Listen to the receptionist's next question.

You should hear the receptionist ask for the missing time. It should withhold any booking confirmation.

  • Switch back to the appointments page.
  • Refresh the page before supplying the missing time.

The count should still match current appointment count. No record should exist for this incomplete request.

  • Switch back to the Node.js terminal.

You should see no new Received tool request: entry for this conversation. You should also see no new Booked appointment: entry.

Did the Tool Run Too Soon?

  • End the test call if the assistant claims success before asking for a time.
  • Confirm the Custom Tool still requires customer_name, service, date, plus time.
  • Republish the assistant after correcting any changed tool or prompt configuration.
  • Need help? Help me find why my Vapi assistant called book_appointment before collecting the required time.
Complete the request and review usage

The conversation is paused at the exact decision boundary. Your next answer tests whether the backend action begins only after the request becomes complete.

Before you supply the time, which surfaces do you expect to change? The next answer tests your prediction.

  • Return to the active Talk session.
  • Supply a future time using HH:MM in 24-hour format.

You should now hear a booking confirmation. The confirmation arrives after the tool returns success.

  • End the Talk session.
  • Switch back to the appointments page.
  • Refresh the page.

The count should now be one higher than current appointment count. The newest record should contain the supplied time.

The newest record should also show status as confirmed.

  • Switch back to the Node.js terminal.

You should now see a fresh Received tool request: entry followed by one Booked appointment: entry. This proves the incomplete request created no record until you supplied the time.

The usage screen can feel tense because it shows real spend. Vapi hosting costs $0.05 per call minute before model-provider costs.

  • Return to the Vapi Dashboard.
  • Select Overview in the dashboard navigation.
  • Review the actual usage shown for your recent Talk sessions.
  • Stop further Talk testing once your total reaches about five minutes.

That's the confirmation boundary proven. Your receptionist now waits for complete input before creating a confirmed appointment record.

Secret mission

Prevent Double-Bookings

Your receptionist can save a well-formed request, but every time still looks bookable. Add live availability checks that offer open alternatives and reject a slot after it has been claimed.

Clean Up Your Resources

Clean Up Your Resources

Your local API has no ongoing cost. Choose whether to keep the project ready, pause its processes, or delete its resources before any more Vapi testing.

Cost warning

Vapi hosting costs $0.05 / min during calls. Model-provider costs are separate.

The ngrok Free plan is $0 within its included limits. Short tests keep your total easy to control.

  • Keep each test call short.
  • Review your actual Vapi usage before starting another Talk session.
  • Stop testing when you reach your chosen budget.

Resources you used:

  • The published Clinic Receptionist assistant.
  • The book_appointment and check_availability Custom Tools.
  • The running Node.js booking API in the Visual Studio Code terminal.
  • The live ngrok process providing the public forwarding endpoint.
  • The local ai-voice-receptionist folder containing server.js.

Keep everything running

No action needed. Choose this if you plan to keep testing the receptionist or extending its booking rules.

  • The Clinic Receptionist assistant stays published with both Custom Tools.
  • The server.js file stays ready for future changes.
  • The local API remains active until its terminal closes.
  • The ngrok forwarding endpoint remains active until its terminal closes.
  • The current appointments and remaining slots stay in memory while server.js keeps running.
  • Each future Talk session adds usage.

Pause - I'll come back to this later

Shut down the local processes to take the integration offline. Keep the assistant, tools, and project files ready for later.

  • Switch back to the Visual Studio Code terminal running the booking API.
  • Close that terminal.
  • Switch to the terminal showing the ngrok forwarding URL.
  • Close that terminal.

The public booking connection is now offline. Your Vapi assistant and Custom Tools remain saved.

Your local files remain on disk. The in-memory appointments disappear when the Node.js process stops.

  • Restart the Node.js API in a Visual Studio Code terminal when you return.
  • Restart ngrok in a second terminal.
  • Update the Server URL for both tools if the forwarding URL changes.

Delete - I don't want to use this again

Remove the Vapi configuration first. Dashboard deletion labels can change, so the confirmation text is your final check before each resource disappears.

  • Return to the Vapi Dashboard from earlier.
  • Select Assistants.
  • Select Clinic Receptionist.
  • Use the current deletion control on the assistant page.
  • Read the confirmation text.
  • Confirm the removal.

The Clinic Receptionist assistant should no longer appear in Assistants.

  • Select Tools.
  • Select book_appointment.
  • Use the current deletion control on the tool page.
  • Read the confirmation text.
  • Confirm the removal.

The book_appointment tool should no longer appear in Tools.

  • Select check_availability.
  • Use the current deletion control on the tool page.
  • Read the confirmation text.
  • Confirm the removal.

The check_availability tool should no longer appear in Tools.

Next, stop the temporary processes. This closes the local API and its public forwarding endpoint.

  • Switch back to the Visual Studio Code terminal running the booking API.
  • Close that terminal.
  • Switch to the terminal showing the ngrok forwarding URL.
  • Close that terminal.

The API and public endpoint are now offline. Any appointments held in memory are cleared.

Deleting the local folder is permanent. The command below only targets the full path you provide.

  • Use Windows search to start File Explorer.
  • Navigate to ai-voice-receptionist in the location you selected earlier.
  • Record the full path shown in the address bar here: full path to ai-voice-receptionist.
  • Close Visual Studio Code.
  • Use Windows search to start Command Prompt.
  • Delete the project folder by running this command:
rmdir /s /q "[[PROJECT_PATH="full path to ai-voice-receptionist"]]"

What does this command remove?

This command permanently removes the folder path you entered. It also removes every file stored inside that folder.

  • Return to the parent location in File Explorer.
  • Confirm that ai-voice-receptionist is no longer listed.

Folder still visible?

  • Check that the recorded path ends with ai-voice-receptionist.
  • Close any app using a file inside the project folder.
  • Help me safely delete the project folder.

That clears the project resources from your computer. Your Vapi assistant and Custom Tools are also removed.

Nice Work!

Nice Work!

Excellent work! Your Vapi voice receptionist now turns a caller's request into a backend-confirmed appointment.

You've learned how to:

  • Design a concise system prompt that guides a browser-based voice receptionist through names, services, dates, and times.
  • Expose a dependency-free Node.js booking webhook through ngrok. Vapi function tools can then turn spoken requests into observable appointment records.
  • Prove the confirmation guardrail with complete and incomplete conversations. Missing details trigger follow-up questions. Only backend success unlocks a spoken confirmation.
  • Complete the optional Secret Mission by adding live availability checks. Each confirmed booking removes its slot. A second request for that time is rejected.

Ready to quiz yourself?