The flow in one line
What you need
GoHighLevel
The shared inbox and CRM. WhatsApp Business (in coexistence mode, so the phone app keeps working) and Instagram DMs land here.
WhatsApp add-on ≈ $20/moCloudflare Workers
Runs the agent code. No server to manage. Auto-deploys from a private GitHub repo on every push.
Paid plan $5/mo for QueuesAnthropic API key
Claude writes every reply. Cost depends on volume and model; caching the long rules prompt cuts it a lot.
pay per useTwo text files
prompt.md: how to talk, tone, dialect, sales steps, what never to say. facts.md: prices, schedules, policies, links, FAQs.
What happens to each message
- Trigger. A GoHighLevel workflow fires on “Customer replied” (WhatsApp and Instagram), waits 10 seconds so a burst of messages becomes one, then POSTs the contact id to the Worker with a secret key in the URL.
- Hand-off to a queue. The Worker answers GoHighLevel straight away and puts the job on a Cloudflare Queue, where it has minutes to finish.
- Read. It loads the contact and the last 20 messages through the GoHighLevel API.
- Decide whether to stay out. Skip staff and personal numbers, contacts tagged
ai-paused, chats where the newest message isn't the customer's, and chats where a team member wrote in the last 30 minutes. - Think. Send Claude: the rules + facts (cached), the chat, and a short customer card (children/orders, payments on file, sales stage).
- Act. Claude can call tools: update CRM fields, move the pipeline stage, check a payment, schedule one follow-up, add to a waiting list, flag a hot lead, or hand off to a human.
- Check again. If the customer wrote again while Claude was thinking, drop this reply; the newer run answers everything at once.
- Send. Post the reply through the GoHighLevel API as up to three short messages with typing pauses.
When a human takes over
- Hand-offs: refunds, complaints, “I want a person”, religious or medical questions, any exception to policy. The customer gets a short holding line, never silence. The team gets a task, a note in the chat, and a WhatsApp alert.
- Reply by WhatsApp: the team member answers the alert in plain words; the agent rewrites it in its own voice and sends it to the customer.
- Hot leads: a buying signal (asks for the link, picks a plan, payment failed) creates a card in a “ready to close” pipeline for your best closer. The agent keeps chatting normally.
- Team steps in: if a person replies in a chat, the agent goes quiet until that person has been silent for 30 minutes, then picks it back up.
Jobs that run every 10 minutes
- Send the follow-ups the agent scheduled (“they said they'd check with their partner”), only inside WhatsApp's 24-hour window and only if they haven't written or paid since.
- Nudge people who started checkout but didn't pay.
- Pick up chats where the team went quiet.
Build it with Claude Code
Paste this into Claude Code in an empty folder. It walks you through the accounts, writes the Worker, and starts in draft mode.
Build me a customer-messaging AI agent for my business. Walk me through it step by step and ask me before anything costs money or touches real customers.
What it does: customers message us on WhatsApp and Instagram. Messages land in GoHighLevel (GHL). A Cloudflare Worker gets each new message, asks Claude (Anthropic API) to write the reply using my rules and facts, and sends it back through the GHL API.
Set-up I need help with:
1. GHL: connect WhatsApp Business in coexistence mode (keep the phone app) and Instagram. Create a Private Integration token with scopes for contacts, conversations, messages, opportunities, custom fields, tags and payments.
2. Anthropic API key. Cloudflare account on the Workers Paid plan (for Queues). A private GitHub repo connected to Cloudflare Workers Builds so every push deploys.
3. Secrets stored as Cloudflare secrets only (ANTHROPIC_API_KEY, GHL_TOKEN, WEBHOOK_KEY), never in code or in plain vars.
The Worker (JavaScript, @anthropic-ai/sdk):
- POST /inbound?key=WEBHOOK_KEY: check the key, put {contactId} on a Cloudflare Queue, return 200 at once. Do the real work in the queue consumer (no ctx.waitUntil for the AI call: background work there is cut off after ~30 seconds and the customer silently gets nothing).
- Consumer: load the contact + the last 20 WhatsApp/Instagram messages from GHL. Stop if: staff number, tag "ai-paused" or "ai-off", the newest message isn't the customer's, or a human team member wrote in the last 30 minutes (then store a re-check for when that person has been quiet 30 minutes).
- Call Claude with: system = prompt.md + facts.md (prompt caching on), the chat as messages, and a short customer card (name, country, orders/payments on file, pipeline stage). The model writes the reply inside .
- Tools for Claude: update_customer (CRM fields), set_stage (pipeline), check_payment (look up GHL transactions by email), schedule_followup (one message 1–20 h later), flag_hot_lead (card in a "ready to close" pipeline + WhatsApp alert to the closer), handoff_to_human (task + note + alert; pause the agent only for complaints, refunds, "I want a person").
- Before sending: re-read the chat; if the customer wrote again, drop this reply.
- Send through GHL POST /conversations/messages (type WhatsApp or IG), split on lines containing only "---" into at most 3 messages with short typing pauses.
- Never fail silently: any error or timeout sends the team a WhatsApp alert naming the customer.
- Cron every 10 minutes: due follow-ups (only inside the 24-hour window, skip if they wrote or paid), quiet-chat re-checks, unfinished-checkout nudges.
- MODE variable: draft (post the reply as an internal GHL comment for a human to send), live, off. Start in draft.
GHL workflow: trigger "Customer replied" (WhatsApp) and (Instagram DM) → wait 10 seconds → webhook POST to the Worker URL with the key.
Then help me write the two files that matter most, from my real past chats:
- prompt.md: who we are, tone and dialect (match the customer), reply length (short, max 3 messages), the sales steps (welcome → one question → answer → next step), when to hand off, what never to say (no invented prices, no promises the team didn't make).
- facts.md: prices, plans, schedules by time zone, refund policy, payment methods, links, FAQs, and the team's real answers to common questions.
Finally: a test script that replays 30–50 real past questions through the agent offline so I can compare its answers with my team's before going live.
Starter files
{
"name": "inbox-agent",
"main": "src/index.js",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"rules": [{ "type": "Text", "globs": ["**/*.md"], "fallthrough": true }],
"vars": { "MODE": "draft", "GHL_LOCATION": "your-location-id", "MODEL": "claude-opus-5-5" },
"kv_namespaces": [{ "binding": "STATE", "id": "your-kv-id" }],
"queues": {
"producers": [{ "binding": "JOBS", "queue": "inbox-agent-jobs" }],
"consumers": [{ "queue": "inbox-agent-jobs", "max_batch_size": 1 }]
},
"triggers": { "crons": ["*/10 * * * *"] }
// Secrets (dashboard → Worker → Settings → Variables → type Secret):
// ANTHROPIC_API_KEY, GHL_TOKEN, WEBHOOK_KEY
}
import Anthropic from "@anthropic-ai/sdk";
import PROMPT from "./prompt.md";
import FACTS from "./facts.md";
export default {
// GHL webhook → queue (answer GHL at once; the queue has minutes to work)
async fetch(req, env) {
const url = new URL(req.url);
if (req.method !== "POST" || url.pathname !== "/inbound") return new Response("not found", { status: 404 });
if (url.searchParams.get("key") !== env.WEBHOOK_KEY) return new Response("forbidden", { status: 403 });
const body = await req.json().catch(() => ({}));
const contactId = body.contact_id || body.contactId;
if (!contactId || env.MODE === "off") return new Response("skip");
await env.JOBS.send({ contactId });
return new Response("queued");
},
// The real work
async queue(batch, env) {
for (const msg of batch.messages) {
try { await handle(msg.body.contactId, env); }
catch (e) { await alertTeam(env, `Couldn't answer ${msg.body.contactId}: ${e.message}`); }
msg.ack();
}
},
// Every 10 minutes: follow-ups, quiet-chat re-checks, checkout nudges
async scheduled(event, env, ctx) {
ctx.waitUntil(runDueJobs(env));
},
};
async function handle(contactId, env) {
const contact = await getContact(env, contactId);
if (isStaff(contact) || hasTag(contact, "ai-paused")) return;
const chat = await lastMessages(env, contactId, 20); // oldest → newest
if (chat.at(-1)?.direction !== "inbound") return; // nothing waiting
if (humanWroteWithin(chat, 30 * 60e3)) return scheduleRecheck(env, contactId, chat);
const { text, actions } = await think(env, chat, customerCard(contact));
for (const a of actions) await runTool(env, contact, a); // CRM, stage, hot lead, hand-off…
const now = await lastMessages(env, contactId, 5);
if (now.at(-1).id !== chat.at(-1).id) return; // they wrote again → newer run answers
if (!text) return;
if (env.MODE === "draft") return postInternalNote(env, contactId, text);
await sendInParts(env, contactId, channelOf(chat), text); // split on "---", max 3, typing pauses
}
async function think(env, chat, card) {
const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
// system: [{ text: PROMPT + FACTS, cache_control: { type: "ephemeral" } }], tools: TOOLS,
// messages: chat mapped to user/assistant + the card on the last user turn.
// Loop while the model calls tools; return the text inside … .
}
What we learned the hard way
| Problem | Fix |
|---|---|
| Some customers got no reply and nobody was told. | Background work on Cloudflare is cut off after about 30 seconds. Run the AI call in a Queue consumer, and alert the team on any failure. |
| The agent went silent for hours after a team member said one line. | Stay quiet only while the human is active; pick the chat back up after 30 minutes of their silence. |
| Invented or outdated prices. | Every number lives in facts.md. The prompt says: quote only from the facts. |
| Hand-offs left the customer on read. | Always send a short holding line; the task and alert go to the team. |
| Long, robotic replies. | Max three short messages, match the customer's dialect, one question at a time. |
| Going live too early. | Draft mode first: the reply appears as an internal note and a human sends it. Go live when the drafts are reliably good. |
Before you go live: replay 30–50 real past questions through the agent and have your team judge its answers against their own, blind. Then keep reviewing real chats weekly and feed every mistake back into prompt.md or facts.md.