Local AI SMS Auto-Responder: Build with Ollama and GoHighLevel

by Fahim

Paying OpenAI token fees for SMS auto-responders gets expensive fast. If a lead sends you a novel, you pay for every single word. I got tired of watching my API bill climb, so I built a local alternative. We’re going to route GoHighLevel webhooks to a local Node.js server, process them using Ollama running Llama 3 on our own hardware, and shoot the replies back via the GHL API. Zero API fees, complete privacy.

Local AI SMS auto-responder terminal logs and incoming SMS on a smartphone
Local AI SMS auto-responder terminal logs and incoming SMS on a smartphone

The Local AI Routing Architecture

To make this work, we need a bridge. GHL lives in the cloud; your local Ollama instance lives on your desk. When a lead texts your GHL number, it triggers a workflow webhook containing the contact details and message body.

Since our local server runs on localhost, we’ll expose it using a secure tunnel. I wrote a guide on how to test webhooks locally with Cloudflare Tunnels if you need to set that up first. Once the webhook hits our Express app, we’ll parse the payload, feed it to Ollama, grab the response, and push it back to the lead using GHL’s v2 API.

Setting Up Your Local Environment

First, make sure Ollama is installed and running on your machine. Fire up your terminal and pull Llama 3 (or Mistral if you’re running on a machine with less RAM):

ollama run llama3

Leave that terminal running so Ollama can listen on its default port, 11434. Next, spin up a new Node.js project in a new folder and grab the dependencies we need:

mkdir local-sms-responder
cd local-sms-responder
npm init -y
npm install express dotenv axios

Now, create a .env file in your project root to hold your GHL API credentials and config. Set it up like this:

PORT=3000
OLLAMA_URL=http://localhost:11434/api/generate
GHL_ACCESS_TOKEN=your_gohighlevel_access_token
GHL_LOCATION_ID=your_gohighlevel_location_id

Building the Express Webhook Receiver

Let’s build the Express server to catch GHL’s webhook. When a lead texts you, GHL sends a POST request with the contact ID, phone number, and message body. We need to parse this payload cleanly.

If you want to make this production-ready, check out my guide on how to validate webhook payloads with Zod in Express. For now, we’ll write a straightforward parser directly in our server.js file.

const express = require('express');
require('dotenv').config(); const app = express();
app.use(express.json()); app.post('/webhook/sms', async (req, res) => { const payload = req.body; console.log('Received webhook payload:', JSON.stringify(payload, null, 2)); // Acknowledge receipt immediately to prevent GoHighLevel from retrying res.status(200).send({ status: 'received' }); // Process the message asynchronously handleIncomingSMS(payload);
}); const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { console.log(`Webhook server listening on port ${PORT}`);
});

Notice that we return a 200 OK immediately. Do not make GHL wait for the local LLM to finish generating its response. If you do, GHL’s server will timeout, retry the webhook, and you’ll end up sending duplicate replies to your leads.

Connecting Ollama for Local Text Generation

Now we need to pass that message to Ollama. Since this is SMS, we have to be incredibly strict with our system prompt. If the model gets wordy, it looks like an obvious bot and gets split into multiple expensive SMS segments. We want short, punchy, human-like replies under 160 characters.

We’ll hit the Ollama API directly using Axios. Here is the helper function to query our local model:

const axios = require('axios'); async function generateAIResponse(incomingMessage) { const prompt = `You are a helpful assistant for a local business. Keep your response under 160 characters. Do not use emojis. Be direct and friendly.
Lead says: "${incomingMessage}"
Response:`; try { const response = await axios.post(process.env.OLLAMA_URL, { model: 'llama3', prompt: prompt, stream: false, options: { temperature: 0.5, num_predict: 50 } }); return response.data.response.trim(); } catch (error) { console.error('Error calling local Ollama:', error.message); return null; }
}

I set stream: false here because we need the full response before sending it back to GHL. I also added num_predict: 50 to hard-cap the token count. This stops the model from rambling if it ignores the system prompt.

Sending the Reply Back to GoHighLevel

Once Ollama spits out a response, we need to send it back to the lead. We’ll hit GHL’s conversations endpoint to trigger an outbound SMS. Grab your location API keys from the GoHighLevel Developer Portal. Here is the function to handle the GHL API v2 call:

async function sendSMS(contactId, messageText) { const url = 'https://services.leadconnectorhq.com/conversations/messages'; const data = { type: 'SMS', contactId: contactId, message: messageText }; const headers = { 'Authorization': `Bearer ${process.env.GHL_ACCESS_TOKEN}`, 'Version': '2021-04-15', 'Content-Type': 'application/json' }; try { const response = await axios.post(url, data, { headers }); console.log('SMS sent successfully. Message ID:', response.data.messageId); } catch (error) { console.error('Error sending GHL SMS:', error.response?.data || error.message); }
}

Now, let’s stitch the Express endpoint, the Ollama query, and the GHL sender together in our handleIncomingSMS handler:

async function handleIncomingSMS(payload) { const contactId = payload.contactId || payload.contact?.id; const incomingMessage = payload.message?.body; const direction = payload.message?.direction; // Skip outgoing messages so we don't reply to ourselves if (direction !== 'inbound') { console.log('Ignoring outbound message event.'); return; } if (!contactId || !incomingMessage) { console.log('Missing contactId or message body in payload.'); return; } console.log(`Processing inbound SMS from contact ${contactId}: "${incomingMessage}"`); const aiReply = await generateAIResponse(incomingMessage); if (aiReply) { console.log(`Ollama generated reply: "${aiReply}"`); await sendSMS(contactId, aiReply); } else { console.log('Failed to generate AI response.'); }
}

The Infinite Loop Gotcha (And How to Fix It)

When I first ran this script, my test phone blew up with five rapid-fire texts in ten seconds before I frantically hit Ctrl+C in my terminal. I hit the classic infinite loop bug. If your GHL workflow triggers on *any* new message in a conversation, your outbound API reply will trigger the exact same webhook.

Your server sees your own AI’s outbound reply, thinks it’s a new lead message, sends it back to Ollama, and fires another SMS. This will drain your SMS wallet in minutes if you don’t stop it. We fixed this in the code above with a simple direction check:

if (direction !== 'inbound') { return;
}

Always make sure the incoming event is explicitly inbound. Also, if you’re routing real traffic through this, you’ll want a queue. A sudden spike in texts will choke your local machine. Check out my guide on how to queue GoHighLevel webhooks with Node.js and BullMQ to handle peak traffic safely.

Running and Testing the Auto-Responder

Let’s test it. Fire up your local Node server:

node server.js

Now, expose your local port to the internet so GHL can talk to it. I use Cloudflare Tunnels, but ngrok works too:

cloudflared tunnel --url http://localhost:3000

Grab that public HTTPS URL from your tunnel and append /webhook/sms to it. Head over to GoHighLevel, create an Automation Workflow triggered by “Customer Replied”, and add a Webhook action. Paste your tunnel URL in there, publish the workflow, and text your GHL number. You should see Ollama spin up and generate a response in your terminal within a couple of seconds.

Frequently Asked Questions

Does this require a dedicated GPU?

No, but it definitely helps. Ollama runs fine on a CPU, but your response times will lag around 4 to 8 seconds. If you run it on an Apple Silicon Mac (M1/M2/M3) or a machine with a dedicated NVIDIA GPU, responses drop to under 1.5 seconds—which actually feels like a natural human typing speed anyway.

Can I use a different model besides Llama 3?

Absolutely. You can use any model Ollama supports, like Mistral or Phi-3. Just run ollama pull phi3 in your terminal and swap the model name in your Node.js Axios request.

How do I prevent the AI from giving weird answers?

It all comes down to your system prompt in the generateAIResponse function. Give the model strict guardrails, a clear persona, and a few examples of how to answer common questions. The more specific you are, the less it will hallucinate.

Next Steps

Now that your local SMS responder is running, you can start building more complex setups. If you want to take this a step further and actively reach out to cold leads instead of just waiting for replies, check out my guide on how to build a local AI outreach agent with Ollama and GoHighLevel.

all_in_one_marketing_tool