Construire un agent WhatsApp
avec OpenClaw + TimelinesAI
Transforme WhatsApp en un canal client que ton agent OpenClaw opère : répond aux messages entrants, envoie des notifications transactionnelles et déclenchées par événement, synchronise avec ton CRM, partage la boîte avec l'équipe. TimelinesAI gère la passerelle WhatsApp. Ton agent gère le raisonnement.
Auto-réponseEnvois transactionnelsSync CRMBoîte partagée
Ce que ton agent peut faire avec WhatsApp
Avec un skill TimelinesAI installé, ton agent OpenClaw peut opérer WhatsApp comme un vrai canal client. La version longue de cette liste est dans les sections ci-dessous — chaque capacité a les appels API spécifiques dont elle a besoin.
Répondre automatiquement aux messages clients entrants — autoresponder 24/7, répondeur hors heures de bureau ou un chatbot complet qui passe la main à un humain quand il bloque.
Envoyer des messages transactionnels et déclenchés par événement — confirmations de commande, mises à jour de livraison, rappels de rendez-vous, reçus de paiement, et notifications déclenchées par d'autres outils (HubSpot, Stripe, Calendly). Uniquement aux clients qui attendent le message.
Qualifier des leads via des conversations à plusieurs tours — poser une séquence de questions, stocker les réponses, taguer le chat comme qualifié ou non.
Synchroniser l'activité WhatsApp avec votre CRM — chercher les numéros entrants dans HubSpot/Pipedrive, mettre à jour les étapes de deal, renvoyer des notes.
Résumer et noter des conversations — « que demandait ACME la semaine dernière », « note ce chat 1–10 sur l'intention ».
Partager la boîte avec l'équipe — votre agent rédige des réponses comme notes privées, les humains les envoient ; ou l'agent envoie et les humains regardent.
Gérer les médias — photos de reçus, PDF, notes vocales — traités par OpenClaw et répondus automatiquement.
Comment utiliser ce guide
Ce guide est dual-mode. Tu peux le lire toi-même et suivre le Setup manuellement — le chemin que les sections ci-dessous déroulent — ou tu peux le passer en entier à ton agent OpenClaw (ou n'importe quel agent capable de lire une URL) et le laisser faire le setup pour toi. Les deux chemins arrivent au même endroit : quatre skills WhatsApp installés, un token et un webhook câblés, ton agent qui répond aux messages clients.
Si tu lis ça toi-même
Parcours les groupes de capabilities en bas — Incoming, Outbound, CRM & analytics, Operations — et décide lesquelles comptent pour ton cas d'usage. Puis suis le Setup manuellement ; quatre étapes, environ dix minutes. Chaque capability liste les appels API exacts qu'elle fait, et chaque code block de ce guide a été exécuté contre l'API TimelinesAI réelle avant publication. Si tu bloques, À savoir rassemble chaque gotcha qui te coûtera une heure si tu ne le connais pas à l'avance.
Si tu veux que ton agent fasse le setup
Colle ce prompt dans OpenClaw (ou Claude Code, Cursor, Claude desktop, n'importe quel agent qui peut lire une URL). Il pointe sur ce guide par URL pour que l'agent lise la version live, puis il te guide à travers install, token, webhook, smoke-test et l'activation de ton premier skill — en te demandant de l'input uniquement là où il a vraiment besoin de quelque chose de toi.
Read https://timelines.ai/guide/openclaw-whatsapp-skills end to end.
Then help me set up an OpenClaw + TimelinesAI WhatsApp agent:
1. Clone InitechSoftware/openclaw-whatsapp-skills into ~/.openclaw/workspace/
and symlink the four skills into ~/.openclaw/skills/.
2. Ask me for my TimelinesAI API token (Integrations → Public API → Copy
on app.timelines.ai) and write it to ~/.openclaw/workspace/.env.timelinesai
as TIMELINES_AI_API_KEY.
3. Run the smoke-test curl from Setup. If it fails, walk me through the
"If you see something else" failure block in the guide.
4. Help me deploy examples/vercel-webhook-receiver from the companion repo
and register the webhook with TimelinesAI.
5. Ask which of the four skills I want to enable first — whatsapp-autoresponder,
whatsapp-lead-qualifier, whatsapp-send, or whatsapp-delivery-check — and
walk me through its capability section plus any relevant gotchas from
Things-to-know.
6. Before enabling anything that sends outbound messages, show me Channel
choice so I pick the right WhatsApp channel (personal number vs Business
API) for my use case.Le prompt s'arrête volontairement avant d'activer quoi que ce soit en outbound — il te route d'abord par Channel choice pour que tu choisisses le bon canal WhatsApp (numéro personnel vs Business API) pour ce que tu essaies vraiment de faire. Le risque de ban est réel et vit en amont, ce n'est pas quelque chose dont le gateway peut te protéger.
Setup
Quatre choses à brancher. Après ça, ton skill ne fait que des appels API.
- 1
Connecte ton numéro WhatsApp
Connecte-toi à app.timelines.ai, scanne le QR code avec le téléphone qui porte ton numéro pro. TimelinesAI gère la passerelle à partir d'ici. Même flux que le setup standard numéro personnel.
- 2
Obtiens un token API
Intégrations → API Publique → Copier. Sauvegarde comme TIMELINES_AI_API_KEY. Un seul token couvre tout le workspace.
- 3
Installe un skill du repo compagnon
Clone InitechSoftware/openclaw-whatsapp-skills dans ~/.openclaw/workspace/, puis crée un symlink de chaque répertoire de skill dans ~/.openclaw/skills/. Quickstart de quatre lignes dans le README du repo compagnon.
- 4
Enregistre un webhook
Pointe message:received:new vers une URL HTTPS publique. TimelinesAI poussera les messages entrants dessus. Ton receiver invoque OpenClaw.
Smoke-test du token
Avant de construire quoi que ce soit, confirme que l'auth fonctionne :
curl -sS -w "\nHTTP: %{http_code}\n" \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/whatsapp_accountsTu devrais voir
{
"status": "ok",
"data": {
"whatsapp_accounts": [
{
"id": "<phone>@s.whatsapp.net",
"phone": "+<phone>",
"status": "connected",
"account_name": "<your label>"
}
]
}
}Si tu vois autre chose
- HTTP 401 avec le texte brut Unauthorized — le token n'est pas arrivé. Revérifie l'étape 2, assure-toi d'avoir inclus le préfixe Bearer et que le token ne s'est pas coupé sur plusieurs lignes au collage. La réponse est du texte brut, pas du JSON — un pipe vers jq va planter.
- Une page HTML style bootstrap « Page not found » avec HTTP 404 — tu as un slash final ou une faute de frappe dans le chemin. L'API renvoie du HTML (pas une erreur JSON) pour les mauvais chemins, donc si ta sortie commence par <!DOCTYPE html>, enlève tout slash final et vérifie le chemin.
Enregistre le webhook une fois
# Generate a random secret once. Add it as a query param so only
# TimelinesAI's registered URL can reach your receiver.
WEBHOOK_SECRET=$(openssl rand -hex 16)
WEBHOOK_URL="https://your-app.example.com/api/webhook?secret=${WEBHOOK_SECRET}"
curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"event_type\":\"message:received:new\",\"url\":\"${WEBHOOK_URL}\",\"enabled\":true}" \
https://app.timelines.ai/integrations/api/webhooksReceiver webhook minimal (Node / Vercel)
~40 lignes. Vérifie le query param ?secret=, filtre les échos sortants, renvoie 2xx dans la fenêtre de retry de 5 secondes, puis transmet le message à ton host OpenClaw.
// api/webhook.js
export default async function handler(req, res) {
// Path-segment auth — TimelinesAI doesn't sign webhooks yet, so the
// ?secret=<token> you registered is the only thing stopping strangers
// from invoking this endpoint.
if (req.query.secret !== process.env.WEBHOOK_SECRET) {
return res.status(404).end();
}
if (req.method !== "POST") return res.status(405).end();
const { event_type, data } = req.body || {};
if (event_type !== "message:received:new") {
return res.status(200).json({ ignored: event_type });
}
// Drop your agent's own sends echoing back as "received".
// TimelinesAI also fires message:received:new for outbound messages
// synced from another WhatsApp client on the same number.
if (data.from_me === true) {
return res.status(200).json({ ignored: "from_me" });
}
// Ack FAST. TimelinesAI retries 3x with a 5-second timeout per attempt —
// if you take longer than 5s the same event hits you up to 3 times.
res.status(200).json({ ok: true });
// TL webhook payloads sometimes arrive flat (data.chat_id) and sometimes
// nested (data.chat.id) depending on event flavor and version. Destructure
// with fallback so both shapes work — see Things to know below.
const chatId = data.chat_id ?? data.chat?.id;
const whatsappAccountId =
data.whatsapp_account_id ?? data.chat?.whatsapp_account_id;
// Fire-and-forget handoff to wherever your OpenClaw host accepts
// inbound messages. For production durability replace this with a
// push to a queue (QStash, Inngest, SQS) your agent drains separately.
try {
await fetch(process.env.OPENCLAW_HOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
whatsapp_account_id: whatsappAccountId,
text: data.text,
sender_phone: data.sender_phone,
message_uid: data.message_uid,
}),
});
} catch (err) {
console.error("[timelinesai-webhook] handoff failed:", err);
}
}OPENCLAW_HOOK_URL est l'endpoint que ton déploiement OpenClaw expose pour les messages entrants — ça dépend de comment tu héberges l'agent. Pour une référence prod-ready avec des notes sur la durabilité, vois examples/vercel-webhook-receiver dans le repo compagnon.
Ce qu'il y a dans ton workspace OpenClaw
Tu as connecté un token TimelinesAI et symlinké des skills depuis le repo compagnon. Voici ce que tu as réellement installé, et quels fichiers tu édites quand tu veux changer la façon dont ton agent pense, ce qu'il sait, et comment il se comporte. Rien n'est magique ici — c'est juste du markdown simple et des fichiers env dans deux répertoires.
Skills — ~/.openclaw/skills/<skill>/
Chaque skill que tu as symlinké est un répertoire avec un fichier obligatoire (SKILL.md) plus les ressources que ce fichier référence. Si tu veux changer comment ton FAQ handler sonne ou ré-ordonner les questions du lead qualifier, l'édition vit dans le SKILL.md de ce skill. Pas de code à recompiler, pas d'étape de build.
| Fichier | Ce qu'il contrôle |
|---|---|
| SKILL.md | Le skill entier. Frontmatter YAML en haut (name, description, user-invocable et tous flags de dispatch) plus le body avec les instructions/prompt en dessous. C'est ici que tu édites ce que le skill fait, quand OpenClaw le choisit et comment il se comporte. Chacun des quatre skills WhatsApp du repo compagnon est un seul SKILL.md — pas de code à recompiler, juste édite et redémarre. |
| README.md | Documentation humaine du skill. OpenClaw NE lit PAS ce fichier — il est pour toi, ton équipe et quiconque parcourt le skill sur GitHub. Optionnel; les skills du repo compagnon en ont tous un. |
| Scripts, resources, schemas | Tout ce qui est référencé depuis SKILL.md via {baseDir}/... — petits scripts helper, templates de prompt, schemas JSON, fixtures de test. Ignoré par OpenClaw à moins que ton SKILL.md ne pointe explicitement dessus. Mets ce dont le skill a besoin à côté de SKILL.md et référence-le par chemin. |
Pour les quatre skills WhatsApp — whatsapp-autoresponder, whatsapp-lead-qualifier, whatsapp-send, whatsapp-delivery-check — le haut de chaque SKILL.md a le role et les tools autorisées ; le body a le vrai prompt avec lequel ton agent tourne. Lis-en un end-to-end avant d'éditer ; les patterns se répètent.
Workspace — ~/.openclaw/workspace/
Le répertoire workspace est la maison d'OpenClaw pour tout ce qui n'est pas un skill : qui est ton agent, ce qu'il sait de toi, quelles tools il a, comment il boot. Les docs d'OpenClaw disent eux-mêmes « ce dossier est la maison. Traite-le comme tel ». Ces fichiers viennent avec l'install — tu les remplis avec le temps :
| Fichier | Ce qu'il contrôle |
|---|---|
| IDENTITY.md | Qui est ton agent — nom, creature (comment il se conçoit), vibe, emoji signature, avatar. Remplis-le tôt ; la plupart des autres fichiers y font référence. |
| SOUL.md | Principes opérationnels et personnalité core. Comment l'agent doit se comporter quand personne ne regarde — quand être genuine vs performatif, comment gagner la confiance, où se situent les limites de vie privée. Les docs d'OpenClaw appellent ça la conscience de l'agent. |
| USER.md | Qui est l'agent aide (toi). Nom, pronoms, fuseau horaire, intérêts, contexte de projet. Les agents sont meilleurs quand ils aident quelqu'un de spécifique que quand ils aident un « user » générique — ce fichier est comment ils s'en souviennent. |
| TOOLS.md | Config spécifique à l'environnement que tu ne veux dans aucun skill — noms d'appareils, adresses de host, préférences locales. Vit dans le workspace pour que tu puisses l'éditer sans toucher à quoi que ce soit que tu pourrais partager. |
| AGENTS.md | Le README du workspace pour les agents. Décrit comment OpenClaw s'attend à ce que ce workspace soit exécuté. Vient normalement avec l'install et est rarement édité. |
| BOOT.md | Instructions courtes et explicites sur ce qu'OpenClaw doit faire au démarrage. Vide par défaut ; remplis-le si tu veux un comportement de boot déterministe entre redémarrages. |
| BOOTSTRAP.md | Conversation d'onboarding du premier boot. Guide un workspace frais pour établir son identité, puis te dirige pour sauver les résultats dans IDENTITY.md, SOUL.md, USER.md. Supprime-le une fois ces autres remplis. |
| HEARTBEAT.md | Définitions de tâches périodiques. Fichier vide signifie pas de heartbeats ; ajoute des tâches ici quand tu veux que l'agent vérifie quelque chose à intervalle (ex. poll une queue toutes les cinq minutes). |
Fichiers env par intégration
À côté des fichiers canoniques ci-dessus, tu ajoutes un fichier env par intégration que tu connectes. Ils ne font PAS partie de l'install OpenClaw — ils sont à toi, et contiennent des secrets qui ne devraient jamais être commités dans un repo public :
| Fichier | Ce qu'il contrôle |
|---|---|
| .env.<integration> | Secrets et config pour une intégration, un fichier par intégration. Pour le travail WhatsApp, tu as créé .env.timelinesai avec TIMELINES_AI_API_KEY et ALLOWED_SENDER_JID dans Setup étape 2. Reproduis ce pattern quand tu ajoutes HubSpot, Stripe, Pipedrive ou n'importe quel autre outil — un .env.<name> et tu le source dans le skill qui en a besoin. |
Numéros personnels vs WhatsApp Business API
Avant de construire des flux sortants, comprends à quel canal WhatsApp ton cas d'usage appartient. Mal choisir fait bannir des numéros.
Sûr avec les skills de ce guide (numéro personnel)
- •Conversations entrantes. Le client t'écrit en premier, tu réponds. Aucun risque.
- •Envois transactionnels. Confirmations de commande, mises à jour de livraison, notifications de livraison, reçus de paiement, rappels de rendez-vous — tout message qu'un client attend parce qu'il vient de faire quelque chose avec ton business.
- •Notifications déclenchées par événement depuis d'autres outils. Deal HubSpot → confirmation de demo, paiement Stripe échoué → note de récupération, réservation Calendly → rappel avant réunion. Le client a opté quand il a utilisé l'outil amont.
- •Répondre dans la fenêtre de service client de 24 heures de WhatsApp. Une fois qu'un client t'écrit, tu as 24 heures pour répondre librement. Les skills autoresponder, FAQ et qualifier opèrent dans cette fenêtre.
PAS sûr sur les numéros personnels
- •Cold outreach vers des listes que tu as achetées ou scrapées — WhatsApp bannit les numéros pour ça en quelques heures.
- •Broadcasts marketing vers des clients qui n'ont pas opté explicitement.
- •Campagnes promotionnelles, offres commerciales, pushs saisonniers, lancements de produit.
- •Tout ce qui ressemble à un blast marketing. Si tu te demandes « quel débit puis-je obtenir », tu es sur le mauvais canal.
Comment savoir quel canal il te faut
- 1.Le client t'a-t-il écrit en premier, ou réponds-tu dans une session active de 24 heures ? → Numéro personnel, ce guide.
- 2.Le client est-il sur le point de recevoir quelque chose qu'il attend explicitement (commande, rendez-vous, paiement, livraison) ? → Numéro personnel, envoi transactionnel.
- 3.Déclenché par un événement opt-in du client dans ton CRM ou outil de billing ? → Numéro personnel, envoi déclenché par événement.
- 4.Broadcast, cold outreach ou campagne promotionnelle ? → Business API, utilise le dashboard TimelinesAI.
- 5.Pas sûr ? → N'envoie pas. Traite-le comme promotionnel et route-le sur le chemin Business API.
Chaque capacité sortante ci-dessous suppose que tu as passé ce test. Si tu n'es pas sûr, relis cette section avant de shipper. TimelinesAI ne peut pas te protéger du bannissement côté WhatsApp — le ban est appliqué en amont, dans l'infrastructure WhatsApp, pas dans la passerelle.
Référence API
Chaque endpoint dont tu as besoin pour construire chaque capacité ci-dessous. URL de base https://app.timelines.ai/integrations/api. Auth Authorization: Bearer $TIMELINES_AI_API_KEY.
Lecture
| Méthode | Chemin | Ce qu'il renvoie |
|---|---|---|
| GET | /whatsapp_accounts | Tes numéros WhatsApp connectés, chacun avec JID, téléphone, statut et nom de compte. |
| GET | /chats | Liste de chats. Accepte les filtres ?phone=... et ?label=.... Pagine avec ?page=N (50 par page, fixe). Chaque chat a whatsapp_account_id avec le JID du numéro propriétaire, plus chatgpt_autoresponse_enabled — vois À savoir avant de lancer ton propre agent. |
| GET | /chats/{id} | Détail complet d'un chat. |
| GET | /chats/{id}/messages | Historique des messages, 50 par page. Pagine avec ?page=N et surveille data.has_more_pages. Chaque message porte from_me, sender_phone, text, timestamp, message_type, status (Sent/Delivered/Read) et origin (API publique vs app WhatsApp). |
| GET | /chats/{id}/labels | Labels du chat. |
| GET | /messages/{uid}/status_history | Timeline Sent / Delivered / Read d'un message sortant. |
| GET | /messages/{uid}/reactions | Réactions sur un message. Renvoie {data: {users: [{name, phone, reaction, current}], reactions: {<emoji>: count}, total: N}} — un objet, pas un tableau plat. users liste qui a réagi (chacun porte l'emoji choisi et un booléen current marquant ton propre workspace) ; reactions est un histogramme indexé par emoji. État vide : {users: [], reactions: {}, total: 0}. |
| GET | /files | Fichiers que tu as uploadés via l'API. |
| GET | /webhooks | Tes abonnements webhook enregistrés. |
Écriture
| Méthode | Chemin | Ce qu'il fait |
|---|---|---|
| POST | /messages | Envoie à un numéro de téléphone. Body: {"phone":"+...","text":"..."}. Renvoie {"message_uid":"..."}. |
| POST | /chats/{id}/messages | Envoie dans un chat existant. Body: {"text":"..."}. L'expéditeur est le numéro WhatsApp propriétaire du chat. |
| POST | /chats/{id}/notes | Attache une note privée à un chat. N'est pas envoyé à WhatsApp, visible uniquement dans TimelinesAI. Utilisé pour l'état de l'agent et les workflows de relecture. |
| POST | /chats/{id}/labels | Ajoute un label à un chat. Pour le tracking d'étape, le routing et les flags stop-reply. |
| PATCH | /chats/{id} | Met à jour les métadonnées du chat — responsable (responsible_email), statut de lecture, et chatgpt_autoresponse_enabled. Désactive le dernier avant que ton agent commence à répondre, sinon le responder intégré de TL fera la course avec le tien. |
| PATCH | /messages/{uid}/reactions | Pose un emoji de réaction sur un message. Le body prend le caractère emoji littéral, pas un shortcode. |
| POST | /files_upload | Upload un fichier via multipart/form-data (champ: file). Renvoie data.uid, que tu passes à chat/messages comme file_uid. Pas de variante upload-par-URL. |
| POST | /webhooks | Enregistre un abonnement webhook. |
| PUT | /webhooks/{id} | Met à jour ou active/désactive un abonnement. |
| DELETE | /webhooks/{id} | Supprime un abonnement. |
À savoir
Quelques détails faciles à manquer dans la référence et qui te coûteront chacun une heure si tu ne les connais pas.
Quand les appels API échouent
Cinq modes d'échec finiront par toucher ton skill. Chacun se voit différemment dans la réponse et chacun a une bonne réponse différente.
Un petit pattern shell qui branche sur chaque code de statut :
$ # Capture the HTTP status into a variable, then branch
$ http_code=$(curl -sS -o /tmp/resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/send.json \
https://app.timelines.ai/integrations/api/messages)
$ case "$http_code" in
200|201) ;; # ok, carry on
400) echo "bad body: $(cat /tmp/resp.json)"; exit 1 ;;
401) echo "token expired - rotate"; exit 2 ;;
429) sleep 30; retry_once ;; # respect Retry-After
5*) sleep $((2 ** attempt)); retry_with_backoff ;;
esacOpérer en sécurité en production
Deux choses de plus qui ne sont pas des échecs, mais qui t'épargneront une mauvaise journée plus tard.
Contexte de la conversation
Le webhook remet à ton skill un seul message — celui qui vient d'arriver. Pas de tours précédents, pas d'historique de chat, pas de transcription en cours. Pour un FAQ sans état ou un répondeur hors-horaires, c'est largement suffisant. Pour un agent conversationnel qui doit se rappeler ce que le client a dit trois tours plus tôt, c'est à ton skill d'aller chercher le contexte lui-même. Trois patterns couvrent toute la gamme, de la boucle la moins chère possible à celle qui se souvient vraiment.
Sans état
Réponds uniquement au dernier message. Un POST par tour, pas de GET, pas d'historique. C'est la boucle la plus simple et le défaut dans les skills prêts à l'emploi. Utilise-la pour les bots de FAQ, les répondeurs hors-horaires et la classification ou le routage — partout où la réponse ne dépend que du message courant. L'agent oublie tout entre les tours : si le client écrit un follow-up qui fait référence à quelque chose de dit avant, il ne comprendra pas.
Fenêtre de contexte complète
Récupère les 20 derniers messages avant chaque réponse et passe-les au modèle comme conversation précédente. Deux appels API par tour au lieu d'un, plus les tokens de prompt supplémentaires à chaque appel. Utilise-la pour les agents conversationnels, la qualification multi-tours et tout cas où l'agent doit suivre le fil. Vingt tours récents suffisent généralement — élargir davantage aide rarement et rend le prompt coûteux ; rétrécir fait oublier à l'agent ce que le client a dit il y a une minute.
Contexte adaptatif
Récupère le contexte uniquement quand le message entrant ressemble à un follow-up. Une heuristique bon marché sur le texte — commence par un pronom, fait référence à « ça » ou « le », arrive dans les 30 secondes après ta réponse précédente, accusés de réception d'un mot — décide si tirer l'historique ou répondre sans état. La plupart des tours restent bon marché ; les suites récupèrent la mémoire. Commence par la fenêtre complète, passe à l'adaptatif seulement une fois que tu connais ton budget coût et que tu as du trafic de production pour régler l'heuristique.
Récupérer la fenêtre est un seul GET. La réponse est un tableau plat de messages du chat, le plus récent en dernier, mélangeant les tours entrants du client avec tes propres réponses sortantes et toute note que ton skill a écrite :
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
"https://app.timelines.ai/integrations/api/chats/12345678/messages?limit=20"
{"status":"ok","data":[
{"message_uid":"INBOUND-UID-PLACEHOLDER",
"text":"hi, is the blue one still available?",
"sender_phone":"+15550200",
"message_type":"text","origin":"WhatsApp",
"created_at":"2026-04-14T10:22:03Z"},
{"message_uid":"OUTBOUND-UID-PLACEHOLDER",
"text":"Yes, one left. Want me to hold it for you?",
"sender_phone":"",
"message_type":"text","origin":"Public API",
"created_at":"2026-04-14T10:22:41Z"},
{"message_uid":"INBOUND-UID-PLACEHOLDER-2",
"text":"yes please, until tomorrow",
"sender_phone":"+15550200",
"message_type":"text","origin":"WhatsApp",
"created_at":"2026-04-14T10:23:08Z"}
]}Pièges qui mordent en production
Persistance d'état
Les skills OpenClaw ne gardent pas d'état en mémoire entre les invocations. Les conversations WhatsApp sont multi-tours. La solution est de stocker l'état sur le chat lui-même :
- •Les labels portent l'étape discrète — discovery/q1, qualified, escalate. Ajoute avec POST /chats/{id}/labels, lis avec GET /chats/{id}/labels.
- •Les notes portent les données structurées — team_size=8, brouillons de réponses, scores de leads. Ajoute avec POST /chats/{id}/notes. Lis en itérant GET /chats/{id}/messages et en filtrant message_type == "note".
L'avantage : sécurité face aux crashs, visibilité pour les collègues humains, handoff propre — un humain peut effacer un label pour rembobiner le flux, ou ajouter escalate pour prendre le relais. Le compromis : chaque transition d'état est un appel HTTP. Pour des flux face-client ça va.
Envoyer depuis le bon numéro
Si ton workspace a plus d'un numéro WhatsApp connecté, ton skill doit s'assurer qu'il envoie depuis le bon. Chaque chat a un champ whatsapp_account_id contenant le JID complet (comme TELEPHONE@s.whatsapp.net) du numéro propriétaire. Quand tu fais POST /chats/{id}/messages, l'expéditeur est toujours ce JID — tu ne le choisis pas, le chat le fait.
Le pattern :
- 1Hardcode le JID expéditeur autorisé dans l'environnement de chaque skill (par ex. ALLOWED_SENDER_JID).
- 2Avant d'envoyer, GET /chats/{id} et compare whatsapp_account_id avec ton JID autorisé.
- 3S'ils ne correspondent pas, saute l'envoi — escalade vers un humain ou ignore l'événement.
Deux appels HTTP supplémentaires par tour, zéro chance d'envoyer depuis la mauvaise persona. Pour les workspaces à un seul numéro, ça ne s'applique pas.
Entrants
Messages entrants — ce que ton agent peut gérer
Chaque fois qu'un client écrit à ton numéro WhatsApp, TimelinesAI déclenche un événement message:received:new vers ton webhook avec l'id du chat, le texte, le téléphone de l'expéditeur et les pièces jointes. Ton skill lit l'événement, décide quoi faire, et répond avec POST /chats/{chat_id}/messages. Tout ce qui suit est une variation de cette boucle.
Répondre automatiquement à chaque message entrant
“Réponds aux questions sur les expéditions, les retours et les horaires. Pour le reste, rédige une réponse et marque le chat pour relecture.”
“Entre 22h00 et 08h00 réponds automatiquement. En heures ouvrables, signale-moi simplement les chats entrants.”
“Gère mes réponses WhatsApp pendant que je suis en réunion.”
le skill reçoit le payload du webhook, compose une réponse et appelle POST /chats/{id}/messages avec {"text":"..."}. Sans état par défaut — un appel API par tour. Pour les conversations multi-tours où l'agent doit se souvenir des messages précédents, voir Contexte de la conversation ci-dessous.
Gestion FAQ avec escalade vers un humain
“Réponds aux questions sur les expéditions, les retours et les horaires. Pour le reste, marque le chat needs-human et arrête de répondre jusqu'à ce que je retire le tag.”
avant de répondre, vérifie GET /chats/{id}/labels. Si le chat a needs-human, sors sans envoyer. Si le texte entrant correspond à un sujet de FAQ, réponds. Sinon, POST /chats/{id}/labels avec le tag d'escalade et sors silencieusement. La boîte de ton équipe filtre sur le tag.
Router les conversations vers la bonne personne
“Pour chaque chat entrant, déduis si c'est ventes, support ou facturation et tague-le. Assigne les chats de ventes à alex@ours et facturation à jamie@ours.”
classifie l'intention depuis le texte, POST /chats/{id}/labels avec intent/sales ou similaire, puis PATCH /chats/{id} avec {"responsible_email":"..."} pour transférer le chat dans la boîte TimelinesAI.
Qualifier des leads via une séquence de questions
“Pour tout nouveau chat de notre campagne Facebook, demande son cas d'usage, la taille de l'équipe et le calendrier. Stocke les réponses comme notes sur le chat. Tague-le qualified si l'équipe est à 5 ou plus.”
les labels indiquent à quelle question tu en es (discovery/q1, q2, q3) ; les notes contiennent les réponses. À chaque tour : lis le label d'étape courant, parse le texte entrant comme réponse, écris-le via POST /chats/{id}/notes, avance le label, pose la question suivante. Pas de base externe — voir Persistance d'état plus bas.
$ # 1. Read current stage from the chat's labels. Labels nest under
$ # data.labels as a string array — see Response shapes in Things to know.
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/chats/12345678/labels
{"status":"ok","data":{"labels":["discovery/q1"]}}
$ # 2. Save the customer's answer as a structured note. Notes are stored
$ # as messages with message_type=note and aren't pushed to WhatsApp.
$ cat > /tmp/note.json <<'JSON'
{"text":"team_size=12"}
JSON
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/note.json \
https://app.timelines.ai/integrations/api/chats/12345678/notes
{"status":"ok","data":{"message_uid":"NOTE-UID-PLACEHOLDER"}}
$ # 3. Advance the stage label. POST /labels REPLACES the full set, so
$ # read existing labels, swap q1 for q2, POST the combined list. The
$ # body key is "labels" (plural, array) — not "label" singular.
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/chats/12345678/labels \
| jq -c '.data.labels | map(if . == "discovery/q1" then "discovery/q2" else . end) | {labels: .}' \
> /tmp/labels.json
$ cat /tmp/labels.json
{"labels":["discovery/q2"]}
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/labels.json \
https://app.timelines.ai/integrations/api/chats/12345678/labels
{"status":"ok","data":{"labels":["discovery/q2"]}}Comprendre les photos, PDF et reçus
“Quand un client envoie une photo de reçu, extrait le montant et le vendeur et ajoute-les comme note.”
“Si quelqu'un envoie un PDF, classe-le comme facture / contrat / pièce d'identité et tague le chat en conséquence.”
les payloads du webhook contiennent une URL de pièce jointe. Télécharge-la dans le handler (elle expire vite), traite avec les outils de vision ou document d'OpenClaw, et écris les données extraites via POST /chats/{id}/notes.
$ # The attachment URL in the webhook payload expires in ~15 minutes.
$ # Download it inline in the receiver, before queuing async work.
$ ATTACH_URL="https://files.timelines.ai/abc123/receipt.jpg?expires=..."
$ curl -sS "$ATTACH_URL" -o /tmp/receipt.jpg
$ # Process /tmp/receipt.jpg with OpenClaw vision tools, then write
$ # the extracted fields back as a structured note on the chat:
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary '{"text":"receipt: amount=$42.50 vendor=Cafe Madrid"}' \
https://app.timelines.ai/integrations/api/chats/12345678/notesTranscrire les notes vocales et répondre
“Transcris les notes vocales entrantes. Réponds en texte — si tu as vraiment besoin d'une réponse vocale, envoie-la depuis le dashboard TimelinesAI.”
le webhook livre une URL vers le fichier vocal. Télécharge, transcris, compose une réponse texte via POST /chats/{id}/messages. Les réponses vocales sont aujourd'hui une fonction du dashboard TimelinesAI et ne sont pas dans la référence API publique actuelle — si ton skill doit envoyer des réponses vocales par programme, contacte le support TimelinesAI pour confirmer si l'endpoint voice_message hérité est encore disponible sur ton workspace.
Suivre la langue du client
“Si le client écrit en espagnol, réponds en espagnol. S'il change de langue en pleine conversation, change avec lui.”
raisonnement côté OpenClaw uniquement sur le texte entrant. TimelinesAI ne fait que transporter la réponse.
Réagir aux messages sans envoyer une réponse complète
“Réagis avec 👀 à chaque message entrant pour que les clients sachent que je l'ai vu, puis je prends mon temps pour rédiger la vraie réponse.”
PATCH /messages/{uid}/reactions avec {"reaction":"👀"}. Le champ reaction doit contenir le caractère emoji littéral — les shortcodes comme "eyes" ou ":eyes:" sont rejetés avec HTTP 400 "Reaction has invalid format". Aucun crédit message consommé — les réactions sont légères.
$ # Send the raw emoji character in the body. Save to a UTF-8 file and
$ # use --data-binary so shells can't downgrade it.
$ printf '{"reaction":"👀"}' > /tmp/react.json
$ curl -sS -X PATCH \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/react.json \
https://app.timelines.ai/integrations/api/messages/INBOUND-UID-PLACEHOLDER/reactions
{"status":"ok"}Sortants
Sortants — messages que ton agent initie
Messages que ton agent initie, pas des réponses. Déclenchés par un événement dans tes autres outils (une nouvelle commande, un paiement échoué, une réunion réservée) ou une instruction humaine directe sur une personne spécifique.
Avant chaque capacité de cette section : le client a soit ouvert le thread récemment (dans la fenêtre de session 24 heures de WhatsApp) soit attend explicitement ce message. Si aucun des deux n'est vrai, ne l'envoie pas depuis un numéro personnel — c'est territoire Business API.
Envoyer un message transactionnel par nom ou à un nouveau destinataire
“Dis à Jean que sa facture est prête.”
“Envoie au plombier la nouvelle adresse du bureau pour qu'il livre les pièces.”
“Envoie le contrat signé au client qui vient de virer l'acompte.”
pour un chat existant, cherche via GET /chats?name=Jean (ou ton CRM) et appelle POST /chats/{id}/messages. Pour un nouveau destinataire, POST /messages avec {"phone":"+...","text":"..."}. Le skill whatsapp-send du repo compagnon gère les deux modes avec une sérialisation UTF-8 sûre.
$ cat > /tmp/send.json <<'JSON'
{"phone":"+15550200",
"text":"Hi - your order shipped. Tracking: ABC123."}
JSON
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/send.json \
https://app.timelines.ai/integrations/api/messages
{"status":"ok","data":{"message_uid":"OUTBOUND-UID-PLACEHOLDER"}}Relances programmées dans une conversation active
“Chaque lundi à 9h, vérifie les chats tagués to-follow-up qui ont eu de l'activité client dans les dernières 24 heures mais aucune réponse de notre part, et envoie un rappel poli.”
programme-la avec un cron job OpenClaw ou une standing order (voir les docs automation d'OpenClaw). Le job récupère l'audience via GET /chats?label=to-follow-up&read=false, lit le last_message_timestamp de chaque chat et n'envoie que si le dernier tour client est de moins de 24 heures. Hors de cette fenêtre, une relance devient un re-engagement et tu reviens en territoire Business API — vois Channel choice.
Déclencher des messages depuis des événements de tes autres outils
“Quand un deal HubSpot passe à 'demo scheduled', envoie une confirmation WhatsApp avec le lien du meeting.”
“Quand Stripe signale un paiement échoué, envoie un message de récupération poli avec un lien pour mettre la carte à jour.”
“Quand une réservation Calendly est créée, envoie un rappel avant la réunion le matin même.”
ton outil existant (HubSpot, Stripe, Calendly, Pipedrive) déclenche son propre webhook vers le même receiver que tu as monté dans Setup — ajoute un handler routé par event source ou path, branche-le depuis le handler TimelinesAI, et appelle POST /messages (nouveau destinataire) ou POST /chats/{id}/messages (dans un chat existant). WhatsApp devient un canal de livraison pour n'importe quel workflow déjà en place. Comme les envois suivent une action client dans l'outil amont, ils sont transactionnels par nature — dans les règles du numéro personnel.
Envoyer fichiers et documents à la demande
“Génère le PDF du devis et envoie-le au client qui vient de demander un prix.”
“Envoie le contrat en PDF par email et dépose-le aussi dans le chat WhatsApp du client.”
deux étapes. Upload des bytes du fichier via POST /files_upload en multipart/form-data (il n'existe pas d'endpoint d'upload par URL — ton agent doit télécharger le fichier lui-même puis le POST). La réponse renvoie un uid que tu passes à POST /chats/{id}/messages dans le champ file_uid. Le client a demandé le document — c'est une réponse à sa demande, pas du cold outreach.
$ # Step 1 - upload the file bytes as multipart/form-data.
$ # The form field name is "file". The response gives you a uid.
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-F "file=@/path/to/quote.pdf" \
https://app.timelines.ai/integrations/api/files_upload
{"status":"ok","data":{
"uid":"FILE-UID-PLACEHOLDER",
"filename":"quote.pdf",
"size":128453,
"mimetype":"application/pdf",
"uploaded_by_email":"you@yourcompany.com",
"uploaded_at":"2026-04-14 10:22:03 +0000",
"temporary_download_url":"https://tl-prod-data.s3.amazonaws.com/..."
}}
$ # Step 2 - attach the uploaded file to a chat message using file_uid.
$ cat > /tmp/send.json <<'JSON'
{"text":"Quote attached for your review.",
"file_uid":"FILE-UID-PLACEHOLDER"}
JSON
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/send.json \
https://app.timelines.ai/integrations/api/chats/12345678/messages
{"status":"ok","data":{"message_uid":"OUTBOUND-UID-PLACEHOLDER"}}Vérifier qu'un message a vraiment été livré
“Est-ce que Jean a vraiment reçu le message de facture que j'ai envoyé ce matin ?”
chaque envoi renvoie un message_uid. Plus tard, GET /messages/{uid}/status_history renvoie la timeline Sent / Delivered / Read. La livraison se fait généralement en une ou deux secondes sur un numéro actif. Le skill whatsapp-delivery-check du repo compagnon encapsule ça.
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/messages/OUTBOUND-UID-PLACEHOLDER/status_history
{"status":"ok","data":[
{"status":"Sent", "timestamp":"2026-04-12 12:28:40 +0000"},
{"status":"Delivered","timestamp":"2026-04-12 12:28:41 +0000"}
]}CRM
CRM et analyse
Les endpoints de lecture donnent à ton agent assez de données pour répondre aux questions analytiques et synchroniser l'état avec ton CRM en langage naturel.
Reporting de temps de réponse
“Quel était notre temps moyen de première réponse sur WhatsApp cette semaine ?”
“Qui dans mon équipe est le plus lent à répondre ?”
tire les chats récents, puis pour chaque chat récupère la timeline des messages, trouve le premier sortant après chaque entrant et agrège les deltas côté client. Deux endpoints, pas d'appel analytique spécial.
$ # Pull the last page of a chat's messages (50 per page, fixed).
$ # Messages nest under data.messages — not a flat .data[] array.
$ # FILTER message_type=="whatsapp" to exclude notes (see Things to know
$ # below — notes live in the same timeline with from_me=false and would
$ # otherwise corrupt the response-time calculation).
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
"https://app.timelines.ai/integrations/api/chats/12345678/messages?page=1" \
| jq -r '.data.messages[] | select(.message_type=="whatsapp") | "\(.timestamp)\tfrom_me=\(.from_me)\t\(.text[0:40])"'
# 2026-04-12 12:28:40 +0300 from_me=false Hi - is the order shipped?
# 2026-04-12 12:30:15 +0300 from_me=true Yes - tracking ABC123, ETA 2-3
# 2026-04-12 14:02:11 +0300 from_me=false Got it, thanks!
# ...
$ # Compute first-reply latency from each consecutive (false -> true)
$ # pair, then average across all chats. Pure client-side aggregation.
$ # For full history walk ?page=2, ?page=3, ... until data.has_more_pages is false.Détection des messages sans réponse
“Combien de messages avons-nous reçus hier ? Combien sont encore sans réponse ?”
“Montre-moi tout chat avec un message entrant dans les dernières 24 heures et aucune réponse.”
GET /chats?read=false pour les chats non lus, puis filtre à .data.messages[] où message_type=="whatsapp" ET from_me==false — les notes ont aussi from_me=false et gonfleraient le compte des non-répondus. Vois À savoir.
Résumer les conversations à la demande
“Résume toute la conversation avec ACME Corp. Quels sont leurs points de douleur ?”
“Donne-moi un brief d'un paragraphe sur chaque chat que je n'ai pas encore répondu.”
tire GET /chats/{id}/messages, filtre à .data.messages[] où message_type=="whatsapp" (sinon les notes injecteraient les propres gribouillis antérieurs de ton agent dans le résumé comme si c'étaient des paroles du client — vois À savoir), puis laisse OpenClaw résumer. Pagine avec ?page=N pour les longs threads.
Enrichir ton CRM avec l'activité WhatsApp
“Pour chaque nouveau chat de cette semaine, cherche le numéro dans HubSpot. Si c'est un contact, tague le chat avec son étape de deal. Sinon, crée le contact.”
combine les lectures TimelinesAI avec l'API de ton CRM. Écris les résultats dans le chat via POST /chats/{id}/labels et POST /chats/{id}/notes.
$ # 1. Pull recent chats with their phone numbers. /chats returns 50 per
$ # page under data.chats — paginate with ?page=N if you need more.
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
"https://app.timelines.ai/integrations/api/chats?page=1" \
| jq -r '.data.chats[] | "\(.id)\t\(.phone)"'
# 12345678 +15550100
# 12345679 +15550200
# ...
$ # 2. For each phone, look up your CRM (HubSpot/Pipedrive/Close).
$ # Pseudo-code:
$ # contact = hubspot.search_by_phone(phone)
$ # stage = contact.deal_stage if contact else "unknown"
$ # 3. Add the deal stage label WITHOUT wiping existing labels.
$ # POST /labels is REPLACE semantics (full set), so GET current labels,
$ # append the new one, and POST the combined list.
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/chats/12345678/labels \
| jq -c '.data.labels + ["hubspot/qualified-lead"] | unique | {labels: .}' \
> /tmp/labels.json
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/labels.json \
https://app.timelines.ai/integrations/api/chats/12345678/labelsNoter les leads depuis le contenu de la conversation
“Note chaque chat tagué inbound-lead de 1 à 10 sur l'adéquation et l'urgence. Écris le score comme une note.”
raisonnement LLM sur GET /chats/{id}/messages — filtre d'abord à message_type=="whatsapp", sinon tes propres notes de lead-score précédentes seront relues comme des paroles du client et le score dérivera à chaque passage. Écris le résultat via POST /chats/{id}/notes avec un préfixe prévisible (ex. "lead_score: fit=8 urgency=6") pour que ton prochain passage puisse le trouver et le remplacer.
Opérations
Mise à l'échelle et handoff
Patterns pour les workflows human-in-the-loop, le routing multi-agent et la mémoire de conversation. Les quatre capacités ci-dessous sont conçues pour coexister avec des humains qui travaillent les mêmes chats depuis la boîte partagée TimelinesAI.
Rédiger des réponses pour relecture humaine au lieu d'envoyer
“Pour chaque nouveau message entrant, rédige une réponse et sauve-la comme note. N'envoie pas — je relis et j'envoie moi-même.”
POST /chats/{id}/notes avec le texte du brouillon au lieu de /messages. La note apparaît dans la même vue de chat que ton équipe utilise déjà.
Passer la main à un humain quand l'agent est bloqué
“Si la conversation passe 5 tours sans résolution, ou si le client demande un humain, tague escalate et arrête de répondre jusqu'à ce que je retire le tag.”
compte les tours avec GET /chats/{id}/messages, vérifie les labels stop-reply avec GET /chats/{id}/labels avant chaque envoi. Si l'escalade se déclenche, POST /chats/{id}/labels avec escalate et sors.
$ # 1. Count outbound 'whatsapp' messages (skip notes) in this chat.
$ # Messages nest under data.messages — not a flat .data[] array.
$ TURNS=$(curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
"https://app.timelines.ai/integrations/api/chats/12345678/messages?page=1" \
| jq '[.data.messages[] | select(.from_me==true and .message_type=="whatsapp")] | length')
$ echo $TURNS
8
$ # 2. Past the threshold? Append 'escalate' to the chat's labels and stop.
$ # POST /labels REPLACES the full set, so read existing first, combine,
$ # then POST — otherwise you wipe every other label on the chat.
$ if [ "$TURNS" -ge 5 ]; then
curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/chats/12345678/labels \
| jq -c '.data.labels + ["escalate"] | unique | {labels: .}' \
> /tmp/labels.json
curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/labels.json \
https://app.timelines.ai/integrations/api/chats/12345678/labels
fi
$ # 3. Before every future send, GET /chats/{id}/labels and bail
$ # if .data.labels contains 'escalate' or 'needs-human'.Faire tourner plusieurs agents spécialisés sur une seule boîte
“L'agent Sales gère les questions de prix, l'agent Support gère les questions produit. Route par intention ; si les deux hésitent, escalade vers moi.”
deux skills spécialistes dans un même workspace OpenClaw (un ventes, un support) plus un troisième skill classificateur d'intention qui tourne en premier sur chaque message entrant, tague le chat avec le spécialiste choisi et sort. Chaque spécialiste lit le label d'intention avant de répondre et sort si le label pointe sur l'autre — un seul skill tire par message.
Se souvenir des conversations précédentes avec le même client
“La semaine dernière tu m'as dit que tu partais en voyage — ça s'est bien passé ?”
la mémoire propre d'OpenClaw plus GET /chats/{id}/messages pour tout l'historique WhatsApp. L'historique survit entre invocations parce qu'il vit sur TimelinesAI.
Teste ton agent end-to-end avec un deuxième numéro WhatsApp
Après le setup, tu vois les capabilities listées ici, mais tu ne peux pas vraiment voir ton agent travailler tant qu'un vrai client ne lui écrit pas. C'est un mauvais loop pour itérer. En voici un meilleur : connecte un deuxième numéro WhatsApp au même workspace TimelinesAI, utilise-le comme client scripted, et regarde ton vrai skill gérer la conversation end-to-end. Pas de mocks, pas de webhooks fake — c'est ton vrai agent, ta vraie API, juste avec un deuxième numéro jouant le rôle de l'humain de l'autre côté.
Le pattern
TimelinesAI permet à plusieurs numéros WhatsApp de vivre dans un seul workspace et route leurs webhooks entrants par le même receiver. L'astuce, c'est le champ whatsapp_account_id sur chaque payload de webhook — il porte le JID du numéro qui a reçu le message. Lis-le, fais un switch dessus, et dispatche soit vers l'étape persona (quand ton agent vient de répondre au client) soit vers l'étape agent (quand le client vient d'écrire à ton agent). Chaque côté envoie via le POST /messages ou POST /chats/{id}/messages normal depuis la perspective de l'autre.
Architecture
[+1 555 0100] [+1 555 0200]
persona agent under test
| |
| one TimelinesAI workspace |
| |
+-------> Public API <-------+
|
message:received:new
|
v
+--------------------------+
| Your test receiver |
| switch (accountJid) { |
| persona_jid -> next |
| agent_jid -> skill |
| } |
+--------------------------+Persona sender
Le côté persona est un client scripted : une liste de lignes à dire ensuite. Quand il est temps du prochain tour, POST sur /messages avec le numéro de téléphone de l'agent comme destinataire. TimelinesAI s'occupe du routing parce que les deux numéros sont dans un seul workspace.
// persona-sender.js — send the next scripted customer turn from the
// persona WhatsApp number to the agent-under-test number. TimelinesAI
// routes the send because both numbers share one workspace.
const PERSONA_SCRIPT = [
"hi I saw your ad",
"we want to reduce customer churn",
"we are 25 people",
"we need to roll out in Q3",
];
let idx = 0;
export async function sendNextPersonaTurn() {
if (idx >= PERSONA_SCRIPT.length) return;
const text = PERSONA_SCRIPT[idx++];
await fetch("https://app.timelines.ai/integrations/api/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.TIMELINES_AI_API_KEY}`,
"Content-Type": "application/json",
},
body: Buffer.from(JSON.stringify({
phone: process.env.AGENT_UNDER_TEST_PHONE,
text,
}), "utf-8"),
});
console.log(`[persona] sent: ${text}`);
}Receiver avec jid-switch
Un receiver, un branch par côté. Tout le reste que tu connais déjà du Setup — l'auth avec ?secret=, le filtre from_me des echoes, le fallback flat-vs-imbriqué du payload — s'applique ici sans changement.
// api/webhook.js — route events by which number received them
export default async function handler(req, res) {
if (req.query.secret !== process.env.WEBHOOK_SECRET) return res.status(404).end();
const { event_type, data } = req.body || {};
if (event_type !== "message:received:new") return res.status(200).end();
if (data?.from_me === true) return res.status(200).end();
res.status(200).json({ ok: true });
const accountJid = data.whatsapp_account_id ?? data.chat?.whatsapp_account_id;
if (accountJid === process.env.AGENT_UNDER_TEST_JID) {
// Customer just messaged the agent — run your agent-under-test skill
await handleAgentTurn(data);
} else if (accountJid === process.env.PERSONA_JID) {
// Agent just replied to the persona — advance the scripted scenario
await advancePersona(data);
}
}Ces deux snippets sont l'idée core. Le harness complet — scripts de persona, loader de scénarios, kickoff CLI, logger split-pane coloré, config Vercel — vit dans examples/test-harness/ dans le repo compagnon, avec son propre README qui guide le deploy, les env vars, l'enregistrement du webhook et l'exécution de ton premier scénario end-to-end.
Regarder ça se passer
Le harness tourne en autonomie — il n'a pas besoin que tu valides chaque réponse. Ta supervision passe par le dashboard TimelinesAI : ouvre le chat entre ton numéro persona et ton numéro agent dans un autre onglet du navigateur et chaque tour apparaît en direct. Si l'agent dit quelque chose de bête, envoie une réponse manuelle depuis la boîte de l'agent pour réorienter la conversation. Si tu dois changer un comportement en plein run, ajoute un label ou écris une note sur le chat — les deux sont visibles au skill de l'agent à son prochain tour. Arrête le run en supprimant le webhook enregistré ou en coupant la fonction Vercel ; le scénario s'arrête immédiatement.
Exemple : un aller-retour complet
Une boucle concrète en cinq étapes qui montre à quoi ressemble l'API en pratique — comment ton agent envoie un sortant, confirme la livraison, traite la réponse du client et envoie un follow-up. Les numéros de téléphone, chat IDs et message UIDs ci-dessous sont des placeholders — remplace-les par les tiens.
Placeholders utilisés tout au long de cet exemple :
Your business number → +1 555 0100 (JID: 15550100@s.whatsapp.net) Your customer's number → +1 555 0200 API token → $TIMELINES_AI_API_KEY
Confirme que ton token fonctionne et liste tes numéros connectés
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
https://app.timelines.ai/integrations/api/whatsapp_accounts
{"status":"ok","data":{"whatsapp_accounts":[
{"id":"15550100@s.whatsapp.net","phone":"+15550100",
"status":"active","account_name":"Your Business"}
]}}Tu devrais voir une liste de tes numéros connectés. Si le statut n'est pas active, corrige-le dans le dashboard TimelinesAI avant de continuer.
Envoie un message sortant
Écris le payload dans un fichier avec encodage UTF-8 explicite, puis passe-le à curl. C'est le pattern pour chaque envoi.
$ cat > /tmp/send.json <<'JSON'
{"phone":"+15550200",
"text":"Hi - your order shipped. Tracking: ABC123."}
JSON
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/send.json \
https://app.timelines.ai/integrations/api/messages
{"status":"ok","data":{"message_uid":"OUTBOUND-UID-PLACEHOLDER"}}La réponse est un reçu, pas une confirmation de livraison. Garde le message_uid pour l'étape suivante.
Vérifie le statut de livraison
$ curl -sS -H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
.../messages/OUTBOUND-UID-PLACEHOLDER/status_history
{"status":"ok","data":[
{"status":"Sent", "timestamp":"2026-04-12 12:28:40 +0000"},
{"status":"Delivered","timestamp":"2026-04-12 12:28:41 +0000"}
]}Sent → Delivered prend généralement une seconde sur un numéro actif. Le statut Read apparaît plus tard, quand le destinataire ouvre vraiment le chat.
Le client répond, ton webhook se déclenche
Quand le client répond, TimelinesAI fait un POST sur l'URL de webhook que tu as enregistrée :
{
"event_type": "message:received:new",
"data": {
"chat_id": 12345678,
"message_uid": "INBOUND-UID-PLACEHOLDER",
"sender_phone": "+15550200",
"sender_name": "Customer",
"text": "Thanks! When will it arrive?",
"timestamp": "2026-04-12 12:29:20 +0000"
}
}Ton receiver répond 200 immédiatement, puis passe le payload à ton skill OpenClaw.
Réponds depuis le même chat
$ cat > /tmp/reply.json <<'JSON'
{"text":"Estimated delivery is 2-3 business days. You'll get tracking updates to this chat."}
JSON
$ curl -sS -X POST \
-H "Authorization: Bearer $TIMELINES_AI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @/tmp/reply.json \
https://app.timelines.ai/integrations/api/chats/12345678/messages
{"status":"ok","data":{"message_uid":"REPLY-UID-PLACEHOLDER"}}Comme tu envoies dans un chat existant via /chats/{id}/messages, l'expéditeur est automatiquement le numéro WhatsApp propriétaire du chat — tu ne le choisis pas, l'enregistrement du chat le fait. C'est pour ça que tu n'envoies pas accidentellement depuis le mauvais numéro dans les workspaces multi-numéros.
Limites et avertissements
Ce que ce guide ne couvre PAS, et pourquoi.
Guides connexes
Si tu construis ça pour un vrai canal client, ces trois pages couvrent du territoire adjacent que tu vas toucher tôt ou tard.
Connecter plusieurs numéros WhatsApp →
Faire tourner plusieurs numéros personnels depuis un seul workspace, avec routage en boîte partagée et pinning JID par numéro.
Intégration HubSpot + WhatsApp →
Synchroniser les conversations dans les deals HubSpot — le compagnon off-the-shelf le plus proche de la capacité #11 (envois déclenchés par événement).
Agents ChatGPT pour WhatsApp →
La version non-OpenClaw de la même idée — un autoresponder géré que tu configures depuis le dashboard, sans skill.
Briques de construction
Bundle de skills compagnon sur GitHub →4 skills qui marchent, un receiver webhook Vercel, des docs de conformité et un mirror complet de ce guide. Licence MIT.
Docs API TimelinesAI · Docs Skills OpenClaw · release v0.1.0
Guide de capacités · 2026 · URL canonique timelines.ai/guide/openclaw-whatsapp-skills