Build a Local AI Outreach Agent with Ollama and GoHighLevel

by Fahim

Sending every single junk lead to OpenAI gets expensive fast. When 80% of your incoming traffic is just tire-kickers or spam, paying per token to draft replies is a waste of money. I wanted a way to qualify leads and draft personalized replies on my own hardware for zero marginal cost, so I hooked up Ollama to GoHighLevel.

If you saw my previous guide on local AI lead scoring with Ollama, you know how good offline models have gotten. We can take that same concept, plug it into GoHighLevel, and build a self-contained system that listens for new contacts, figures out what they actually want, and drafts a context-aware reply—all without sending a single byte of customer data to third-party cloud APIs.

Local AI Outreach Agent terminal running Ollama with GoHighLevel webhook logs
Local AI Outreach Agent terminal running Ollama with GoHighLevel webhook logs

How the Local Agent Setup Works

The architecture is pretty straightforward. When a contact messages you, GoHighLevel fires a webhook to a local Express server. Our server grabs the payload, passes the message history to a local Ollama instance running Llama 3, and then hits the GoHighLevel API to post the drafted reply back to the contact’s timeline.

To run this on your machine, you’ll need Node.js and Ollama installed. Since GoHighLevel needs a public HTTPS endpoint to send those webhooks to, you’ll also want a local tunneling tool like Cloudflare Tunnels or ngrok during development.

Spinning Up Ollama and the Local Model

First, make sure Ollama is running and you’ve pulled your model. I’m using Llama 3 (8B) here because it’s fast and smart enough for lead sorting on consumer hardware. If you’re on an older laptop, Mistral (7B) is a solid fallback.

Fire up your terminal and run this to grab and start the model:

ollama run llama3

Once that finishes, let’s make sure the local API is actually listening. Ollama defaults to port 11434. Run a quick curl to verify it’s responding:

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

If you get back a clean JSON object, your local AI engine is ready. If you need to tweak your model configuration, the Ollama API documentation has all the endpoints documented.

Building the Express Server

Next, let’s build the Node.js middleware. Create a new folder, initialize the project, and grab the dependencies. We just need Express for the webhooks and dotenv to keep our keys out of source control.

mkdir local-ghl-agent
cd local-ghl-agent
npm init -y
npm install express dotenv body-parser

Now, create a .env file in your root directory. This is where your GoHighLevel Location API Key goes (you can grab this from your GHL developer settings).

PORT=3000
GHL_API_KEY=your_gohighlevel_location_api_key
OLLAMA_URL=http://localhost:11434

Let’s write a basic skeleton for our server in server.js. This exposes a simple POST endpoint at /webhook/ghl-lead to catch incoming GHL contact events.

require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser'); const app = express();
app.use(bodyParser.json()); app.post('/webhook/ghl-lead', async (req, res) => { const leadData = req.body; console.log('Received lead from GoHighLevel:', leadData.email); // We will process the lead here res.status(200).json({ status: 'received' });
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Local agent listening on port ${PORT}`);
});

Writing the AI Prompt Analysis Engine

To make this actually useful, we can’t just ask Ollama to “write a reply.” We need structured data. The goal is to analyze the lead’s query, figure out their intent, score how hot they are, and *then* draft a natural response.

Let’s build a helper file called agent.js to handle the prompt. We’ll force Ollama to return a raw JSON payload so our Node app can parse it without breaking.

const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434'; async function analyzeLeadAndDraftReply(leadMessage) { const systemPrompt = `You are an expert sales assistant. Analyze the incoming message and return a JSON object with these exact keys: "intent" (string, e.g., "booking", "pricing", "spam"), "interestScore" (number from 1 to 10), "draftReply" (string, a friendly, short response asking to book a call or answering their question). Do not include any markdown formatting, backticks, or extra text. Return ONLY valid JSON.`; const response = await fetch(`${OLLAMA_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'llama3', prompt: `System: ${systemPrompt}nnUser Message: "${leadMessage}"nnJSON Response:`, stream: false, format: 'json' }) }); if (!response.ok) { throw new Error(`Ollama API error: ${response.statusText}`); } const data = await response.json(); return JSON.parse(data.response.trim());
} module.exports = { analyzeLeadAndDraftReply };

Using the format: 'json' option in Ollama is a lifesaver here. It forces the model to return strict JSON, saving us from having to write regex to strip out conversational fluff like “Sure, here is your JSON:” which completely breaks standard JSON parsers.

Pushing Responses Back to GoHighLevel

Once Ollama drafts the response, we need to get it back into GoHighLevel. We’ll use the GHL API v2 to add a note to the contact’s timeline and queue up our reply. You can check out the full spec in the official GoHighLevel API v2 Reference.

Let’s write a quick service in ghlService.js to handle these API calls.

const GHL_API_KEY = process.env.GHL_API_KEY; async function sendGhlReply(contactId, messageText) { const response = await fetch('https://services.leadconnectorhq.com/conversations/messages', { method: 'POST', headers: { 'Authorization': `Bearer ${GHL_API_KEY}`, 'Content-Type': 'application/json', 'Version': '2021-04-15' }, body: JSON.stringify({ type: 'SMS', contactId: contactId, message: messageText }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`GHL API error: ${response.status} - ${errText}`); } return await response.json();
} module.exports = { sendGhlReply };

By the way, if you’re importing contacts from external sheets before running this outreach workflow, you might want to check out my guide on how to push Google Sheets contacts to GoHighLevel API with Apps Script.

The Gotcha: Handling Local Latency and Webhook Timeouts

When I first spun this up, I hit a massive wall: GoHighLevel kept retrying the webhooks. My logs were flooded with duplicate requests, meaning Ollama was drafting the exact same response three or four times.

Here’s why: GoHighLevel expects a 200 OK response to its webhook within 3 seconds. Running Llama 3 locally on a consumer GPU can take anywhere from 4 to 8 seconds to generate a complete JSON payload. Because my Express server was holding the connection open while waiting for Ollama, GHL assumed the request failed, timed out, and retried.

To fix this, we have to process the webhooks asynchronously. We need to send a 200 OK back to GoHighLevel immediately to shut it up, and *then* run the Ollama generation and GHL API calls in the background.

Let’s rewrite server.js to handle this background execution pattern properly.

require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const { analyzeLeadAndDraftReply } = require('./agent');
const { sendGhlReply } = require('./ghlService'); const app = express();
app.use(bodyParser.json()); app.post('/webhook/ghl-lead', (req, res) => { const leadData = req.body; const contactId = leadData.contact_id || leadData.id; const leadMessage = leadData.message?.body || leadData.last_message; if (!contactId || !leadMessage) { return res.status(400).json({ error: 'Missing contact_id or message body' }); } // 1. Respond immediately to prevent GHL webhook timeout res.status(200).json({ status: 'queued' }); // 2. Process the heavy AI payload in the background processOutreach(contactId, leadMessage).catch(err => { console.error('Background processing error:', err.message); });
}); async function processOutreach(contactId, leadMessage) { console.log(`Starting background processing for Contact: ${contactId}`); const analysis = await analyzeLeadAndDraftReply(leadMessage); console.log('AI Analysis Result:', analysis); if (analysis.intent === 'spam') { console.log(`Lead ${contactId} flagged as spam. Skipping reply.`); return; } if (analysis.interestScore >= 4) { console.log(`Sending drafted response to ${contactId}...`); await sendGhlReply(contactId, analysis.draftReply); console.log('Reply sent successfully.'); } else { console.log(`Interest score too low (${analysis.interestScore}). No automated reply sent.`); }
} const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Local agent listening on port ${PORT}`);
});

Keep in mind: if you’re running this under heavy production load, a simple in-memory background process will drop tasks if your server crashes. For actual production setups, you’ll want to look at how to queue GoHighLevel webhooks with Node.js and BullMQ to handle retries and failures safely.

Testing the Local Setup

Instead of waiting for a live lead to test this, you can mock the webhook payload using curl. Make sure your server is running (node server.js).

Open another terminal window and fire off this POST request to simulate a lead asking about pricing:

curl -X POST http://localhost:3000/webhook/ghl-lead 
-H "Content-Type: application/json" 
-d '{ "contact_id": "12345", "last_message": "Hey, I saw your services online. How much do you charge for custom development?"
}'

Your server terminal should immediately log status: queued. A few seconds later, you’ll see the structured JSON analysis spit out by Ollama, followed by the mock API call hitting GoHighLevel.

FAQ

Can I run this on a standard laptop?

Yeah, you can run smaller models like Mistral 7B or Llama 3 8B on a standard machine with 16GB of RAM. If you don’t have a dedicated GPU, Ollama falls back to CPU execution. It’ll take longer (around 15-30 seconds per reply), but since we process everything asynchronously anyway, it won’t break the webhook integration.

How secure is this?

Completely secure. Because Ollama runs entirely on your local hardware, none of your customer data is sent to OpenAI, Anthropic, or any other third-party cloud. It’s a huge win if you’re dealing with strict privacy requirements or GDPR compliance.

How do I secure the webhook endpoint?

When you expose your local port via Cloudflare Tunnels or ngrok, anyone who finds the URL can hit it. You should always verify that incoming requests actually come from GoHighLevel by checking the signature headers. Check out my guide on how to verify webhook signatures with HMAC in Node.js to lock this down.

What’s Next?

Now that your local outreach agent is up and running, you can start hooking it up to other communication channels. If you want to take this offline setup and apply it to email, check out my guide on how to build an AI email reply agent with Ollama and Node.js.

all_in_one_marketing_tool