Skip to main content
intermediatePart 1

Build a WhatsApp AI assistant from scratch with Evolution and the WEC API

· 15 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
WhatsApp++Evolution
0/2
🎯 Skill path0/2 earned
WhatsApp automation on WEC
  • 1Self-host a WhatsApp AI bridge
  • 🏆Production delivery via Cloud API

Most "self-host a WhatsApp AI" guides stop at "the container started." This one goes all the way: you deploy a real, programmable WhatsApp gateway (Evolution API), then write the bridge yourself — the ~50 lines that turn an incoming message into an LLM answer and send it back. That bridge (webhook → model → reply) is the reusable pattern behind every chat-AI integration: SMS, Slack, Telegram, voice — swap the channel, the shape is identical.

And because this is a real build, we hit — and fix — every gotcha: an image that moved publishers, a Baileys version loop, an infinite reply loop, group-chat spam, WhatsApp's new LID addressing, and a genuine delivery wall that most tutorials pretend doesn't exist. Every command, error, and output below is from an actual run.

Read this before you use your personal number

Evolution links to a WhatsApp number as a companion device (like WhatsApp Web) — the bot acts as that account and can read every DM and group it receives. For a demo on a number you control it's fine; for anything real, use a dedicated number. And see the delivery limitation at the end before you build on this in production.


What you'll build

Four containers: Evolution + its Postgres + Redis, and the bridge you'll write. The bridge is the whole point — everything else is off-the-shelf.

Prerequisites: a WEC Instance with Docker + Compose, a WEC Inference API key (Inference → API Keys), a WhatsApp number for the demo, and ~2 GB free disk.


Step 1 — Deploy the Evolution stack

Evolution needs its own Postgres and Redis. We give it a dedicated set on an internal network and expose only the API port (8080). docker-compose.yml:

~/evolution-api/docker-compose.yml
services:
evolution-api:
image: evoapicloud/evolution-api:v2.2.3
restart: unless-stopped
ports:
- "8080:8080"
env_file: .env
volumes:
- evolution_instances:/evolution/instances
depends_on: [evolution-postgres, evolution-redis]
logging:
driver: json-file
options: { max-size: "50m", max-file: "3" }

evolution-postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: evolution
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: evolution
volumes:
- evolution_pgdata:/var/lib/postgresql/data

evolution-redis:
image: redis:7
restart: unless-stopped
volumes:
- evolution_redis:/data

volumes:
evolution_instances:
evolution_pgdata:
evolution_redis:
Log rotation from day one

The logging: block caps container logs at 3×50 MB. Skip it and a chatty container can fill the disk and take the box down — a lesson learned the hard way on this VM. Set it before you need it.

The .env (generated secrets, connection URIs, and — importantly — a pinned Baileys WhatsApp-Web version):

Generate the .env
cd ~/evolution-api && cat > .env << EOF
AUTHENTICATION_API_KEY=$(openssl rand -hex 24)
SERVER_URL=http://<your-vm-ip>:8080
POSTGRES_PASSWORD=$(openssl rand -hex 16)
DATABASE_ENABLED=true
DATABASE_PROVIDER=postgresql
DATABASE_CONNECTION_URI=postgresql://evolution:\${POSTGRES_PASSWORD}@evolution-postgres:5432/evolution?schema=public
CACHE_REDIS_ENABLED=true
CACHE_REDIS_URI=redis://evolution-redis:6379/0
CACHE_REDIS_PREFIX_KEY=evolution
CACHE_LOCAL_ENABLED=false
QRCODE_LIMIT=30
EOF

Bring it up:

docker compose up -d
sleep 15
curl -s http://localhost:8080 | jq
Output
{
"status": 200,
"message": "Welcome to the Evolution API, it is working!",
"version": "2.2.3",
...
}

Evolution API responding on port 8080 with the welcome payload Figure 1. The stack is up: Evolution answers on :8080 with its version and status.

Two real gotchas pulling the image

(1) The publisher moved. The image most guides reference — atendai/evolution-api — now returns "pull access denied / repository does not exist." The current image is evoapicloud/evolution-api. The old repo's tag list still resolves, which sends you down a rabbit hole; confirm with a fresh pull of hello-world that it's not rate-limiting. (2) Pin a real tag. v2.1.1 (from a popular blog) was never published — check https://hub.docker.com/v2/repositories/evoapicloud/evolution-api/tags and pin one that exists (we use v2.2.3).


Set your API key as a shell var (every request needs it in an apikey: header):

APIKEY=$(grep '^AUTHENTICATION_API_KEY=' .env | cut -d= -f2)

Create the instance:

curl -s -X POST http://localhost:8080/instance/create \
-H "apikey: $APIKEY" -H "Content-Type: application/json" \
-d '{"instanceName":"wec-demo","integration":"WHATSAPP-BAILEYS","qrcode":true}' | jq '.instance'

Then open the built-in Manager at http://<your-vm-ip>:8080/manager, enter your server URL + API key, click wec-demo, and scan the QR from WhatsApp → Settings → Linked Devices → Link a Device. Confirm it's connected:

curl -s http://localhost:8080/instance/fetchInstances -H "apikey: $APIKEY" \
| jq '.[] | {name, connectionStatus}'
Output
{ "name": "wec-demo", "connectionStatus": "open" }

Evolution Manager showing the wec-demo instance connected Figure 2. The Evolution Manager dashboard once the instance links: Connected, with live contact, chat, and message counts.

The Baileys version loop — the #1 self-host failure

If the instance never leaves connecting and the logs show ChannelStartupService re-initializing every few seconds with a Baileys version env: 2,3000,... line, the pinned WhatsApp-Web version is stale and WhatsApp rejects the handshake before a QR is ever generated. Fetch the current version and pin it, then recreate:

curl -s "https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/baileys-version.json"
# {"version":[2,3000,1035194821]}
echo "CONFIG_SESSION_PHONE_VERSION=2.3000.1035194821" >> .env
docker compose up -d --force-recreate evolution-api

Step 3 — The bridge, v1: see the webhook

Now the part you actually write. Rule: never wire logic before you've seen the real data. So v1 does nothing but print whatever Evolution sends.

The bridge runs as another container in the same Compose, so Evolution reaches it by name (http://bridge:8090) and the bridge reaches Evolution at http://evolution-api:8080. Create bridge/main.py:

~/evolution-api/bridge/main.py (v1)
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
data = await request.json()
print("=== WEBHOOK RECEIVED ===", flush=True)
print(data, flush=True)
return {"received": True}
flush=True matters

Without it, print output is buffered and never shows in docker compose logs — you'll stare at an empty log convinced it's broken.

bridge/Dockerfile:

~/evolution-api/bridge/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn requests
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]

Add the service to docker-compose.yml (inside services:), then build it:

docker-compose.yml (add under services:)
bridge:
build: ./bridge
restart: unless-stopped
env_file: .env
depends_on: [evolution-api]
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
docker compose up -d --build bridge

Now register the webhook so Evolution POSTs messages to the bridge:

curl -s -X POST "http://localhost:8080/webhook/set/wec-demo" \
-H "apikey: $APIKEY" -H "Content-Type: application/json" \
-d '{"webhook":{"enabled":true,"url":"http://bridge:8090/webhook","webhookByEvents":false,"events":["MESSAGES_UPSERT"]}}' | jq '.enabled, .url'

Tail the logs (docker compose logs -f bridge) and send yourself a WhatsApp message. The real payload appears:

A real MESSAGES_UPSERT webhook
{'event': 'messages.upsert', 'instance': 'wec-demo',
'data': {'key': {'remoteJid': '123456789012345@lid', 'fromMe': False, 'id': '...'},
'pushName': 'Test Contact',
'message': {'conversation': 'Oii'},
'messageType': 'conversation'},
'sender': '[email protected]', ...}

Three things we now know — and every one drives the code next:

  • text is at data.message.conversation (plain) or data.message.extendedTextMessage.text (quoted)
  • data.key.fromMeTrue for our own messages (the loop guard)
  • remoteJid ends in @lid — WhatsApp's privacy identifier, not a phone number (this one's a saga; Step 5)

Step 4 — v2: read, think, reply (with the loop guard)

Give the bridge the WEC Inference key so it can call the model:

echo "WEC_API_KEY=<your-wec-inference-key>" >> ~/evolution-api/.env

Now the logic — main.py v2:

~/evolution-api/bridge/main.py (v2)
import os, requests
from fastapi import FastAPI, Request

app = FastAPI()
EVOLUTION_URL = "http://evolution-api:8080"
INSTANCE = "wec-demo"
EVOLUTION_APIKEY = os.environ["AUTHENTICATION_API_KEY"]
WEC_API_KEY = os.environ["WEC_API_KEY"]
WEC_URL = "https://inference.wiline.com/v1/chat/completions"
MODEL = "Qwen2.5-3B-Instruct"

def ask_llm(text: str) -> str:
r = requests.post(WEC_URL,
headers={"Authorization": f"Bearer {WEC_API_KEY}"},
json={"model": MODEL, "messages": [{"role": "user", "content": text}]},
timeout=60)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]

def send_whatsapp(number: str, text: str):
r = requests.post(f"{EVOLUTION_URL}/message/sendText/{INSTANCE}",
headers={"apikey": EVOLUTION_APIKEY, "Content-Type": "application/json"},
json={"number": number, "text": text}, timeout=60)
print(f"SEND -> {r.status_code}", flush=True)

@app.post("/webhook")
async def webhook(request: Request):
data = (await request.json()).get("data", {})
key = data.get("key", {})

if key.get("fromMe"): # (1) LOOP GUARD
return {"skipped": "fromMe"}

msg = data.get("message", {}) # (2) extract text
text = msg.get("conversation") or msg.get("extendedTextMessage", {}).get("text")
if not text:
return {"skipped": "no text"}

to = key.get("remoteJid")
print(f"IN {to}: {text}", flush=True)
answer = ask_llm(text) # (3) think
send_whatsapp(to, answer) # (4) reply
print(f"OUT {to}: {answer[:60]}...", flush=True)
return {"ok": True}

The one line beginners always miss is (1) the loop guard. When the bridge sends a reply, that outgoing message fires another MESSAGES_UPSERT webhook with fromMe: true — without the guard, the bridge answers itself, forever. Rebuild:

docker compose up -d --build bridge

The bridge logging IN and OUT for a live DM Figure 3. v2 in action: a DM comes in, the model answers, the reply goes out.

It answers DMs now. But watch what happens next.


Step 5 — The two real walls: group spam, then LID

Tail the logs with the bot live and you'll see it try to answer every group you're in, including promo/spam groups, and Evolution times out sending to them — flooding errors:

Groups flood the bridge
IN [email protected]: *ALÔ CORREDORES 🏃 ... pechin.co/135449*
requests.exceptions.ReadTimeout: HTTPConnectionPool(host='evolution-api', port=8080): Read timed out.

A companion account receives everything. Fix: answer DMs only — groups end in @g.us:

to = key.get("remoteJid", "")
if to.endswith("@g.us") or "broadcast" in to: # DMs only
return {"skipped": "not a DM"}

Now the subtler wall. With groups filtered, a real DM comes in — but the reply gets rejected:

SEND -> 400
{"status":400,"error":"Bad Request","response":{"message":[
{"exists":false,"jid":"123456789012345@lid","name":"Test Contact","number":"123456789012345@lid"}]}}

Evolution rejecting a reply to an @lid address with exists Figure 4. The LID wall in the logs: the incoming DM arrives as @lid, and the reply is rejected with exists:false.

exists: false. This is WhatsApp's LID addressing (a 2025 privacy change): incoming DMs arrive with an @lid identifier, and you cannot send back to an @lid — Evolution needs the real @s.whatsapp.net phone JID. Evolution's contact store has both, though:

curl -s -X POST "http://localhost:8080/chat/findContacts/wec-demo" \
-H "apikey: $APIKEY" -H "Content-Type: application/json" -d '{}' \
| jq '[.[] | select(.pushName=="Test Contact")]'
Output — same person, two identities
[ { "remoteJid": "123456789012345@lid", "pushName": "Test Contact" },
{ "remoteJid": "[email protected]", "pushName": "Test Contact" } ]

findContacts returning the LID and the phone JID for one contact Figure 5. Evolution stores both identities for the same person — the @lid and the real phone JID. That is the key to resolving the reply address.

So we resolve the @lid to the phone number before replying. Add a resolver and use it:

def resolve_number(remote_jid: str, push_name: str):
if remote_jid.endswith("@s.whatsapp.net"):
return remote_jid.split("@")[0]
# LID: find the contact's real number by matching the name
contacts = requests.post(f"{EVOLUTION_URL}/chat/findContacts/{INSTANCE}",
headers={"apikey": EVOLUTION_APIKEY, "Content-Type": "application/json"},
json={}, timeout=30).json()
for c in contacts:
if c.get("pushName") == push_name and c.get("remoteJid", "").endswith("@s.whatsapp.net"):
return c["remoteJid"].split("@")[0]
return None

In the handler, resolve before sending:

number = resolve_number(to, data.get("pushName", ""))
if not number:
return {"skipped": "unresolved lid"}
...
send_whatsapp(number, answer)

Now SEND -> 201. (Matching by name is a heuristic — two contacts sharing a name would collide; production keeps a proper LID→phone map. But it works, and it's the honest state of Evolution's LID support in v2.2.3.)


Step 6 — Speak WhatsApp, not Markdown

LLMs emit Markdown (**bold**, [text](url)), but WhatsApp has its own formatting: bold is *single asterisk*, and Markdown links don't render. So **Compute** shows up with literal asterisks. Translate the model's output before sending, and append the RAG's sources as plain URLs:

import re

def to_whatsapp(md: str) -> str:
md = re.sub(r"\*\*(.+?)\*\*", r"*\1*", md) # **bold** -> *bold*
md = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\1 (\2)", md) # [t](url) -> t (url)
md = re.sub(r"(?m)^#{1,6}\s*", "", md) # drop # headings
return md

Verified against a real answer — exactly what WhatsApp will render:

Formatted output (deterministic, no delivery needed)
To create a compute instance on WiLine Edge Cloud (WEC), follow these steps:

1. Log in to the WiLine Edge Cloud.
2. In the sidebar, click *Compute*.
3. Select *Instances*.
4. Click the "Launch Virtual Machine" wizard to begin the deployment process.

_Sources:_
https://wec.wiline.com/docs/cloud_portal/platform/compute/instances/compute_instance/

The Markdown-to-WhatsApp formatted answer Figure 6. The model output after to_whatsapp(): single-asterisk bold, plain links, sources appended.


Level up — point the bridge at your docs (RAG)

Everything so far uses the raw model, so a WEC question gets a general answer. To make it answer from your documentation, swap ask_llm to call the RAG service from the evals seriesone function, nothing else changes:

RAG_URL = "http://<your-vm-ip>:8000/ask"

def ask_llm(text: str) -> str:
r = requests.post(RAG_URL, json={"question": text}, timeout=120)
r.raise_for_status()
d = r.json()
answer = to_whatsapp(d["answer"])
sources = d.get("sources", [])[:2]
if sources:
answer += "\n\n_Sources:_\n" + "\n".join(sources)
return answer

That's the payoff of the bridge pattern: swap the brain, keep the plumbing. Now WhatsApp answers WEC questions from the real docs, with sources:

WhatsApp delivering a WEC-docs-grounded answer Figure 7. A real message in → the bridge → RAG over the WEC docs → a grounded answer delivered on WhatsApp, sources included.

Skill unlocked 🏅

You self-hosted a programmable WhatsApp gateway and wrote the bridge that turns it into an AI assistant — webhook in, model out, reply back — grounded in your own docs.


The delivery wall

Here's the part other tutorials won't tell you. Even with SEND -> 201, replies to a LID-migrated account frequently arrive as "Waiting for this message" on the recipient's phone — WhatsApp accepted the ciphertext but no device can decrypt it. It's a known Baileys companion-device limitation, and WhatsApp's LID rollout makes it worse.

What we observed, honestly:

  • A fresh re-link (log out the device, scan a new QR) buys a short window where delivery works cleanly.
  • After a while the session degrades and "Waiting for this message" returns — even though every send still reports 201.

So the bridge is correct end-to-end; the unofficial Baileys transport is the weak link. If you're building this for real:

  • Use a dedicated, non-LID number — the issue is tied to LID-migrated accounts.
  • For production-grade reliability, use the official WhatsApp Cloud API, or — if your goal is an AI agent rather than a programmable gateway — OpenClaw's native WhatsApp channel, which handles the session for you.

Evolution shines for outbound automation to numbers you control (notifications, alerts, flows). As a two-way AI assistant on a personal LID account, treat delivery as best-effort.


Troubleshooting recap

  • pull access denied on atendai/evolution-api → image moved to evoapicloud/evolution-api.
  • Instance stuck connecting, re-init loop → stale Baileys version; pin CONFIG_SESSION_PHONE_VERSION.
  • Bridge answers itself forever → missing fromMe loop guard.
  • Flood of group messages / send timeouts → filter @g.us; answer DMs only.
  • SEND -> 400 exists:false ...@lid → resolve the LID to the phone JID via findContacts.
  • **asterisks** in replies → translate Markdown to WhatsApp formatting.
  • "Waiting for this message" → Baileys/LID decryption wall; fresh re-link is a temporary window; use a non-LID number / Cloud API for production.

Teardown

cd ~/evolution-api && docker compose down # add -v to also wipe the DB/session

Unlink the device in WhatsApp → Linked Devices if you're done.

Finished this tutorial?
Mark it complete to earn Self-host a WhatsApp AI bridge on your skill path.

What's next

You built the reusable bridge pattern — webhook → resolve → model → reply. Point it at a different brain, a different channel, or add tools. If you want an AI agent on WhatsApp without the Baileys caveats, the OpenClaw native WhatsApp channel is the managed path; for docs-grounded answers, the evals & observability series builds the RAG service this tutorial plugs into.