Build your own WhatsApp sales assistant
Thanks for joining the webinar. This is the step-by-step version of what I showed: an assistant on the official WhatsApp API that tags chats, answers simple questions, drafts replies you approve with one tap, and sends a morning recap.
- ⏱ About an afternoon
- 💸 No software subscription
- 🧑💻 No coding experience needed, just copy and paste
- 🔒 Official WhatsApp API
- Sign up3 free accounts · step 1
- Two promptsinterview, then AI builds it · step 2
- Fill in your keysfrom OpenRouter and Meta · steps 3–4
- Start ityour AI does this · step 5
- Test itwith two numbers · step 6
Each step ends with a ✅ Check. If it doesn’t match what you see, fix that step before moving on (see Troubleshooting).
1What you need
💻 A computer
Mac or Windows. Install Node.js (the “LTS” version) from nodejs.org.
📱 Two WhatsApp numbers
One is you (the owner, who receives the cards). The other plays the customer while testing.
🟦 A Meta developer account
Free, at developers.facebook.com. Log in with Facebook.
🤖 An OpenRouter account
At openrouter.ai. Add a few dollars of credit; one chat costs under 1 US cent.
🌐 An ngrok account
Free, at ngrok.com. WhatsApp needs a public web address to send messages to; your laptop doesn't have one, and ngrok gives it one while you build. After signing up, follow the Setup & Installation page in your ngrok dashboard (install it, then run the add-authtoken line it shows). Skip this if you start on a cloud server, which has its own address.
🛠 An AI coding assistant
Something that can create files on your computer, e.g. Claude Code or Cursor. It does the typing in step 2 and helps if you get stuck.
🗂 Who's who
Business number = where customers message (Meta's free test number while you build). Owner = your own WhatsApp. Customer = the second number.
✅ CheckNode.js is installed (in Terminal, node -v shows a version number), and you have logins for Meta, OpenRouter and ngrok.
2Let AI build it for you
You don't write any code. You have two conversations with an AI coding assistant: first it asks about your business, then it builds the assistant around your answers.
- Open an AI assistant that can create files on your computer: Claude Code, Cursor, or another coding assistant. ChatGPT and Claude in a browser can't save files to your computer, so they won't work for this.
- Make an empty folder called
whatsapp-assistant(your Desktop is fine) and open that folder in the assistant.
First: tell it about your business
This is the part that decides how good your assistant turns out, and it's all plain English. Paste this and answer its questions:
I'm building a WhatsApp assistant for my business, and the first job is to write down how my business actually works. Please interview me.
Ask me one question at a time, in plain English, and wait for my answer before the next one. Cover:
- what I sell, and where
- who usually messages me on WhatsApp, and what they usually ask
- the tone you should write in (formal, friendly, Singlish, "I" or "we", emoji or not)
- what a hot lead looks like for me: the signs someone is ready to buy
- what you must never handle on your own, and always pass to me (prices, discounts, complaints, anything sensitive)
- the facts you're allowed to answer by yourself: opening hours, location, delivery area, website, how long things take
- what you must never say
- what the next step is for a good lead (a call, a site visit, a quote)
If an answer is vague, ask me to be more specific instead of guessing. Never invent a price, an opening time, or anything else I haven't told you.
When we're done, write it all into a file called business-profile.md, organised under those headings, and show it to me so I can correct anything.Take your time here. Vague answers give you a vague assistant. When it shows you the finished profile, correct anything that isn't quite how you'd say it.
Then: let it build
In the same conversation, paste this:
Now build me the assistant itself, using the business-profile.md we just wrote.
What it should do:
1. Receive customer WhatsApp messages through a Meta WhatsApp Cloud API webhook.
2. Wait about 30 seconds after the last message, so several quick messages from one person are read together.
3. Ask the AI to read the chat and return JSON with: a tag (hot / warm / general / not_lead / needs_you), an action (auto_reply / draft / hand_off / none), a one-line summary, what information is still missing, and a suggested reply.
4. Auto-reply ONLY to simple questions that my profile can fully answer. When in doubt, draft instead.
5. For hot chats and chats that need me, send ME on WhatsApp a card with the summary and the draft, and three buttons: Send, Edit, Skip. Replies always go out from the business number.
6. Everything else waits in a queue. Send me at most one "N chats waiting" message every 30 minutes. If I type "next", show me the most urgent waiting chat.
7. After I reply to someone myself, offer a "Take over 2h" button that stops cards and auto-replies for that one chat.
8. Every morning at 9, and whenever I type "recap", send me a short summary of today's priorities.
9. Keep the general rules in agent-prompt.md, and my business details in businesses/my-business/profile.md (move business-profile.md there). Both in plain English, re-read on every message, so I can edit them without touching code.
Please keep these technical choices exactly as written, because I'm following a guide that assumes them:
- Node.js, no npm packages, started with "npm start", listening on port 3000.
- Settings in a .env file using exactly these names: OPENROUTER_API_KEY, MODEL, WHATSAPP_TOKEN, PHONE_NUMBER_ID, VERIFY_TOKEN, OWNER_NUMBER, RECAP_TIME, PORT.
- The webhook at /webhook: GET answers Meta's check using VERIFY_TOKEN, POST receives messages.
- Meta's official WhatsApp Cloud API (graph.facebook.com). No unofficial WhatsApp libraries, ever.
- OpenRouter for the AI, and on every request ask only for providers that don't store or train on the data (provider data_collection "deny"). If none is available, fail rather than quietly using another one.
- Never send the customer's phone number to the AI, only their name and messages.
- Chats stored in a simple JSON file, and a plain local page at localhost:3000 showing every chat, its tag and its draft.
Build it in small pieces and explain each one in plain English: I'm not a programmer. Don't ask me for my API keys or tokens and never print them in this chat; create .env with the values blank and I'll fill it in myself.
When the files exist, start the program once to check it runs, then stop and tell me what to do next.It writes the program, built around your business rather than a generic template. It ends up with roughly these files:
| File | What it is |
|---|---|
| package.json | Tells Node.js how to start the program |
| .env | Your keys and settings. Never share this file. |
| agent-prompt.md | The general rules: how to tag chats and when to reply |
| businesses/my-business/profile.md | Everything about your business, from the interview |
| server.js | The program itself |
| public/index.html | The test page at localhost:3000 |
The one file you fill in yourself
.env holds your keys, so it arrives blank. Open it in a text editor and fill in five lines as you go through the next two steps:
| Line in .env | What to put there | Step |
|---|---|---|
| OPENROUTER_API_KEY | Your OpenRouter key | 3 |
| WHATSAPP_TOKEN | The access token from Meta | 4 |
| PHONE_NUMBER_ID | The long ID shown under the test number (not the phone number itself) | 4 |
| VERIFY_TOKEN | Any secret word you make up | 4 |
| OWNER_NUMBER | Your own WhatsApp number, e.g. 65XXXXXXXX | 4 |
| Everything else | Leave as it is | – |
Type each value straight after the =, with no spaces or quotes. Keep the file private, and never paste your keys into an AI chat. On a Mac, files starting with a dot are hidden in Finder; press Cmd + Shift + . to show them.
✅ CheckThe files exist, your profile reads like your business, and the program starts without errors.
Want the exact code I used in the webinar? Open this
These are my files, the ones running in the demo. Use them if you'd rather start from something already tested: hand them to your assistant, or create each file with a plain text editor and copy it exactly. In TextEdit, choose Format → Make Plain Text first. Notepad may save .env as .env.txt: pick “All files” when saving. businesses/my-business/ means a folder businesses with a folder my-business inside.
package.json
{
"name": "kite-whatsapp-inbox",
"version": "1.0.0",
"private": true,
"description": "AI WhatsApp inbox: tags chats, drafts replies for review, auto-replies simple questions, daily recap.",
"type": "module",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=20.12"
}
}
.env
# Your keys and settings. Never share this file.
# --- The brain: OpenRouter (https://openrouter.ai/keys) ---
OPENROUTER_API_KEY=
MODEL=anthropic/claude-sonnet-5
# --- WhatsApp: Meta Cloud API (developers.facebook.com → your app → WhatsApp → API Setup) ---
WHATSAPP_TOKEN=
PHONE_NUMBER_ID=
# Any secret word you choose. Type the same word into Meta's webhook settings.
VERIFY_TOKEN=choose-a-secret-word
GRAPH_VERSION=v23.0
# --- Daily recap ---
# Your own WhatsApp number (with country code, e.g. 65XXXXXXXX). Drafts to approve and the daily recap are sent here.
# Must be a different number from the phone you use as the "customer".
OWNER_NUMBER=
# Time to send the recap every day (24h, your computer's time zone). Leave empty to turn off.
RECAP_TIME=09:00
# Which business profile to start with (a folder name in businesses/). You can also switch on the local page.
BUSINESS=my-business
# Group quick messages from the same customer (seconds). Use 5 for a live demo.
WAIT_SECONDS=30
# Send at most one "chats waiting" note per this many minutes.
NUDGE_MINUTES=30
PORT=3000
# After you reply to a chat yourself, the AI stays quiet on that chat for this many minutes.
TAKEOVER_MINUTES=120
agent-prompt.md
# WhatsApp sales assistant — general rules
You are the WhatsApp sales assistant for the business described in BUSINESS PROFILE below.
You read each customer chat and decide three things: a TAG, an ACTION, and a REPLY.
These rules work for any business. Everything specific to this business is in the profile.
## 1. Tag the chat
- `hot` — a real enquiry that is ready to move: it matches the profile's "hot lead" signals, or they want to proceed / book / confirm
- `warm` — interested, but key information from the profile's "what we need to know" list is missing, or they are only exploring
- `general` — a quick question, not a real enquiry yet
- `not_lead` — vendors, recruiters, spam, anything that is not a potential customer
- `needs_you` — the owner must handle it personally: anything on the profile's "always hand to the owner" list, complaints, angry customers, or anything you are unsure about
## 2. Choose the action
- `auto_reply` — ONLY for `general` questions you can fully answer from the profile's facts. Never guess.
- `draft` — for `hot` and `warm` chats. Write the reply; a human reviews it and presses Send.
- `hand_off` — for `needs_you`. Write a short holding reply anyway, but a human decides.
- `none` — for `not_lead`. No reply.
When in doubt, choose `draft`, never `auto_reply`.
## 3. Write the reply
- Match the tone in the profile. WhatsApp style: short and clear, max 60 words.
- For warm leads, ask at most 3 questions, choosing the most important missing items from "what we need to know".
- For hot leads, move them to the next step in the profile.
- If a customer has messaged more than once without a reply, apologise briefly for the wait.
- Never invent prices, rates, availability, dates or promises. Never agree to discounts or special terms.
- Never confirm a date, time or availability yourself. Ask the customer for options; the owner confirms.
- If the profile speaks as "I" (a solo business), never say "our team".
- Reply in the language the customer used.
businesses/my-business/profile.md
# Business profile
Name: Your Business Name
What we do: one or two sentences about what you sell and where.
Customers: who usually messages you.
Tone: e.g. friendly, professional Singapore English. One emoji max.
## What we need to know before we can quote
1. ...
2. ...
3. ...
## Hot lead signals
- They give the key details (what, how much, when)
- They want to book, confirm, or ask for next steps
## Always hand to the owner (needs_you)
- Discounts or price negotiation
- Complaints
- Anything about contracts, payments or refunds
## Facts the assistant may use for auto-replies
- Opening hours: ...
- Where we are / where we deliver: ...
- Website or portfolio: ...
## Never
- Never give a price
- Never promise a date or availability
## Next step for hot leads
e.g. Suggest a call and ask which time suits them.
businesses/bunker-fuel/profile.md (example)
# Business profile
Name: Meridian Marine Fuels (sample business for the demo)
What we do: we sell and arrange delivery of marine fuel (bunkers) to ships. We are a trader/reseller; we do not own barges.
Customers: ship operators, ship managers, charterers and bunker brokers.
Tone: professional, brief, trader-style. First names are fine. No emoji.
## What we sell
- Grades: VLSFO 0.5%S, HSFO 380cst 3.5%S (for scrubber-fitted ships), MGO / LSMGO 0.1%S
- Ports: Singapore, Johor (Tanjung Pelepas), Port Klang. Other ports in Asia on request, subject to supplier.
- Minimum quantity: 100 MT for fuel oil, 30 MT for MGO.
- Every delivery comes with a Bunker Delivery Note (BDN) and a sample.
## What we need to know before we can quote (a "firm enquiry")
1. Vessel name and IMO number
2. Port
3. Delivery window / ETA
4. Grade and quantity (MT)
5. Buyer company name (for credit check)
## Hot lead signals
- A firm enquiry: most of the 5 items above are given
- They ask us to confirm, nominate or lock a stem
- Urgent delivery within the next 72 hours
## Always hand to the owner (needs_you)
- Price negotiation, "can you beat this price", price matching
- Credit terms, payment terms, open account requests
- Quality claims, off-spec fuel, quantity disputes, delays
- Anything legal or about sanctions / sanctioned vessels
## Facts the assistant may use for auto-replies
- Prices move daily with the market, so we only quote against a firm enquiry. Never give a price.
- Quotes are normally sent within 1 hour during office hours (Mon–Fri, 9am–6pm Singapore time). Urgent enquiries are handled after hours too.
- We supply the ports listed above; other Asian ports on request.
- New buyers need a short credit check before the first delivery.
## Next step for hot leads
Confirm we received the details, say we are checking availability and will revert with an offer shortly, and ask for any missing item from the list above.
server.js
// Kite WhatsApp Inbox
// WhatsApp (Meta Cloud API) → AI brain (OpenRouter) → Inbox page (tags, drafts, Send) → Daily recap
import http from 'node:http';
import crypto from 'node:crypto';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
try { process.loadEnvFile(fileURLToPath(new URL('./.env', import.meta.url))); } catch { /* no .env yet: demo mode still works without WhatsApp */ }
const {
PORT = '3000',
OPENROUTER_API_KEY,
MODEL = 'anthropic/claude-sonnet-5',
WHATSAPP_TOKEN,
PHONE_NUMBER_ID,
VERIFY_TOKEN = 'kite-demo-verify',
APP_SECRET,
GRAPH_VERSION = 'v23.0',
OWNER_NUMBER,
RECAP_TIME,
BUSINESS = 'renovation',
GRAPH_URL = 'https://graph.facebook.com',
OPENROUTER_URL = 'https://openrouter.ai/api/v1',
} = process.env;
const OWNER = (OWNER_NUMBER || '').replace(/\D/g, '');
// Wait this long after a customer's last message, so 3 quick messages become 1 card.
const WAIT_MS = Number(process.env.WAIT_SECONDS ?? 30) * 1000;
// "N chats waiting" reminders for non-urgent chats are sent at most this often.
const NUDGE_MS = Number(process.env.NUDGE_MINUTES ?? 30) * 60000;
// After you reply to a chat yourself, the AI stays quiet on it (no cards, no auto-replies) for this long.
const TAKEOVER_MS = Number(process.env.TAKEOVER_MINUTES ?? 120) * 60000;
const takenOver = chat => (chat.takenOverUntil || 0) > Date.now();
const TAKEOVER_HOURS = Math.round(TAKEOVER_MS / 3600000 * 10) / 10;
const URGENT = new Set(['hot', 'needs_you']);
const timers = new Map();
const DATA_FILE = new URL('./data/store.json', import.meta.url);
const TAG_RANK = { needs_you: 0, hot: 1, warm: 2, general: 3, not_lead: 4 };
// ---------- storage (one JSON file) ----------
let store = { chats: {}, recap: null, lastRecapDate: null };
if (existsSync(DATA_FILE)) store = JSON.parse(await readFile(DATA_FILE, 'utf8'));
async function save() {
await mkdir(new URL('./data/', import.meta.url), { recursive: true });
await writeFile(DATA_FILE, JSON.stringify(store, null, 2));
}
// ---------- business profiles (businesses/<id>/profile.md) ----------
const BUSINESSES_DIR = new URL('./businesses/', import.meta.url);
const businessIds = () => readdirSync(BUSINESSES_DIR, { withFileTypes: true })
.filter(d => d.isDirectory() && existsSync(new URL(`${d.name}/profile.md`, BUSINESSES_DIR)))
.map(d => d.name);
const currentBusiness = () => store.business || BUSINESS;
const readProfile = (id = currentBusiness()) => readFile(new URL(`${id}/profile.md`, BUSINESSES_DIR), 'utf8');
async function businessName(id) {
const match = (await readProfile(id)).match(/^Name:\s*(.+?)(\s*\(.*\))?$/m);
return match ? match[1] : id;
}
// Process one chat at a time so messages from the same person stay in order.
const queues = new Map();
function enqueue(waId, job) {
const next = (queues.get(waId) || Promise.resolve()).then(job).catch(err => console.error(err));
queues.set(waId, next);
return next;
}
// ---------- 1. WhatsApp: send a message ----------
async function sendWhatsApp(to, text) {
if (!WHATSAPP_TOKEN || !PHONE_NUMBER_ID) throw new Error('WhatsApp is not connected (add WHATSAPP_TOKEN and PHONE_NUMBER_ID to .env)');
const res = await fetch(`${GRAPH_URL}/${GRAPH_VERSION}/${PHONE_NUMBER_ID}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${WHATSAPP_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }),
});
if (!res.ok) throw new Error(`WhatsApp send failed (${res.status}): ${await res.text()}`);
}
// Message with tap buttons (max 3 buttons, 20 characters each).
async function sendButtons(to, text, buttons) {
if (!WHATSAPP_TOKEN || !PHONE_NUMBER_ID) throw new Error('WhatsApp is not connected');
const res = await fetch(`${GRAPH_URL}/${GRAPH_VERSION}/${PHONE_NUMBER_ID}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${WHATSAPP_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
messaging_product: 'whatsapp', to, type: 'interactive',
interactive: {
type: 'button',
body: { text: text.length > 1024 ? text.slice(0, 1020) + '…' : text },
action: { buttons: buttons.map(([id, title]) => ({ type: 'reply', reply: { id, title } })) },
},
}),
});
if (!res.ok) throw new Error(`WhatsApp send failed (${res.status}): ${await res.text()}`);
}
// ---------- 2. The brain: ask the AI ----------
async function askAI(system, user) {
if (!OPENROUTER_API_KEY) throw new Error('Add OPENROUTER_API_KEY to .env');
const res = await fetch(`${OPENROUTER_URL}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${OPENROUTER_API_KEY}`, 'Content-Type': 'application/json', 'X-Title': 'Kite WhatsApp Inbox' },
body: JSON.stringify({
model: MODEL,
temperature: 0.2,
// Privacy: only use AI providers that don't store or train on these chats.
// If none is available, the request fails instead of falling back.
provider: { data_collection: 'deny', zdr: true },
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
}),
});
if (!res.ok) throw new Error(`OpenRouter error (${res.status}): ${await res.text()}`);
const data = await res.json();
return data.choices[0].message.content;
}
const OUTPUT_FORMAT = `
## Output
Answer with ONE JSON object only, no other text. "tag" must be exactly one of: hot, warm, general, not_lead, needs_you. "action" must be exactly one of: auto_reply, draft, hand_off, none.
{"tag": "hot|warm|general|not_lead|needs_you", "summary": "what they want, max 12 words", "missing": "key info still missing, or empty", "action": "auto_reply|draft|hand_off|none", "reply": "the WhatsApp reply, or empty if action is none", "reason": "why, max 12 words"}`;
const clock = ts => new Date(ts).toLocaleString('en-SG', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
async function analyzeChat(chat) {
// Re-read both files every time, so edits apply to the next message.
const rules = await readFile(new URL('./agent-prompt.md', import.meta.url), 'utf8');
const profile = await readProfile();
const biz = await businessName(currentBusiness());
const transcript = chat.messages
.map(m => `[${clock(m.ts)}] ${m.from === 'customer' ? chat.name : m.from === 'ai' ? `${biz} (AI auto-reply)` : biz}: ${m.text}`)
.join('\n');
const raw = await askAI(`${rules}\n\n# BUSINESS PROFILE\n\n${profile}${OUTPUT_FORMAT}`, `Time now: ${clock(Date.now())}\nCustomer name: ${chat.name}\n\nChat so far:\n${transcript}`);
const json = raw.match(/\{[\s\S]*\}/);
if (!json) throw new Error(`AI did not return JSON: ${raw.slice(0, 200)}`);
return JSON.parse(json[0]);
}
// ---------- The pipeline: a customer message comes in ----------
async function handleIncoming({ waId, name, text, ts = Date.now(), id, simulated = false }) {
return enqueue(waId, async () => {
const chat = (store.chats[waId] ||= { waId, name, messages: [], simulated });
if (id && chat.messages.some(m => m.id === id)) return; // Meta can deliver the same message twice
chat.name = name || chat.name;
chat.messages.push({ id, from: 'customer', text, ts });
chat.lastCustomerAt = Math.max(chat.lastCustomerAt || 0, ts);
chat.status = 'thinking';
chat.error = null;
await save();
if (simulated || !WAIT_MS) return analyze(chat);
clearTimeout(timers.get(waId));
timers.set(waId, setTimeout(() => enqueue(waId, () => analyze(chat)), WAIT_MS));
});
}
async function analyze(chat) {
try {
const result = await analyzeChat(chat);
const tag = String(result.tag || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
if (!(tag in TAG_RANK)) throw new Error(`AI returned an unknown tag: ${result.tag}`);
Object.assign(chat, { tag, summary: result.summary, missing: result.missing, reason: result.reason, action: result.action });
chat.draft = result.action === 'none' ? '' : result.reply || '';
chat.status = 'ready';
if (takenOver(chat) && !chat.simulated) {
// You're handling this chat: keep the tag and draft for next/recap, but don't ping or auto-reply.
} else if (result.action === 'auto_reply' && chat.draft) {
await deliver(chat, chat.draft, 'ai'); // answered quietly; it shows up in the recap
} else if (!chat.simulated && OWNER && chat.draft) {
if (URGENT.has(tag)) await askOwnerToApprove(chat);
else await nudgeOwner();
}
} catch (err) {
chat.error = err.message;
console.error(`[${chat.name}]`, err.message);
}
chat.status = 'ready';
await save();
}
// Send a reply (for real on WhatsApp, or pretend for demo chats) and record it.
async function deliver(chat, text, from) {
if (!chat.simulated) await sendWhatsApp(chat.waId, text);
chat.messages.push({ from, text, ts: Date.now() });
chat.lastReplyAt = Date.now();
chat.draft = '';
if (from === 'you' && chat.action === 'hand_off') chat.action = 'draft';
}
// ---------- The owner approves on WhatsApp ----------
const TAG_LABEL = { needs_you: '⚠️ NEEDS YOU', hot: '🔥 HOT', warm: '🌤 WARM', general: '💬 GENERAL', not_lead: '🚫 NOT A LEAD' };
const minutes = ms => { const m = Math.round(ms / 60000); return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`; };
// Chats with a reply waiting for the owner, most urgent first.
const waitingForOwner = () => sortedChats().filter(c => c.draft && !c.simulated && c.status === 'ready');
async function askOwnerToApprove(chat) {
const more = waitingForOwner().filter(c => c.waId !== chat.waId).length;
const last = chat.messages.filter(m => m.from === 'customer').at(-1);
const count = chat.messages.filter(m => m.from === 'customer').length;
const text = [
`${TAG_LABEL[chat.tag]} · *${chat.name}*`,
`⏱ Waiting ${minutes(Date.now() - chat.lastCustomerAt)}${count > 1 ? ` · ${count} messages` : ''}`,
chat.summary ? `*Wants:* ${chat.summary}` : null,
chat.missing ? `*Missing:* ${chat.missing}` : null,
takenOver(chat) ? '👤 You’re handling this chat' : null,
'',
`💬 “${last.text}”`,
'',
chat.action === 'hand_off' ? `✍️ *Suggestion (your call):*` : `✍️ *Draft reply:*`,
chat.draft,
more ? `\n📥 ${more} more waiting · reply *next*` : null,
].filter(line => line !== null).join('\n');
await sendButtons(OWNER, text, [[`send:${chat.waId}`, '✅ Send'], [`edit:${chat.waId}`, '✏️ Edit'], [`skip:${chat.waId}`, '⏭ Skip']]);
}
async function showNext() {
const [first] = waitingForOwner();
if (!first) return sendWhatsApp(OWNER, '🎉 All caught up. Nothing is waiting for you.');
return askOwnerToApprove(first);
}
async function nudgeOwner() {
if (Date.now() - (store.lastNudgeAt || 0) < NUDGE_MS) return;
store.lastNudgeAt = Date.now();
// Wait a little so chats arriving together are counted in one reminder.
setTimeout(() => sendNudge().catch(err => console.error('Reminder failed:', err.message)), Math.max(WAIT_MS * 2, 5000));
}
async function sendNudge() {
const list = waitingForOwner();
if (!list.length) return;
const count = tag => list.filter(c => c.tag === tag).length;
const parts = [['hot', '🔥'], ['needs_you', '⚠️'], ['warm', '🌤'], ['general', '💬']].filter(([t]) => count(t)).map(([t, e]) => `${e} ${count(t)}`);
await sendWhatsApp(OWNER, `📥 *${list.length} chat${list.length > 1 ? 's' : ''} waiting for a reply* (${parts.join(' · ')})\nReply *next* to go through them one by one, most urgent first.`);
}
async function handleOwner(msg) {
const say = text => sendWhatsApp(OWNER, text);
// Send a reply to the customer; if WhatsApp refuses, tell the owner instead of failing silently.
const sendOrExplain = async (chat, text, done) => {
try {
await deliver(chat, text, 'you');
} catch (err) {
console.error(`[${chat.name}]`, err.message);
const notAllowed = /131030|not in allowed list/i.test(err.message);
await say(`⚠️ Couldn’t send to ${chat.name}.${notAllowed ? '\nThe free test number can only reply to the numbers added in Meta.' : ''}`);
return;
}
await save();
if (takenOver(chat)) await say(done);
else await sendButtons(OWNER, `${done}\nTalking to them yourself? Tap below and I’ll stay quiet on this chat for ${TAKEOVER_HOURS}h.`, [[`takeover:${chat.waId}`, `👤 Take over ${TAKEOVER_HOURS}h`]]);
await showNext();
};
if (msg.type === 'interactive' && msg.interactive?.button_reply) {
const [cmd, waId] = msg.interactive.button_reply.id.split(':');
const chat = store.chats[waId];
if (!chat) return say('I can’t find that chat any more.');
if (cmd === 'send') {
return enqueue(waId, async () => {
if (!chat.draft) return say(`Nothing waiting to send for ${chat.name}.`);
await sendOrExplain(chat, chat.draft, `✅ Sent to ${chat.name}.`);
});
}
if (cmd === 'edit') {
store.pendingEdit = waId;
await save();
return say(`✏️ Type your reply to *${chat.name}*. I’ll send it exactly as you write it.\n(Type *cancel* to stop.)`);
}
if (cmd === 'takeover') {
chat.takenOverUntil = Date.now() + TAKEOVER_MS;
await save();
return say(`👤 You’ve taken over *${chat.name}*. I won’t ping you or auto-reply on this chat for ${TAKEOVER_HOURS}h. New messages wait for *next* or *recap*.`);
}
if (cmd === 'skip') {
chat.draft = '';
await save();
await say(`⏭ Skipped ${chat.name}. It stays in your recap until someone replies.`);
return showNext();
}
}
const text = msg.type === 'text' ? msg.text.body.trim() : '';
if (store.pendingEdit) {
const chat = store.chats[store.pendingEdit];
store.pendingEdit = null;
await save();
if (/^cancel$/i.test(text) || !chat) return say('Cancelled. Nothing was sent.');
if (!text) return say('Please type the reply as text. Nothing was sent.');
return enqueue(chat.waId, () => sendOrExplain(chat, text, `✅ Sent your reply to ${chat.name}.`));
}
if (/^(next|n)$/i.test(text)) return showNext();
if (/^(recap|today|summary)$/i.test(text)) {
await makeRecap();
return;
}
return say('Hi! I’m your sales assistant.\n\n🔥 Hot leads and ⚠️ chats that need you arrive here straight away, with a draft reply.\nEverything else waits in a queue.\n\nType *next* to review waiting chats one by one.\nType *recap* to see today’s priorities.');
}
// ---------- 3. Daily recap ----------
async function makeRecap() {
const now = Date.now();
const lines = sortedChats().map(c => {
const waiting = c.lastCustomerAt > (c.lastReplyAt || 0) ? `waiting ${Math.round((now - c.lastCustomerAt) / 60000)} min` : 'replied';
const aiAnswered = c.messages.some(m => m.from === 'ai') ? ' | answered by AI' : '';
return `- ${c.name} | ${c.tag} | ${c.summary} | ${waiting}${aiAnswered} | messages from customer: ${c.messages.filter(m => m.from === 'customer').length}`;
}).join('\n');
const text = await askAI(
'You write a short daily recap for a busy business owner or salesperson, sent on WhatsApp. Start with "☀️ *Today’s priorities*". Use WhatsApp formatting (*bold*). Sections: 🔥 Reply first (hot leads and anyone waiting, most urgent first, with waiting time), ⚠️ Needs you, 🌤 Follow up, 📊 One line with counts. Skip empty sections. Only list chats that are still waiting or need follow-up. Max 120 words. Only use the data given. Mention how many simple questions the AI answered by itself. End with: "Reply *next* to go through them."',
`Chats:\n${lines || '(no chats)'}`,
);
store.recap = { text, at: now, sentTo: null };
if (OWNER && WHATSAPP_TOKEN) {
try { await sendWhatsApp(OWNER, text); store.recap.sentTo = OWNER; }
catch (err) { store.recap.error = err.message; }
}
await save();
return store.recap;
}
if (RECAP_TIME) {
setInterval(() => {
const d = new Date();
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const today = d.toDateString();
if (hhmm === RECAP_TIME && store.lastRecapDate !== today) {
store.lastRecapDate = today;
makeRecap().catch(err => console.error('Recap failed:', err.message));
}
}, 20_000);
}
function sortedChats() {
return Object.values(store.chats).sort((a, b) => {
const rank = c => (c.status === 'thinking' ? -1 : TAG_RANK[c.tag] ?? 5);
if (rank(a) !== rank(b)) return rank(a) - rank(b);
const waitA = a.lastCustomerAt > (a.lastReplyAt || 0), waitB = b.lastCustomerAt > (b.lastReplyAt || 0);
if (waitA !== waitB) return waitA ? -1 : 1;
return waitA ? a.lastCustomerAt - b.lastCustomerAt : b.lastCustomerAt - a.lastCustomerAt;
});
}
// ---------- Web server ----------
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 1_000_000) throw new Error('Body too large');
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
const sendJSON = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
try {
// Meta checks the webhook URL once, when you save it in the dashboard.
if (url.pathname === '/webhook' && req.method === 'GET') {
if (url.searchParams.get('hub.verify_token') === VERIFY_TOKEN) {
res.writeHead(200); return res.end(url.searchParams.get('hub.challenge'));
}
res.writeHead(403); return res.end('Wrong verify token');
}
// Meta sends every new WhatsApp message here.
if (url.pathname === '/webhook' && req.method === 'POST') {
const body = await readBody(req);
if (APP_SECRET) {
const expected = 'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(body).digest('hex');
const got = req.headers['x-hub-signature-256'] || '';
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
res.writeHead(401); return res.end();
}
}
res.writeHead(200); res.end(); // answer Meta fast, then do the work
const payload = JSON.parse(body.toString() || '{}');
for (const entry of payload.entry || []) {
for (const change of entry.changes || []) {
const value = change.value || {};
for (const msg of value.messages || []) {
if (OWNER && msg.from === OWNER) {
console.log(`← owner: ${msg.type === 'text' ? msg.text.body : msg.interactive?.button_reply?.title || msg.type}`);
handleOwner(msg).catch(err => console.error('Owner command failed:', err.message));
continue;
}
const contact = (value.contacts || []).find(c => c.wa_id === msg.from);
const text = msg.type === 'text' ? msg.text.body : `[${msg.type} message]`;
console.log(`← ${contact?.profile?.name || msg.from}: ${text}`);
handleIncoming({ waId: msg.from, name: contact?.profile?.name || 'Customer', text, ts: Number(msg.timestamp) * 1000, id: msg.id });
}
}
}
return;
}
// Everything below is the private inbox. Block it when reached through the public tunnel.
if (req.headers['x-forwarded-for'] || req.headers['ngrok-trace-id']) {
res.writeHead(403); return res.end('The inbox is only available on this computer.');
}
if (url.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(await readFile(new URL('./public/index.html', import.meta.url)));
}
if (url.pathname === '/api/chats' && req.method === 'GET') {
return sendJSON(res, 200, {
chats: sortedChats(),
recap: store.recap,
whatsappConnected: Boolean(WHATSAPP_TOKEN && PHONE_NUMBER_ID),
aiConnected: Boolean(OPENROUTER_API_KEY),
model: MODEL,
business: currentBusiness(),
businessName: await businessName(currentBusiness()),
businesses: await Promise.all(businessIds().map(async id => ({ id, name: await businessName(id) }))),
});
}
if (url.pathname === '/api/send' && req.method === 'POST') {
const { waId, text } = JSON.parse(await readBody(req));
const chat = store.chats[waId];
if (!chat || !text?.trim()) return sendJSON(res, 400, { error: 'Missing chat or text' });
await deliver(chat, text.trim(), 'you');
await save();
return sendJSON(res, 200, { ok: true });
}
if (url.pathname === '/api/recap' && req.method === 'POST') {
return sendJSON(res, 200, await makeRecap());
}
// Demo tools: fake customer messages that go through the same pipeline (replies are not really sent).
if (url.pathname === '/api/simulate' && req.method === 'POST') {
const { name, text } = JSON.parse(await readBody(req));
if (!name?.trim() || !text?.trim()) return sendJSON(res, 400, { error: 'Name and message are required' });
const waId = 'demo-' + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
handleIncoming({ waId, name: name.trim(), text: text.trim(), simulated: true });
return sendJSON(res, 200, { ok: true });
}
if (url.pathname === '/api/demo' && req.method === 'POST') {
const demo = JSON.parse(await readFile(new URL(`${currentBusiness()}/demo.json`, BUSINESSES_DIR), 'utf8'));
for (const m of demo) {
handleIncoming({ waId: m.waId, name: m.name, text: m.text, ts: Date.now() - m.minutesAgo * 60000, simulated: true });
}
return sendJSON(res, 200, { ok: true, count: demo.length });
}
// Switch to another business profile (clears the chats, keeps everything else).
if (url.pathname === '/api/business' && req.method === 'POST') {
const { id } = JSON.parse(await readBody(req));
if (!businessIds().includes(id)) return sendJSON(res, 400, { error: `Unknown business: ${id}` });
store = { chats: {}, recap: null, lastRecapDate: store.lastRecapDate, business: id };
await save();
return sendJSON(res, 200, { ok: true });
}
if (url.pathname === '/api/reset' && req.method === 'POST') {
store = { chats: {}, recap: null, lastRecapDate: store.lastRecapDate, business: store.business };
await save();
return sendJSON(res, 200, { ok: true });
}
res.writeHead(404); res.end('Not found');
} catch (err) {
console.error(err);
if (!res.headersSent) sendJSON(res, 500, { error: err.message });
}
});
server.listen(Number(PORT), () => {
console.log(`\nKite WhatsApp Inbox → http://localhost:${PORT}`);
console.log(`Business: ${currentBusiness()} (switch on the local page; profiles are in businesses/)`);
console.log(`AI: ${OPENROUTER_API_KEY ? `connected (${MODEL})` : 'NOT connected — add OPENROUTER_API_KEY to .env'}`);
console.log(`WhatsApp: ${WHATSAPP_TOKEN && PHONE_NUMBER_ID ? 'connected' : 'not connected — demo mode only'}`);
console.log(`Owner: ${OWNER ? `approvals and recap go to WhatsApp ${OWNER}` : 'not set — add OWNER_NUMBER to .env to approve replies on WhatsApp'}`);
console.log(`Recap: ${RECAP_TIME ? `daily at ${RECAP_TIME}` : 'off'}\n`);
});
public/index.html (optional test page)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Sales Inbox</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Figtree:wght@400;500;600&display=swap">
<style>
:root{
--bg:#EEF2F4; --surface:#FFFFFF; --ink:#15212B; --muted:#5E6E79; --line:#D9E1E6; --accent:#2356D4; --accent-soft:#E4ECFD;
--in:#FFFFFF; --out:#DCF3E4; --ai:#E6EDFD;
--needs:#B42318; --needs-bg:#FDE7E4; --hot:#C2410C; --hot-bg:#FFEBDD; --warm:#8A6100; --warm-bg:#FFF4CF;
--general:#35607F; --general-bg:#E3EEF6; --not:#66727B; --not-bg:#ECEFF1;
--display:"Bricolage Grotesque","Avenir Next",system-ui,sans-serif; --body:"Figtree",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:15px;display:flex;flex-direction:column}
button,textarea,input{font:inherit}
button{cursor:pointer}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:var(--surface);border-bottom:1px solid var(--line);flex-wrap:wrap}
.brand{display:flex;flex-direction:column;line-height:1.1;margin-right:auto}
.brand b{font-family:var(--display);font-size:1.3rem}
.brand span{font-size:.78rem;color:var(--muted)}
.brand select{font:inherit;font-size:.78rem;border:1px solid var(--line);border-radius:6px;padding:1px 4px;background:var(--surface);color:var(--ink)}
.pill{font-size:.78rem;font-weight:600;padding:4px 10px;border-radius:999px;background:var(--not-bg);color:var(--not)}
.pill.on{background:#DDF3E5;color:#1C7A45}
.btn{border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:8px 14px;border-radius:8px;font-weight:600}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{filter:brightness(1.1);color:#fff}
.btn:disabled{opacity:.5;cursor:wait}
.recap{margin:14px 20px 0;padding:14px 18px;background:var(--surface);border:1px solid var(--line);border-radius:12px;display:flex;gap:16px;align-items:flex-start}
.recap .label{font-family:var(--display);font-weight:700;white-space:nowrap}
.recap .text{white-space:pre-wrap;flex:1;line-height:1.5}
.recap .when{font-size:.78rem;color:var(--muted);white-space:nowrap}
main{flex:1;display:grid;grid-template-columns:minmax(300px,380px) 1fr;gap:14px;padding:14px 20px 20px;min-height:0}
.col{background:var(--surface);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
.filters{display:flex;gap:6px;flex-wrap:wrap;padding:12px;border-bottom:1px solid var(--line)}
.filters button{border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:4px 10px;font-size:.8rem;font-weight:600;color:var(--muted)}
.filters button.active{background:var(--ink);color:#fff;border-color:var(--ink)}
.list{overflow-y:auto;flex:1}
.item{display:flex;flex-direction:column;gap:4px;padding:12px 14px;border-bottom:1px solid var(--line);cursor:pointer;border-left:4px solid transparent;text-align:left;background:none;border-top:0;border-right:0;width:100%;color:inherit}
.item:hover{background:#F6F8FA}
.item.sel{background:var(--accent-soft)}
.item .row{display:flex;align-items:center;gap:8px}
.item .name{font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .sum{font-size:.86rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .wait{font-size:.76rem;font-weight:600;color:var(--needs)}
.item .flag{font-size:.74rem;color:var(--accent);font-weight:600}
.item.t-needs_you{border-left-color:var(--needs)} .item.t-hot{border-left-color:var(--hot)} .item.t-warm{border-left-color:#E0A800}
.tag{font-size:.7rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:2px 8px;border-radius:4px;white-space:nowrap}
.tag.needs_you{color:var(--needs);background:var(--needs-bg)} .tag.hot{color:var(--hot);background:var(--hot-bg)}
.tag.warm{color:var(--warm);background:var(--warm-bg)} .tag.general{color:var(--general);background:var(--general-bg)}
.tag.not_lead{color:var(--not);background:var(--not-bg)} .tag.thinking{color:var(--accent);background:var(--accent-soft)}
.sim{border-top:1px solid var(--line);padding:10px 12px}
.sim summary{font-size:.82rem;font-weight:600;color:var(--muted);cursor:pointer}
.sim form{display:flex;flex-direction:column;gap:6px;margin-top:8px}
.sim input,.sim textarea{border:1px solid var(--line);border-radius:6px;padding:6px 8px;resize:vertical}
.detail-head{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:6px}
.detail-head h2{margin:0;font-family:var(--display);font-size:1.35rem;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.facts{display:flex;gap:6px 18px;flex-wrap:wrap;font-size:.86rem;color:var(--muted)}
.facts b{color:var(--ink);font-weight:600}
.thread{flex:1;overflow-y:auto;padding:18px;display:flex;flex-direction:column;gap:8px;background:#F4F1EC}
.bubble{max-width:72%;padding:8px 12px;border-radius:10px;box-shadow:0 1px 0 rgba(0,0,0,.06);line-height:1.45;white-space:pre-wrap}
.bubble.customer{background:var(--in);align-self:flex-start}
.bubble.you{background:var(--out);align-self:flex-end}
.bubble.ai{background:var(--ai);align-self:flex-end}
.bubble .meta{display:block;font-size:.72rem;color:var(--muted);margin-top:4px;text-align:right}
.composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;flex-direction:column;gap:8px}
.composer .hint{font-size:.8rem;font-weight:600;color:var(--accent);display:flex;gap:8px;align-items:center}
.composer .hint.handoff{color:var(--needs)}
.composer textarea{width:100%;min-height:84px;border:1px solid var(--line);border-radius:8px;padding:10px;resize:vertical;line-height:1.45}
.composer .actions{display:flex;justify-content:flex-end;gap:8px;align-items:center}
.composer .err{color:var(--needs);font-size:.84rem;margin-right:auto}
.empty{margin:auto;color:var(--muted);text-align:center;padding:24px;max-width:36ch;line-height:1.5}
.error-box{margin:10px 18px 0;padding:8px 12px;border-radius:8px;background:var(--needs-bg);color:var(--needs);font-size:.85rem}
@media (max-width:760px){ main{grid-template-columns:1fr} .col.detail{min-height:70vh} }
</style>
</head>
<body>
<header>
<div class="brand"><b id="biz-name">AI Sales Inbox</b><span>AI WhatsApp assistant · <label for="biz">business</label> <select id="biz"></select></span></div>
<span class="pill" id="ai-pill">AI</span>
<span class="pill" id="wa-pill">WhatsApp</span>
<button class="btn" id="demo-btn" type="button">Load demo chats</button>
<button class="btn" id="reset-btn" type="button">Clear</button>
<button class="btn primary" id="recap-btn" type="button">☀️ Morning recap</button>
</header>
<section class="recap" id="recap" hidden>
<span class="label">☀️ Today</span>
<div class="text" id="recap-text"></div>
<span class="when" id="recap-when"></span>
</section>
<main>
<div class="col">
<div class="filters" id="filters"></div>
<div class="list" id="list"></div>
<details class="sim">
<summary>Simulate a customer message</summary>
<form id="sim-form">
<input id="sim-name" placeholder="Customer name" required>
<textarea id="sim-text" rows="2" placeholder="Message" required></textarea>
<button class="btn" type="submit">Send in</button>
</form>
</details>
</div>
<div class="col detail" id="detail"><div class="empty">Select a chat. New WhatsApp messages appear here automatically, already tagged, with a reply drafted.</div></div>
</main>
<script>
const TAGS = { needs_you: '⚠️ Needs you', hot: '🔥 Hot', warm: '🌤 Warm', general: '💬 General', not_lead: '🚫 Not a lead' };
const ACTION_HINT = {
draft: 'AI draft · review, edit, then send',
hand_off: '⚠️ Handed to you · AI suggestion below, you decide',
auto_reply: 'AI already replied automatically',
};
let state = { chats: [] }, selected = null, filter = 'all';
const drafts = {}; // edits you typed, kept while the page refreshes
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const time = ts => new Date(ts).toLocaleTimeString('en-SG', { hour: 'numeric', minute: '2-digit' });
const waiting = c => c.lastCustomerAt > (c.lastReplyAt || 0) ? Math.round((Date.now() - c.lastCustomerAt) / 60000) : null;
const fmtWait = m => m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
const setHTML = (el, html) => { if (el._html !== html) { el.innerHTML = html; el._html = html; } };
const tagHTML = c => c.status === 'thinking' ? '<span class="tag thinking">AI reading…</span>' : c.tag ? `<span class="tag ${esc(c.tag)}">${TAGS[c.tag] || esc(c.tag)}</span>` : '';
async function api(path, body) {
const res = await fetch(path, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json' }, body: body && JSON.stringify(body) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
async function refresh() {
try { state = await api('/api/chats'); render(); } catch (e) { console.error(e); }
}
function render() {
$('biz-name').textContent = state.businessName || 'AI Sales Inbox';
const opts = (state.businesses || []).map(b => `<option value="${esc(b.id)}" ${b.id === state.business ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
if (document.activeElement !== $('biz')) setHTML($('biz'), opts);
$('ai-pill').className = 'pill' + (state.aiConnected ? ' on' : '');
$('ai-pill').textContent = state.aiConnected ? `AI · ${state.model.split('/').pop()}` : 'AI not connected';
$('wa-pill').className = 'pill' + (state.whatsappConnected ? ' on' : '');
$('wa-pill').textContent = state.whatsappConnected ? 'WhatsApp connected' : 'WhatsApp: demo mode';
if (state.recap) {
$('recap').hidden = false;
$('recap-text').textContent = state.recap.text.replace(/\*(.+?)\*/g, '$1');
$('recap-when').textContent = `${time(state.recap.at)}${state.recap.sentTo ? ' · sent to your WhatsApp' : ''}`;
} else $('recap').hidden = true;
const counts = { all: state.chats.length };
state.chats.forEach(c => { if (c.tag) counts[c.tag] = (counts[c.tag] || 0) + 1; });
setHTML($('filters'), ['all', ...Object.keys(TAGS)].map(k =>
`<button type="button" data-f="${k}" class="${filter === k ? 'active' : ''}">${k === 'all' ? 'All' : TAGS[k]} ${counts[k] || 0}</button>`).join(''));
const shown = state.chats.filter(c => filter === 'all' || c.tag === filter);
setHTML($('list'), shown.length ? shown.map(c => {
const w = waiting(c);
const hasDraft = c.draft && c.status !== 'thinking';
return `<button type="button" class="item t-${esc(c.tag)} ${c.waId === selected ? 'sel' : ''}" data-id="${esc(c.waId)}">
<span class="row"><span class="name">${esc(c.name)}</span>${tagHTML(c)}</span>
<span class="sum">${esc(c.summary || c.messages.at(-1)?.text)}</span>
<span class="row">${w !== null ? `<span class="wait">Waiting ${fmtWait(w)}</span>` : ''}${hasDraft ? '<span class="flag">✎ Draft ready</span>' : ''}${c.action === 'auto_reply' && w === null ? '<span class="flag">✓ AI replied</span>' : ''}</span>
</button>`;
}).join('') : '<div class="empty">No chats yet. Message your WhatsApp test number, or press “Load demo chats”.</div>');
renderDetail();
}
function renderDetail() {
const c = state.chats.find(x => x.waId === selected);
const box = $('detail');
if (!c) { delete box.dataset.id; setHTML(box, '<div class="empty">Select a chat. New WhatsApp messages appear here automatically, already tagged, with a reply drafted.</div>'); return; }
// Don't redraw while you're typing in the reply box
const ta = document.getElementById('reply');
if (ta && document.activeElement === ta && box.dataset.id === c.waId) return;
const w = waiting(c);
const draftText = drafts[c.waId] ?? c.draft ?? '';
const hint = c.status === 'thinking' ? 'AI is reading the chat…' : (c.draft ? ACTION_HINT[c.action] : (w !== null ? 'Write a reply' : 'Reply'));
const html = `
<div class="detail-head">
<h2>${esc(c.name)} ${tagHTML(c)}</h2>
<div class="facts">
${c.summary ? `<span><b>Wants:</b> ${esc(c.summary)}</span>` : ''}
${c.missing ? `<span><b>Missing:</b> ${esc(c.missing)}</span>` : ''}
${c.reason ? `<span><b>Why:</b> ${esc(c.reason)}</span>` : ''}
${w !== null ? `<span style="color:var(--needs)"><b style="color:inherit">Waiting ${fmtWait(w)}</b></span>` : ''}
${c.simulated ? '<span>Demo chat · replies are not really sent</span>' : ''}
</div>
</div>
${c.error ? `<div class="error-box">${esc(c.error)}</div>` : ''}
<div class="thread" id="thread">
${c.messages.map(m => `<div class="bubble ${m.from}">${esc(m.text)}<span class="meta">${m.from === 'ai' ? '🤖 AI auto-reply · ' : ''}${time(m.ts)}</span></div>`).join('')}
</div>
<form class="composer" id="composer">
<span class="hint ${c.action === 'hand_off' && c.draft ? 'handoff' : ''}">${esc(hint)}</span>
<textarea id="reply" placeholder="Type a reply…">${esc(draftText)}</textarea>
<div class="actions"><span class="err" id="send-err"></span><button class="btn primary" type="submit">Send on WhatsApp</button></div>
</form>`;
if (box._html === html) return;
box.innerHTML = html; box._html = html; box.dataset.id = c.waId;
const th = $('thread'); th.scrollTop = th.scrollHeight;
$('reply').addEventListener('input', e => { drafts[c.waId] = e.target.value; });
$('composer').addEventListener('submit', async e => {
e.preventDefault();
const btn = e.submitter; btn.disabled = true; $('send-err').textContent = '';
try {
await api('/api/send', { waId: c.waId, text: $('reply').value });
delete drafts[c.waId];
$('reply').blur();
await refresh();
} catch (err) { $('send-err').textContent = err.message; }
btn.disabled = false;
});
}
$('list').addEventListener('click', e => {
const item = e.target.closest('.item'); if (!item) return;
selected = item.dataset.id; render();
});
$('filters').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
filter = b.dataset.f; render();
});
async function busy(btn, fn) {
btn.disabled = true;
try { await fn(); } catch (err) { alert(err.message); }
btn.disabled = false; refresh();
}
$('demo-btn').onclick = e => busy(e.currentTarget, () => api('/api/demo', {}));
$('reset-btn').onclick = e => { if (confirm('Clear all chats from this inbox?')) { selected = null; busy(e.currentTarget, () => api('/api/reset', {})); } };
$('recap-btn').onclick = e => {
const btn = e.currentTarget; btn.textContent = '☀️ Writing recap…';
busy(btn, () => api('/api/recap', {})).finally(() => { btn.textContent = '☀️ Morning recap'; });
};
$('biz').addEventListener('change', async e => {
const id = e.target.value;
if (!confirm('Switch business? This clears the current chats.')) { render(); return; }
selected = null;
try { await api('/api/business', { id }); } catch (err) { alert(err.message); }
e.target.blur(); refresh();
});
$('sim-form').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/api/simulate', { name: $('sim-name').value, text: $('sim-text').value });
$('sim-text').value = ''; refresh();
} catch (err) { alert(err.message); }
});
refresh();
setInterval(refresh, 2000);
</script>
</body>
</html>
businesses/my-business/demo.json (optional)
[
{ "waId": "demo-rahul", "name": "Rahul (Oceanic Shipmgmt)", "minutesAgo": 300, "text": "Morning. Need VLSFO 600 MT for MV Eastern Pearl, IMO 9481234, Singapore, ETA 21 Sep. Pls offer." },
{ "waId": "demo-lina", "name": "Lina Tan", "minutesAgo": 280, "text": "Hi do you supply at Port Klang?" },
{ "waId": "demo-marco", "name": "Marco (broker)", "minutesAgo": 240, "text": "What's your indication for HSFO Spore next week? Client still firming up the vessel" },
{ "waId": "demo-james", "name": "James Koh", "minutesAgo": 200, "text": "Your offer yesterday was 8 dollars above another supplier. Can you match?" },
{ "waId": "demo-crew", "name": "CrewLink Recruitment", "minutesAgo": 170, "text": "Hello sir, we provide experienced marine engineers for your fleet. Can share CVs?" },
{ "waId": "demo-rahul", "name": "Rahul (Oceanic Shipmgmt)", "minutesAgo": 120, "text": "Any update? Charterer waiting" },
{ "waId": "demo-kenji", "name": "Kenji Sato", "minutesAgo": 90, "text": "Hello, we are a new operator with 2 tankers trading Asia. Would like to set up an account with you" },
{ "waId": "demo-anna", "name": "Anna (Blue Fin Tankers)", "minutesAgo": 60, "text": "Received the MGO yesterday but BDN quantity is 4 MT more than our sounding. Pls check urgently" },
{ "waId": "demo-dev", "name": "Dev Patel", "minutesAgo": 30, "text": "LSMGO 50 MT, Tanjung Pelepas, tomorrow morning. Vessel Sea Harmony. Can do?" }
]
3Get your AI key
Steps 3 and 4 collect five values that go into .env. Ask your AI assistant to open that file and keep it open while you work through both steps:
Please open the .env file in my editor and keep it open. Tell me which five lines I need to fill in: OPENROUTER_API_KEY, WHATSAPP_TOKEN, PHONE_NUMBER_ID, VERIFY_TOKEN and OWNER_NUMBER, and what each one is in plain English.
Don't ask me for the values and don't type them in for me. I'll type them into the file myself, a few at a time, and tell you when I'm done.You type the keys into the file yourself. Never paste a key or token into the AI chat.
- On openrouter.ai, add credit (a few dollars is plenty to start).
- Go to Keys → Create key. Paste it after
OPENROUTER_API_KEY=in.env. - Go to Settings → Privacy. Turn off every option that allows providers to train on your data, and Save changes.
✅ Check.env has your key after OPENROUTER_API_KEY=, and you clicked Save on the privacy page.
4Set up WhatsApp (Meta)
This part happens on Meta's website, and only you can do it: your assistant can't log in as you, verify your phone numbers or accept Meta's terms. What it can do is tell you exactly what to click, and write what you find into .env. Log in at developers.facebook.com, then send this:
Now take me through the Meta WhatsApp setup. I'm logged in at developers.facebook.com. I'm not a programmer, so tell me exactly what to click, one screen at a time, and wait for me to say done before the next one.
I need to end up with: an app with the WhatsApp product added, the free test number, its Phone number ID, an access token, and both of my test numbers added to the allowed list.
When I read out or paste a Phone number ID, write it into .env for me. I'll type the access token in myself, so don't ask me to paste it here.The screens it walks you through are these:
- On developers.facebook.com, click My Apps → Create app. Choose the WhatsApp / “connect with customers” use case and create or pick a business portfolio.
- Open WhatsApp → API Setup. Meta gives you a free test number.
- Copy the Phone number ID into
PHONE_NUMBER_ID=. - Click Generate access token and paste it into
WHATSAPP_TOKEN=. (This one expires quickly; step 8 shows the long-lasting version.) - Under To, add both your numbers (owner and customer) and confirm each with the code WhatsApp sends. The test number can only message up to 5 numbers you add here.
- In
.env, setOWNER_NUMBER=to your own number with country code, digits only (e.g.65XXXXXXXX). - Choose any secret word for
VERIFY_TOKEN=. You'll paste the same word into Meta in step 5. - Save
.env.
Then ask your AI assistant to check the file:
I've filled in the .env file. Please check it for me: are all five lines filled in, with no spaces or quotes around the values, and is OWNER_NUMBER digits only with the country code?
Tell me if anything looks wrong, but don't print my keys or tokens back to me, here or anywhere else.✅ CheckMeta shows both your numbers under To, and the five lines from the table in step 2 are filled in.
5Start it, and connect WhatsApp
Your AI assistant starts the program and the tunnel that lets WhatsApp reach your computer, then hands you the two lines you paste into Meta.
Please start everything for me and keep it running:
1. Run "npm start" in this folder as a background process, and show me the startup lines. I want to see "AI: connected" and "WhatsApp: connected".
2. Run ngrok in the background too ("ngrok http 3000"), sending its output to a log file. Don't run it in the foreground, it never exits. Read the https forwarding address from that log.
3. Then tell me, clearly and separately, the two things I have to paste into Meta:
- the Callback URL: the ngrok https address with /webhook at the end
- the Verify token: the value of VERIFY_TOKEN in my .env
Keep both running while I do the Meta part, and tell me if either one stops.Take the two lines it gives you back to Meta:
- Open WhatsApp → Configuration → Webhook → Edit.
- Paste the Callback URL and the Verify token, then click Verify and save.
- Under Webhook fields, Subscribe to
messages. - Publish the app (Development → Live). Meta may ask for a privacy policy link first. Until it's published, real messages don't reach your computer.
✅ CheckMeta accepted the webhook, and your app shows as Live. If verifying fails, ask your assistant whether the program and the tunnel are both still running.
Prefer to run it yourself? Open this
Open Terminal (Mac) or Command Prompt (Windows). Type cd and a space, drag your whatsapp-assistant folder into the window, and press Enter. Then:
npm startLeave that window open. In a second window (Mac: Cmd + N), start the tunnel:
ngrok http 3000The https://… address after “Forwarding”, plus /webhook, is your Callback URL. It changes each time you restart ngrok, unless you use the free fixed address from your ngrok dashboard (Domains): ngrok http --url=your-name.ngrok-free.app 3000. After changing .env, stop the program with Ctrl + C and run npm start again.
6Test with two numbers
Ask your assistant to watch along and tell you what the program is doing:
I'm testing now with two phones. Please watch the server output and tell me in plain English what's happening after each message: which chat came in, what tag it got, and whether it auto-replied, sent me a card, or did nothing.
If something doesn't arrive, tell me what to check. Don't change any code unless I ask.- From your owner number, send
hito the test number. You should get the assistant's help message back.ImportantWhatsApp only lets the business message you within 24 hours of your last message to it. If cards stop arriving, sendhiagain. - From the customer number, send a simple question your profile can answer, e.g. your opening hours. Wait about 30 seconds (the assistant waits in case the customer sends more). It should reply by itself, and your owner number stays quiet.
- Send a real enquiry with details. A 🔥 card with a draft should arrive on your owner number. Try Send, or Edit and type your own reply.
- After sending, tap 👤 Take over 2h if you want the AI to stay quiet on that chat.
- Send a complaint or a discount request. It should arrive as ⚠️ needs you.
- On your owner number, try
nextandrecap.
Optional: open http://localhost:3000 in your browser to see every chat, its tag and its draft.
✅ CheckA 🔥 card arrived on your owner number, and your reply reached the customer. It works! Now make it yours.
7Keep tuning it
Your profile from step 2 is what the assistant thinks with. Now that you've seen it reply to real messages, keep correcting it. Just say what you want changed and your assistant edits the file: “be warmer”, “stop offering site visits”, “add our new Jurong branch”, “always ask for the delivery date”.
It's all plain English, and it's worth going back to:
- Tone: “friendly Singapore English, one emoji max”, “formal”, “first person, I not we”.
- Hot lead: what makes someone ready to buy for you.
- Always hand to me: prices, discounts, complaints, anything sensitive.
- Facts: only what the assistant may say on its own (hours, locations, links).
- Never: e.g. never give a price, never promise a date.
Changes apply to the very next message, with no restart.
It replies in the customer's language automatically: English, 中文, Bahasa Melayu, தமிழ், and more.
✅ CheckSend a test question from the customer number and see whether the reply sounds like you. If not, tell your assistant what felt off.
8For daily use
A token that doesn't expire every day
- Go to business.facebook.com → Settings → Users → System users → Add. Give it the Admin role.
- Assign assets: your app and your WhatsApp account, both with full control.
- Generate token → pick your app → choose an expiry → tick
whatsapp_business_messagingandwhatsapp_business_management. - Paste it into
WHATSAPP_TOKEN=and restart the program.
Run it without your laptop
Your laptop has to stay on while the program runs on it. For 24/7 use, move the same folder to a small cloud server (a “VPS”, around US$5 a month). Pick a Singapore region if your customers are here. The server gets its own web address, so you won't need ngrok. Cloud servers usually run on UTC time, so add TZ=Asia/Singapore to .env to keep the recap at 9am your time.
Only accept real WhatsApp messages
Your webhook address is public. In Meta, open App settings → Basic, copy the App secret, and add APP_SECRET= with it to .env. The program then rejects anything not signed by Meta.
Your real business number
When you're ready to leave the test number, add your own business number in Meta and complete business verification. Meta charges per message for some message types, so check its current pricing.
Privacy & PDPA
- Customer chats are personal data. This build sends the AI only the customer's WhatsApp name and messages, never their phone number.
- Training and data storage are blocked on every AI request, and in your OpenRouter privacy settings (step 3).
- Keep
.envprivate: never paste keys into chats, slides or screenshots. - Delete old chats you no longer need (the
datafolder), and keep your computer password-protected. - Only use the official API. Unofficial WhatsApp bots can get your number banned.
This is general guidance, not legal advice. See PDPC's guidelines if you handle sensitive data.
Troubleshooting
| What happens | Try this |
|---|---|
| Nothing happens at all | Is the program running? Is ngrok running? Is the app published, and are you subscribed to messages? |
| Customer gets a reply, but no cards arrive | Send hi from your owner number again (24-hour rule). Check OWNER_NUMBER is digits only, with country code. |
| “not in allowed list” | Add that number under API Setup → To. The test number only messages numbers you've added. |
| “WhatsApp send failed (401)” | The token expired. Generate a new one (step 8 for a long-lasting one), then restart. |
| “OpenRouter error” | Check the key and your credit. |
| Webhook won't verify | The verify word must match .env exactly, and the URL must end with /webhook. |
| ngrok address changed | Update the Callback URL in Meta. Your free ngrok account also gives you a fixed address you can use instead. |
While you're here
Keep going with the group
Scan with your phone camera to join “Practical AI for Your Sales Workflow” on WhatsApp. Get stuck, post a screenshot; that's what it's for. I share what I build there, and workshop dates go out there first.
Reading on your laptop? Scan the code. Reading on your phone? Tap the button.
Prefer email? hello@kitecollective.co