From the floor
Vapi Final Expense AI Agent: Setup Guide
One prompt in Claude Code builds a live final expense calling agent on Vapi. It writes the sales prompt, creates the live transfer tool, connects your phone number, and places a test call. An n8n workflow then calls every new web lead automatically and logs each result.
September 29, 2026 · @Anthony. Morgan is not a licensed agent. Whoever answers the transfer must be licensed in the lead's state. AI voice calls need the lead's prior express written consent.
Prerequisites: the subscriptions you need
You need three paid services and two free ones. Check each pricing page for current plans before you start.
| Service | What it does here | How you pay | Sign up |
|---|---|---|---|
| Vapi | The voice agent (Morgan), the phone number, and the live transfer | Pay as you go: prepaid credits billed per call minute, plus a monthly fee per phone number | vapi.ai/pricing |
| Claude Pro or Max | Runs Claude Code, which builds and pushes everything with the master prompt | Monthly subscription (Claude Code is included) | claude.com/pricing |
| n8n | Calls each new lead automatically and logs call results | Monthly n8n Cloud plan with a free trial, or free if you self-host | n8n.io/pricing |
| Google account | Google Sheets call log | Free | sheets.google.com |
| Web form or CRM with webhooks | Sends each new lead to n8n | Varies. Many form tools include webhooks only on paid tiers | Your existing tool |
Twilio is optional. Import a Twilio number into Vapi only if you want your own carrier or higher call volume.
Also have these ready:
- Your Vapi private API key. It goes in
.envasVAPI=.... The public key is only for browser call widgets. - A licensed agent's phone number for transfers. Whoever answers must be licensed in the lead's state.
- Your cell phone for the test call.
- Node.js 18 or newer. Free from nodejs.org.
- A consent checkbox on your form. AI voice calls need the lead's prior express written consent. Follow calling-hour rules.
Order of operations
Set up the dashboard pieces first, then let one prompt do the rest. Claude stops before the test call so you can review.
- Sign up for Vapi (add credits), Claude Pro or Max, and n8n.
- Buy or import a phone number in the Vapi dashboard.
- Copy your Vapi private API key from the dashboard.
- Make an empty folder. In it, create a file named
.envwith one line:VAPI=your-private-key. - Open Claude Code in that folder. Fill in the six blanks in the master prompt, then paste it.
- Read the summary Claude shows you and reply "go". Answer the test call, try a few objections, and let it transfer. Warn whoever answers the agent line first.
- Import the n8n workflow. Add your Vapi credential, paste your IDs into the Settings + Lead node, and connect your Google Sheet.
- Point Morgan's Server URL at the n8n Vapi Call Report Webhook production URL, so every finished call gets logged.
- Point your web form's webhook at the n8n New Lead Webhook production URL. Submit a test lead with your own number.
- Turn on (activate) the workflow. Every consented lead now gets a call within seconds of submitting the form.
The master prompt
Fill in the six lines under MY DETAILS, then paste the whole block into Claude Code. It builds, verifies and tests everything in one run. Download as a text file.
Set up a live final expense AI calling agent on Vapi for me. My Vapi private API key is in .env as VAPI. Use the Vapi REST API (https://api.vapi.ai) with Node scripts, and never print the key.
MY DETAILS
- Assistant ID: <ASSISTANT-ID, or "create new">
- Company name the agent says: <AGENCY NAME, or "the final expense department">
- Licensed agent's transfer number: <AGENT-PHONE>
- My Vapi phone number: <VAPI-NUMBER>
- My cell for the test call: <MY-CELL>
- My details for the test lead: <NAME>, <AGE>, <STATE>
STEP 1: LOOK BEFORE CHANGING ANYTHING
If I gave an assistant ID, read its config (model, voice, tools, first message) and back up its current prompt to backup-prompt.txt. Only ever change that exact ID. Never look an assistant up by name.
If I said "create new", create an assistant named "Morgan" with these settings:
- Model: anthropic claude-haiku-4-5-20251001, temperature 0.3, maxTokens 250
- Voice: vapi Emma at speed 0.85
- Transcriber: deepgram flux-general-en
- Other: backgroundDenoisingEnabled true, startSpeakingPlan waitSeconds 0.2, stopSpeakingPlan numWords 2, silenceTimeoutSeconds 20, maxDurationSeconds 600, endCallFunctionEnabled true
STEP 2: LIVE TRANSFER TOOL
Create a transferCall tool with POST /tool, then attach it by adding its ID to model.toolIds. If the assistant already has a transfer tool, replace it instead of adding a second one.
- Description: "Transfer the call to the licensed agent. Call this IMMEDIATELY when the client agrees to be connected, asks about pricing, or asks for a human."
- Destination: my agent's number, converted to E.164 format (+15551234567)
- Message to the caller: "Perfect, I'm connecting you now. Stay on the line."
- Mode: warm-transfer-say-message. The agent should hear this whisper first: "Hi, I have {{firstName}} on the line, around {{age}}, in {{state}}, tobacco {{tobacco}}, has coverage: {{hasCoverage}}, looking at {{product}}. They came in off the web form. Ready to talk numbers."
STEP 3: THE SYSTEM PROMPT (save it as morgan-prompt.md)
The agent is Morgan. Morgan calls back leads who just filled out our web form and asked to be called, so the call is expected, not cold.
Flow:
1. Wait for the lead to confirm their name.
2. Introduce Morgan and the company, and ask "Did I catch you at an okay time?"
3. Confirm three things, one at a time: date of birth, whether they would pay the premium, and any major health events in the last two years (heart attack, stroke, cancer, COPD, dialysis, nursing home care).
4. Assume the transfer: say "That's everything I need, I'm getting you to a licensed agent now," then call transferCall immediately, with no goodbye.
5. Transfer early if they ask about price, ask something Morgan can't answer, or ask for a human.
Lead variables: firstName, age, state, product, tobacco, hasCoverage, coverageAmount. Never re-ask anything the form gave. Give every variable a neutral fallback using the Liquid default filter, and never a made-up name. Make the first message "Hi, is this {{firstName}}?" with a neutral fallback, and have Morgan wait for an answer before saying anything else.
Phone style: one or two short sentences per turn, varied reactions, pause after questions, say numbers naturally, and never talk over a senior. Never use "Great question," "I understand your concern," or "As an AI."
Sales psychology, used ethically:
- Jeremy Miner (NEPQ): ask, don't tell, with a curious, detached tone
- Tony Robbins: reframes, shifting focus from cost to family
- Grant Cardone: a first no is a reflex, so find the real objection
- Jordan Belfort: Straight Line tonality and the three tens (the idea, trust in Morgan, trust in the company)
Objection ladder: join, clarify, reframe, advance. Use it at most twice per topic. Trace every objection to one of six roots: no money, no trust, no need, no urgency, no authority, no hope. Write short responses for: can't afford it, what does it cost, is this a scam, I don't give info over the phone, I already have coverage, my kids will handle it, I'll think about it, bad time, I need to talk to my spouse, I won't qualify, grief or not wanting to talk about death, and not interested.
Hard rules: never quote a premium, promise approval, claim government affiliation, or imply Morgan is licensed. No fake urgency. If sincerely asked, admit being an AI. On do-not-call or remove me, confirm and end the call. If they refuse the transfer after one gentle try, accept it warmly.
STEP 4: PUSH AND VERIFY
PATCH the assistant with the prompt, first message and end call message, and keep everything else. Read it back and show me that the live prompt matches the file and the transfer tool is attached with the right number. Render the prompt locally with liquidjs twice, once with no variables and once with sample variables, to prove nothing is ever said blank.
STEP 5: PHONE NUMBER
List my Vapi phone numbers. Set this assistant on my number so inbound calls reach Morgan, and note its phoneNumberId.
STEP 6: CALLING SCRIPT
Write call-lead.mjs. It places an outbound call with POST /call, using assistantId, phoneNumberId, customer.number, and the lead's details as assistantOverrides.variableValues.
Usage: node call-lead.mjs +15551234567 "Linda" 68 Ohio "final expense"
STEP 7: TEST CALL
Show me a short summary: the assistant, the transfer number, the phone number, and the first message. Then STOP and wait for me to say "go". After that, call my cell using my details as the lead.
Claude backs up the old prompt before changing anything, so you can always roll back. To change the agent later, edit morgan-prompt.md and ask Claude to push it again.
The n8n workflow
This workflow calls each consented lead within seconds of the form submit, and logs every finished call to Google Sheets. All nine nodes are pre-built. You only paste in two IDs, one key and one sheet URL.
Flow 1: new lead to live call
- New Lead Webhook receives the form submission.
- Settings + Lead holds your assistant and phone number IDs, cleans up the lead's fields, and converts the phone number to +1 format.
- Consent + Valid Phone? continues only if consent is checked and the phone number is a valid US number.
- Call Lead with Morgan sends POST /call to Vapi with the lead's details, so Morgan greets them by name.
- Skip: No Consent or Bad Phone is where every other submission ends. No call is placed.
Flow 2: call results to Google Sheets
- Vapi Call Report Webhook receives Vapi's report when a call ends.
- Is End-of-Call Report? ignores all other Vapi messages.
- Extract Call Results pulls out the date, call ID, phone, name, ended reason, transferred, do-not-call, interest level, summary and recording link.
- Log Call to Google Sheet adds one row per call.
Set it up
- In n8n, create a new workflow. Copy the JSON below and paste it onto the empty canvas with Ctrl+V (Cmd+V on a Mac). Or download the JSON and use Import from File.
- Open Call Lead with Morgan. Create a Header Auth credential with Name
Authorizationand ValueBearer your-vapi-private-key. - Open Settings + Lead and replace the two PASTE values with your assistant ID and phone number ID. Claude Code shows both at the end of the master prompt.
- Create a Google Sheet with a tab named Calls. Put these headers in row 1: date, callId, phone, firstName, endedReason, transferred, doNotCall, interestLevel, summary, recordingUrl. In Log Call to Google Sheet, connect your Google account and paste the sheet URL.
- Activate the workflow, then copy the production URL of each webhook.
- Set the Vapi Call Report Webhook URL as your assistant's Server URL. You can ask Claude Code: "Set my assistant's server URL to <the URL>."
- Set your web form to POST each submission to the New Lead Webhook URL.
The transferred, doNotCall and interestLevel columns fill only if your assistant has a structured data plan with those fields. If they come back empty, ask Claude Code to add one.
Fields your form sends
Only phone and consent are required. The phone number can be in any US format.
{
"firstName": "Linda",
"phone": "(512) 555-0142",
"age": "68",
"state": "Ohio",
"product": "final expense",
"tobacco": "no",
"hasCoverage": "no",
"coverageAmount": "10000",
"consent": "yes"
}
The workflow JSON
Download vapi-fronter-workflow.json if you prefer Import from File.
{
"name": "Final Expense Leads to Morgan (Vapi)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "new-lead",
"responseMode": "onReceived",
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000001",
"name": "New Lead Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "5d3c9a10-1111-4a6e-9c01-6f0a1b2c3d41"
},
{
"parameters": {
"assignments": {
"assignments": [
{ "id": "s01", "name": "assistantId", "value": "PASTE-YOUR-ASSISTANT-ID", "type": "string" },
{ "id": "s02", "name": "phoneNumberId", "value": "PASTE-YOUR-VAPI-PHONE-NUMBER-ID", "type": "string" },
{ "id": "s03", "name": "phone", "value": "={{ '+1' + String($json.body.phone || '').replace(/[^0-9]/g, '').slice(-10) }}", "type": "string" },
{ "id": "s04", "name": "firstName", "value": "={{ String($json.body.firstName || '').trim() }}", "type": "string" },
{ "id": "s05", "name": "age", "value": "={{ String($json.body.age || '') }}", "type": "string" },
{ "id": "s06", "name": "state", "value": "={{ String($json.body.state || '') }}", "type": "string" },
{ "id": "s07", "name": "product", "value": "={{ $json.body.product || 'final expense' }}", "type": "string" },
{ "id": "s08", "name": "tobacco", "value": "={{ String($json.body.tobacco || '') }}", "type": "string" },
{ "id": "s09", "name": "hasCoverage", "value": "={{ String($json.body.hasCoverage || '') }}", "type": "string" },
{ "id": "s10", "name": "coverageAmount", "value": "={{ String($json.body.coverageAmount || '') }}", "type": "string" },
{ "id": "s11", "name": "consent", "value": "={{ ['true', 'yes', 'on', '1'].includes(String($json.body.consent).toLowerCase()) }}", "type": "boolean" }
]
},
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000002",
"name": "Settings + Lead",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [220, 0]
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 },
"conditions": [
{
"id": "c01",
"leftValue": "={{ $json.consent }}",
"rightValue": "",
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
},
{
"id": "c02",
"leftValue": "={{ $json.phone }}",
"rightValue": "^[+]1[0-9]{10}$",
"operator": { "type": "string", "operation": "regex" }
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000003",
"name": "Consent + Valid Phone?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [440, 0]
},
{
"parameters": {
"method": "POST",
"url": "https://api.vapi.ai/call",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ assistantId: $json.assistantId, phoneNumberId: $json.phoneNumberId, customer: { number: $json.phone, name: $json.firstName }, assistantOverrides: { variableValues: { firstName: $json.firstName, age: $json.age, state: $json.state, product: $json.product, tobacco: $json.tobacco, hasCoverage: $json.hasCoverage, coverageAmount: $json.coverageAmount } } }) }}",
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000004",
"name": "Call Lead with Morgan",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [680, -100]
},
{
"parameters": {},
"id": "a1f0c001-0000-4000-8000-000000000005",
"name": "Skip: No Consent or Bad Phone",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [680, 100]
},
{
"parameters": {
"httpMethod": "POST",
"path": "vapi-report",
"responseMode": "onReceived",
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000006",
"name": "Vapi Call Report Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 360],
"webhookId": "7e4d8b20-2222-4b7f-8d12-7a1b2c3d4e52"
},
{
"parameters": {
"conditions": {
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2 },
"conditions": [
{
"id": "c03",
"leftValue": "={{ $json.body.message.type }}",
"rightValue": "end-of-call-report",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
},
"looseTypeValidation": true,
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000007",
"name": "Is End-of-Call Report?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [220, 360]
},
{
"parameters": {
"assignments": {
"assignments": [
{ "id": "r01", "name": "date", "value": "={{ $now.toISO() }}", "type": "string" },
{ "id": "r02", "name": "callId", "value": "={{ $json.body.message.call?.id }}", "type": "string" },
{ "id": "r03", "name": "phone", "value": "={{ $json.body.message.call?.customer?.number }}", "type": "string" },
{ "id": "r04", "name": "firstName", "value": "={{ $json.body.message.call?.assistantOverrides?.variableValues?.firstName }}", "type": "string" },
{ "id": "r05", "name": "endedReason", "value": "={{ $json.body.message.endedReason }}", "type": "string" },
{ "id": "r06", "name": "transferred", "value": "={{ String($json.body.message.analysis?.structuredData?.transferred ?? '') }}", "type": "string" },
{ "id": "r07", "name": "doNotCall", "value": "={{ String($json.body.message.analysis?.structuredData?.doNotCall ?? '') }}", "type": "string" },
{ "id": "r08", "name": "interestLevel", "value": "={{ $json.body.message.analysis?.structuredData?.interestLevel }}", "type": "string" },
{ "id": "r09", "name": "summary", "value": "={{ $json.body.message.analysis?.summary }}", "type": "string" },
{ "id": "r10", "name": "recordingUrl", "value": "={{ $json.body.message.artifact?.recordingUrl ?? $json.body.message.recordingUrl }}", "type": "string" }
]
},
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000008",
"name": "Extract Call Results",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [440, 300]
},
{
"parameters": {
"operation": "append",
"documentId": { "__rl": true, "mode": "url", "value": "PASTE-YOUR-GOOGLE-SHEET-URL" },
"sheetName": { "__rl": true, "mode": "name", "value": "Calls" },
"columns": { "mappingMode": "autoMapInputData", "value": {}, "matchingColumns": [], "schema": [] },
"options": {}
},
"id": "a1f0c001-0000-4000-8000-000000000009",
"name": "Log Call to Google Sheet",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [660, 300]
}
],
"connections": {
"New Lead Webhook": { "main": [[{ "node": "Settings + Lead", "type": "main", "index": 0 }]] },
"Settings + Lead": { "main": [[{ "node": "Consent + Valid Phone?", "type": "main", "index": 0 }]] },
"Consent + Valid Phone?": {
"main": [
[{ "node": "Call Lead with Morgan", "type": "main", "index": 0 }],
[{ "node": "Skip: No Consent or Bad Phone", "type": "main", "index": 0 }]
]
},
"Vapi Call Report Webhook": { "main": [[{ "node": "Is End-of-Call Report?", "type": "main", "index": 0 }]] },
"Is End-of-Call Report?": { "main": [[{ "node": "Extract Call Results", "type": "main", "index": 0 }], []] },
"Extract Call Results": { "main": [[{ "node": "Log Call to Google Sheet", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" },
"pinData": {}
}
After the transfer
Morgan's job ends when the licensed agent picks up. The rest of the book — the contact, the callback, the policy, and the commission — belongs in a CRM built for a final expense floor. That is what policydaddy is for.
Put the transferred lead in policydaddy.
The AI fronter gets the callback. The licensed agent still needs the dialer, the record, and the follow-up in one place.