Skill: brianxmacdonald/ghl (version 1.3.0)

--- name: ghl-crm-mastery description: Full read/write access to GoHighLevel CRM via API v2. Contacts, conversations, notes, opportunities, calendars, tags, tasks, forms, workflows, payments, and invoices. Includes rate-limit retries, safety guardrails, and real-time conversation support. version: 1.3.0 author: SetupClaw.Tech homepage: https://setupclaw.tech metadata: { "openclaw": { "requires": { "env": ["GHL_API_KEY", "GHL_LOCATION_ID"], "bins": ["python3"] }, "primaryEnv": "GHL_API_KEY", "homepage": "https://setupclaw.tech" } } --- # GHL CRM Mastery — GoHighLevel API v2 Skill ## What This Does Gives your agent full operational access to GoHighLevel — read, write, and real-time conversation management. Designed for CRM automation agents (appointment setting, lead qualification, pipeline management). ### Contact Operations - **contacts list** — List contacts with pagination - **contacts get** — Full contact record by ID (name, email, phone, company, tags, custom fields) - **contacts search** — Find contact by email - **contacts search-phone** — Find contact by phone number - **contacts update** — Update any contact field (company, tags, custom fields, etc.) - **contacts add-tags** — Apply tags to a contact - **contacts remove-tag** — Remove a tag from a contact ### Conversation Operations (Real-Time) - **conversations list** — List recent conversations - **conversations get** — Full conversation history for a contact (last 20 messages with direction, type, timestamp) - **conversations send** — Send SMS, email, or DM reply through GHL. This is how the agent responds to leads. ### Notes - **notes add** — Add research notes, call summaries, or interaction logs to a contact record - **notes list** — Read existing notes for a contact ### Pipeline & Opportunities - **opportunities list** — List pipeline items - **opportunities get** — Full opportunity details - **opportunities update** — Move opportunity to a different pipeline stage ### Calendars - **calendars list** — List available calendars - **calendars events** — List events for a calendar ### Tags, Forms, Workflows - **tags list** — List all tags in the location - **forms list** — List forms - **forms submissions** — Get submissions for a specific form - **workflows list** — List workflows (read-only) - **workflows trigger** — Trigger a workflow for a contact (NEVER create/edit/delete) ### System - **health** — Test GHL API connection ## Real-Time Conversation Pattern The agent uses this skill for instant lead response. The pattern: **First touch (new lead, no prior research):** 1. `contacts search-phone` or `contacts search` — find the contact 2. `contacts get` — pull full record 3. `notes list` — check for existing research 4. If no research notes → run identity verification (separate skill/process) 5. `conversations get` — read conversation history 6. Craft personalized response 7. `conversations send` — reply via GHL 8. `notes add` — log research findings + what was sent **Follow-up messages (contact already researched):** 1. `contacts get` — pull record (already has research in notes) 2. `conversations get` — read latest messages 3. Craft contextual reply (15-30 second target) 4. `conversations send` — reply 5. `notes add` — brief log of interaction **Key principle:** Research happens ONCE per contact. After that, the agent reads its own notes and the conversation history, then replies fast. The goal is real-time chat feel — under 30 seconds for follow-ups. ## Prerequisites **Required environment variables** (must be set in your `.env` file): ``` GHL_API_KEY=your-private-integration-token GHL_LOCATION_ID=your-location-id ``` **Required runtime:** `python3` (ships with macOS; verify with `python3 --version`). ## Getting Your GHL Credentials 1. Log into GoHighLevel → Settings → Integrations → Private Integrations → Create New 2. Name it appropriately for your deployment 3. Enable all required scopes: - contacts.readonly, contacts.write - conversations.readonly, conversations.write - conversations/message.readonly, conversations/message.write - opportunities.readonly, opportunities.write - calendars.readonly, calendars.write - calendars/events.readonly, calendars/events.write - locations/tags.readonly, locations/tags.write - locations/tasks.readonly, locations/tasks.write - forms.readonly, workflows.readonly - payments/transactions.readonly, locations.readonly - invoices.readonly, invoices.write, users.readonly 4. Copy the Private Integration Token → `GHL_API_KEY` in `.env` 5. Settings → Business Info → copy Location ID → `GHL_LOCATION_ID` in `.env` ## Usage All commands are run via the included Python CLI at `{baseDir}/scripts/ghl_api.py`: ```bash export GHL_API_KEY=$(grep GHL_API_KEY ~/.openclaw/.env | cut -d= -f2) export GHL_LOCATION_ID=$(grep GHL_LOCATION_ID ~/.openclaw/.env | cut -d= -f2) # Test connection python3 {baseDir}/scripts/ghl_api.py health # Test contact lookup python3 {baseDir}/scripts/ghl_api.py contacts list --limit 1 # Test conversation read python3 {baseDir}/scripts/ghl_api.py conversations get --contact-id "YOUR_CONTACT_ID" # Send a message python3 {baseDir}/scripts/ghl_api.py conversations send --contact-id "CONTACT_ID" --message "Hello" --type sms # Add a note python3 {baseDir}/scripts/ghl_api.py notes add --contact-id "CONTACT_ID" --body "Research findings..." ``` ## Safety Guardrails (Non-Negotiable) 1. **Never create contacts** — GHL creates contacts automatically when forms are submitted, SMS received, or DMs received. Creating contacts manually risks duplicates. If a contact doesn't exist, flag to the appropriate channel. 2. **Never delete contacts** without explicit human confirmation in Slack 3. **Never send bulk SMS** without human approval 4. **Never modify workflows** — trigger only, never create/edit/delete 5. **Log every write operation** — every `conversations send` and `notes add` prints a timestamped log line 6. **TCPA compliance** — no SMS between 9PM and 8AM in the recipient's time zone (enforced by TCPA handler layer, not this skill) ## Network Endpoints This skill connects exclusively to the GoHighLevel API: - `https://services.leadconnectorhq.com` — GHL API v2 (all operations) No other external endpoints are contacted. All credentials are read from local environment variables only. ## Changelog ### v1.3.0 (March 2026) - Added `metadata.openclaw` frontmatter declarations (requires.env, requires.bins, primaryEnv) for ClawHub security compliance - Added `{baseDir}` references for portable skill paths - Added explicit network endpoint documentation - Updated _meta.json with runtime and credential requirements - Generalized agent references for broader deployment compatibility ### v1.2.0 (March 2026) - Added conversations send, notes list, contacts search-phone - Real-time conversation pattern documentation ### v1.0.0 (March 2026) - Initial release: 24 endpoints covering contacts, conversations, notes, opportunities, calendars, tags, forms, workflows

path: _meta.json
content: {
  "name": "ghl-crm-mastery",
  "version": "1.3.0",
  "description": "Full read/write access to GoHighLevel CRM via API v2. Contacts, conversations, notes, opportunities, calendars, tags, tasks, forms, workflows, payments, and invoices. Includes rate-limit retries, safety guardrails, and real-time conversation support.",
  "author": "SetupClaw.Tech",
  "license": "MIT-0",
  "source": "custom",
  "homepage": "https://setupclaw.tech",
  "requires": {
    "openclaw": ">=2026.2.0",
    "env": ["GHL_API_KEY", "GHL_LOCATION_ID"],
    "bins": ["python3"]
  },
  "primaryCredential": "GHL_API_KEY"
}


path: scripts/ghl_api.py
content: #!/usr/bin/env python3
"""
GHL CRM Mastery — API CLI Tool
SetupClaw.Tech · v1.3.0

Usage:
    # Contacts
    python3 ghl_api.py contacts list [--limit N]
    python3 ghl_api.py contacts get --id "CONTACT_ID"
    python3 ghl_api.py contacts search --query "[REDACTED_SECRET]"
    python3 ghl_api.py contacts search-phone --query "+15555550123"
    python3 ghl_api.py contacts update --id "CONTACT_ID" --field value
    python3 ghl_api.py contacts add-tags --id "CONTACT_ID" --tags "tag1,tag2"
    python3 ghl_api.py contacts remove-tag --id "CONTACT_ID" --tag-id "TAG_ID"

    # Conversations
    python3 ghl_api.py conversations list [--limit N]
    python3 ghl_api.py conversations get --contact-id "CONTACT_ID"
    python3 ghl_api.py conversations send --contact-id "CONTACT_ID" --message "Hello" --type sms

    # Notes
    python3 ghl_api.py notes add --contact-id "CONTACT_ID" --body "Research findings..."
    python3 ghl_api.py notes list --contact-id "CONTACT_ID"

    # Opportunities
    python3 ghl_api.py opportunities list [--limit N]
    python3 ghl_api.py opportunities get --id "OPP_ID"
    python3 ghl_api.py opportunities update --id "OPP_ID" --stage "STAGE_ID"

    # Calendars
    python3 ghl_api.py calendars list
    python3 ghl_api.py calendars events --id "CALENDAR_ID"

    # Tags, Forms, Workflows
    python3 ghl_api.py tags list
    python3 ghl_api.py forms list
    python3 ghl_api.py forms submissions --id "FORM_ID"
    python3 ghl_api.py workflows list
    python3 ghl_api.py workflows trigger --id "WORKFLOW_ID" --contact-id "CONTACT_ID"

    # System
    python3 ghl_api.py health

Environment variables required:
    GHL_API_KEY       - GoHighLevel Private Integration Token
    GHL_LOCATION_ID   - GoHighLevel Location ID
"""

import os
import sys
import json
import time
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime

BASE_URL = "https://services.leadconnectorhq.com"
[REDACTED_SECRET]"GHL_API_KEY", "")
LOCATION_ID = os.environ.get("GHL_LOCATION_ID", "")

MAX_RETRIES = 3
RETRY_DELAY = 2


def check_env():
    if not [REDACTED_SECRET]"ERROR: GHL_API_KEY not set.")
        print("Run: export GHL_API_KEY=$(grep GHL_API_KEY ~/.openclaw/.env | cut -d= -f2)")
        sys.exit(1)
    if not LOCATION_ID:
        print("ERROR: GHL_LOCATION_ID not set.")
        print("Run: export GHL_LOCATION_ID=$(grep GHL_LOCATION_ID ~/.openclaw/.env | cut -d= -f2)")
        sys.exit(1)


def api_request(method, path, params=None, body=None):
    check_env()
    url = f"{BASE_URL}{path}"
    if params:
        url = f"{url}?{urllib.parse.urlencode(params)}"

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Version": "2021-07-28",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    data = json.dumps(body).encode() if body else None

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, data=data, headers=headers, method=method)
            with urllib.request.urlopen(req, timeout=30) as resp:
                return json.loads(resp.read().decode())
        except urllib.error.HTTPError as e:
            if e.code == 429:
                wait = RETRY_DELAY * (2 ** attempt)
                print(f"  Rate limited. Waiting {wait}s... ({attempt + 1}/{MAX_RETRIES})")
                time.sleep(wait)
                continue
            elif e.code == 500 and attempt == 0:
                print("  Server error (500). Retrying in 5s...")
                time.sleep(5)
                continue
            else:
                error_body = e.read().decode() if e.fp else ""
                print(f"ERROR: HTTP {e.code} - {e.reason}")
                if error_body:
                    try:
                        print(json.dumps(json.loads(error_body), indent=2))
                    except json.JSONDecodeError:
                        print(error_body)
                sys.exit(1)
        except urllib.error.URLError as e:
            print(f"ERROR: Connection failed - {e.reason}")
            sys.exit(1)

    print(f"ERROR: Max retries ({MAX_RETRIES}) exceeded.")
    sys.exit(1)


# === CONTACTS ===

def cmd_contacts_list(limit=5):
    data = api_request("GET", "/contacts/", params={"locationId": LOCATION_ID, "limit": limit})
    contacts = data.get("contacts", [])
    print(f"Contacts ({len(contacts)} of {data.get('meta', {}).get('total', '?')}):")
    for c in contacts:
        name = f"{c.get('firstName', '')} {c.get('lastName', '')}".strip() or "(no name)"
        print(f"  - {name} | {c.get('email', '—')} | {c.get('phone', '—')} | ID: {c.get('id', '?')}")
    return data

def cmd_contacts_get(contact_id):
    data = api_request("GET", f"/contacts/{contact_id}")
    c = data.get("contact", data)
    name = f"{c.get('firstName', '')} {c.get('lastName', '')}".strip() or "(no name)"
    print(f"Contact: {name}")
    print(f"  Email: {c.get('email', '—')}")
    print(f"  Phone: {c.get('phone', '—')}")
    print(f"  Company: {c.get('companyName', '—')}")
    print(f"  Tags: {', '.join(c.get('tags', [])) or '—'}")
    print(f"  Source: {c.get('source', '—')}")
    print(f"  Date Added: {c.get('dateAdded', '—')}")
    print(f"  ID: {c.get('id', '?')}")
    custom = c.get("customFields", c.get("customField", []))
    if custom:
        print("  Custom Fields:")
        for cf in custom:
            print(f"    {cf.get('id', '?')}: {cf.get('value', '—')}")
    return data

def cmd_contacts_search(query):
    data = api_request("GET", "/contacts/search/duplicate", params={"locationId": LOCATION_ID, "email": query})
    contacts = data.get("contacts", data.get("contact", []))
    if isinstance(contacts, dict):
        contacts = [contacts]
    print(f"Search by email '{query}': {len(contacts)} found")
    for c in contacts:
        name = f"{c.get('firstName', '')} {c.get('lastName', '')}".strip()
        print(f"  - {name} | {c.get('email', '')} | {c.get('phone', '')} | ID: {c.get('id', '?')}")
    return data

def cmd_contacts_search_phone(query):
    data = api_request("GET", "/contacts/search/duplicate", params={"locationId": LOCATION_ID, "phone": query})
    contacts = data.get("contacts", data.get("contact", []))
    if isinstance(contacts, dict):
        contacts = [contacts]
    print(f"Search by phone '{query}': {len(contacts)} found")
    for c in contacts:
        name = f"{c.get('firstName', '')} {c.get('lastName', '')}".strip()
        print(f"  - {name} | {c.get('email', '')} | {c.get('phone', '')} | ID: {c.get('id', '?')}")
    return data

def cmd_contacts_update(contact_id, updates):
    data = api_request("PUT", f"/contacts/{contact_id}", body=updates)
    print(f"  ✓ Contact {contact_id} updated: {json.dumps(updates)}")
    return data

def cmd_contacts_add_tags(contact_id, tags):
    data = api_request("POST", f"/contacts/{contact_id}/tags", body={"tags": tags})
    print(f"  ✓ Tags added to {contact_id}: {', '.join(tags)}")
    return data

def cmd_contacts_remove_tag(contact_id, tag_id):
    data = api_request("DELETE", f"/contacts/{contact_id}/tags/{tag_id}")
    print(f"  ✓ Tag {tag_id} removed from {contact_id}")
    return data


# === CONVERSATIONS ===

def cmd_conversations_list(limit=5):
    data = api_request("GET", "/conversations/search", params={"locationId": LOCATION_ID, "limit": limit})
    convos = data.get("conversations", [])
    print(f"Conversations ({len(convos)}):")
    for c in convos:
        print(f"  - ID: {c.get('id', '?')} | Contact: {c.get('contactId', '?')} | Last: {c.get('lastMessageDate', '?')}")
    return data

def cmd_conversations_get(contact_id):
    data = api_request("GET", "/conversations/search", params={"locationId": LOCATION_ID, "contactId": contact_id})
    convos = data.get("conversations", [])
    if not convos:
        print(f"No conversations found for contact {contact_id}")
        return data
    convo_id = convos[0].get("id")
    print(f"Conversation ID: {convo_id}")
    messages = api_request("GET", f"/conversations/{convo_id}/messages", params={"limit": 20})
    msgs = messages.get("messages", {}).get("messages", messages.get("messages", []))
    if isinstance(msgs, list):
        print(f"Messages ({len(msgs)}):")
        for m in msgs:
            direction = m.get("direction", "?")
            body = m.get("body", "(no body)")
            ts = m.get("dateAdded", "?")
            msg_type = m.get("type", "?")
            print(f"  [{direction}] [{msg_type}] {ts}: {body[:200]}")
    return messages

def cmd_conversations_send(contact_id, message, msg_type="SMS"):
    """Send a message via GHL. TCPA compliance enforced by handler layer."""
    timestamp = datetime.utcnow().isoformat() + "Z"
    body = {"type": msg_type, "contactId": contact_id, "message": message}
    data = api_request("POST", "/conversations/messages", body=body)
    print(f"  ✓ [{timestamp}] OUTBOUND {msg_type} to {contact_id}: {message[:100]}")
    return data


# === NOTES ===

def cmd_notes_add(contact_id, body_text):
    timestamp = datetime.utcnow().isoformat() + "Z"
    data = api_request("POST", f"/contacts/{contact_id}/notes", body={"body": body_text, "userId": ""})
    print(f"  ✓ Note added to {contact_id} at {timestamp}")
    return data

def cmd_notes_list(contact_id):
    data = api_request("GET", f"/contacts/{contact_id}/notes")
    notes = data.get("notes", [])
    print(f"Notes for {contact_id} ({len(notes)}):")
    for n in notes:
        print(f"  [{n.get('dateAdded', '?')}] {n.get('body', '(empty)')[:200]}")
    return data


# === OPPORTUNITIES ===

def cmd_opportunities_list(limit=5):
    data = api_request("GET", "/opportunities/search", params={"location_id": LOCATION_ID, "limit": limit})
    opps = data.get("opportunities", [])
    print(f"Opportunities ({len(opps)}):")
    for o in opps:
        print(f"  - {o.get('name', '?')} | Stage: {o.get('pipelineStageId', '?')} | Value: ${o.get('monetaryValue', 0)}")
    return data

def cmd_opportunities_get(opp_id):
    data = api_request("GET", f"/opportunities/{opp_id}")
    o = data.get("opportunity", data)
    print(f"Opportunity: {o.get('name', '?')}")
    print(f"  Stage: {o.get('pipelineStageId', '?')} | Value: ${o.get('monetaryValue', 0)} | Status: {o.get('status', '?')}")
    return data

def cmd_opportunities_update(opp_id, stage_id):
    data = api_request("PUT", f"/opportunities/{opp_id}", body={"pipelineStageId": stage_id})
    print(f"  ✓ Opportunity {opp_id} moved to stage {stage_id}")
    return data


# === CALENDARS ===

def cmd_calendars_list():
    data = api_request("GET", "/calendars/", params={"locationId": LOCATION_ID})
    cals = data.get("calendars", [])
    print(f"Calendars ({len(cals)}):")
    for c in cals:
        print(f"  - {c.get('name', '?')} | ID: {c.get('id', '?')}")
    return data

def cmd_calendars_events(cal_id):
    data = api_request("GET", f"/calendars/{cal_id}/events", params={"locationId": LOCATION_ID})
    events = data.get("events", [])
    print(f"Events ({len(events)}):")
    for e in events:
        print(f"  - {e.get('title', '?')} | {e.get('startTime', '?')} | Contact: {e.get('contactId', '?')}")
    return data


# === TAGS, FORMS, WORKFLOWS ===

def cmd_tags_list():
    data = api_request("GET", f"/locations/{LOCATION_ID}/tags")
    tags = data.get("tags", [])
    print(f"Tags ({len(tags)}):")
    for t in tags:
        print(f"  - {t.get('name', '?')} | ID: {t.get('id', '?')}")
    return data

def cmd_forms_list():
    data = api_request("GET", "/forms/", params={"locationId": LOCATION_ID})
    forms = data.get("forms", [])
    print(f"Forms ({len(forms)}):")
    for f_item in forms:
        print(f"  - {f_item.get('name', '?')} | ID: {f_item.get('id', '?')}")
    return data

def cmd_forms_submissions(form_id):
    data = api_request("GET", "/forms/submissions", params={"formId": form_id, "locationId": LOCATION_ID})
    subs = data.get("submissions", [])
    print(f"Submissions ({len(subs)}):")
    for s in subs:
        print(f"  - Contact: {s.get('contactId', '?')} | {s.get('createdAt', '?')}")
    return data

def cmd_workflows_list():
    data = api_request("GET", "/workflows/", params={"locationId": LOCATION_ID})
    workflows = data.get("workflows", [])
    print(f"Workflows ({len(workflows)}):")
    for w in workflows:
        print(f"  - {w.get('name', '?')} | ID: {w.get('id', '?')} | Status: {w.get('status', '?')}")
    return data

def cmd_workflows_trigger(workflow_id, contact_id):
    """Trigger only. NEVER create/edit/delete workflows."""
    data = api_request("POST", f"/workflows/{workflow_id}/trigger", body={"contactId": contact_id})
    print(f"  ✓ Workflow {workflow_id} triggered for {contact_id}")
    return data


# === HEALTH ===

def cmd_health():
    print("Checking GHL API connection...")
    print(f"  API Key: {API_KEY[:8]}...{API_KEY[-4:]}" if len(API_KEY) > 12 else f"  API Key: {API_KEY}")
    print(f"  Location: {LOCATION_ID}")
    print()
    try:
        data = api_request("GET", "/contacts/", params={"locationId": LOCATION_ID, "limit": 1})
        total = data.get("meta", {}).get("total", "?")
        print(f"  ✓ Connection OK — {total} contacts in location")
    except SystemExit:
        print("  ✗ Connection failed")
        sys.exit(1)


# === CLI ROUTER ===

def parse_args():
    args = sys.argv[1:]
    params = {}
    positional = []
    i = 0
    while i < len(args):
        if args[i].startswith("--") and i + 1 < len(args):
            params[args[i][2:]] = args[i + 1]
            i += 2
        else:
            positional.append(args[i])
            i += 1
    return positional, params


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(0)

    pos, params = parse_args()
    resource = pos[0] if pos else ""
    action = pos[1] if len(pos) > 1 else "list"
    limit = int(params.get("limit", 5))

    routes = {
        ("contacts", "list"): lambda: cmd_contacts_list(limit),
        ("contacts", "get"): lambda: cmd_contacts_get(params["id"]),
        ("contacts", "search"): lambda: cmd_contacts_search(params["query"]),
        ("contacts", "search-phone"): lambda: cmd_contacts_search_phone(params["query"]),
        ("contacts", "update"): lambda: cmd_contacts_update(params.pop("id"), params),
        ("contacts", "add-tags"): lambda: cmd_contacts_add_tags(params["id"], params["tags"].split(",")),
        ("contacts", "remove-tag"): lambda: cmd_contacts_remove_tag(params["id"], params["tag-id"]),
        ("conversations", "list"): lambda: cmd_conversations_list(limit),
        ("conversations", "get"): lambda: cmd_conversations_get(params["contact-id"]),
        ("conversations", "send"): lambda: cmd_conversations_send(params["contact-id"], params["message"], params.get("type", "SMS")),
        ("notes", "add"): lambda: cmd_notes_add(params["contact-id"], params["body"]),
        ("notes", "list"): lambda: cmd_notes_list(params["contact-id"]),
        ("opportunities", "list"): lambda: cmd_opportunities_list(limit),
        ("opportunities", "get"): lambda: cmd_opportunities_get(params["id"]),
        ("opportunities", "update"): lambda: cmd_opportunities_update(params["id"], params["stage"]),
        ("calendars", "list"): lambda: cmd_calendars_list(),
        ("calendars", "events"): lambda: cmd_calendars_events(params["id"]),
        ("tags", "list"): lambda: cmd_tags_list(),
        ("forms", "list"): lambda: cmd_forms_list(),
        ("forms", "submissions"): lambda: cmd_forms_submissions(params["id"]),
        ("workflows", "list"): lambda: cmd_workflows_list(),
        ("workflows", "trigger"): lambda: cmd_workflows_trigger(params["id"], params["contact-id"]),
        ("health", "list"): lambda: cmd_health(),
        ("health", "health"): lambda: cmd_health(),
    }

    handler = routes.get((resource, action))
    if handler:
        try:
            handler()
        except KeyError as e:
            print(f"Missing required parameter: --{e.args[0]}")
            print(f"Run: python3 ghl_api.py {resource} {action} --help")
            sys.exit(1)
    else:
        print(f"Unknown command: {resource} {action}")
        print(__doc__)
        sys.exit(1)


if __name__ == "__main__":
    main()


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