Multi-Tenant Voice Agent with Vapi

Build a SaaS voice agent backend that serves multiple industries from one codebase.

Introduction

30 Second Summary

Every business needs a receptionist, but no two businesses answer the phone the same way. A dental office books appointments. A restaurant reserves tables. An auto shop schedules repairs. The engineering challenge is making one system handle all of them.

In this project, you will build a multi-tenant voice agent infrastructure that dynamically creates and configures AI receptionists for any industry vertical using Vapi and Supabase.

What You'll Build

A backend orchestration layer that turns a generic voice AI platform into a multi-vertical SaaS product, dynamically composing system prompts and tool schemas per client.

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

  • A multi-tenant database schema with Row Level Security isolating voice agent configurations per SaaS tenant.
  • Next.js API routes that programmatically create, configure, and manage Vapi voice assistants from composable industry templates stored in Supabase.
  • A live voice conversation running through Vapi's web transport, driven by dynamically-composed prompts and database-driven tool definitions.
  • Secret Mission: Build an LLM-powered pipeline that auto-generates complete agent configurations from a natural language business description.

Are there any prerequisites?

This project assumes you're familiar with LLMs, prompt engineering, and function calling patterns. You'll also need basic TypeScript and REST API experience.

No prior experience with Vapi or Supabase is required. You'll set up both from scratch in Step 1.

Before We Start

You are building a multi-tenant agent orchestration layer. The core engineering problem: how do you make ONE codebase serve completely different voice agent behaviors (a dental receptionist vs. a restaurant host vs. an auto shop scheduler) without duplicating agent logic?

The answer is separating agent configuration (prompts, tools, voice settings) from agent infrastructure (lifecycle management, call routing, state persistence). In this step, you'll set up the accounts and project scaffold that power this separation.

In this step, get ready to:
  • Create your Supabase and Vapi accounts.
  • Scaffold the Next.js project and install dependencies.
  • Configure environment variables and verify the dev server runs.
Create your Supabase and Vapi accounts

You need two platform accounts for this project. Supabase provides the multi-tenant database. Vapi provides the voice AI platform your orchestration layer controls.

  • Open your browser and navigate to database.new.
  • Sign up for a free Supabase account (you can use GitHub, email, or SSO).
  • Once signed in, click New Project.
  • Choose your organization (or create one), give your project a name (e.g. voice-agent-infra), select a region close to you, and set a database password.
  • Click Create new project.
  • Wait for your project to finish provisioning (this takes about 1 minute).

Now you need to grab three values from Supabase that your app will use to connect.

  • In the left sidebar, click Settings, then click API Keys.
  • Copy your Project URL (it looks like https://abcdefg.supabase.co). Save it somewhere temporary.
  • Under the Publishable and secret API keys tab, copy the Publishable key value. Save it.
  • Copy the Secret key value. Save it securely. This key bypasses Row Level Security and should never be exposed in client-side code.

What are these keys for?

The publishable key is safe to use in browser code. It respects your database's Row Level Security policies.

The secret key (also called the service role key) has elevated privileges. Your server-side API routes use it to perform admin operations like inserting agent records.

Next, set up your Vapi account.

  • Open a new browser tab and navigate to dashboard.vapi.ai.
  • Sign up for a free account (Vapi provides $10 in free credits on signup).
  • Once signed in, click your profile icon in the top right corner.
  • Select Vapi API Keys.
  • Copy your Private API Key. Save it securely alongside your Supabase credentials.

Keep these values handy

You will paste all four values (Supabase URL, publishable key, secret key, and Vapi private key) into a single environment file in a few minutes.

Scaffold the Next.js project and install dependencies

Your orchestration layer runs on Next.js with the App Router. This gives you server-side API routes that can safely hold secrets while exposing a lightweight frontend for testing.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Next.js project by running this command:
npx create-next-app@latest voice-agent-infra --typescript --app --tailwind --eslint --src-dir --import-alias "@/*"

What does this command do?

This creates a new Next.js project called voice-agent-infra with TypeScript, the App Router, Tailwind CSS, ESLint, a src/ directory, and the @/* import alias preconfigured. The flags bypass most interactive prompts.

Seeing interactive prompts?

If the CLI asks additional questions (such as Turbopack or package manager), accept the defaults by pressing Enter. The flags handle the main configuration choices automatically.

help me with a create-next-app error

  • Move into your new project folder by running:
cd voice-agent-infra
  • Install the Supabase client library and the Vapi server SDK by running:
npm install @supabase/supabase-js@2.108.1 @vapi-ai/server-sdk@1.2.0

What are these packages?

  • @supabase/supabase-js@2.108.1 is the Supabase client for querying your database and managing auth from both client and server code.
  • @vapi-ai/server-sdk@1.2.0 is the Vapi server SDK for programmatically creating assistants, initiating calls, and managing the voice agent lifecycle.
  • Confirm the installation succeeded. You should see output ending with added X packages and no error messages.

Install failed?

These packages require Node.js 20 or higher. Run node --version to check. If you see a version below 20, update Node.js before retrying.

help me fix my npm install error

Configure environment variables and verify the dev server

Your API routes need credentials to connect to Supabase and Vapi. Next.js loads these automatically from a .env.local file at the project root.

  • In Cursor, open the voice-agent-infra folder. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Open Folder, and select the voice-agent-infra folder on your Desktop.
  • Create a new file in the project root called .env.local. Right-click the file explorer panel, select New File, and type .env.local.
  • Paste the following into .env.local, replacing each placeholder with the real values you saved earlier:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
VAPI_API_KEY=your-vapi-private-api-key

What do these variables do?

  • NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY are prefixed with NEXT_PUBLIC_ so Next.js exposes them to client-side code. They are safe to expose publicly.
  • SUPABASE_SERVICE_ROLE_KEY has no NEXT_PUBLIC_ prefix. Next.js keeps it server-side only, which is critical because this key bypasses Row Level Security.
  • VAPI_API_KEY is your Vapi private key. It stays server-side and authenticates your API routes with Vapi's platform.
  • Save .env.local.
  • Back in your terminal (still inside voice-agent-infra), start the development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000.
  • You should see the default Next.js welcome page with the Next.js logo and links to documentation.

Page not loading?

Check your terminal for error messages. If you see an error about a missing module, re-run npm install inside the voice-agent-infra folder. If port 3000 is already in use, stop the other process or set a different port with PORT=3001 npm run dev.

help me get my Next.js dev server running

✔️ Awesome, I've got everything!

Great. Double check you've saved .env.local and your dev server is running at localhost:3000.

ⓧ I'd like to double check the full code

Here is the complete .env.local file. Make sure yours matches this structure with your own values substituted in:

NEXT_PUBLIC_SUPABASE_URL=<your-supabase-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-supabase-publishable-key>
SUPABASE_SERVICE_ROLE_KEY=<your-supabase-service-role-key>
VAPI_API_KEY=<your-vapi-private-api-key>

Your environment is ready. Next up, you'll design the multi-tenant database schema that powers dynamic agent configuration across industries.

Set Up Accounts and Project Scaffold

Your accounts are ready and the project scaffold is in place. Before building any features, you need to set up reusable SDK clients that every API route in the project will import.

Centralizing these clients in a shared lib folder keeps your configuration in one place. If a key changes or you need to swap a provider, you update one file instead of hunting through dozens of routes.

In this step, get ready to:
  • Create a Supabase client utility for server-side database access.
  • Create a Vapi SDK client utility for assistant lifecycle management.
  • Verify the project compiles and runs with both clients configured.
Create the Supabase client utility

Every API route that reads or writes to your database needs a Supabase client. Instead of initializing one in every file, you will create a single shared module that exports a configured client.

  • In Cursor, create a new folder called lib inside your src folder.
  • Inside src/lib, create a new file called supabase.ts and paste the following code:
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

What does this code do?

This file creates a server-side Supabase client using the service role key. The service role key bypasses Row Level Security, which is exactly what your API routes need to manage data across all tenants.

The ! after each environment variable is a TypeScript non-null assertion. It tells the compiler these values will always exist at runtime (because you defined them in .env.local).

  • Save src/lib/supabase.ts.
  • Check the terminal where npm run dev is running. You should see no new error messages.

Seeing a module not found error?

Make sure @supabase/supabase-js is installed. Run npm list @supabase/supabase-js in your terminal to confirm. If it is missing, run npm install @supabase/supabase-js@2.108.1 again.

Also confirm the file is at src/lib/supabase.ts (inside the src folder, not the project root).

Help me debug this module not found error

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/lib/supabase.ts before moving on.

ⓧ I'd like to double check the full code

import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);
Create the Vapi SDK client utility

The same pattern applies to Vapi. Your API routes will call Vapi to create assistants, start calls, and manage the assistant lifecycle. A single shared client keeps authentication in one place.

  • Inside src/lib, create a new file called vapi.ts and paste the following code:
import { VapiClient } from '@vapi-ai/server-sdk';

export const vapi = new VapiClient({ token: process.env.VAPI_API_KEY! });

What does this code do?

This initializes a VapiClient instance using your private API key. The client handles authentication automatically for every SDK call (creating assistants, starting calls, deleting assistants).

Every API route in the project will import this vapi instance instead of constructing its own client.

  • Save src/lib/vapi.ts.
  • Check the terminal again. You should still see no errors from the dev server.
  • Open your browser and confirm localhost:3000 still loads the default Next.js page.

Dev server showing a compilation error?

Confirm that @vapi-ai/server-sdk is installed by running npm list @vapi-ai/server-sdk in a separate terminal tab. If missing, run npm install @vapi-ai/server-sdk@1.2.0.

Double-check that the import path is exactly @vapi-ai/server-sdk (not @vapi-ai/sdk or vapi-ai).

Help me debug this compilation error

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/lib/vapi.ts before moving on.

ⓧ I'd like to double check the full code

import { VapiClient } from '@vapi-ai/server-sdk';

export const vapi = new VapiClient({ token: process.env.VAPI_API_KEY! });

Why a shared lib folder?

In Next.js projects with the src directory enabled, src/lib is the conventional place for shared utility modules. This keeps your API route files focused on request handling while the SDK configuration lives in one importable location.

When you build API routes in the next steps, you will import these clients with import { supabase } from '@/lib/supabase' and import { vapi } from '@/lib/vapi'.

Your SDK clients are configured and your project compiles cleanly. Next up, you will design the multi-tenant database schema that powers agent configuration across different industries.

Build the Multi-Tenant Database Schema

Your project scaffold is running and your SDK clients are ready. Now you need a database schema that can power completely different voice agents from a single codebase.

The core challenge is storing agent configurations so that a dental receptionist, a restaurant host, and an auto shop scheduler all come from the same table structure. You will design three tables that separate reusable templates from per-client agent instances and conversation logs.

In this step, get ready to:
  • Create the multi-tenant database schema in Supabase (industry_templates, agents, call_logs).
  • Lock down data access with Row Level Security policies.
  • Seed three industry templates with system prompts and tool definitions.
Create the database tables

Your schema needs three tables. The industry_templates table stores composable prompt fragments, default voice and model settings, and tool definitions for each vertical. The agents table tracks each deployed agent instance. The call_logs table records conversation metadata for analytics.

  • Return to your Supabase project dashboard.
  • Click SQL Editor in the left sidebar.
  • Click New Query to open a blank editor.
  • Paste the following SQL to create all three tables, then click Run or press Cmd+Enter (macOS) / Ctrl+Enter (Windows):
-- Industry templates: composable prompt fragments and tool definitions per vertical
create table industry_templates (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  system_prompt_template text not null,
  first_message text not null,
  model_config jsonb not null default '{}',
  voice_config jsonb not null default '{}',
  transcriber_config jsonb not null default '{}',
  tool_definitions jsonb not null default '[]',
  created_at timestamptz not null default now()
);

-- Agents: each deployed agent instance, linked to a template and an owner
create table agents (
  id uuid primary key default gen_random_uuid(),
  template_id uuid not null references industry_templates(id),
  owner_id uuid not null,
  vapi_assistant_id text,
  business_name text not null,
  custom_instructions text,
  created_at timestamptz not null default now()
);

-- Call logs: records of each conversation for analytics
create table call_logs (
  id uuid primary key default gen_random_uuid(),
  agent_id uuid not null references agents(id),
  vapi_call_id text,
  duration_seconds integer,
  summary text,
  created_at timestamptz not null default now()
);

What does this schema do?

The industry_templates table holds everything a voice agent needs to behave correctly for a vertical: a system prompt template with placeholder variables, a greeting message, model/voice/transcriber configuration, and an array of tool definitions. All of these are stored as JSONB columns, making them fully database-driven.

The agents table represents deployed instances. Each row ties a template to a specific business owner and stores the Vapi assistant ID returned after creation. The call_logs table captures conversation outcomes for each agent.

  • After clicking Run, you should see a "Success. No rows returned" message in the results panel.
  • Click Table Editor in the left sidebar. You should see industry_templates, agents, and call_logs listed.

Not seeing the tables?

Check the results panel at the bottom of the SQL Editor for a red error message. Common causes: a missing semicolon between statements, or a typo in a column type (e.g. timestampz instead of timestamptz).

If you accidentally ran the query twice, you will see a "relation already exists" error. That means the tables were created on the first run.

Help me debug my Supabase table creation SQL.

Enable Row Level Security

Row Level Security ensures that each authenticated user can only see and modify their own data. This is the foundation of multi-tenant isolation in a SaaS product.

  • In the SQL Editor, click New Query to open a fresh editor tab.
  • Paste the following RLS policies and click Run:
-- Enable Row Level Security on all tables
alter table industry_templates enable row level security;
alter table agents enable row level security;
alter table call_logs enable row level security;

-- Industry templates: any authenticated user can read templates
create policy "Authenticated users can read templates"
  on industry_templates for select
  to authenticated
  using (true);

-- Agents: users can only manage their own agents
create policy "Users can manage their own agents"
  on agents for all
  to authenticated
  using (owner_id = auth.uid())
  with check (owner_id = auth.uid());

-- Call logs: users can read logs for their own agents
create policy "Users can read their own call logs"
  on call_logs for select
  to authenticated
  using (agent_id in (select id from agents where owner_id = auth.uid()));

-- Call logs: users can insert logs for their own agents
create policy "Users can insert their own call logs"
  on call_logs for insert
  to authenticated
  with check (agent_id in (select id from agents where owner_id = auth.uid()));

Why these specific policies?

Templates are public to all authenticated users because any tenant should be able to browse available verticals. Agent rows are locked to owner_id = auth.uid() so each business owner can only see their own deployed agents.

Call logs use a subquery to check ownership indirectly. A log belongs to an agent, and the agent belongs to a user. This pattern avoids duplicating owner_id on the call_logs table while still enforcing tenant isolation.

  • You should see "Success. No rows returned" again.
  • To verify, click Authentication in the left sidebar, then Policies. You should see the RLS policies listed under each table name.

Seeing a 'policy already exists' error?

This means you ran the RLS script more than once. The policies were created on the first execution. You can safely ignore this error and continue.

Help me troubleshoot my Row Level Security setup in Supabase.

Seed the industry templates

Your database needs starter data. Each template includes a system prompt with placeholder variables (like {{business_name}} and {{operating_hours}}) that get injected when an agent is created. The tool definitions follow Vapi's function tool schema format so they can be passed directly to an assistant at creation time.

  • Open a new query in the SQL Editor and paste the following seed data:
insert into industry_templates (name, system_prompt_template, first_message, model_config, voice_config, transcriber_config, tool_definitions)
values
(
  'dental',
  'You are a friendly and professional receptionist for {{business_name}}. Your role is to help patients schedule, reschedule, or cancel dental appointments.

Business hours: {{operating_hours}}
Available services: {{services}}

Guidelines:
- Always confirm the patient''s name and contact number before booking.
- Check availability before confirming any appointment.
- Be empathetic and reassuring with nervous patients.
- If a request is outside your capabilities, let the caller know a team member will follow up.',
  'Hello! Thank you for calling {{business_name}}. How can I help you today?',
  '{"provider": "openai", "model": "gpt-4o"}',
  '{"provider": "vapi", "voiceId": "Elliot"}',
  '{"provider": "deepgram", "model": "nova-2", "language": "en"}',
  '[
    {
      "type": "function",
      "function": {
        "name": "check_availability",
        "description": "Check available appointment slots for a given service and date",
        "parameters": {
          "type": "object",
          "properties": {
            "service": { "type": "string", "description": "The dental service requested (cleaning, filling, extraction, etc.)" },
            "date": { "type": "string", "description": "The requested date in YYYY-MM-DD format" }
          },
          "required": ["service", "date"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "book_appointment",
        "description": "Book a dental appointment for a patient",
        "parameters": {
          "type": "object",
          "properties": {
            "customer_name": { "type": "string", "description": "Full name of the patient" },
            "service": { "type": "string", "description": "The dental service to book" },
            "time": { "type": "string", "description": "The appointment time in ISO 8601 format" }
          },
          "required": ["customer_name", "service", "time"]
        }
      }
    }
  ]'
),
(
  'restaurant',
  'You are a warm and welcoming host for {{business_name}}. Your role is to help guests make, modify, or cancel table reservations.

Business hours: {{operating_hours}}
Seating capacity: {{capacity}}

Guidelines:
- Always ask for party size and preferred date and time.
- Check availability before confirming any reservation.
- Mention any specials or events happening on the requested date.
- For large parties (8+), let the caller know a deposit may be required.',
  'Hi there! Thanks for calling {{business_name}}. Would you like to make a reservation?',
  '{"provider": "openai", "model": "gpt-4o"}',
  '{"provider": "vapi", "voiceId": "Elliot"}',
  '{"provider": "deepgram", "model": "nova-2", "language": "en"}',
  '[
    {
      "type": "function",
      "function": {
        "name": "check_availability",
        "description": "Check available table slots for a given party size and date",
        "parameters": {
          "type": "object",
          "properties": {
            "party_size": { "type": "integer", "description": "Number of guests" },
            "date": { "type": "string", "description": "The requested date in YYYY-MM-DD format" }
          },
          "required": ["party_size", "date"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "book_reservation",
        "description": "Book a table reservation for guests",
        "parameters": {
          "type": "object",
          "properties": {
            "customer_name": { "type": "string", "description": "Name for the reservation" },
            "party_size": { "type": "integer", "description": "Number of guests" },
            "time": { "type": "string", "description": "The reservation time in ISO 8601 format" }
          },
          "required": ["customer_name", "party_size", "time"]
        }
      }
    }
  ]'
),
(
  'auto_repair',
  'You are a knowledgeable and helpful service advisor for {{business_name}}. Your role is to help customers schedule vehicle service appointments.

Business hours: {{operating_hours}}
Services offered: {{services}}

Guidelines:
- Ask for the vehicle make, model, and year.
- Get a brief description of the issue or service needed.
- Check availability and suggest the earliest open slot.
- For urgent issues (strange noises, warning lights), prioritize same-day availability.',
  'Hey there! You''ve reached {{business_name}}. Are you looking to schedule a service appointment?',
  '{"provider": "openai", "model": "gpt-4o"}',
  '{"provider": "vapi", "voiceId": "Elliot"}',
  '{"provider": "deepgram", "model": "nova-2", "language": "en"}',
  '[
    {
      "type": "function",
      "function": {
        "name": "check_availability",
        "description": "Check available service slots for a given service type and date",
        "parameters": {
          "type": "object",
          "properties": {
            "service": { "type": "string", "description": "The type of service needed (oil change, brake inspection, diagnostics, etc.)" },
            "date": { "type": "string", "description": "The requested date in YYYY-MM-DD format" }
          },
          "required": ["service", "date"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "book_appointment",
        "description": "Book a vehicle service appointment",
        "parameters": {
          "type": "object",
          "properties": {
            "customer_name": { "type": "string", "description": "Full name of the customer" },
            "vehicle": { "type": "string", "description": "Vehicle make, model, and year" },
            "service": { "type": "string", "description": "The service to perform" },
            "time": { "type": "string", "description": "The appointment time in ISO 8601 format" }
          },
          "required": ["customer_name", "vehicle", "service", "time"]
        }
      }
    }
  ]'
);

What is stored in tool_definitions?

Each tool definition follows Vapi's function tool schema. The type is always "function", and the function object contains a name, description, and parameters (a JSON Schema defining what the LLM should collect from the caller).

Storing these in the database means you can add, modify, or remove tool capabilities for any industry without changing application code. When you create an agent later, these definitions get passed directly to Vapi's assistant creation API.

  • Click Run to execute the insert. You should see "Success. 3 rows affected" in the results.
  • Click Table Editor in the left sidebar, then click industry_templates. You should see 3 rows: dental, restaurant, and auto_repair.
  • Click on the tool_definitions cell for the dental row. You should see a JSON array with check_availability and book_appointment function objects.

Seeing a syntax error on the insert?

The most common cause is a missing single-quote escape. In PostgreSQL, apostrophes inside string literals must be doubled ('' instead of '). If you edited the prompt text, check for any unescaped apostrophes.

Another common issue is a missing comma between the three value groups. Each parenthesized row must be separated by a comma.

Help me fix my seed data SQL insert.

Why store config as JSONB instead of separate columns?

Vapi's API accepts model, voice, and transcriber settings as nested objects. By storing them as JSONB, you can pass them directly to the API without transformation. If Vapi adds new configuration options in the future, your schema does not need a migration.

Your database is ready. You have a multi-tenant schema with isolated data access and three complete industry templates. Next up, you will build the API routes that pull these templates and use them to create live Vapi voice assistants.

Build the Agent Orchestration API

Your database has industry templates ready to go. But templates alone don't create voice agents. You need an API layer that reads a template, composes the final configuration, and tells Vapi to create a real, callable assistant.

In this step, you'll build three Next.js API routes that form the complete agent lifecycle: create an agent from a template, initiate a call to that agent, and fetch or tear down the agent when you're done.

In this step, get ready to:
  • Build a creation route that composes a Vapi assistant from a Supabase template.
  • Build a call route that initiates a web-based voice call to an agent.
  • Build GET and DELETE routes to read and tear down agents.
Build the agent creation route

The creation route is the heart of the orchestration layer. It accepts client-specific overrides, pulls the matching industry template from Supabase, composes a system prompt by injecting those overrides into the template, then calls the Vapi SDK to create a real assistant.

  • Create the folder path src/app/api/agents/create/ inside your project.
  • Create a new file called route.ts in that folder, then paste the following code:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';

export async function POST(req: NextRequest) {
  // Parse the incoming request body for template ID and client overrides
  const { templateId, businessName, operatingHours, customInstructions } = await req.json();

  // Fetch the industry template from Supabase
  const { data: template } = await supabase.from('industry_templates').select('*').eq('id', templateId).single();
  if (!template) return NextResponse.json({ error: 'Template not found' }, { status: 404 });

  // Compose the final system prompt by injecting client context into the template
  const systemPrompt = template.system_prompt_template
    .replace('{{businessName}}', businessName)
    .replace('{{operatingHours}}', operatingHours ?? '')
    .replace('{{customInstructions}}', customInstructions ?? '');

  // Create the Vapi assistant with the composed config
  const assistant = await vapi.assistants.create({
    name: businessName,
    model: { ...template.model_config, systemPrompt, tools: template.tool_definitions },
    voice: template.voice_config,
    transcriber: template.transcriber_config,
    firstMessage: template.first_message,
  });

  // Store the new agent record in Supabase, linking to the Vapi assistant
  const { data: agent } = await supabase.from('agents').insert({
    template_id: templateId,
    owner_id: 'test-user',
    vapi_assistant_id: assistant.id,
    business_name: businessName,
    custom_instructions: customInstructions,
  }).select().single();

  return NextResponse.json(agent, { status: 201 });
}

What does this code do?

This route handles the full agent creation flow in one request.

  • It reads templateId, businessName, operatingHours, and customInstructions from the POST body.
  • It fetches the matching template from the industry_templates table by ID.
  • The .replace() calls inject the client's business name, hours, and custom instructions into placeholder slots in the template's system prompt.
  • It passes the composed prompt, model config, voice, transcriber, and tool definitions to vapi.assistants.create() to spin up a live assistant on Vapi's platform.
  • Finally, it saves the agent record (including the returned vapi_assistant_id) to the agents table so you can reference it later.
  • Save src/app/api/agents/create/route.ts.

You won't see a change in the browser yet. The next substep will add more routes, and then you'll test everything together.

Seeing a TypeScript error on save?

  • Make sure the folder path is exactly src/app/api/agents/create/. Next.js App Router requires this nested structure for the /api/agents/create endpoint to work.
  • Check that your src/lib/supabase.ts and src/lib/vapi.ts files exist. The imports rely on the @/* path alias configured during scaffolding.

Why is my route file not recognized?

Build the call initiation route

Once an agent exists, you need a way to start a voice conversation with it. This route looks up the agent's Vapi assistant ID, then tells Vapi to create a call session that a browser can connect to.

  • Create the folder path src/app/api/agents/[id]/call/ inside your project.
  • Create a new file called route.ts in that folder, then paste the following code:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';

export async function POST(_req: NextRequest, { params }: { params: { id: string } }) {
  // Look up the agent record by ID
  const { data: agent } = await supabase.from('agents').select('*').eq('id', params.id).single();
  if (!agent) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  // Create a call session via Vapi using the agent's assistant ID
  const call = await vapi.calls.create({
    assistantId: agent.vapi_assistant_id,
    transport: { provider: 'daily.co', assistantVideoEnabled: false },
  });

  return NextResponse.json(call);
}

What does this code do?

This route initiates a real-time voice call session.

  • It fetches the agent from Supabase to get its vapi_assistant_id.
  • It calls vapi.calls.create() with the assistant ID and a transport configuration. Vapi returns call metadata (including connection credentials) that a browser-based voice widget can use to join the conversation.
  • Save src/app/api/agents/[id]/call/route.ts.

You won't test this route until a later step when you build the voice test page. The next chunk completes the lifecycle routes.

Folder name must include the brackets

The folder must be literally named [id] with square brackets. This is Next.js dynamic route syntax. If you name it just id without brackets, the route won't capture the agent ID from the URL.

My dynamic route isn't matching the URL parameter.

Build the GET and DELETE lifecycle routes

The final piece of the CRUD lifecycle lets you inspect an agent's configuration (from both Supabase and Vapi) and tear it down when you no longer need it.

  • In the src/app/api/agents/[id]/ folder (the same [id] folder you just created), create a new file called route.ts and paste the following code:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';

export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  // Fetch the agent record from Supabase
  const { data: agent } = await supabase.from('agents').select('*').eq('id', params.id).single();
  if (!agent) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  // Also fetch the full assistant details from Vapi for comparison
  const assistant = await vapi.assistants.get(agent.vapi_assistant_id);

  return NextResponse.json({ agent, assistant });
}

export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
  // Fetch the agent to get its Vapi assistant ID
  const { data: agent } = await supabase.from('agents').select('*').eq('id', params.id).single();
  if (!agent) return NextResponse.json({ error: 'Not found' }, { status: 404 });

  // Delete the assistant from Vapi's platform
  await vapi.assistants.delete(agent.vapi_assistant_id);

  // Remove the agent record from Supabase
  await supabase.from('agents').delete().eq('id', params.id);

  return NextResponse.json({ success: true });
}

What does this code do?

This file exports two handlers for the same /api/agents/[id] endpoint.

  • GET returns both the Supabase agent record and the full Vapi assistant config. This lets you inspect the composed prompt, voice settings, and tool definitions that were set during creation.
  • DELETE removes the assistant from Vapi first (so it stops accepting calls), then deletes the database record. Order matters here. If you deleted the database row first, you'd lose the vapi_assistant_id needed to clean up the Vapi side.
  • Save src/app/api/agents/[id]/route.ts.
  • Make sure your dev server is running. If it isn't, start it by running:
npm run dev
  • Open your Supabase dashboard and navigate to the Table Editor.
  • Click on the industry_templates table and copy the id value from any row (for example, the dental template).
  • Test the creation endpoint by running this command in a new terminal tab:
curl -X POST http://localhost:3000/api/agents/create \
  -H "Content-Type: application/json" \
  -d '{"templateId": "[[TEMPLATE_ID="your dental template UUID from Supabase"]]", "businessName": "Sunshine Dental", "operatingHours": "Mon-Fri 9am-5pm", "customInstructions": "Always greet warmly"}'

You should see a JSON response with a 201 status containing the new agent record, including a vapi_assistant_id field.

  • Copy the id from the response, then test the GET route:
curl http://localhost:3000/api/agents/[[AGENT_ID="the agent id from the previous response"]]

You should see a JSON object containing both agent (from Supabase) and assistant (from Vapi) fields, confirming the agent exists on both platforms.

Getting a 404 on the create route?

  • Double-check the templateId in your curl command matches a real UUID from your industry_templates table. Copy it directly from the Supabase dashboard to avoid typos.
  • Verify your .env.local has the correct VAPI_API_KEY and SUPABASE_SERVICE_ROLE_KEY. A wrong key causes silent failures when querying Supabase or calling Vapi.
  • If you see a ECONNREFUSED error, make sure your dev server is running on port 3000.

Help me debug my agent creation API route.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three route files before moving on.

ⓧ I'd like to double check the full code

Here are the three complete route files for this step:

import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';

export async function POST(req: NextRequest) {
  const { templateId, businessName, operatingHours, customInstructions } = await req.json();
  const { data: template } = await supabase.from('industry_templates').select('*').eq('id', templateId).single();
  if (!template) return NextResponse.json({ error: 'Template not found' }, { status: 404 });
  const systemPrompt = template.system_prompt_template
    .replace('{{businessName}}', businessName)
    .replace('{{operatingHours}}', operatingHours ?? '')
    .replace('{{customInstructions}}', customInstructions ?? '');
  const assistant = await vapi.assistants.create({
    name: businessName,
    model: { ...template.model_config, systemPrompt, tools: template.tool_definitions },
    voice: template.voice_config,
    transcriber: template.transcriber_config,
    firstMessage: template.first_message,
  });
  const { data: agent } = await supabase.from('agents').insert({
    template_id: templateId,
    owner_id: 'test-user',
    vapi_assistant_id: assistant.id,
    business_name: businessName,
    custom_instructions: customInstructions,
  }).select().single();
  return NextResponse.json(agent, { status: 201 });
}
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';

export async function POST(_req: NextRequest, { params }: { params: { id: string } }) {
  const { data: agent } = await supabase.from('agents').select('*').eq('id', params.id).single();
  if (!agent) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  const call = await vapi.calls.create({
    assistantId: agent.vapi_assistant_id,
    transport: { provider: 'daily.co', assistantVideoEnabled: false },
  });
  return NextResponse.json(call);
}
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';

export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  const { data: agent } = await supabase.from('agents').select('*').eq('id', params.id).single();
  if (!agent) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  const assistant = await vapi.assistants.get(agent.vapi_assistant_id);
  return NextResponse.json({ agent, assistant });
}

export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
  const { data: agent } = await supabase.from('agents').select('*').eq('id', params.id).single();
  if (!agent) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  await vapi.assistants.delete(agent.vapi_assistant_id);
  await supabase.from('agents').delete().eq('id', params.id);
  return NextResponse.json({ success: true });
}

Your orchestration API is live. You can now programmatically create, inspect, and destroy voice agents from database templates. Next up, you'll build the prompt composition engine and tool schema design that makes each agent behave differently based on its industry.

Implement Dynamic Prompt Composition and Tool Webhooks

Your API routes can create agents, but the prompt logic is still inline and rigid. Every agent gets the same simple string replacement with no way to add runtime context like time of day or caller history.

In this step, you will extract prompt composition into a dedicated utility that handles three distinct layers: base template structure, slot variable injection, and runtime context appending. You will also build the webhook endpoint that receives tool-call payloads from Vapi when your agent tries to execute a function like booking an appointment.

In this step, get ready to:
  • Build a reusable prompt composition utility that separates template structure from runtime context.
  • Create a tool-call webhook handler that routes Vapi function calls to the correct business logic.
  • Wire the prompt utility into your existing agent creation route and verify the end-to-end flow.
Build the prompt composition utility

A production voice agent system needs prompts that are composable across three layers. The base template carries structural instructions (persona, constraints, response format). Slot variables get injected at creation time (business name, hours, services). Runtime context gets appended per-call (caller history, current time).

  • Create a new file at src/lib/composeSystemPrompt.ts by adding the following code:
interface PromptContext {
  businessName: string;
  operatingHours?: string;
  customInstructions?: string;
  callerHistory?: string;
  timeOfDay?: string;
}

export function composeSystemPrompt(template: string, context: PromptContext): string {
  // Layer 1 + 2: Inject slot variables into the base template
  let prompt = template
    .replace('{{businessName}}', context.businessName)
    .replace('{{operatingHours}}', context.operatingHours ?? 'standard business hours')
    .replace('{{customInstructions}}', context.customInstructions ?? '');

  // Layer 3: Append runtime context that changes per-call
  if (context.callerHistory || context.timeOfDay) {
    prompt += '\n\n## Runtime Context';
    if (context.timeOfDay) prompt += `\nCurrent time: ${context.timeOfDay}`;
    if (context.callerHistory) prompt += `\nCaller history: ${context.callerHistory}`;
  }

  return prompt;
}

What does this code do?

The PromptContext interface defines all the variables that can flow into a prompt. Only businessName is required. Everything else is optional.

The function handles three composition layers. First, it replaces {{businessName}}, {{operatingHours}}, and {{customInstructions}} placeholders in the template with actual values. Then it conditionally appends a Runtime Context section with per-call data like the current time or caller history.

  • Save src/lib/composeSystemPrompt.ts.

Seeing a TypeScript error?

Make sure the file is inside the src/lib/ directory. If your editor shows a module resolution error, confirm the @/* import alias is configured in tsconfig.json (this was set up automatically by create-next-app).

help me fix a TypeScript module resolution error in my Next.js project

Now you need to wire this utility into the existing agent creation route so it replaces the inline string replacement logic.

  • In src/app/api/agents/create/route.ts, find the existing import lines at the top of the file:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';
  • Add the following import line directly below the existing imports:
import { composeSystemPrompt } from '@/lib/composeSystemPrompt';
  • Next, find these three lines inside the POST function that build the system prompt:
  const systemPrompt = template.system_prompt_template
    .replace('{{businessName}}', businessName)
    .replace('{{operatingHours}}', operatingHours ?? '')
    .replace('{{customInstructions}}', customInstructions ?? '');
  • Replace those three lines with the following call to your new utility:
  const systemPrompt = composeSystemPrompt(template.system_prompt_template, {
    businessName,
    operatingHours,
    customInstructions,
    timeOfDay: new Date().toLocaleTimeString(),
  });

Why pass timeOfDay at creation time?

The current time gets stamped into the system prompt when the assistant is created. This tells the agent what part of the day it is so it can greet callers appropriately ("Good morning" vs "Good evening"). In a production system, you would update this on each call rather than at creation.

  • Save src/app/api/agents/create/route.ts.
  • Confirm there are no red squiggles in your editor for the new import or function call.

Import not resolving?

Double check that the filename is exactly composeSystemPrompt.ts (capital S and P). TypeScript import paths are case-sensitive on Linux-based deployments.

help me debug why my composeSystemPrompt import is not resolving

Create the tool-call webhook handler

When a Vapi assistant decides to execute a function (like checking appointment availability), it sends a POST request to your server with the tool name and parameters. Your server processes the function and returns the result so the assistant can continue the conversation.

  • Create the directory src/app/api/vapi/tool-call/ and add a new file called route.ts with the following code:
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const body = await req.json();

  // Extract tool call details from the Vapi payload
  const { toolCallId, name, parameters } = body.toolCall ?? body;

  let result: Record<string, unknown>;

  // Route to the correct handler based on tool name
  switch (name) {
    case 'check_availability':
      result = { available: true, slots: ['10:00 AM', '2:00 PM', '4:30 PM'] };
      break;
    case 'book_appointment':
      result = { success: true, confirmationNumber: 'CONF-' + Math.random().toString(36).slice(2, 8).toUpperCase() };
      break;
    case 'check_table_availability':
      result = { available: true, tables: [{ size: 2, time: '7:00 PM' }, { size: 4, time: '7:30 PM' }] };
      break;
    case 'reserve_table':
      result = { success: true, reservationId: 'RES-' + Math.random().toString(36).slice(2, 8).toUpperCase() };
      break;
    default:
      result = { success: true, message: `Tool ${name} executed` };
  }

  // Return the result with the matching toolCallId
  return NextResponse.json({ toolCallId, result });
}

What does this webhook do?

When Vapi triggers a tool call during a voice conversation, it POSTs a payload containing the tool name, parameters, and a unique toolCallId. Your handler routes by name to the correct business logic.

Right now these return mock data (hardcoded available slots, random confirmation numbers). In production, you would connect to a real scheduling system. The toolCallId in the response lets Vapi correlate the result back to the correct in-flight tool call.

  • Save src/app/api/vapi/tool-call/route.ts.
  • Confirm your dev server is still running without errors in the terminal.

Seeing a compile error?

Make sure you created the full nested directory path src/app/api/vapi/tool-call/. Next.js App Router requires the file to be named route.ts (not index.ts) inside the correct directory structure.

help me fix my Next.js API route directory structure

Verify the end-to-end flow

With both pieces in place, you can now test that an agent created from the dental template uses your composed prompt and that the tool-call webhook correctly receives requests.

  • Test your tool-call webhook directly by running this curl command in a new terminal:
curl -X POST http://localhost:3000/api/vapi/tool-call \
  -H "Content-Type: application/json" \
  -d '{"toolCallId": "test-123", "name": "check_availability", "parameters": {"service": "cleaning", "date": "2026-07-01"}}'
  • You should see a JSON response containing toolCallId, available: true, and an array of time slots.
  • Test the full agent creation flow by running this command (replace the template ID with your actual dental template ID from Supabase):
curl -X POST http://localhost:3000/api/agents/create \
  -H "Content-Type: application/json" \
  -d '{"templateId": "[[DENTAL_TEMPLATE_ID="your dental template UUID from Supabase"]]", "businessName": "Bright Smile Dental", "operatingHours": "Mon-Fri 9am-5pm"}'
  • You should receive a 201 response with an agent object containing a vapi_assistant_id.
  • Verify the agent in Vapi by copying the returned agent id and running:
curl http://localhost:3000/api/agents/[[AGENT_ID="the agent id returned from the create call"]]/
  • In the response, check the assistant object's model.systemPrompt field. It should contain "Bright Smile Dental" and a "Runtime Context" section with the current time.

What confirms it is working?

Two things prove the system is wired correctly. First, the system prompt in Vapi contains the injected business name and runtime context. This means composeSystemPrompt is doing its job.

Second, the tool-call webhook returns structured mock data when hit directly. During a real voice call, Vapi will POST to this endpoint whenever the agent's LLM decides to call a function like check_availability or book_appointment.

Getting a 404 on the webhook URL?

Next.js only registers API routes after a successful compile. Check your terminal for any TypeScript errors. Also make sure the file path is exactly src/app/api/vapi/tool-call/route.ts with no typos in the directory names.

help me debug why my /api/vapi/tool-call endpoint returns 404

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file modified or created in this step.

interface PromptContext {
  businessName: string;
  operatingHours?: string;
  customInstructions?: string;
  callerHistory?: string;
  timeOfDay?: string;
}

export function composeSystemPrompt(template: string, context: PromptContext): string {
  let prompt = template
    .replace('{{businessName}}', context.businessName)
    .replace('{{operatingHours}}', context.operatingHours ?? 'standard business hours')
    .replace('{{customInstructions}}', context.customInstructions ?? '');

  if (context.callerHistory || context.timeOfDay) {
    prompt += '\n\n## Runtime Context';
    if (context.timeOfDay) prompt += `\nCurrent time: ${context.timeOfDay}`;
    if (context.callerHistory) prompt += `\nCaller history: ${context.callerHistory}`;
  }

  return prompt;
}
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { vapi } from '@/lib/vapi';
import { composeSystemPrompt } from '@/lib/composeSystemPrompt';

export async function POST(req: NextRequest) {
  const { templateId, businessName, operatingHours, customInstructions } = await req.json();
  const { data: template } = await supabase.from('industry_templates').select('*').eq('id', templateId).single();
  if (!template) return NextResponse.json({ error: 'Template not found' }, { status: 404 });
  const systemPrompt = composeSystemPrompt(template.system_prompt_template, {
    businessName,
    operatingHours,
    customInstructions,
    timeOfDay: new Date().toLocaleTimeString(),
  });
  const assistant = await vapi.assistants.create({
    name: businessName,
    model: { ...template.model_config, systemPrompt, tools: template.tool_definitions },
    voice: template.voice_config,
    transcriber: template.transcriber_config,
    firstMessage: template.first_message,
  });
  const { data: agent } = await supabase.from('agents').insert({
    template_id: templateId,
    owner_id: 'test-user',
    vapi_assistant_id: assistant.id,
    business_name: businessName,
    custom_instructions: customInstructions,
  }).select().single();
  return NextResponse.json(agent, { status: 201 });
}
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const { toolCallId, name, parameters } = body.toolCall ?? body;
  let result: Record<string, unknown>;
  switch (name) {
    case 'check_availability':
      result = { available: true, slots: ['10:00 AM', '2:00 PM', '4:30 PM'] };
      break;
    case 'book_appointment':
      result = { success: true, confirmationNumber: 'CONF-' + Math.random().toString(36).slice(2, 8).toUpperCase() };
      break;
    case 'check_table_availability':
      result = { available: true, tables: [{ size: 2, time: '7:00 PM' }, { size: 4, time: '7:30 PM' }] };
      break;
    case 'reserve_table':
      result = { success: true, reservationId: 'RES-' + Math.random().toString(36).slice(2, 8).toUpperCase() };
      break;
    default:
      result = { success: true, message: `Tool ${name} executed` };
  }
  return NextResponse.json({ toolCallId, result });
}

Your prompt composition engine and tool-call webhook are ready. In the next step, you will build a test page that connects to Vapi's web transport so you can have a real voice conversation with your dynamically created agent.

Run a Live Voice Test with Web Transport

In the previous step, you built a prompt composition engine and wired up tool-call handling. Your orchestration layer can now create fully configured agents from templates and route tool calls back to your server.

Now it is time to prove the whole system works end-to-end with a real voice conversation. You will build a minimal test page, talk to your AI dental receptionist, and confirm that call metadata gets logged to your database.

In this step, get ready to:
  • Create a test page that creates a dental agent and starts a web call.
  • Run an end-to-end voice conversation with your dynamically created agent.
  • Build a call-ended webhook that logs call metadata to your database.
Create the test page

Your API routes handle agent creation and call initiation on the server side. To trigger them from a browser, you need a simple frontend page with a button that kicks off the full flow.

  • Create a new folder and file at src/app/test-call/page.tsx by running this command from your voice-agent-infra project folder:
mkdir -p src/app/test-call && touch src/app/test-call/page.tsx
  • Before you paste the code, find the UUID of your dental template. In your Supabase dashboard, open the Table Editor, select the industry_templates table, and copy the id value from the dental row.
  • Add the following code to src/app/test-call/page.tsx, replacing the template ID placeholder with your actual dental template UUID:
'use client';
import { useState } from 'react';

export default function TestCallPage() {
  const [status, setStatus] = useState('idle');
  const [agentId, setAgentId] = useState<string | null>(null);
  const [callData, setCallData] = useState<Record<string, unknown> | null>(null);

  async function createAndCall() {
    setStatus('creating agent...');
    // Call our agent creation API with dental template
    const agentRes = await fetch('/api/agents/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        templateId: '[[DENTAL_TEMPLATE_ID="your dental template UUID from Supabase"]]',
        businessName: 'Smile Bright Dental',
        operatingHours: 'Mon-Fri 8am-5pm',
      }),
    });
    const agent = await agentRes.json();
    setAgentId(agent.id);
    setStatus('starting call...');
    // Initiate a web call via our call API
    const callRes = await fetch(`/api/agents/${agent.id}/call`, { method: 'POST' });
    const call = await callRes.json();
    setCallData(call);
    setStatus('call initiated \u2014 connect your audio client');
  }

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-4">Voice Agent Test</h1>
      <button onClick={createAndCall} className="bg-blue-600 text-white px-4 py-2 rounded">
        Create Dental Agent & Start Call
      </button>
      <p className="mt-4">Status: {status}</p>
      {callData && <pre className="mt-4 bg-gray-100 p-4 rounded text-sm">{JSON.stringify(callData, null, 2)}</pre>}
    </main>
  );
}

What does this code do?

This React page has a single button that triggers the full agent lifecycle. When clicked, it calls your /api/agents/create route with the dental template and a custom business name.

Once the agent is created, it immediately calls /api/agents/[id]/call to initiate a web call via Vapi's transport layer. The response contains the call metadata needed to connect an audio client.

  • Save src/app/test-call/page.tsx.
  • Make sure your dev server is running with npm run dev. Open http://localhost:3000/test-call in your browser. You should see the "Voice Agent Test" heading and a blue button.

Page not loading?

  • Check that you created the file at src/app/test-call/page.tsx (not src/pages/). The App Router requires this path structure.
  • Verify the file starts with 'use client' on the first line. Without this directive, React hooks like useState will throw an error.

Why isn't my test page rendering at /test-call?

Run the end-to-end voice test

With the test page ready, you can now trigger a full voice conversation. This proves your entire pipeline works: template lookup, prompt composition, Vapi assistant creation, and call initiation.

  • On your test page at http://localhost:3000/test-call, click the Create Dental Agent & Start Call button.
  • Watch the status text. It should progress from "creating agent..." to "starting call..." to "call initiated".
  • Once the call data JSON appears on screen, the Vapi

The call response JSON includes the connection details for the web transport. In a production app, you would connect a client-side audio SDK to this transport URL. For this test, the key confirmation is that the call was created successfully on Vapi's side.

  • To verify the agent was created correctly, open a new terminal and run:
curl http://localhost:3000/api/agents/[[AGENT_ID="the agent ID shown on your test page"]]}
  • In the JSON response, confirm that assistant.model.systemPrompt contains "Smile Bright Dental" and your operating hours. This proves the prompt composition engine injected the client-specific context into the template.
  • Check your terminal running npm run dev. If the agent attempted to use a tool during the call, you should see incoming POST requests to /api/vapi/tool-call in the server logs.

What should I see?

The call data JSON on your test page should include fields like id, status, and transport connection details. The GET request to your agent endpoint should return both the Supabase record and the Vapi assistant config with your composed system prompt.

Getting a 404 or 500 error?

  • A 404 on /api/agents/create means the route file is missing or misnamed. Verify it exists at src/app/api/agents/create/route.ts.
  • A 500 error likely means the template ID does not match a row in your industry_templates table. Double-check the UUID you pasted into page.tsx matches the dental template row in Supabase.
  • If you see a Vapi authentication error, confirm your VAPI_API_KEY in .env.local is correct and the dev server was restarted after adding it.

Help me debug why my agent creation or call is failing.

Build the call-ended webhook

When a Vapi call ends, Vapi can send an end-of-call report to your server with the call duration, transcript, and an AI-generated summary. You need a webhook endpoint to receive this data and log it to your call_logs table.

  • Create the webhook route file by running:
mkdir -p src/app/api/vapi/call-ended && touch src/app/api/vapi/call-ended/route.ts
  • Add the following code to src/app/api/vapi/call-ended/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const { call } = body;
  if (!call) return NextResponse.json({ error: 'No call data' }, { status: 400 });
  // Look up the agent record using the Vapi assistant ID from the call
  const { data: agent } = await supabase.from('agents').select('id').eq('vapi_assistant_id', call.assistantId).single();
  if (agent) {
    // Calculate duration from timestamps and store the call log
    await supabase.from('call_logs').insert({
      agent_id: agent.id,
      vapi_call_id: call.id,
      duration_seconds: call.endedAt && call.startedAt
        ? Math.round((new Date(call.endedAt).getTime() - new Date(call.startedAt).getTime()) / 1000)
        : null,
      summary: call.analysis?.summary ?? null,
    });
  }
  return NextResponse.json({ success: true });
}

What does this code do?

  • Vapi sends a POST request to this endpoint when a call ends. The payload includes the full call object with startedAt, endedAt timestamps, and an optional analysis.summary generated by Vapi's built-in call analysis.
  • The handler looks up the matching agent in your agents table by the vapi_assistant_id to link the call log to the correct tenant.
  • It calculates the call duration in seconds from the two timestamps and inserts a record into call_logs with the Vapi call ID, duration, and summary.
  • Save src/app/api/vapi/call-ended/route.ts.
  • To test this webhook locally, you need Vapi to be able to reach your localhost. For a production deployment, you would configure your server URL in the Vapi Dashboard under your assistant's settings. For local testing, you can simulate the webhook by running:
curl -X POST http://localhost:3000/api/vapi/call-ended \
  -H "Content-Type: application/json" \
  -d '{"call":{"id":"test-call-123","assistantId":"YOUR_VAPI_ASSISTANT_ID","startedAt":"2026-06-29T10:00:00Z","endedAt":"2026-06-29T10:02:30Z","analysis":{"summary":"Test call about scheduling a dental appointment."}}}'
  • Replace YOUR_VAPI_ASSISTANT_ID in the curl command with the vapi_assistant_id value from your agents table in Supabase (the assistant you created with the test page).
  • After running the curl command, go to your Supabase dashboard. Open the Table Editor and select the call_logs table. You should see a new row with duration_seconds set to 150 and the summary text from your test payload.

No row appearing in call_logs?

  • Check that the assistantId in your curl payload exactly matches a vapi_assistant_id value in your agents table. If the lookup fails, the webhook still returns 200 but inserts nothing.
  • Verify that your call_logs table has Row Level Security policies that allow inserts from the service role. Since your server uses SUPABASE_SERVICE_ROLE_KEY, it bypasses RLS by default.
  • Look at the terminal running your dev server for any error messages from the route handler.

Why isn't my call-ended webhook inserting a row into call_logs?

How do I receive this webhook from Vapi in production?

In the Vapi Dashboard, navigate to your account settings and set your Server URL to your deployed app's endpoint (for example, https://your-app.vercel.app/api/vapi/call-ended). Vapi will POST the end-of-call report to this URL after every call completes.

For local development, you can use a tunneling service like ngrok to expose your localhost to the internet, then set that URL in the Vapi Dashboard.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/app/test-call/page.tsx and src/app/api/vapi/call-ended/route.ts.

ⓧ I'd like to double check the full code

Here is the full code for the two new files created in this step:

'use client';
import { useState } from 'react';

export default function TestCallPage() {
  const [status, setStatus] = useState('idle');
  const [agentId, setAgentId] = useState<string | null>(null);
  const [callData, setCallData] = useState<Record<string, unknown> | null>(null);

  async function createAndCall() {
    setStatus('creating agent...');
    const agentRes = await fetch('/api/agents/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        templateId: '<dental-template-id>',
        businessName: 'Smile Bright Dental',
        operatingHours: 'Mon-Fri 8am-5pm',
      }),
    });
    const agent = await agentRes.json();
    setAgentId(agent.id);
    setStatus('starting call...');
    const callRes = await fetch(`/api/agents/${agent.id}/call`, { method: 'POST' });
    const call = await callRes.json();
    setCallData(call);
    setStatus('call initiated \u2014 connect your audio client');
  }

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-4">Voice Agent Test</h1>
      <button onClick={createAndCall} className="bg-blue-600 text-white px-4 py-2 rounded">
        Create Dental Agent & Start Call
      </button>
      <p className="mt-4">Status: {status}</p>
      {callData && <pre className="mt-4 bg-gray-100 p-4 rounded text-sm">{JSON.stringify(callData, null, 2)}</pre>}
    </main>
  );
}
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';

export async function POST(req: NextRequest) {
  const body = await req.json();
  const { call } = body;
  if (!call) return NextResponse.json({ error: 'No call data' }, { status: 400 });
  const { data: agent } = await supabase.from('agents').select('id').eq('vapi_assistant_id', call.assistantId).single();
  if (agent) {
    await supabase.from('call_logs').insert({
      agent_id: agent.id,
      vapi_call_id: call.id,
      duration_seconds: call.endedAt && call.startedAt
        ? Math.round((new Date(call.endedAt).getTime() - new Date(call.startedAt).getTime()) / 1000)
        : null,
      summary: call.analysis?.summary ?? null,
    });
  }
  return NextResponse.json({ success: true });
}

You have now completed the full lifecycle: creating agents from templates, initiating web calls, handling tool calls, and logging call metadata. Your multi-tenant voice agent infrastructure is fully operational.

Secret mission

Auto-Generate Industry Templates with LLM

Right now, adding a new industry vertical means manually writing a system prompt, a greeting message, and a set of tool definitions. What if a business owner could just describe their business in plain English, and your system would generate the entire voice agent configuration automatically? Build that pipeline.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses the Supabase free tier and Vapi's signup credits. Vapi assistants do not cost anything when they are not receiving calls, and Supabase free-tier projects have no idle charges.

Resources you used:

  • Supabase project (database tables, RLS policies, and seed data).
  • Vapi assistants created during testing (dental, restaurant, auto repair, and any auto-generated agents).
  • Local voice-agent-infra project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on this infrastructure or plan to extend it soon.

  • Your Supabase project stays on the free tier with no charges while under usage limits.
  • Vapi assistants remain available but cost nothing until they receive calls.
  • Your local project folder stays on your Desktop ready for further development.

Pause - I'll come back to this later

Shut down your local dev server to free up memory. Everything else stays in place for when you return.

  • Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
  • Your Supabase project and Vapi assistants remain idle at no cost.
  • When you return, run npm run dev from inside the voice-agent-infra folder to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible for your Supabase data and Vapi assistants.

  • Delete your Vapi assistants by calling your DELETE endpoint for each agent, or by opening the Vapi Dashboard, selecting each assistant, and deleting it from there.
  • Delete your Supabase project by opening your project in the Supabase Dashboard, navigating to Project Settings, scrolling to the Danger Zone section, and clicking Delete project. This permanently removes all database tables, RLS policies, and stored data.
  • Delete your local project folder by running the following commands in your terminal:
cd ~/Desktop
rm -rf voice-agent-infra
  • Revoke your Vapi API key from the Vapi Dashboard if you no longer need it.
  • Revoke your OpenAI API key from the OpenAI dashboard if you created one specifically for this project.

Nice Work!

Nice Work!

You just built a multi-tenant voice agent infrastructure layer from scratch. This is the backend engine that turns a generic voice AI platform into a multi-vertical SaaS product.

You've learned how to:

  • Build a multi-tenant database schema with Row Level Security that isolates voice agent configurations per SaaS tenant, and orchestrate the full Vapi assistant lifecycle (create, read, delete) through Next.js API routes.
  • Design a composable prompt architecture that separates structural instructions, slot variables, and runtime context, with cross-industry tool schemas stored as database-driven JSONB.
  • Test a live voice conversation with a dynamically created agent using Vapi's web transport, verifying end-to-end prompt composition and tool call routing.
  • Secret Mission: Built an LLM-powered template generator that creates complete agent configurations from natural language business descriptions.

Ready to quiz yourself?