---
name: ghl-api-integration
description: Connect Hermes to a GoHighLevel (GHL) subaccount via a Private Integration Token, probe/extend scopes, and build or edit funnels, websites, and AI Studio ("vibe coding") pages through the API. Trigger when the user mentions GHL, GoHighLevel, HighLevel, LeadConnector, their subaccount, a Private Integration Token (pit-...), funnels/websites in GHL, AI Studio, or asks "can you edit my GHL site / build a landing page in GHL".
---

# GHL API Integration

Class-level skill for working with GoHighLevel from the VPS: connection setup, scope debugging, API quirks, and the realistic edit paths for GHL-hosted pages (including AI Studio vibe-coded sites).

## Credential storage

- Token file: `/root/.hermes/secrets/ghl.env` (chmod 600). **/root is mounted read-only** except `/root/.hermes` — any path outside `.hermes` (e.g. `/root/.config/ghl/`) fails with `Read-only file system`. Keep all writable state under `/root/.hermes/`.
- File shape:
  ```
  GHL_TOKEN=pit-...
  GHL_API_BASE=https://services.leadconnectorhq.com
  GHL_API_VERSION=2021-07-28
  ```
- Every API call needs BOTH headers: `Authorization: Bearer $GHL_TOKEN` and `Version: $GHL_API_VERSION`. Missing Version header = 401s that look like scope errors.
- Tokens pasted in chat persist in session history. Warn once, offer the alternative (user pastes directly into the file via Hostinger web terminal), then proceed — don't lecture.

## Getting a token from the user

Subaccount → Settings → Private Integrations → Create New Integration → tick scopes → copy token (shown once).

**⚠️ CORRECTED 2026-07: scopes are effectively LOCKED at token-mint time.** Earlier claim ("scopes attach to the integration, re-saving is enough") is WRONG in practice. Observed: user created a token with only locations.readonly, later opened the integration, ticked Select-All, saved — API kept rejecting with "not authorized for this scope" across every endpoint. Re-saving an existing integration does NOT reliably expand scopes on the already-minted token. **The reliable fix is a NEW token** (rotate, or delete + recreate the integration) minted AFTER the desired scopes are selected. When scope edits don't take, stop re-probing the old token — get a fresh one.

**Two DIFFERENT 401 messages — don't conflate:**
- `{"statusCode":401,"message":"The token is not authorized for this scope."}` → token is VALID, missing scope. Fix = scope (then re-mint token, per above).
- `{"statusCode":401,"message":"Invalid Private Integration token"}` → token string itself rejected (wrong/transcribed-char/not-yet-active/revoked). Fix = get the exact current token value.

**Token rotation UI (2026-07):** rotating shows BOTH tokens — "new" (becomes primary after ~7 days) and "old" (auto-expires in ~7 days, has "Expire now" button). There is also a pause option ("paused for 7 days") that looks like a state but is really the rotation schedule. During grace, BOTH tokens are supposed to work — but a freshly-minted "new" token can return "Invalid Private Integration token" for a while. If user rotates, expect a propagation window and DON'T debug scopes against a not-yet-active new token.

**Never transcribe a token from a screenshot.** UUIDs OCR poorly (0/O, 1/l, 8/B). I misread `pit-03c0f23b-cb31-4ca5-a217-3fcd11b7ebed` from an image description and it kept returning "Invalid Private Integration token" — could have been a transcription error the whole time. Always: user clicks **Copy** in GHL → pastes as TEXT. Non-negotiable for credential strings.

**Sanity-check which generation you're holding:** the token the user pastes may be an OLDER generation than what GHL's UI currently shows (user handed me `pit-bbd1...` while GHL showed `pit-62f9...` as "old" and `pit-03c0...` as new — three generations existed). If the UI and the pasted token disagree, ask which one is live before probing.

## Scope debugging (the "401 but token is valid" pattern)

GHL returns `{"statusCode":401,"message":"The token is not authorized for this scope."}` for BOTH invalid tokens and valid-token-missing-scope. To distinguish:

1. Probe `GET /locations/search?limit=1` — this endpoint works with almost any valid token and returns the subaccount's `id`, `name`, `companyId`, address. If it answers, the token is valid and the 401s elsewhere are scope gaps.
2. Then probe each capability you need (funnels, contacts, forms, calendars) and report back EXACTLY which scopes are missing, so the user ticks the right boxes in one trip.

A ready-to-run probe script lives at `scripts/probe-ghl-scopes.sh` — run it after any token/scope change instead of hand-typing curls. It prints ✓/✗ per capability and extracts the locationId.

Known scope names in the GHL Private Integration UI (as of 2026-07): Funnels (view/edit), Websites, Contacts, Forms, Calendars, Locations. Cheaper to have the user tick broadly up front than to round-trip per scope.

## Key IDs and endpoints

- Base: `https://services.leadconnectorhq.com`
- List funnels: `GET /funnels/funnel/list?locationId=<LOC>` (NOTE: it's `/funnel/list`, singular — `/funnels?locationId=` is NOT the path)
- List pages in a funnel: `GET /funnels/page?locationId=<LOC>&funnelId=<FID>&limit=..&offset=..`
- Contacts: `GET /contacts/?locationId=<LOC>&limit=..`
- Rob's subaccount (Real Results Ready LLC, pipelinelayer.ai): locationId `4yTvHUHrmVuP8CgCt1Q9`, companyId `e4QT67gW4Bz6yO6r7Teu`. Re-derive via /locations/search if it ever changes.

## Editing GHL-hosted sites — what's actually possible

- **CONFIRMED 2026-07: Private Integration tokens have NO funnel/website WRITE scopes at all.** User ticked "Select all" in the scope picker; the only funnel scopes that exist are `funnels/funnel.readonly`, `funnels/page.readonly`, `funnels/pagecount.readonly`, `funnels/redirect.readonly`, `funnels/redirect.write`. There is no `funnels/funnel.write` / `funnels/page.write`. So the public API can list/read funnels and pages — page CONTENT is not API-writable, for anyone, period. Don't promise "I'll push edits via the API." The edit paths below are the whole game.
- **AI Studio ("vibe coding" builder)** generates real code files (html/css/js in a project tree), editable in AI Studio's own code editor. There is NO bulk export (no zip, no GitHub sync) as of 2026-07 — GHL's own ideas board has an open feature request for it. So the practical edit paths are:
  1. **Round-trip through chat** (default for small sites): user pastes a file from the AI Studio code editor → you edit → return the full updated file → user pastes it back over. Works fine for landing pages (usually 1–3 files).
  2. **API where it exists**: AI Studio sites typically publish as GHL websites/funnels — after token setup, list funnels and see what the site published to, then test what's writable.
  3. **Rebuild as standalone**: for bigger sites, pull the code onto the VPS, put it under version control, deploy as a normal funnel page or host outside GHL.
- GHL's page-builder UI (drag-and-drop elements) is NOT scriptable through the API — you push finished code in as custom code blocks / page content, never manipulate builder elements. Set this expectation early.

## Pitfalls

- **Don't assert "no connection exists" without checking.** First move when user asks "you have the connection, yes?": tool_search for GHL tools, grep config.yaml + env for ghl/highlevel/leadconnector, check ~/.hermes/secrets/. Answer from evidence.
- **This skill's PIT-only framing is outdated (2026-08-03).** The primary GHL access path is now the OAuth marketplace app ("Hermes App V3") with an agency-level token covering all sub-accounts — full runbook in the `gohighlevel-ops` skill. Use THIS skill for PIT/scope-debugging specifics and AI Studio editing; use `gohighlevel-ops` for anything OAuth/app/install/Social-Planner. If the two disagree, `gohighlevel-ops` is newer.
- **Agency-token access to sub-account data is ENDPOINT-DEPENDENT (verified 2026-08-03).** A full-scope agency OAuth token (`userType: Company`) read AND wrote `socialplanner/*` endpoints on a sub-account (post create → 201; a 422 validation error proves write auth passed), while `/funnels/funnel/list?locationId=...` 401'd `authClass type is not allowed` on the SAME token. Probe the exact endpoint family before concluding a location-level install is needed. Funnels/website CONTENT remains API-unwritable for everyone (no write scopes exist).
- **Marketplace install flows (mapped 2026-08-03, all hit live):** (a) the listing's blue "Install" button grants AGENCY level; (b) the direct `chooselocation?...&client_id=<ID>` link only shows the per-location picker when the app has ZERO active installs — any existing install short-circuits to agency or straight-to-✅ with no code issued; (c) ticking "all eligible sub-accounts" mints an agency token, not per-location tokens; (d) "scopes cannot be empty" = the app has no scopes saved in the dashboard (app-side fix); (e) the scope picker has no Select All (~160 checkboxes for a full grant); (f) GHL's "active on N sub-accounts" count is install FLAGS, not proof of usable tokens — server-side token logs are ground truth.
- **`/root` read-only mount**: write_file to anywhere outside `/root/.hermes` fails. Symptom looks like a write tool bug (`No such file or directory` on the tmp file) — the real cause is the mount. Check with `mount | grep /root`.
- **401 ambiguity**: always run the /locations/search probe before telling the user their token is bad — it's usually just scopes.
- **Scope edits don't reliably stick to an existing token (2026-07).** If the user ticks more scopes and re-saves but probes still fail, STOP re-probing — the fix is a freshly-minted token, not another save. Walk them to rotate or delete+recreate the integration.
- **Token generations drift from what the user pastes.** User may paste a token that is an older generation than what GHL's rotation UI shows as current. If GHL shows a different "old"/"new" pair than the pasted string, ask which one is actually live before debugging scopes. Confirm with the /locations/search probe — if THAT 401s "Invalid", the pasted string itself is dead.
- **Never OCR a token from a screenshot.** Have the user click GHL's Copy button and paste as text. UUID transcription errors look exactly like "Invalid Private Integration token" and burn debugging cycles.
- **New "rotated" tokens can be inactive for a window.** During rotation grace, the just-minted token may return "Invalid Private Integration token" until it becomes primary. If a brand-new token 401s "Invalid" while an older one 401s "scope", wait/retry the new one rather than assuming scopes.
- **Don't bury the token request in prose.** Getting the token is a multi-step UI task for the user; give it as a numbered list with exact navigation path, same standard as shell command blocks.

## Related

- `scripts/probe-ghl-scopes.sh` — one-shot capability probe (locations/funnels/pages/contacts/forms/calendars) against the stored token.
- `verifying-user-claims` — the general protocol this skill applies to the "you have my GHL connection, yes?" question.
