Skill: danielfoch/ghl-crm-for-realtors (version 1.0.0)

--- name: ghl-crm-for-realtors description: Use this skill for GoHighLevel CRM work for realtors: contact lookup and updates, opportunity/pipeline actions, conversation messaging, calendar slots, and workflow enrollment using GoHighLevel API v2. --- # GHL CRM for Realtors Use this skill when a user wants realtor CRM actions in GoHighLevel (GHL), including lead follow-up, pipeline movement, appointment booking context, and messaging workflows. ## Required Environment Set these variables before running the scripts: - `HIGHLEVEL_TOKEN` (Private Integration token) - `HIGHLEVEL_LOCATION_ID` (sub-account location ID) Optional runtime variables: - `PYTHONUNBUFFERED=1` ## Setup If the user asks to connect or set up GHL, run: ```bash python3 scripts/setup-wizard.py ``` The wizard validates credentials and tests API connectivity. ## Primary Script Use the helper script for direct actions: ```bash python3 scripts/ghl-api.py <command> [args...] ``` Common commands for realtor workflows: - `test_connection` - `search_contacts [query]` - `get_contact [contact_id]` - `create_contact [json]` - `update_contact [contact_id] [json]` - `list_opportunities` - `list_pipelines` - `list_conversations` - `send_message [contact_id] [message]` - `list_calendars` - `get_free_slots [calendar_id] [start_date] [end_date]` - `list_workflows` - `add_to_workflow [contact_id] [workflow_id]` ## Realtor-Focused Playbooks ### New Lead Intake 1. `search_contacts` to prevent duplicates. 2. If not found, `create_contact` with source tags (for example: `buyer`, `zillow`, `open-house`). 3. Add next-step task/note using supported contact endpoints. ### Pipeline Progress 1. `list_opportunities` to inspect active deals. 2. Move stage using the opportunity update command path in `ghl-api.py`. 3. Confirm stage and status in response payload. ### Follow-Up Messaging 1. Resolve contact first (`search_contacts` or `get_contact`). 2. Send message with `send_message`. 3. Re-check conversation history with `list_conversations`. ### Appointment Assist 1. `list_calendars` 2. `get_free_slots` for date range. 3. Use the calendar endpoints in script for appointment creation if requested. ## Safety Rules - Never print or echo raw tokens in chat output. - Prefer dry informational reads before write actions when intent is ambiguous. - Validate contact/opportunity IDs from GHL responses instead of guessing. - If an API error returns 401/403, stop and ask for corrected scopes or token. ## References Load these only as needed: - `references/contacts.md` - `references/opportunities.md` - `references/conversations.md` - `references/calendars.md` - `references/troubleshooting.md`

path: references/calendars.md
content: # Calendars & Appointments API Reference

Base: `https://services.leadconnectorhq.com/calendars/`

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/calendars/?locationId={id}` | List calendars |
| POST | `/calendars/` | Create calendar |
| GET | `/calendars/{calendarId}` | Get calendar |
| PUT | `/calendars/{calendarId}` | Update calendar |
| DELETE | `/calendars/{calendarId}` | Delete calendar |
| GET | `/calendars/{calendarId}/free-slots?startDate={}&endDate={}` | Get free slots |
| GET | `/calendars/blocked-slots?locationId={id}` | Get blocked slots |
| GET | `/calendars/events?locationId={id}` | List appointments |
| POST | `/calendars/events` | Create appointment |
| GET | `/calendars/events/{eventId}` | Get appointment |
| PUT | `/calendars/events/{eventId}` | Update appointment |
| DELETE | `/calendars/events/{eventId}` | Delete appointment |
| GET | `/calendars/groups?locationId={id}` | List calendar groups |
| POST | `/calendars/groups` | Create group |
| GET | `/calendars/resources?locationId={id}` | List resources |
| POST | `/calendars/resources` | Create resource |

## Free Slots Parameters
- `startDate` — YYYY-MM-DD (required)
- `endDate` — YYYY-MM-DD (required)
- `timezone` — e.g., "America/New_York" (optional, defaults to location TZ)

## Create Appointment Body
```json
{
  "calendarId": "calendar_id",
  "locationId": "location_id",
  "contactId": "contact_id",
  "startTime": "2026-03-15T10:00:00-05:00",
  "endTime": "2026-03-15T11:00:00-05:00",
  "title": "Strategy Call",
  "appointmentStatus": "confirmed",
  "assignedUserId": "user_id"
}
```

## Scopes Required
`calendars.readonly`, `calendars.write`, `calendars/events.readonly`, `calendars/events.write`, `calendars/groups.readonly`, `calendars/groups.write`, `calendars/resources.readonly`, `calendars/resources.write`


path: references/contacts.md
content: # Contacts API Reference

Base: `https://services.leadconnectorhq.com/contacts/`

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/contacts/?locationId={id}&query={q}&limit={n}` | Search contacts |
| GET | `/contacts/{contactId}` | Get contact by ID |
| POST | `/contacts/` | Create contact (requires `locationId` in body) |
| PUT | `/contacts/{contactId}` | Update contact fields |
| DELETE | `/contacts/{contactId}` | Delete contact |
| POST | `/contacts/upsert` | Create or update by email/phone match |
| GET | `/contacts/business/{businessId}` | Get contacts by business |
| POST | `/contacts/{contactId}/tags` | Add tags (`{"tags": ["tag1","tag2"]}`) |
| DELETE | `/contacts/{contactId}/tags` | Remove tags |
| GET | `/contacts/{contactId}/notes` | List notes |
| POST | `/contacts/{contactId}/notes` | Create note (`{"body": "..."}`) |
| PUT | `/contacts/{contactId}/notes/{noteId}` | Update note |
| DELETE | `/contacts/{contactId}/notes/{noteId}` | Delete note |
| GET | `/contacts/{contactId}/tasks` | List tasks |
| POST | `/contacts/{contactId}/tasks` | Create task |
| PUT | `/contacts/{contactId}/tasks/{taskId}` | Update task |
| DELETE | `/contacts/{contactId}/tasks/{taskId}` | Delete task |
| GET | `/contacts/{contactId}/appointments` | Get appointments |
| POST | `/contacts/{contactId}/campaigns/{campaignId}` | Add to campaign |
| DELETE | `/contacts/{contactId}/campaigns/{campaignId}` | Remove from campaign |
| POST | `/contacts/{contactId}/workflow/{workflowId}` | Add to workflow |
| DELETE | `/contacts/{contactId}/workflow/{workflowId}` | Remove from workflow |

## Create/Update Contact Fields

```json
{
  "locationId": "required",
  "firstName": "John",
  "lastName": "Doe",
  "email": "[REDACTED_SECRET]",
  "phone": "+15551234567",
  "companyName": "Acme Inc",
  "address1": "123 Main St",
  "city": "Austin",
  "state": "TX",
  "postalCode": "73301",
  "country": "US",
  "source": "API",
  "tags": ["customer", "vip"],
  "customFields": [{"id": "field_id", "value": "field_value"}]
}
```

## Search Parameters
- `query` — Search by name, email, phone, company
- `limit` — 1-100, default 20
- `startAfter` / `startAfterId` — Cursor pagination
- `sortBy` — Field to sort by
- `sortOrder` — `asc` or `desc`

## Scopes Required
`contacts.readonly`, `contacts.write`


path: references/conversations.md
content: # Conversations & Messaging API Reference

Base: `https://services.leadconnectorhq.com/conversations/`

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/conversations/search?locationId={id}&limit={n}` | Search conversations |
| GET | `/conversations/{conversationId}` | Get conversation |
| POST | `/conversations/` | Create conversation |
| PUT | `/conversations/{conversationId}` | Update conversation |
| DELETE | `/conversations/{conversationId}` | Delete conversation |
| POST | `/conversations/messages` | Send message |
| POST | `/conversations/messages/inbound` | Add inbound message |
| POST | `/conversations/messages/upload` | Upload attachment |
| PUT | `/conversations/messages/{messageId}/status` | Update message status |
| DELETE | `/conversations/messages/{messageId}/schedule` | Cancel scheduled message |
| GET | `/conversations/messages/{messageId}/recording` | Get call recording |
| GET | `/conversations/messages/{messageId}/transcription` | Get call transcription |

## Send Message Body

```json
{
  "type": "SMS",
  "contactId": "contact_id",
  "message": "Hello from Claude!"
}
```

**Supported message types**: `SMS`, `Email`, `WhatsApp`, `FB` (Facebook Messenger), `IG` (Instagram DM), `Live_Chat`, `Custom`

For Email type, include:
```json
{
  "type": "Email",
  "contactId": "contact_id",
  "subject": "Email Subject",
  "message": "<p>HTML email body</p>",
  "emailFrom": "[REDACTED_SECRET]"
}
```

## Scopes Required
`conversations.readonly`, `conversations.write`, `conversations/message.readonly`, `conversations/message.write`


path: references/opportunities.md
content: # Opportunities & Pipelines API Reference

Base: `https://services.leadconnectorhq.com/opportunities/`

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/opportunities/search?locationId={id}` | Search opportunities |
| GET | `/opportunities/{opportunityId}` | Get opportunity |
| POST | `/opportunities/` | Create opportunity |
| PUT | `/opportunities/{opportunityId}` | Update opportunity |
| DELETE | `/opportunities/{opportunityId}` | Delete opportunity |
| PUT | `/opportunities/{opportunityId}/status` | Update status (won/lost/open/abandoned) |
| GET | `/opportunities/pipelines?locationId={id}` | List pipelines + stages |
| POST | `/opportunities/{opportunityId}/followers` | Add follower |
| DELETE | `/opportunities/{opportunityId}/followers` | Remove follower |

## Create Opportunity Body
```json
{
  "locationId": "location_id",
  "pipelineId": "pipeline_id",
  "pipelineStageId": "stage_id",
  "contactId": "contact_id",
  "name": "Deal Name",
  "monetaryValue": 5000,
  "status": "open",
  "source": "API",
  "assignedTo": "user_id"
}
```

## Search Parameters
- `pipelineId` — Filter by pipeline
- `pipelineStageId` — Filter by stage
- `contactId` — Filter by contact
- `status` — `open`, `won`, `lost`, `abandoned`
- `assignedTo` — Filter by user
- `q` — Text search
- `limit` — 1-100, default 20

## Scopes Required
`opportunities.readonly`, `opportunities.write`


path: references/troubleshooting.md
content: # Troubleshooting Reference

## Common Errors

| HTTP Code | Cause | Fix |
|-----------|-------|-----|
| 401 | Invalid/expired token | Regenerate token via Settings → Private Integrations |
| 401 | Missing Version header | Add `Version: 2021-07-28` to all requests |
| 403 | Missing scope | Edit integration → enable required scopes → rotate token |
| 404 | Invalid endpoint or ID | Verify path and resource IDs |
| 422 | Validation error | Check required fields (locationId, etc.) |
| 429 | Rate limited | Wait and retry with exponential backoff |
| 500+ | GHL server error | Retry after 2-5 seconds |

## Environment Variable Checklist
```bash
# Both must be set:
echo $HIGHLEVEL_TOKEN      # Should show your private integration token
echo $HIGHLEVEL_LOCATION_ID # Should show your location ID

# Test GET — Retrieve location:
curl -s --request GET \
  --url "https://services.leadconnectorhq.com/locations/$HIGHLEVEL_LOCATION_ID" \
  --header "Accept: application/json" \
  --header "Authorization: Bearer $HIGHLEVEL_TOKEN" \
  --header "Version: 2021-07-28" | head -c 200

# Test POST — Create a contact:
curl --request POST \
  --url https://services.leadconnectorhq.com/contacts/ \
  --header "Authorization: Bearer $HIGHLEVEL_TOKEN" \
  --header "Content-Type: application/json" \
  --header "Version: 2021-07-28" \
  --data '{"firstName":"Test","lastName":"User","email":"[REDACTED_SECRET]","locationId":"'$HIGHLEVEL_LOCATION_ID'"}'
```

## OAuth Token Lifecycle (Marketplace Apps Only)
- Access tokens expire in **24 hours** (86,399 seconds)
- Refresh tokens last up to **1 year**
- Private Integration tokens do NOT expire (but 90-day rotation recommended)
- `/oauth/token` is the ONLY endpoint that does NOT require the `Version` header

## Rate Limits

**Burst**: 100 requests per 10 seconds per app per resource
**Daily**: 200,000 requests per day per app per resource
**SaaS endpoints**: 10 requests per second (global)

Rate limits are **per-resource** — if an app is installed on Sub-account A and Sub-account B, each sub-account independently gets its own 100/10s and 200K/day allocation.

**Monitor via response headers**:
- `X-RateLimit-Limit-Daily` — Daily limit
- `X-RateLimit-Daily-Remaining` — Remaining daily
- `X-RateLimit-Max` — Burst max
- `X-RateLimit-Remaining` — Remaining burst
- `X-RateLimit-Interval-Milliseconds` — Reset interval

## Token Rotation

Tokens don't auto-expire, but GHL recommends rotating every 90 days. Unused tokens expire after 90 days inactivity.

**To rotate**: Settings → Private Integrations → click integration → Rotate Token

Two methods:
- **"Rotate and expire later"** — generates new token, old stays active for **7-day grace period**. You can cancel rotation or force immediate expiry during this window
- **"Rotate and expire now"** — immediately invalidates old token (use for compromised credentials)

You can edit name, description, and scopes **without generating a new token**. Permission to manage Private Integrations can be restricted per user at Settings → Team → Edit user → Roles & Permissions.

## Pagination

**Default**: 20 records per request, max 100 via `limit` parameter
**Cursor-based**: Use `startAfter` / `startAfterId` from previous response

```python
# Paginate through all contacts
all_contacts = []
start_after = None
while True:
    params = f"locationId={LOC_ID}&limit=100"
    if start_after:
        params += f"&startAfterId={start_after}"
    result = _get(f"/contacts/?{params}")
    contacts = result.get("contacts", [])
    all_contacts.extend(contacts)
    if len(contacts) < 100:
        break
    start_after = contacts[-1]["id"]
```

## Webhook Debugging

50+ event types available. Webhooks fire regardless of token expiry.
Webhook config is per marketplace app, not per private integration.

**Key webhook events:**
- Contact: `ContactCreate`, `ContactDelete`, `ContactDndUpdate`, `ContactTagUpdate`
- Conversation: `InboundMessage`, `OutboundMessage`, `ConversationUnreadUpdate`
- Opportunity: `OpportunityCreate`, `OpportunityStageUpdate`, `OpportunityStatusUpdate`, `OpportunityDelete`
- Appointment: `AppointmentCreate`, `AppointmentUpdate`, `AppointmentDelete`
- Payment: `InvoiceSent`, `InvoicePaid`, `PaymentReceived`
- Forms: `FormSubmission`, `SurveySubmission`
- Location: `LocationCreate`, `LocationUpdate`

Docs: https://marketplace.gohighlevel.com/docs/webhook/WebhookIntegrationGuide

## Official Resources
- **API Docs**: https://marketplace.gohighlevel.com/docs/
- **OpenAPI Specs**: https://github.com/GoHighLevel/highlevel-api-docs
- **Setup Video**: https://youtu.be/ssDO6tz6b1w
- **Developer Slack**: https://developers.gohighlevel.com/join-dev-community


path: scripts/ghl-api.py
content: #!/usr/bin/env python3
"""GoHighLevel API v2 Helper — supports all 39 endpoint groups.
Usage: python3 ghl-api.py <command> [args...]

Environment:
  HIGHLEVEL_TOKEN       — Private Integration Bearer token (required)
  HIGHLEVEL_LOCATION_ID — Sub-account Location ID (required)

Base URL: https://services.leadconnectorhq.com
All requests include: Authorization: Bearer <token>, Version: 2021-07-28
"""

import json, os, re, sys, time, urllib.request, urllib.error, urllib.parse

BASE = "https://services.leadconnectorhq.com"
VERSION = "2021-07-28"
MAX_RETRIES = 3

# ──────────────────────────────────────────────
# Credentials & Validation
# ──────────────────────────────────────────────

# Strict pattern for GHL IDs (alphanumeric, no path traversal)
_SAFE_ID = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")


def _validate_id(value, name="ID"):
    """Validate that a value is a safe GHL identifier (alphanumeric, hyphens, underscores).
    Prevents path traversal and injection via URL path segments."""
    if not value or not _SAFE_ID.match(str(value)):
        raise ValueError(
            f"Invalid {name}: must be 1-128 alphanumeric/hyphen/underscore characters. Got: {repr(value)}"
        )
    return str(value)


def _load_creds():
    """Load credentials from environment variables."""
    return (
        os.environ.get("HIGHLEVEL_TOKEN", "").strip(),
        os.environ.get("HIGHLEVEL_LOCATION_ID", "").strip(),
    )


TOKEN, LOC_ID = _load_creds()


# ──────────────────────────────────────────────
# HTTP Client (native urllib.request)
# ──────────────────────────────────────────────

def _headers():
    return {
        "Authorization": f"Bearer {TOKEN}",
        "Version": VERSION,
        "Content-Type": "application/json",
        "Accept": "application/json",
        "User-Agent": "OpenClaw-GHL-Skill/1.1.0 (stdlib-only)",
    }


def _request(method, path, body=None, retries=MAX_RETRIES):
    """Make API request using native urllib with retry logic for 429/5xx errors."""
    url = f"{BASE}{path}" if path.startswith("/") else f"{BASE}/{path}"
    data = json.dumps(body).encode() if body else None

    for attempt in range(retries):
        try:
            req = urllib.request.Request(url, data=data, headers=_headers(), method=method)
            with urllib.request.urlopen(req, timeout=30) as resp:
                raw = resp.read().decode()
                return json.loads(raw) if raw else {"status": resp.status}
        except urllib.error.HTTPError as e:
            err_body = ""
            try:
                err_body = e.read().decode() if e.fp else ""
            except Exception:
                pass

            # Retry on rate-limit (429) with exponential backoff
            if e.code == 429 and attempt < retries - 1:
                retry_after = e.headers.get("Retry-After")
                wait = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** (attempt + 1)
                print(f"Rate limited (429). Retrying in {wait}s...", file=sys.stderr)
                time.sleep(wait)
                continue

            # Retry on server errors (5xx)
            if e.code >= 500 and attempt < retries - 1:
                time.sleep(2 ** attempt)
                continue

            return {"error": e.code, "message": err_body}
        except urllib.error.URLError as e:
            if attempt < retries - 1:
                time.sleep(2)
                continue
            return {"error": "connection_failed", "message": str(e.reason)}
        except Exception as ex:
            return {"error": "unexpected", "message": str(ex)}

    return {"error": "max_retries_exceeded"}


def _get(path):
    return _request("GET", path)


def _post(path, body=None):
    return _request("POST", path, body)


def _put(path, body=None):
    return _request("PUT", path, body)


def _delete(path):
    return _request("DELETE", path)


def _get_paginated(endpoint_base, params=None, max_pages=50):
    """Automatically paginate through all results using cursor pagination.

    Args:
        endpoint_base: API endpoint path (e.g., "/contacts/")
        params: Dict of query parameters (locationId added automatically)
        max_pages: Safety limit (default 50 = 5000 records max)

    Returns:
        Dict with all results, total count, and pages fetched
    """
    all_items = []
    start_after = None
    start_after_id = None
    page = 1
    item_key = None

    base_params = {"locationId": LOC_ID, "limit": "100"}
    if params:
        base_params.update({k: str(v) for k, v in params.items()})

    while page <= max_pages:
        url_params = base_params.copy()
        if start_after and start_after_id:
            url_params["startAfter"] = start_after
            url_params["startAfterId"] = start_after_id

        query_string = urllib.parse.urlencode(url_params)
        path = f"{endpoint_base}?{query_string}"
        data = _get(path)

        if "error" in data:
            break

        # Detect item key from first response
        if item_key is None:
            for key in data:
                if key not in ("meta", "traceId") and isinstance(data[key], list):
                    item_key = key
                    break

        if item_key:
            items = data.get(item_key, [])
            all_items.extend(items)

        # Check for next page
        meta = data.get("meta", {})
        if not meta.get("nextPageUrl"):
            break

        start_after = meta.get("startAfter")
        start_after_id = meta.get("startAfterId")
        if not start_after or not start_after_id:
            break
        page += 1

    return {
        item_key or "items": all_items,
        "total": len(all_items),
        "pages": page,
        "endpoint": endpoint_base,
    }


def _out(data):
    print(json.dumps(data, indent=2))


# ──────────────────────────────────────────────
# Setup & Connection
# ──────────────────────────────────────────────

def test_connection():
    """Verify token and location ID are working using contacts endpoint."""
    if not [REDACTED_SECRET] {"error": "HIGHLEVEL_TOKEN not set. Set the environment variable or run /highlevel-setup"}
    if not LOC_ID:
        return {"error": "HIGHLEVEL_LOCATION_ID not set. Set the environment variable or run /highlevel-setup"}

    result = _get(f"/contacts/?locationId={urllib.parse.quote(LOC_ID, safe='')}&limit=1")

    if "error" in result:
        error_code = result.get("error")
        if error_code == 403:
            return {
                "error": 403,
                "message": "Token needs scopes enabled in GHL",
                "fix": [
                    "1. Go to app.gohighlevel.com",
                    "2. Settings → Private Integrations",
                    "3. Find your 'Claude AI Assistant' integration",
                    "4. Click 'Edit Scopes'",
                    "5. ENABLE: contacts.readonly (and others needed)",
                    "6. Save - no need to regenerate token",
                ],
            }
        return result

    total = result.get("total", 0)
    return {
        "status": "connected",
        "locationId": LOC_ID,
        "totalContacts": total,
        "message": f"Successfully connected! Found {total} contacts.",
    }


# ──────────────────────────────────────────────
# Contacts
# ──────────────────────────────────────────────

def search_contacts(query="", limit=20, paginate=True):
    """Search contacts by name, email, phone, or company.

    Args:
        query: Search term
        limit: Max results per page (default 20, max 100) — only used if paginate=False
        paginate: If True (default), fetch ALL contacts across all pages
    """
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "query": query, "limit": limit})
        return _get(f"/contacts/?{params}")

    search_params = {"query": query} if query else {}
    return _get_paginated("/contacts/", search_params)


def list_all_contacts():
    """Get ALL contacts with automatic pagination."""
    return search_contacts(query="", limit=100, paginate=True)


def get_contact(contact_id):
    """Get full contact details by ID."""
    cid = _validate_id(contact_id, "contact_id")
    return _get(f"/contacts/{cid}")


def create_contact(data):
    """Create a new contact. data = JSON with firstName, lastName, email, phone, etc."""
    if isinstance(data, str):
        data = json.loads(data)
    data["locationId"] = LOC_ID
    return _post("/contacts/", data)


def update_contact(contact_id, data):
    """Update contact fields. data = JSON with fields to update."""
    cid = _validate_id(contact_id, "contact_id")
    if isinstance(data, str):
        data = json.loads(data)
    return _put(f"/contacts/{cid}", data)


def delete_contact(contact_id):
    """Delete a contact by ID."""
    cid = _validate_id(contact_id, "contact_id")
    return _delete(f"/contacts/{cid}")


def upsert_contact(data):
    """Create or update contact by email/phone match."""
    if isinstance(data, str):
        data = json.loads(data)
    data["locationId"] = LOC_ID
    return _post("/contacts/upsert", data)


def add_contact_tags(contact_id, tags):
    """Add tags to a contact. tags = JSON array of tag strings."""
    cid = _validate_id(contact_id, "contact_id")
    if isinstance(tags, str):
        tags = json.loads(tags)
    return _post(f"/contacts/{cid}/tags", {"tags": tags})


def remove_contact_tags(contact_id, tags):
    """Remove tags from a contact."""
    cid = _validate_id(contact_id, "contact_id")
    if isinstance(tags, str):
        tags = json.loads(tags)
    return _delete(f"/contacts/{cid}/tags")


# ──────────────────────────────────────────────
# Conversations & Messaging
# ──────────────────────────────────────────────

def list_conversations(limit=20):
    """List recent conversations."""
    params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit})
    return _get(f"/conversations/search?{params}")


def get_conversation(conversation_id):
    """Get a specific conversation."""
    cid = _validate_id(conversation_id, "conversation_id")
    return _get(f"/conversations/{cid}")


def send_message(contact_id, message, msg_type="SMS"):
    """Send a message to a contact. msg_type: SMS, Email, WhatsApp, FB, IG, Live_Chat."""
    _validate_id(contact_id, "contact_id")
    body = {
        "type": msg_type,
        "contactId": contact_id,
        "message": message,
    }
    return _post("/conversations/messages", body)


# ──────────────────────────────────────────────
# Calendars & Appointments
# ──────────────────────────────────────────────

def list_calendars():
    """List all calendars for this location."""
    params = urllib.parse.urlencode({"locationId": LOC_ID})
    return _get(f"/calendars/?{params}")


def get_free_slots(calendar_id, start_date, end_date):
    """Get available booking slots. Dates in YYYY-MM-DD format."""
    cal_id = _validate_id(calendar_id, "calendar_id")
    params = urllib.parse.urlencode({
        "startDate": start_date,
        "endDate": end_date,
    })
    return _get(f"/calendars/{cal_id}/free-slots?{params}")


def create_appointment(calendar_id, data):
    """Create a calendar appointment."""
    cal_id = _validate_id(calendar_id, "calendar_id")
    if isinstance(data, str):
        data = json.loads(data)
    data["calendarId"] = cal_id
    data["locationId"] = LOC_ID
    return _post("/calendars/events", data)


# ──────────────────────────────────────────────
# Opportunities & Pipelines
# ──────────────────────────────────────────────

def list_opportunities(pipeline_id=None, limit=20, paginate=True):
    """List opportunities. Optionally filter by pipeline.

    Args:
        pipeline_id: Filter by specific pipeline
        limit: Max per page (only if paginate=False)
        paginate: If True (default), fetch ALL opportunities
    """
    if not paginate:
        params = {"locationId": LOC_ID, "limit": limit}
        if pipeline_id:
            params["pipelineId"] = _validate_id(pipeline_id, "pipeline_id")
        return _get(f"/opportunities/search?{urllib.parse.urlencode(params)}")

    search_params = {}
    if pipeline_id:
        search_params["pipelineId"] = _validate_id(pipeline_id, "pipeline_id")
    return _get_paginated("/opportunities/search", search_params)


def get_opportunity(opp_id):
    """Get opportunity by ID."""
    oid = _validate_id(opp_id, "opportunity_id")
    return _get(f"/opportunities/{oid}")


def create_opportunity(data):
    """Create a new opportunity."""
    if isinstance(data, str):
        data = json.loads(data)
    data["locationId"] = LOC_ID
    return _post("/opportunities/", data)


def list_pipelines():
    """List all pipelines and their stages."""
    return _get(f"/opportunities/pipelines?locationId={urllib.parse.quote(LOC_ID, safe='')}")


# ──────────────────────────────────────────────
# Workflows & Campaigns
# ──────────────────────────────────────────────

def list_workflows(paginate=True):
    """List all workflows.

    Args:
        paginate: If True (default), fetch ALL workflows
    """
    if not paginate:
        return _get(f"/workflows/?locationId={urllib.parse.quote(LOC_ID, safe='')}")
    return _get_paginated("/workflows/")


def add_to_workflow(contact_id, workflow_id):
    """Add a contact to a workflow."""
    cid = _validate_id(contact_id, "contact_id")
    wid = _validate_id(workflow_id, "workflow_id")
    return _post(f"/contacts/{cid}/workflow/{wid}", {"eventStartTime": ""})


def remove_from_workflow(contact_id, workflow_id):
    """Remove a contact from a workflow."""
    cid = _validate_id(contact_id, "contact_id")
    wid = _validate_id(workflow_id, "workflow_id")
    return _delete(f"/contacts/{cid}/workflow/{wid}")


def list_campaigns(paginate=True):
    """List all campaigns.

    Args:
        paginate: If True (default), fetch ALL campaigns
    """
    if not paginate:
        return _get(f"/campaigns/?locationId={urllib.parse.quote(LOC_ID, safe='')}")
    return _get_paginated("/campaigns/")


# ──────────────────────────────────────────────
# Invoices
# ──────────────────────────────────────────────

def list_invoices(limit=20, paginate=True):
    """List invoices."""
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit})
        return _get(f"/invoices/?{params}")
    return _get_paginated("/invoices/")


def get_invoice(invoice_id):
    """Get invoice details."""
    iid = _validate_id(invoice_id, "invoice_id")
    return _get(f"/invoices/{iid}")


def create_invoice(data):
    """Create a new invoice."""
    if isinstance(data, str):
        data = json.loads(data)
    data["altId"] = LOC_ID
    data["altType"] = "location"
    return _post("/invoices/", data)


# ──────────────────────────────────────────────
# Payments
# ──────────────────────────────────────────────

def list_orders(limit=20, paginate=True):
    """List payment orders."""
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit})
        return _get(f"/payments/orders/?{params}")
    return _get_paginated("/payments/orders/")


def list_transactions(limit=20, paginate=True):
    """List payment transactions."""
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit})
        return _get(f"/payments/transactions/?{params}")
    return _get_paginated("/payments/transactions/")


def list_subscriptions(limit=20, paginate=True):
    """List payment subscriptions."""
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit})
        return _get(f"/payments/subscriptions/?{params}")
    return _get_paginated("/payments/subscriptions/")


# ──────────────────────────────────────────────
# Products
# ──────────────────────────────────────────────

def list_products(limit=20, paginate=True):
    """List products."""
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit})
        return _get(f"/products/?{params}")
    return _get_paginated("/products/")


def get_product(product_id):
    """Get product details."""
    pid = _validate_id(product_id, "product_id")
    return _get(f"/products/{pid}")


# ──────────────────────────────────────────────
# Forms, Surveys, Funnels
# ──────────────────────────────────────────────

def list_forms(paginate=True):
    """List all forms."""
    if not paginate:
        return _get(f"/forms/?locationId={urllib.parse.quote(LOC_ID, safe='')}")
    return _get_paginated("/forms/")


def list_form_submissions(form_id, limit=20, paginate=True):
    """Get form submissions."""
    fid = _validate_id(form_id, "form_id")
    if not paginate:
        params = urllib.parse.urlencode({"locationId": LOC_ID, "limit": limit, "formId": fid})
        return _get(f"/forms/submissions?{params}")
    return _get_paginated("/forms/submissions", {"formId": fid})


def list_surveys(paginate=True):
    """List all surveys."""
    if not paginate:
        return _get(f"/surveys/?locationId={urllib.parse.quote(LOC_ID, safe='')}")
    return _get_paginated("/surveys/")


def list_funnels(paginate=True):
    """List all funnels."""
    if not paginate:
        return _get(f"/funnels/funnel/list?locationId={urllib.parse.quote(LOC_ID, safe='')}")
    return _get_paginated("/funnels/funnel/list")


# ──────────────────────────────────────────────
# Social Media Planner
# ──────────────────────────────────────────────

def list_social_posts(limit=20):
    """List social media posts."""
    loc = _validate_id(LOC_ID, "location_id")
    return _post(f"/social-media-posting/{loc}/posts/list", {
        "limit": limit, "skip": 0,
    })


def create_social_post(data):
    """Create a social media post."""
    loc = _validate_id(LOC_ID, "location_id")
    if isinstance(data, str):
        data = json.loads(data)
    return _post(f"/social-media-posting/{loc}/posts", data)


# ──────────────────────────────────────────────
# Media, Users, Links
# ──────────────────────────────────────────────

def list_media():
    """List media files."""
    return _get(f"/medias/files?locationId={urllib.parse.quote(LOC_ID, safe='')}")


def list_users():
    """List users for this location."""
    return _get(f"/users/?locationId={urllib.parse.quote(LOC_ID, safe='')}")


def list_trigger_links():
    """List trigger links."""
    return _get(f"/links/?locationId={urllib.parse.quote(LOC_ID, safe='')}")


# ──────────────────────────────────────────────
# Location & Settings
# ──────────────────────────────────────────────

def get_location_details():
    """Get current location details."""
    loc = _validate_id(LOC_ID, "location_id")
    return _get(f"/locations/{loc}")


def list_location_custom_fields():
    """List custom fields for this location."""
    loc = _validate_id(LOC_ID, "location_id")
    return _get(f"/locations/{loc}/customFields")


def list_location_tags():
    """List tags for this location."""
    loc = _validate_id(LOC_ID, "location_id")
    return _get(f"/locations/{loc}/tags")


def list_location_custom_values():
    """List custom values for this location."""
    loc = _validate_id(LOC_ID, "location_id")
    return _get(f"/locations/{loc}/customValues")


def list_courses():
    """List courses/memberships."""
    return _get(f"/courses/?locationId={urllib.parse.quote(LOC_ID, safe='')}")


def list_snapshots():
    """List available snapshots (Agency only)."""
    return _get("/snapshots/")


def get_snapshot_status(snapshot_id):
    """Get snapshot installation status."""
    sid = _validate_id(snapshot_id, "snapshot_id")
    return _get(f"/snapshots/{sid}/status")


# ──────────────────────────────────────────────
# CLI Router
# ──────────────────────────────────────────────

COMMANDS = {
    "test_connection": lambda: test_connection(),
    "search_contacts": lambda: search_contacts(sys.argv[2] if len(sys.argv) > 2 else ""),
    "list_all_contacts": lambda: list_all_contacts(),
    "get_contact": lambda: get_contact(sys.argv[2]),
    "create_contact": lambda: create_contact(sys.argv[2]),
    "update_contact": lambda: update_contact(sys.argv[2], sys.argv[3]),
    "delete_contact": lambda: delete_contact(sys.argv[2]),
    "upsert_contact": lambda: upsert_contact(sys.argv[2]),
    "add_contact_tags": lambda: add_contact_tags(sys.argv[2], sys.argv[3]),
    "list_conversations": lambda: list_conversations(),
    "get_conversation": lambda: get_conversation(sys.argv[2]),
    "send_message": lambda: send_message(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) > 4 else "SMS"),
    "list_calendars": lambda: list_calendars(),
    "get_free_slots": lambda: get_free_slots(sys.argv[2], sys.argv[3], sys.argv[4]),
    "create_appointment": lambda: create_appointment(sys.argv[2], sys.argv[3]),
    "list_opportunities": lambda: list_opportunities(),
    "get_opportunity": lambda: get_opportunity(sys.argv[2]),
    "create_opportunity": lambda: create_opportunity(sys.argv[2]),
    "list_pipelines": lambda: list_pipelines(),
    "list_workflows": lambda: list_workflows(),
    "add_to_workflow": lambda: add_to_workflow(sys.argv[2], sys.argv[3]),
    "remove_from_workflow": lambda: remove_from_workflow(sys.argv[2], sys.argv[3]),
    "list_campaigns": lambda: list_campaigns(),
    "list_invoices": lambda: list_invoices(),
    "get_invoice": lambda: get_invoice(sys.argv[2]),
    "create_invoice": lambda: create_invoice(sys.argv[2]),
    "list_orders": lambda: list_orders(),
    "list_transactions": lambda: list_transactions(),
    "list_subscriptions": lambda: list_subscriptions(),
    "list_products": lambda: list_products(),
    "get_product": lambda: get_product(sys.argv[2]),
    "list_forms": lambda: list_forms(),
    "list_form_submissions": lambda: list_form_submissions(sys.argv[2]),
    "list_surveys": lambda: list_surveys(),
    "list_funnels": lambda: list_funnels(),
    "list_social_posts": lambda: list_social_posts(),
    "create_social_post": lambda: create_social_post(sys.argv[2]),
    "list_media": lambda: list_media(),
    "list_users": lambda: list_users(),
    "list_trigger_links": lambda: list_trigger_links(),
    "get_location_details": lambda: get_location_details(),
    "list_location_custom_fields": lambda: list_location_custom_fields(),
    "list_location_tags": lambda: list_location_tags(),
    "list_location_custom_values": lambda: list_location_custom_values(),
    "list_courses": lambda: list_courses(),
    "list_snapshots": lambda: list_snapshots(),
    "get_snapshot_status": lambda: get_snapshot_status(sys.argv[2]),
}

if __name__ == "__main__":
    if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
        print(f"Commands: {', '.join(sorted(COMMANDS.keys()))}")
        sys.exit(1)
    try:
        _out(COMMANDS[sys.argv[1]]())
    except ValueError as e:
        _out({"error": "validation_failed", "message": str(e)})
        sys.exit(1)
    except IndexError:
        _out({"error": "missing_argument", "message": f"Command '{sys.argv[1]}' requires additional arguments."})
        sys.exit(1)


path: scripts/setup-wizard.py
content: #!/usr/bin/env python3
"""GoHighLevel Setup Wizard — Interactive onboarding for new users.

Usage: python3 setup-wizard.py

Walks through:
  1. Check if HIGHLEVEL_TOKEN and HIGHLEVEL_LOCATION_ID are set
  2. Guide user through Private Integration creation (correct 2025-2026 method)
  3. Test the connection
  4. Pull first 5 contacts as a quick win
"""

import json, os, sys, urllib.request, urllib.error

BASE = "https://services.leadconnectorhq.com"
VERSION = "2021-07-28"

# ── Colors for terminal output ──────────────────────────
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
RESET = "\033[0m"


def p(msg, color="", **kwargs):
    """Print with optional color."""
    print(f"{color}{msg}{RESET}", **kwargs)


def header(title):
    p(f"\n{'─' * 50}", CYAN)
    p(f"  {title}", BOLD)
    p(f"{'─' * 50}", CYAN)


def check_mark():
    return f"{GREEN}✓{RESET}"


def x_mark():
    return f"{RED}✗{RESET}"


def api_request(token, path):
    """Make a GET request to the GHL API."""
    url = f"{BASE}{path}"
    headers = {
        "Authorization": f"Bearer {token}",
        "Version": VERSION,
        "Accept": "application/json",
        "User-Agent": "OpenClaw-GHL-Skill/1.1.0",
    }
    try:
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        body = ""
        try:
            body = e.read().decode() if e.fp else ""
        except Exception:
            pass
        return None, f"HTTP {e.code}: {body[:200]}"
    except Exception as ex:
        return None, str(ex)


def step1_check_env():
    """Check if environment variables are already set."""
    header("Step 1: Checking Environment Variables")

    [REDACTED_SECRET]"HIGHLEVEL_TOKEN", "").strip()
    loc_id = os.environ.get("HIGHLEVEL_LOCATION_ID", "").strip()

    token_ok = bool(token)
    loc_ok = bool(loc_id)

    if token_ok:
        masked = token[:8] + "..." + token[-4:] if len(token) > 12 else "***"
        p(f"  {check_mark()} HIGHLEVEL_TOKEN is set ({masked})")
    else:
        p(f"  {x_mark()} HIGHLEVEL_TOKEN is NOT set")

    if loc_ok:
        p(f"  {check_mark()} HIGHLEVEL_LOCATION_ID is set ({loc_id})")
    else:
        p(f"  {x_mark()} HIGHLEVEL_LOCATION_ID is NOT set")

    return token, loc_id, token_ok, loc_ok


def step2_guide_setup(token_ok, loc_ok):
    """Guide user through getting their token and location ID."""
    if token_ok and loc_ok:
        p(f"\n  {check_mark()} Both variables are set! Skipping to connection test.", GREEN)
        return

    header("Step 2: Setting Up Your Private Integration")

    if not token_ok:
        p(f"""
  {BOLD}How to get your Private Integration [REDACTED_SECRET]

  1. Log into {CYAN}app.gohighlevel.com{RESET}
  2. Switch to your {BOLD}Sub-Account{RESET} (recommended for single-location use)
  3. Click {BOLD}Settings{RESET} (bottom-left gear icon)
  4. Select {BOLD}Private Integrations{RESET} in the left sidebar
     {YELLOW}→ Not visible? Enable it: Settings → Labs → toggle ON{RESET}
  5. Click {BOLD}"Create new Integration"{RESET}
  6. Name it (e.g., "Claude AI Assistant")
  7. Select only the scopes you need (e.g., contacts, conversations, calendars)
     {YELLOW}→ Start minimal — you can add more scopes later without regenerating the token{RESET}
  8. Click Create → {RED}COPY THE TOKEN IMMEDIATELY{RESET}
     {YELLOW}→ It is shown ONLY ONCE and cannot be retrieved later!{RESET}

  {BOLD}⚠️  DO NOT use Settings → API Keys — that's the old V1 method (deprecated){RESET}

  Then set it in your terminal:
  {CYAN}export HIGHLEVEL_TOKEN="your-token-here"{RESET}
""")

    if not loc_ok:
        p(f"""
  {BOLD}How to find your Location ID:{RESET}

  {BOLD}Method 1 — URL bar:{RESET}
  While in your sub-account, check the browser URL:
  {CYAN}app.gohighlevel.com/v2/location/{YELLOW}THIS-IS-YOUR-LOCATION-ID{CYAN}/...{RESET}

  {BOLD}Method 2 — Business Profile:{RESET}
  Sub-account → {BOLD}Settings{RESET} → {BOLD}Business Info{RESET} (or Business Profile)
  → Location ID is in the General Information section

  Then set it in your terminal:
  {CYAN}export HIGHLEVEL_LOCATION_ID="your-location-id"{RESET}
""")

    p(f"  After setting both variables, run this wizard again:", YELLOW)
    p(f"  {CYAN}python3 scripts/setup-wizard.py{RESET}")


def step3_test_connection(token, loc_id):
    """Test the API connection."""
    header("Step 3: Testing Connection")

    p("  Connecting to GoHighLevel API v2...")
    p(f"  Base URL: {CYAN}{BASE}{RESET}")
    p(f"  Version:  {CYAN}{VERSION}{RESET}")
    print()

    # Test: Token + Location validity
    p("  Testing token...", end="")
    data, err = api_request(token, f"/locations/{loc_id}")

    if err:
        p(f" {x_mark()}")
        print()
        if "401" in str(err):
            p("  ❌ Authentication failed — your token is invalid or expired.", RED)
            p("  Fix: Go to Settings → Private Integrations → create a new one")
            p("  or rotate your existing token.")
        elif "403" in str(err):
            p("  ❌ Permission denied — your token is missing required scopes.", RED)
            p("  Fix: Edit your Private Integration → add the needed scopes.")
            p("  (You can add scopes without regenerating the token)")
        elif "404" in str(err):
            p("  ❌ Location not found — your HIGHLEVEL_LOCATION_ID is wrong.", RED)
            p("  Fix: Check the URL bar in your sub-account or Settings → Business Info")
        elif "422" in str(err):
            p("  ❌ Invalid request — check your Location ID format.", RED)
        else:
            p(f"  ❌ Unexpected error: {err}", RED)
        return False

    p(f" {check_mark()}")

    # Extract location info
    loc = data.get("location", data)
    name = loc.get("name", "Unknown")
    address = loc.get("address", "")
    tz = loc.get("timezone", "")
    phone = loc.get("phone", "")
    email = loc.get("email", "")

    print()
    p(f"  {check_mark()} {GREEN}Connected successfully!{RESET}")
    print()
    p(f"  {BOLD}Location:{RESET}  {name}")
    if address:
        p(f"  {BOLD}Address:{RESET}   {address}")
    if tz:
        p(f"  {BOLD}Timezone:{RESET}  {tz}")
    if phone:
        p(f"  {BOLD}Phone:{RESET}     {phone}")
    if email:
        p(f"  {BOLD}Email:{RESET}     {email}")

    return True


def step4_quick_win(token, loc_id):
    """Pull first 5 contacts as a quick win to show it works."""
    header("Step 4: Quick Win — Your First 5 Contacts")

    p("  Fetching contacts...")
    params = f"locationId={loc_id}&limit=5"
    data, err = api_request(token, f"/contacts/?{params}")

    if err:
        p(f"  {x_mark()} Could not fetch contacts: {err}", RED)
        if "403" in str(err):
            p("  Your token may be missing the 'contacts.readonly' scope.", YELLOW)
            p("  Edit your Private Integration to add it.")
        return

    contacts = data.get("contacts", [])
    total = data.get("total", data.get("meta", {}).get("total", len(contacts)))

    if not contacts:
        p(f"  No contacts found yet. This sub-account might be empty.", YELLOW)
        p(f"  Try creating a test contact in GHL or via the API.")
        return

    print()
    p(f"  {GREEN}Found {total} total contacts. Here are the first {len(contacts)}:{RESET}")
    print()

    for i, c in enumerate(contacts, 1):
        name = f"{c.get('firstName', '')} {c.get('lastName', '')}".strip() or "No Name"
        email = c.get("email", "—")
        phone = c.get("phone", "—")
        tags = ", ".join(c.get("tags", [])) if c.get("tags") else "—"
        p(f"  {BOLD}{i}.{RESET} {name}")
        p(f"     Email: {email}")
        p(f"     Phone: {phone}")
        p(f"     Tags:  {tags}")
        print()


def print_next_steps():
    """Show what the user can do next."""
    header("🎉 Setup Complete!")
    p(f"""
  You're all set! Here's what you can do now:

  {BOLD}Ask Claude things like:{RESET}
  • "Search my contacts for John"
  • "Show me my upcoming appointments"
  • "List my pipeline opportunities"
  • "Send an SMS to [contact] saying..."
  • "Create a new contact for Jane Doe at [REDACTED_SECRET]"
  • "What workflows do I have?"
  • "Show me my recent invoices"

  {BOLD}Helper script commands:{RESET}
  {CYAN}python3 scripts/ghl-api.py search_contacts "john"
  python3 scripts/ghl-api.py list_calendars
  python3 scripts/ghl-api.py list_opportunities
  python3 scripts/ghl-api.py list_workflows{RESET}

  {BOLD}─── Connect with us ───{RESET}
  🌐  {CYAN}https://launchmyopenclaw.com{RESET}
  🌐  {CYAN}https://myfbleads.com{RESET}
  ▶️   {CYAN}https://youtube.com/@10xcoldleads{RESET}
  📘  {CYAN}https://facebook.com/ty.shane.howell.2025{RESET}
  💼  {CYAN}https://linkedin.com/in/ty-shane/{RESET}
  📧  [REDACTED_SECRET]

  {BOLD}Don't have GoHighLevel yet?{RESET}
  Start with the free 5-Day AI Employee Challenge:
  👉  {CYAN}https://gohighlevel.com/5-day-challenge?fp_ref=369ai{RESET}
""")


def main():
    p(f"""
{BOLD}{CYAN}╔══════════════════════════════════════════════════╗
║        GoHighLevel API — Setup Wizard            ║
║        by Ty Shane · @10xcoldleads               ║
║        launchmyopenclaw.com · myfbleads.com      ║
╚══════════════════════════════════════════════════╝{RESET}
""")

    # Step 1: Check environment
    token, loc_id, token_ok, loc_ok = step1_check_env()

    # Step 2: Guide setup if needed
    if not token_ok or not loc_ok:
        step2_guide_setup(token_ok, loc_ok)
        sys.exit(0)

    # Step 3: Test connection
    connected = step3_test_connection(token, loc_id)
    if not connected:
        print()
        p("  Fix the issue above and run this wizard again.", YELLOW)
        sys.exit(1)

    # Step 4: Quick win
    step4_quick_win(token, loc_id)

    # Done!
    print_next_steps()


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)