---
name: ghl-voice-agent-build
description: Build, configure, and test a GHL Voice AI agent end-to-end for a sub-account — create the agent, attach a phone number, wire the script, enable transcription + recording, build the post-call notification workflow, and verify the demo. Trigger when Rob asks to "build a voice agent", "set up an AI receptionist", "answer missed calls with AI", "set up 877-LAN-EXIT", or any GHL Voice AI creation task. Also trigger when an existing voice agent build is misbehaving (calls not routing, transcripts missing, notification workflow not firing).
---

# GHL Voice AI Agent — End-to-End Build Playbook

The proven sequence for getting a GHL voice AI agent answering calls, capturing data, and notifying Rob on every call end. Verified live 2026-08-07 against the bail-bonds template pilot — call routing, script, recording, transcription, and email handoff all working end-to-end.

## When to load this skill

- Building any new GHL Voice AI agent (receptionist, sales qualifier, after-hours, etc.)
- Debugging an existing agent (calls not connecting, transcripts missing, workflow not firing)
- Setting up post-call notification (SMS or email to a specific number/address)
- Verifying a demo build before showing it to a client

## Architecture: what makes a voice agent "work"

Five pieces must all be wired correctly. Missing any one and the call silently breaks somewhere:

1. **The agent** (`/voice-ai/agents`) — created with prompt, voice, language, max duration
2. **The phone number** (`/phone-system/numbers`) — attached to the agent via `inboundNumber`/`inboundNumbers`; "Calls Go To" UI in Phone System must show the agent as 1st Priority
3. **Call recording** — enabled **per phone number** (Phone System → Phone Numbers → Configure → Call Recording toggle)
4. **Call transcription** — enabled **at the sub-account level** (Phone System → Voice → Call Recording & Transcription → Call Transcription toggle). Transcription only works on calls that are also being recorded.
5. **Post-call notification** — a **Workflow** with a non-premium trigger ("Call details", filter: status=completed, direction=incoming, in phone number=THIS number) → action (Internal Notification SMS or Email). Custom phone numbers must pass GHL's validator — see pitfalls below.

## Required setup before any code: scope reality check

Voice AI endpoints (`/voice-ai/*`) require a **LOCATION-LEVEL** GHL token, NOT the agency token. The agency token 401s with `authClass type is not allowed` even though the scope is granted. Same restriction pattern as the funnels API (see `gohighlevel-ops` skill pitfall on per-endpoint authClass).

**Verify with `ghl-probe.sh` or a direct curl:**
```bash
TOKEN=$(curl -s http://127.0.0.1:9120/ghl/token?locationId=4yTvHUHrmVuP8CgCt1Q9 | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s -H "Authorization: Bearer $TOKEN" -H "Version: 2021-07-28" "https://services.leadconnectorhq.com/voice-ai/agents?locationId=4yTvHUHrmVuP8CgCt1Q9"
# If HTTP 200 → location token works on voice-ai
# If HTTP 401 authClass → still on agency token; need to mint location-level install
```

**Minting a location token:** agency Settings → Apps → uninstall V3 from the target sub-account (or just that sub-account's inherited install), then re-run the chooselocation link picking **exactly that one sub-account** at the picker (NOT "all eligible sub-accounts" — that mints an agency token, the trap). See `gohighlevel-ops` pitfalls #9, #11, #13, #14, #15.

**Token store:** `/root/.hermes/secrets/ghl-oauth.json` — keys are `agency:<companyId>` for agency tokens, plain `<locationId>` for location tokens.

## Build sequence (the working order)

### Step 1 — preflight check
Before doing anything, list what exists:
- `GET /voice-ai/agents?locationId=<id>` → see existing agents (Connie + N demos is typical)
- `GET /phone-system/numbers?locationId=<id>` → see available numbers (digits are masked; titles only)
- Pick the agent name, the phone number to attach, and the callback window before writing any code

### Step 2 — build the agent spec file
Save to `/root/.hermes/work/ghl/<agent-slug>.json`. Include the full prompt verbatim, the voice ID (copy from an existing working agent — `g6xIsTj2HwM6VR4iXFCw` is the ElevenLabs voice Connie uses in RRR), responsiveness (0.7), max duration (600), timezone (`America/Denver`), language (`en-US`).

### Step 3 — POST the agent
```
POST /voice-ai/agents
Body: {locationId, agentName, businessName, welcomeMessage, agentPrompt, voiceId, language, responsiveness, maxCallDuration, sendUserIdleReminders, reminderAfterIdleTimeSeconds, timezone, sendPostCallNotificationTo: {admins: false, allUsers: false, contactAssignedUser: false, specificUsers: [], customEmails: []}}
Returns: {id: "<agent_id>", ...}
```

### Step 4 — attach the phone number via PATCH
The PATCH endpoint wants `locationId` as a **query parameter**, NOT in the body. (Body form returns 403 Forbidden.)
```
PATCH /voice-ai/agents/<agent_id>?locationId=<locationId>
Body: {inboundNumber: "+18775263948", inboundNumbers: ["+18775263948"]}
```
Verify by re-GETting the agent — `inboundNumbers` should show the number.

### Step 5 — enable recording + transcription (UI only — both)
**The API doesn't expose either toggle.** Must do via GHL UI:
- Phone System → Voice → Call Recording & Transcription → left sidebar → **Call Transcription** → toggle ON
- Phone System → Phone Numbers → click the target number → Configure → **Call Recording** toggle ON (with disclosure message)

Both must be ON. Transcription depends on recording being enabled at the number level.

### Step 6 — build the post-call notification workflow (UI)
**Use the workflow AI builder inside GHL.** It's faster than manual step-by-step and the UI filter mechanics are non-obvious. The verified-working path:

**Trigger:** "Call details" (NON-PREMIUM — the "Transcript generated" trigger IS premium and incurs per-execution charges)
**Trigger filters (AND all three):**
- Call status contains any of: `completed`
- Call direction is: `incoming`
- In phone number contains any of: `<the number's title, e.g. "Rob's Toll Free">`

**Why Call details, not Transcript generated:** the premium trigger doesn't expose call-native filter fields (Agent Name, Phone Number); the standard "Call details" trigger does. With Call details you get proper scoping. Also no per-call surcharge.

**Action: Internal Notification → SMS to Custom Number** (NOT "Send SMS" — that texts the contact by design)
- Recipient type: **Custom Number**
- Number: **digits only, no +1, no dashes: `3038287753`** — see pitfalls
- Body: include call details with `{{contact.first_name}}`, `{{contact.last_name}}`, `{{contact.phone}}`, `{{call.duration}}`
- Followers checkbox: unchecked

### Step 7 — verify wiring end-to-end
Before declaring the build done, test from a phone that's a FRESH contact in the sub-account. Existing contacts with tags like "DND", "Spam Call", "do not message" will have the notification action skipped even when the trigger fires correctly.

Test flow:
1. Call from a number that's NOT yet in the Contacts list (Google Voice, friend's cell, borrow a phone)
2. Let the AI walk through the script
3. Hang up
4. Check three places within 90 seconds:
   - The notification destination (SMS or email)
   - GHL Conversations (transcript appears)
   - Workflow Execution Log (status should be "Finished", not "Skipped")

If "Skipped" → check the Execution Log detail for the reason. Common reasons and fixes below.

## Pitfalls (all hit live 2026-08-07 bail-bonds build)

### 1. Agency token gets 401 authClass on voice-ai endpoints, even with the scope granted
This is a per-endpoint restriction, not a token scope problem. Voice AI is in the same category as funnels — location tokens only. Mint a location-level install per the `gohighlevel-ops` skill.

### 2. PATCH endpoint wants locationId as query param, not body
Body form returns 403 "LocationId is required". Use `PATCH /voice-ai/agents/<id>?locationId=<id>`.

### 3. Action names with em-dashes or other punctuation get 400 Bad Request
GHL's validator: "Action name can only contain letters, numbers, spaces, underscores, and hyphens". Use underscores or hyphens.

### 4. SMS action type "Sends a text message to the contact" — recipient is hardcoded to the contact
"Send SMS" or generic "SMS" actions have NO "To:" field — they text the enrolled contact. For "text ME, not the caller," you need **Internal Notification → SMS → Custom Number**. Subtle distinction; easy to miss.

### 5. Internal Notification Custom Number validation is strict — and certain real numbers are silently rejected
The "Skipped: All the custom phone numbers provided are invalid" message means GHL's platform-level SMS validator rejected the number. Common causes:
- Wrong format: try digits-only first (`3038287753`), then dashes (`303-828-7753`), then E.164 (`+13038287753`)
- **Number has STOP/unsubscribed on the GHL account from a prior opt-out** — block persists even if YOU are the account owner. Cannot be cleared by Support without documented opt-in date.
- **VoIP or landline that GHL can't deliver SMS to** — Google Voice is particularly prone to rejection

If you hit this, switch the action to **Internal Notification → Email** instead — email isn't subject to STOP lists.

### 6. Existing contacts with protective tags (DND, Spam Call, do not message, Disposition=Spam Call) skip workflow actions
The trigger fires correctly, the contact enrolls, but the notification action shows "Skipped: contact has protective tag". For demos, test from a FRESH number. For production, this is actually desirable behavior — but document it for clients.

### 7. GHL auto-applies Disposition=Spam Call to brand-new contacts from suspicious callers
Even if you delete the contact and they call again, the disposition may be re-applied based on carrier/spam-list data. Use a phone that's never been flagged (the client's own phone, a verified business line).

### 8. Workflows de-duplicate enrollments per contact — second call from same contact skips
"Contact is already part of this workflow and can not be added again." For multi-call testing, delete the contact record between calls.

### 9. The "Transcript generated" trigger is PREMIUM — charges per execution
Use "Call details" (standard, free) instead. Same basic capability, no per-call surcharge.

### 10. API schema does NOT include a transcription field on the agent object
The "Enable Call Transcription" toggle is UI-only. Don't waste time trying to PATCH it via API. The `callEndWorkflowIds` field on the agent IS settable via API and is the native way to scope a workflow to a specific agent (vs the UI filter mechanism).

### 11. The workflow UI's filter dropdown lists every contact custom field, not call-native fields
First instinct is to scroll the dropdown looking for "Phone Number" — it's not there for some triggers. The "Trigger details" grouping only appears after the trigger is saved (or on certain trigger types like "Call details"). If you can't find Agent/Phone Number/Direction as filter fields, the trigger is wrong or hasn't been saved.

### 12. Merge tags `{{call.duration}}` and similar may not be valid in Internal Notification → Email body
The action may reject ALL merge tags, not just one. If it does, the body has to be plain text. The transcript and call duration are still in GHL Conversations regardless.

### 13. "Missed Call" naming is misleading — the trigger fires on COMPLETED calls
Our pattern is: agent answers → caller talks → call ends → notification fires. "Missed" calls (busy, no-answer, voicemail) are NOT caught by this workflow because the agent never engaged. If the goal is "missed call text-back," that's a different workflow entirely (use the "Voicemail & Missed Call TextBack" feature in Phone System, not a custom workflow).

## Key endpoint reference

| Endpoint | Method | Notes |
|---|---|---|
| `/voice-ai/agents?locationId=<id>` | GET | List agents (location token only) |
| `/voice-ai/agents` | POST | Create agent (full payload) |
| `/voice-ai/agents/<id>?locationId=<id>` | GET | Get single agent |
| `/voice-ai/agents/<id>?locationId=<id>` | PATCH | Update agent — query param, not body |
| `/voice-ai/agents/<id>?locationId=<id>` | DELETE | Delete agent |
| `/voice-ai/actions?locationId=<id>` | POST | Create action |
| `/voice-ai/actions/<id>?locationId=<id>&agentId=<id>` | DELETE | Delete action — needs BOTH params |
| `/phone-system/numbers?locationId=<id>` | GET | List numbers (digits masked) |
| `/conversations/search?locationId=<id>&limit=N` | GET | Recent conversations — useful for confirming call activity |

All requests need three headers: `Authorization: Bearer <token>`, `Version: 2021-07-28`, `Accept: application/json`.

## Demo verification checklist

Before declaring a build ready for a client demo:

- [ ] Call from a fresh number connects to the agent
- [ ] Agent follows the script (walked through intake correctly)
- [ ] Agent respects hard rules (no fee quotes, no legal advice if those are in the prompt)
- [ ] Call ends → notification arrives on the demo recipient's phone/email within 60 seconds
- [ ] GHL Conversations shows the transcript
- [ ] Workflow Execution Log shows "Finished" (not "Skipped")
- [ ] Recording is stored (call recording toggle was on for the number)

## What's NOT covered here (deferred to `gohighlevel-ops` skill)

- OAuth app install + location-token minting flow
- Per-endpoint authClass restrictions
- Funnels/sites read-only API
- Social Planner scheduling
- AI Studio site editing

When this skill references "the GHL skill" without qualification, it means `gohighlevel-ops`. Read it for any auth flow you haven't done before — there are ~17 documented pitfalls and they all hit live.