Auto-Tag GoHighLevel Contacts with a Local Ollama AI Agent

by Fahim

Unstructured data inside CRM notes is a massive headache. I got tired of manually reading through long form submissions, messy custom fields, and call notes just to apply basic tags to contacts in GoHighLevel (GHL). So, I built a local AI agent to handle it for me.

In this guide, we’ll build a local Node.js webhook receiver that catches GHL contact updates, feeds the raw notes to a local Ollama AI model, extracts clean tags, and pushes them back to the GoHighLevel API. No expensive OpenAI bills, no data leaving your machine, and zero complex cloud infrastructure to manage.

Terminal screen showing JSON output from Ollama local AI model auto-tagging GoHighLevel contacts
Terminal screen showing JSON output from Ollama local AI model auto-tagging GoHighLevel contacts

The Local AI Tagging Pipeline

The pipeline is simple, but there’s a major catch: timeouts. When GoHighLevel fires a webhook, it expects a fast response. If our local LLM takes more than a couple of seconds to process the text, GHL will think the request failed and retry it. This quickly spirals into an infinite loop of duplicate requests.

To prevent this, our Node.js server will immediately return a 200 OK response to GHL to say “got it,” and then process the AI tagging asynchronously in the background. Here is how the data flows:

  • GoHighLevel triggers a webhook when a contact is created or a note is added.
  • Our Express server receives the payload and acknowledges it instantly.
  • The server passes the contact notes to a local Ollama instance running Llama 3.
  • Ollama parses the text and returns a clean, structured JSON list of matching tags.
  • Our script updates the contact in GoHighLevel with the new tags using API v2.

Setting Up Your Local LLM with Ollama

Before writing any code, we need our local LLM engine running. I use Ollama for this because it’s fast, lightweight, and gives us a dead-simple local HTTP API out of the box.

Go ahead and download Ollama for your OS. Once it’s installed, open up your terminal and pull the Llama 3 model (the 8B parameter model is perfect for this kind of text classification):

ollama pull llama3

Let’s verify Ollama is actually running by sending a quick test curl request to its default port (11434):

curl http://localhost:11434/api/generate -d '{ "model": "llama3", "prompt": "Why is the sky blue? Answer in one sentence.", "stream": false
}'

If you get a JSON response back, your local AI engine is ready to roll. Now we can build our Node.js middleware.

Initializing the Node.js Project

Let’s set up a new directory for the project, initialize it, and install what we need. We’ll use Express for our webhook server, Axios for making HTTP requests, and dotenv to manage our GHL API keys.

mkdir ghl-ollama-tagger
cd ghl-ollama-tagger
npm init -y
npm install express axios dotenv

Next, create a .env file in the root of your project. You’ll need your GoHighLevel Location API Key, which you can grab from your GHL sub-account settings under the Developer Portal.

PORT=3000
GHL_API_KEY=your_gohighlevel_api_key_here
GHL_LOCATION_ID=your_gohighlevel_location_id_here
OLLAMA_URL=http://localhost:11434/api/generate

Writing the AI Agent Prompt for Structured Output

The biggest headache with using local LLMs for automation is getting them to return reliable, structured data. If the model spits out a conversational sentence like “I think this contact should have the ‘hot-lead’ tag,” your code will crash. We need raw, predictable JSON.

We can enforce this using system instructions and Ollama’s built-in format parameter. Create a file named tagger.js and add this function to handle the AI logic:

const axios = require('axios');
require('dotenv').config(); const AVAILABLE_TAGS = [ 'hot-lead', 'spam', 'needs-followup', 'enterprise', 'smb', 'interested-in-seo', 'interested-in-ads'
]; async function analyzeContactNotes(notes) { const systemPrompt = `You are an automated CRM tagging assistant. Analyze the following contact notes and select the most appropriate tags from this allowed list: [${AVAILABLE_TAGS.join(', ')}]. Rules:
1. Only return tags that are explicitly present in the allowed list.
2. Do not invent new tags.
3. Return your response as a raw JSON array of strings.
4. Do not include any conversational text, explanations, or markdown formatting. Example Output:
["hot-lead", "interested-in-seo"]`; try { const response = await axios.post(process.env.OLLAMA_URL, { model: 'llama3', prompt: `System: ${systemPrompt}nnContact Notes: ${notes}`, stream: false, format: 'json' }); const result = JSON.parse(response.data.response.trim()); return Array.isArray(result) ? result : []; } catch (error) { console.error('Ollama processing failed:', error.message); return []; }
}

By passing format: 'json', we force Llama 3 to output a valid JSON string. This saves us from writing fragile regex hacks to clean up conversational filler.

Updating the Contact in GoHighLevel

Once our local AI agent figures out the correct tags, we need to push them back to GoHighLevel. We’ll use the GHL API v2 contacts endpoint, which expects tags as an array of strings in the payload.

Add this helper function to your tagger.js file to handle the API call:

async function updateGoHighLevelContact(contactId, tags) { if (tags.length === 0) { console.log('No tags identified. Skipping GHL update.'); return; } const url = `https://services.leadconnectorhq.com/contacts/${contactId}`; try { await axios.put(url, { tags: tags }, { headers: { 'Authorization': `Bearer ${process.env.GHL_API_KEY}`, 'Version': '2021-07-28', 'Content-Type': 'application/json' } }); console.log(`Successfully updated contact ${contactId} with tags:`, tags); } catch (error) { console.error('GoHighLevel API update failed:', error.response ? error.response.data : error.message); }
}

Notice that we’re using the PUT method here. In GoHighLevel API v2, updating the tags field via PUT appends the new tags to the contact rather than overwriting existing ones—which is exactly what we want so we don’t wipe out existing data.

Building the Asynchronous Express Webhook Receiver

Now let’s glue these pieces together inside our Express server. Remember, we have to process this asynchronously. If you await the Ollama API call before returning a response to GHL, you’ll hit a webhook timeout every single time.

If you want to dive deeper into managing high-volume webhook architectures, check out my guide on how to prevent duplicate webhook processing with Redis and Express.

Add the Express server configuration to your tagger.js file:

const express = require('express');
const app = express(); app.use(express.json()); app.post('/webhooks/ghl-tagger', (req, res) => { const { id: contactId, notes, customFields } = req.body; if (!contactId) { return res.status(400).json({ error: 'Missing contact ID' }); } // 1. Immediately acknowledge receipt to GoHighLevel res.status(200).json({ status: 'queued' }); // 2. Process the AI tagging task asynchronously const textToAnalyze = notes || (customFields ? JSON.stringify(customFields) : ''); if (!textToAnalyze) { console.log(`Skipping contact ${contactId} - no notes or custom fields to analyze.`); return; } console.log(`Processing contact ${contactId} in background...`); // Using an IIFE to run the async task without blocking the response (async () => { const tags = await analyzeContactNotes(textToAnalyze); if (tags.length > 0) { await updateGoHighLevelContact(contactId, tags); } })();
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Local AI Tagger running on port ${PORT}`);
});

If you want to make sure your webhook payloads are structurally sound before running them through your processing logic, you can also read my tutorial on how to validate webhook payloads with Zod in Express.

Testing Your Setup Locally

To test this locally, we need to expose our local Express server to the internet so GoHighLevel can actually reach it. I highly recommend using Cloudflare Tunnels for this—it’s faster, more stable, and more secure than Ngrok.

If you haven’t set this up before, follow my step-by-step guide on how to test webhooks locally with Cloudflare Tunnels.

Once your tunnel is running, fire up your local Node.js server:

node tagger.js

Now, let’s send a mock webhook request using curl to verify that our asynchronous queue and Ollama integration are working as expected:

curl -X POST http://localhost:3000/webhooks/ghl-tagger -H "Content-Type: application/json" -d '{ "id": "test_contact_123", "notes": "The lead mentioned they are looking to scale their organic traffic and need help with SEO. They have a decent budget but are skeptical of ads."
}'

You should see an immediate {"status":"queued"} response in your terminal. A few seconds later, your Node.js console will spit out the tags identified by Ollama:

Processing contact test_contact_123 in background...
Successfully updated contact test_contact_123 with tags: [ 'hot-lead', 'interested-in-seo' ]

When This Setup Breaks (and How to Fix It)

Running LLMs locally is incredibly cheap, but you’ll hit production bottlenecks that cloud APIs don’t have. Here are the exact points of failure I ran into and how to deal with them:

  • Ollama Concurrency Limits: Out of the box, Ollama processes requests sequentially. If GHL fires off 10 webhooks at the exact same second, Ollama will queue them up, and the last few requests will take 30+ seconds to process. If you hit this bottleneck, you’ll need to set up a Redis-backed queue to manage your job concurrency.
  • Model Hallucinations: Sometimes Llama 3 will ignore your system instructions and return tags that aren’t in your AVAILABLE_TAGS array. Don’t trust the LLM blindly. Always filter the model’s output against your allowed tag list in your JavaScript code before hitting the GHL API.
  • GPU Memory Exhaustion: If your machine runs out of VRAM, Ollama will silently fall back to CPU processing. This will tank your performance, slowing down generation times from 800ms to over 15 seconds. Make sure you’re running a model size that fits comfortably within your GPU’s memory.

Frequently Asked Questions

Can I run this on a cheap VPS?

Short answer: No. Local LLMs need serious CPU and RAM, or ideally a dedicated GPU. If you want to host this in the cloud, you’ll need a GPU-enabled instance from a provider like RunPod or Vast.ai. For small-scale or local agency use, running it on an M-series Mac or a local Windows machine with an RTX GPU works perfectly.

How do I secure the webhook endpoint?

You definitely need to verify the webhook source. GoHighLevel doesn’t sign webhooks by default, but a quick fix is to append a secret query parameter to your webhook URL (like /webhooks/ghl-tagger?token=your_secret_token) and check it in your Express route before doing any heavy lifting.

What happens if Ollama fails to respond?

If Ollama crashes or times out, our try/catch block will catch the error, log it, and keep the Node.js server alive. The contact just won’t get updated. If you need bulletproof reliability, you’ll want to implement a proper retry queue.

Next Steps

Now that you’ve automated your contact tagging with a local AI model, you can take things a step further. Learn how to build a local AI SMS auto-responder using Ollama and GoHighLevel to handle incoming leads in real-time.

Official resources

all_in_one_marketing_tool