Hermes Agent · Part 2

Build a WhatsApp Agent with Cloud API, Chatwoot, Hermes, and OpenAI

A practical guide to wiring Meta's WhatsApp Cloud API through Chatwoot into Hermes Agent with OpenAI — architecture, bridge service, handoff to humans, and common operational pitfalls.

21 min read

TL;DR

  • Meta’s Facebook app config allows a single webhook callback URL (per Chatwoot’s WhatsApp FAQ). For a team inbox plus an AI agent, let Chatwoot own the WhatsApp Cloud API channel and use Hermes as the brain behind a Chatwoot Agent Bot webhook — not as a second WhatsApp webhook receiver.
  • A practical stack (July 2026, editorial): WhatsApp Cloud API → Chatwoot inbox → Agent Bot webhook → small bridge service → Hermes API server (OpenAI-compatible) with OPENAI_API_KEY → reply posted back through Chatwoot → Meta delivers to WhatsApp.
  • Hermes direct WhatsApp (hermes whatsapp-cloud) is a reasonable path for a solo bot without a support desk. This article focuses on the Chatwoot + Hermes pattern for support teams that need visibility, assignment, and human takeover.
  • Go-live gotchas: subscribe your Meta app to the WABA (subscribed_apps), respect the 24-hour customer service window, use a System User permanent token, and test dev-mode phone number formatting carefully.
  • Hermes value here: full agent loop (tools, skills, memory, sessions) behind OpenAI — not a thin ChatGPT wrapper in the bridge. The sample bridge uses Hermes’s Responses API conversation parameter for multi-turn continuity; X-Hermes-Session-Key scopes long-term memory, not transcript history.

What You Will Learn Here

  • Why Chatwoot and Hermes should not both receive WhatsApp webhooks from Meta
  • A reference architecture for WhatsApp → Chatwoot → Hermes → OpenAI
  • How to set up Chatwoot’s WhatsApp Cloud API inbox (manual flow)
  • How to configure Hermes with OpenAI and expose it via the API server
  • A minimal FastAPI bridge that connects Chatwoot Agent Bot events to Hermes
  • Human handoff, the 24-hour messaging window, and operational checklist
  • Common failure modes from Meta and Chatwoot issue threads

This is Part 2 of the Hermes Agent series. Start with Part 1: Hermes architecture and RAG lessons. Part 3 covers scheduled cron ops; Part 4 covers internal support copilots for human agents.

The Architecture Question

You have four ingredients:

ComponentRole
WhatsApp Cloud APIOfficial Meta channel for inbound/outbound WhatsApp messages
ChatwootTeam inbox, conversation history, agent assignment, human handoff
Hermes AgentSelf-hosted agent runtime — tools, skills, memory, sessions
OpenAI APILLM provider Hermes calls for reasoning and tool use

The mistake to avoid: pointing Meta’s webhook at Hermes and Chatwoot. Per Chatwoot’s manual WhatsApp guide, a Facebook app configures only one webhook endpoint — you point it at Chatwoot’s callback URL. Other numbers on the same app can share that endpoint via additional Chatwoot inboxes, but you do not get two independent Meta webhook receivers.

So pick an owner for the WhatsApp webhook:

Option A — Support desk (this article)
  WhatsApp → Meta → Chatwoot → Bridge → Hermes (+ OpenAI) → Chatwoot → WhatsApp

Option B — Solo Hermes bot (no Chatwoot)
  WhatsApp → Meta → Hermes gateway (hermes whatsapp-cloud) → OpenAI

This article implements Option A.

When to use Option A vs Option B

Option A — Chatwoot + Hermes (this article)Option B — Hermes direct
Best forSupport teams, human handoff, conversation audit in a team inboxSolo bots, personal assistants, fastest first message
Team visibilityAgents see full WhatsApp history in ChatwootNo shared inbox; you build your own logging
Human takeoverNative via Chatwoot toggle_statusopenYou implement escalation yourself
Webhook ownerChatwoot (one Meta callback URL)Hermes gateway (hermes whatsapp-cloud)
Time to first replyHigher — bridge + inbox + bot setupLower — wizard + tunnel
Compliance / opsConversation audit lives in Chatwoot; Hermes sessions for agent reasoningYou own both audit trails

Reference Architecture

                         CUSTOMER PHONE
                               |
                               v
                    +---------------------+
                    |  WhatsApp Cloud API |
                    |  (Meta Graph API)   |
                    +----------+----------+
                               | HTTPS webhook
                               v
                    +---------------------+
                    |      Chatwoot       |
                    |  WhatsApp inbox     |
                    |  conversations DB   |
                    +----------+----------+
                               |
              message_created webhook (Agent Bot)
                               v
                    +---------------------+
                    |   Bridge service    |
                    |   (FastAPI)         |
                    | - verify Chatwoot   |
                    | - map conversation  |
                    | - call Hermes API   |
                    +----------+----------+
                               | OpenAI-compatible HTTP
                               v
                    +---------------------+
                    |  Hermes API server  |
                    |  (+ AIAgent loop)   |
                    +----------+----------+
                               |
                               v
                    +---------------------+
                    |    OpenAI API       |
                    |  (gpt-4o, etc.)     |
                    +---------------------+

Reply path:
  Bridge --POST--> Chatwoot Messages API (agent bot token)
       --> Chatwoot --> Meta --> customer WhatsApp

Why a bridge service?

Chatwoot’s Agent Bot sends message_created events to your outgoing_url. Hermes exposes an OpenAI-compatible API server — not a native Chatwoot adapter. A thin bridge translates between the two protocols.

The bridge should stay dumb: validate webhooks, extract the user message, call Hermes, post the reply. Keep skills, tools, memory, and session logic inside Hermes.

Apply Part 1 patterns here

Part 1 described Hermes as a harness, not a thin LLM proxy. Carry these patterns into the WhatsApp stack:

Part 1 patternWhatsApp implementation
Sessions as infrastructureResponses API conversation key per Chatwoot conversation (see Step 5)
Repo-native instructionsAGENTS.md for support rules, HANDOFF keyword, tone — not hardcoded bridge prompts
Tool exposure per surfaceNarrow to the WhatsApp toolset in Step 3; avoid full terminal/browser on customer chat
Bounded memory + on-demand recallLet Hermes load MEMORY.md / skills; bridge does not inject policy text
Write-approval gatesOptional skills.write_approval if the bot learns support macros from conversations

If your support bot also needs document grounding, see RAG Apps in Practice for eval gates before enabling qmd/wiki skills on customer-facing channels.

Prerequisites

Before you wire components together:

  1. Meta Business account and a WhatsApp app with Cloud API enabled
  2. Dedicated business phone number (not your personal WhatsApp)
  3. Chatwoot instance — Cloud or self-hosted with a public HTTPS URL
  4. Hermes Agent installed on a host that can run the gateway/API server
  5. OpenAI API key with billing enabled
  6. Public HTTPS for Chatwoot (Meta must reach your webhook). Hermes’s WhatsApp Cloud docs mention Cloudflare Tunnel and ngrok for local dev; Chatwoot itself just needs a URL Meta can reach.

Step 1: Set Up Chatwoot’s WhatsApp Cloud API Inbox

Follow Chatwoot’s manual WhatsApp Cloud API guide:

  1. Create a permanent System User access token in Meta Business Manager with whatsapp_business_messaging and whatsapp_business_management permissions.
  2. In Chatwoot: Settings → Inboxes → Add inbox → WhatsApp.
  3. Enter your phone number, Phone Number ID, and Business Account ID from Meta’s API Setup page.
  4. Copy Chatwoot’s webhook URL (format: https://<your-chatwoot>/webhooks/whatsapp/<phone_number>) and verify token.
  5. In Meta Developer Portal → WhatsApp → Configuration, paste the callback URL and verify token. Subscribe to the messages field.

Critical Meta step: subscribe the app to your WABA

Multiple Chatwoot GitHub issues (#11725, #13097) report webhooks verified but no messages arriving. Community reports and Meta support point to subscribing the app to the WABA:

curl -X POST \
  "https://graph.facebook.com/v<API_VERSION>/<WABA_ID>/subscribed_apps" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

Replace v<API_VERSION> with the Graph API version shown in your Meta app dashboard (for example v21.0 or newer).

Also confirm under Webhook fields → messages that message delivery is subscribed. Chatwoot merged automation for this in PR #13278 (January 2026), but the manual curl is still worth knowing for debugging.

Dev-mode phone numbers

In development mode, Meta restricts which numbers you can message. Add testers under API Setup → Manage phone number list. If outbound messages fail with error #131030 Recipient phone number not in allowed list, check that the stored phone format matches Meta’s allowed-list format exactly — Chatwoot issue #13932 documents Argentine number formatting mismatches.

Step 2: Configure Hermes with OpenAI

Hermes supports OpenAI through the openai-api provider. Per the AI Providers docs:

# ~/.hermes/.env
OPENAI_API_KEY=sk-...

# Interactive model selection
hermes model
# Choose "OpenAI API (direct)" and pick a model (e.g. gpt-4o)

Or set in ~/.hermes/config.yaml:

model:
  provider: openai-api
  default: gpt-4o

Verify with a local chat:

hermes chat -q "Reply with exactly: Hermes OpenAI OK"

See Configuring Models for provider-specific env vars and config.yaml layout.

Optional: project context for support

Add repo-specific instructions Hermes will load automatically:

<!-- AGENTS.md in your project root -->
# Support Agent Rules

- You are Acme Corp's WhatsApp support assistant.
- Be concise. WhatsApp messages should be short paragraphs, not essays.
- If the user asks for a human, reply HANDOFF and stop.
- Never invent order IDs. Use the order_lookup tool when available.

Hermes loads AGENTS.md, .hermes.md, and compatible context files per its Prompt Assembly docs.

Step 3: Start the Hermes API Server

The API server exposes Hermes as an OpenAI-compatible HTTP endpoint on port 8642 by default.

# ~/.hermes/.env
API_SERVER_ENABLED=true
API_SERVER_KEY=change-me-to-a-long-random-secret
API_SERVER_HOST=127.0.0.1
API_SERVER_PORT=8642

# Start gateway (API server runs alongside it)
hermes gateway

Health check:

curl -H "Authorization: Bearer $API_SERVER_KEY" \
  http://127.0.0.1:8642/health

The API server gives callers access to Hermes’s full toolset. Treat API_SERVER_KEY as a deployment secret — the docs require it even on loopback.

Scope tools for WhatsApp

Customer-facing WhatsApp turns should not expose the full terminal/browser tool surface. Per Hermes’s WhatsApp Cloud troubleshooting docs, the default platform mapping uses the hermes-whatsapp toolset. When starting the gateway for this integration, restrict exposed tools to what support agents actually need:

# Example: start gateway with a WhatsApp-safe tool surface
hermes gateway --toolsets hermes-whatsapp,skills

Verify the exact flag against your Hermes version (hermes gateway --help). For API-server-only calls, Hermes resolves toolsets for the api_server platform — confirm via GET /v1/toolsets with your API_SERVER_KEY.

Step 4: Create the Chatwoot Agent Bot

Create a webhook bot in Chatwoot. Via API (developer docs):

curl -X POST "https://<chatwoot-host>/api/v1/accounts/<account_id>/agent_bots" \
  -H "api_access_token: <admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Hermes WhatsApp Agent",
    "description": "Hermes + OpenAI support bot",
    "outgoing_url": "https://<bridge-host>/chatwoot/webhook",
    "bot_type": 0
  }'

Save two different credentials from this step:

CredentialPurpose
access_token (agent bot)Bridge uses this as api_access_token to post replies and toggle conversation status
Webhook signing secret (if shown)Separate from the bot token — used to verify X-Chatwoot-Signature on inbound webhooks

Do not confuse the bot access_token with Meta’s WhatsApp verify token or with Hermes’s API_SERVER_KEY. Per Chatwoot’s webhook verification guide, account-level webhooks sign with sha256=HMAC-SHA256(secret, "{timestamp}.{raw_body}"). Agent Bot outgoing_url webhooks may not include signatures on all Chatwoot versions yet (chatwoot/chatwoot#13809) — implement verification when your instance provides a secret, and restrict bridge ingress by network policy until then.

Connect the bot to your WhatsApp inbox: Inbox Settings → Bot Configuration → select the bot → Save. New conversations will enter pending status and Chatwoot will POST events to your bridge.

Step 5: Build the Bridge Service

Minimal FastAPI bridge. It listens for Chatwoot Agent Bot webhooks, verifies signatures when a signing secret is available, calls Hermes /v1/responses with a stable conversation key for multi-turn continuity, and posts the reply back. The handler only auto-replies to incoming messages (message_type == 0) on pending conversations.

Channel rules (tone, HANDOFF keyword, support macros) belong in AGENTS.md from Step 2 — not hardcoded in the bridge. Hermes layers frontend instructions on top of its core prompt per the API server docs.

# bridge/main.py
import hashlib
import hmac
import json
import os
import time

import httpx
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

CHATWOOT_BASE = os.environ["CHATWOOT_BASE_URL"].rstrip("/")
CHATWOOT_ACCOUNT_ID = os.environ["CHATWOOT_ACCOUNT_ID"]
CHATWOOT_BOT_TOKEN = os.environ["CHATWOOT_BOT_TOKEN"]
CHATWOOT_BOT_WEBHOOK_SECRET = os.environ.get("CHATWOOT_BOT_WEBHOOK_SECRET", "")

HERMES_BASE = os.environ.get("HERMES_API_BASE", "http://127.0.0.1:8642")
HERMES_API_KEY = os.environ["HERMES_API_KEY"]
HERMES_MODEL = os.environ.get("HERMES_MODEL", "hermes-agent")

INCOMING = 0
OUTGOING = 1


def verify_chatwoot(request: Request, body: bytes) -> None:
    """Verify Chatwoot webhook signature when a signing secret is configured."""
    if not CHATWOOT_BOT_WEBHOOK_SECRET:
        return

    timestamp = request.headers.get("X-Chatwoot-Timestamp", "")
    received = request.headers.get("X-Chatwoot-Signature", "")
    if not timestamp or not received:
        raise HTTPException(status_code=401, detail="missing signature headers")

    try:
        ts = int(timestamp)
    except ValueError as exc:
        raise HTTPException(status_code=401, detail="invalid timestamp") from exc

    if abs(time.time() - ts) > 300:
        raise HTTPException(status_code=401, detail="stale webhook timestamp")

    signed_payload = f"{ts}.".encode() + body
    expected = "sha256=" + hmac.new(
        CHATWOOT_BOT_WEBHOOK_SECRET.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="invalid signature")


def is_incoming_message(message: dict) -> bool:
    message_type = message.get("message_type")
    return message_type in (INCOMING, "incoming")


def extract_response_text(data: dict) -> str:
    for item in data.get("output", []):
        if item.get("type") != "message":
            continue
        for part in item.get("content", []):
            if part.get("type") == "output_text":
                text = (part.get("text") or "").strip()
                if text:
                    return text
    return ""


async def ask_hermes(user_text: str, conversation_key: str) -> str:
    # Responses API chains turns via `conversation` — unlike stateless /v1/chat/completions
    payload = {
        "model": HERMES_MODEL,
        "input": user_text,
        "conversation": conversation_key,
        "store": True,
    }
    headers = {
        "Authorization": f"Bearer {HERMES_API_KEY}",
        "Content-Type": "application/json",
        # Scopes long-term memory providers (e.g. Honcho) — not transcript replay by itself
        "X-Hermes-Session-Key": conversation_key,
    }
    async with httpx.AsyncClient(timeout=120.0) as client:
        resp = await client.post(
            f"{HERMES_BASE}/v1/responses",
            json=payload,
            headers=headers,
        )
        resp.raise_for_status()
        data = resp.json()
    return extract_response_text(data)


async def post_chatwoot_reply(conversation_id: int, content: str) -> None:
    url = (
        f"{CHATWOOT_BASE}/api/v1/accounts/{CHATWOOT_ACCOUNT_ID}"
        f"/conversations/{conversation_id}/messages"
    )
    headers = {"api_access_token": CHATWOOT_BOT_TOKEN}
    payload = {
        "content": content,
        "message_type": OUTGOING,
        "private": False,
    }
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(url, json=payload, headers=headers)
        resp.raise_for_status()


async def handoff_to_human(conversation_id: int) -> None:
    """Toggle conversation status to open so human agents can take over."""
    url = (
        f"{CHATWOOT_BASE}/api/v1/accounts/{CHATWOOT_ACCOUNT_ID}"
        f"/conversations/{conversation_id}/toggle_status"
    )
    headers = {"api_access_token": CHATWOOT_BOT_TOKEN}
    payload = {"status": "open"}
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(url, json=payload, headers=headers)
        resp.raise_for_status()


@app.post("/chatwoot/webhook")
async def chatwoot_webhook(request: Request):
    body = await request.body()
    verify_chatwoot(request, body)
    event = json.loads(body)

    if event.get("event") != "message_created":
        return {"status": "ignored"}

    message = event.get("message", {})
    conversation = event.get("conversation", {})

    if conversation.get("status") != "pending":
        return {"status": "ignored"}

    if not is_incoming_message(message):
        return {"status": "ignored"}

    content = (message.get("content") or "").strip()
    if not content:
        return {"status": "ignored"}

    conversation_id = conversation["id"]
    conversation_key = f"chatwoot:wa:{conversation_id}"

    reply = await ask_hermes(content, conversation_key=conversation_key)

    if reply.upper().startswith("HANDOFF"):
        await post_chatwoot_reply(
            conversation_id,
            "Connecting you with a human agent. Please hold.",
        )
        await handoff_to_human(conversation_id)
        return {"status": "handoff"}

    await post_chatwoot_reply(conversation_id, reply)
    return {"status": "ok"}

Run with:

export CHATWOOT_BASE_URL=https://support.example.com
export CHATWOOT_ACCOUNT_ID=1
export CHATWOOT_BOT_TOKEN=<agent-bot-access-token>
export CHATWOOT_BOT_WEBHOOK_SECRET=<webhook-signing-secret-if-available>
export HERMES_API_KEY=<API_SERVER_KEY>
export HERMES_MODEL=hermes-agent
uvicorn bridge.main:app --host 0.0.0.0 --port 8080

Expose the bridge with HTTPS (reverse proxy or tunnel). Chatwoot must reach outgoing_url.

Session continuity

/v1/chat/completions is stateless — each request must carry the full messages[] history or the model forgets prior turns. The sample bridge uses POST /v1/responses with a stable conversation key (chatwoot:wa:{conversation_id}) so Hermes chains turns server-side.

Header / parameterPurpose
conversation (Responses API body)Multi-turn transcript continuity for this Chatwoot thread
X-Hermes-Session-KeyScopes long-term memory providers (e.g. Honcho) per channel — not transcript replay by itself
X-Hermes-Session-IdTranscript-scoped session ID when using /api/sessions/{id}/chat instead

Alternatives if Responses API chaining is unavailable in your Hermes version:

  • POST /api/sessions/{id}/chat — create a Hermes session per Chatwoot conversation, persist the session ID in Chatwoot custom attributes
  • Replay Chatwoot message history into a stateless /v1/chat/completions messages[] array

See Stateful vs Stateless Agents in Production for the general tradeoff framing.

Step 6: End-to-End Message Flow

1. Customer sends "Where is order 12345?" on WhatsApp
2. Meta POSTs webhook → Chatwoot /webhooks/whatsapp/<phone>
3. Chatwoot creates/updates conversation, fires Agent Bot webhook
4. Bridge receives message_created event
5. Bridge POSTs to Hermes `/v1/responses` with `conversation=chatwoot:wa:{id}` (OpenAI-backed)
6. Hermes may call tools (order lookup, knowledge search, etc.)
7. Bridge receives assistant text from the Responses `output` array
8. Bridge POSTs outgoing message to Chatwoot Messages API
9. Chatwoot sends via WhatsApp Cloud API → Meta → customer

Return HTTP 200 from the webhook handler as soon as you have validated the event. The sample bridge processes synchronously for clarity; if Hermes tool loops exceed Chatwoot’s webhook timeout, move ask_hermes to a background task and post the reply when the agent finishes.

Latency budget: Meta → Chatwoot → bridge → Hermes agent loop → Chatwoot → Meta. Tool-heavy Hermes turns can exceed WhatsApp UX expectations. For simple FAQ, restrict tools on the WhatsApp toolset; for complex lookups, send a quick “Looking that up…” ack first.

Human Handoff

Chatwoot’s value is the handoff path. Per the Agent Bot handoff docs, bot-owned conversations start in pending status. When the bot needs a human, call the Toggle Status API with {"status": "open"} using the agent bot access_token.

TriggerAction
User asks for humanBridge posts handoff message; toggle_statusopen
Hermes low confidenceReply + offer human; optional private note for agents with trace summary
Agent takes over in Chatwoot UIConversation status becomes open; bridge ignores non-pending conversations
Escalation keywordsBridge matches regex before calling Hermes

After handoff, the bridge must stop auto-replying — check conversation.status == "pending" and message_type == 0 (incoming). Agents can return a conversation to the bot queue by toggling status back to pending.

WhatsApp Messaging Rules

These are Meta constraints, not Hermes or Chatwoot limits:

24-hour customer service window

Hermes’s WhatsApp Cloud docs note that replies more than 24 hours after the user’s last message require a pre-approved template. Chatwoot’s Messages API supports template_params for structured template messages.

Plan for:

  • Free-form AI replies inside the 24h window
  • Approved templates for re-engagement outside the window

Tokens and security

SecretWherePurpose
Meta System User tokenChatwoot inbox configSend/receive WhatsApp messages via Cloud API
Meta webhook verify tokenMeta Developer Portal + Chatwoot inboxGET webhook verification handshake
Meta App Secret (WHATSAPP_APP_SECRET)Hermes direct path only (hermes whatsapp-cloud)Validates Meta webhook signatures when Hermes receives webhooks directly
Chatwoot agent bot access_tokenBridge servicePost replies and toggle conversation status
Chatwoot bot webhook signing secretBridge service (optional)Verify X-Chatwoot-Signature on inbound Agent Bot events
Hermes API_SERVER_KEYBridge service + Hermes ~/.hermes/.envAuthenticate calls to /v1/chat/completions
OpenAI API keyHermes ~/.hermes/.env onlyLLM provider for the agent loop

Never put OpenAI or Hermes keys in Chatwoot’s frontend or the bridge’s client-side code.

Alternative: Hermes Direct (No Chatwoot)

If you do not need a team inbox, skip Chatwoot entirely:

hermes whatsapp-cloud   # guided wizard
hermes gateway          # starts Cloud API webhook on port 8090

Use Cloudflare Tunnel to expose https://<tunnel>/whatsapp/webhook to Meta. Configure OPENAI_API_KEY, set WHATSAPP_CLOUD_ALLOWED_USERS, and you have a direct Hermes ↔ WhatsApp ↔ OpenAI loop.

Trade-off: no agent dashboard, no human handoff UI, no conversation analytics in Chatwoot. Faster to stand up for personal bots.

Deployment Topology

┌─────────────────────────────────────────────────────────┐
│ VPS / container host                                     │
│                                                          │
│  ┌──────────────┐   ┌──────────────┐   ┌─────────────┐ │
│  │ Hermes       │   │ Bridge       │   │ (optional)  │ │
│  │ gateway +    │◄──│ FastAPI      │   │ nginx/Caddy │ │
│  │ API :8642    │   │ :8080        │   │ TLS proxy   │ │
│  └──────────────┘   └──────┬───────┘   └─────────────┘ │
│                             │                            │
└─────────────────────────────┼────────────────────────────┘
                              │ HTTPS
                              v
                    ┌──────────────────┐
                    │ Chatwoot (Cloud  │
                    │ or self-hosted)  │
                    └────────┬─────────┘

                             v
                    ┌──────────────────┐
                    │ Meta WhatsApp    │
                    │ Cloud API        │
                    └──────────────────┘

Minimum viable deployment:

  • Chatwoot on a managed host or self-hosted with TLS
  • Hermes + bridge on a host with a public HTTPS ingress (VM, container, or tunnel)
  • System User token (not 24h temp token)
  • Process supervisor for hermes gateway and uvicorn

Operational Checklist

[ ] Meta app subscribed to WABA (subscribed_apps curl)
[ ] Webhook messages field enabled in Meta dashboard
[ ] Chatwoot inbox receives inbound test message
[ ] Agent Bot webhook fires on message_created
[ ] Bridge verifies webhook signatures when a signing secret is configured
[ ] Hermes API health check passes with API_SERVER_KEY
[ ] OpenAI billing and model access confirmed
[ ] End-to-end reply delivered to WhatsApp
[ ] Handoff path tested (human agent can take conversation)
[ ] 24h window + template message path documented for your team
[ ] Secrets rotated; no tokens in git

Who owns what (ops)

  • Meta token rotation — platform/DevOps; System User token in Chatwoot inbox config
  • WhatsApp template approval — support ops + marketing; templates for re-engagement outside the 24h window
  • Escalation SLA — support lead; define when the bridge must toggle_statusopen
  • Conversation audit — Chatwoot for customer-visible threads; Hermes session DB for agent reasoning traces

Common Failure Modes

SymptomLikely causeFix
Webhook verifies but no messages in ChatwootApp not subscribed to WABAsubscribed_apps POST
Outbound fails #131030Dev-mode number format mismatchAlign allowed-list and stored contact format
Bridge 401 from HermesWrong API_SERVER_KEYMatch bridge HERMES_API_KEY to Hermes env
Double repliesBridge handling outgoing messages or open conversationsFilter message_type == 0 and status == "pending"
Empty Hermes responseModel/provider misconfighermes model + hermes chat -q "test" locally
Chatwoot “reauthorization required”Token expired or app modeRefresh System User token; check Meta app live mode
Slow repliesHermes tool loop on WhatsAppNarrow to hermes-whatsapp toolset; ack first on slow lookups
Bot forgets prior turnsUsed stateless /v1/chat/completions without historySwitch to /v1/responses conversation key or /api/sessions/{id}/chat

Limitations of This Minimal Bridge

Before treating the sample as production-ready, know what it omits:

  • No idempotency — Chatwoot webhook retries can produce duplicate replies unless you dedupe on message.id or X-Chatwoot-Delivery.
  • Synchronous Hermes call — tool-heavy turns may exceed webhook timeouts; process async in production.
  • No rate limiting — bridge accepts every message_created event; add per-conversation throttling.
  • Signature verification is release-dependent — confirm Agent Bot signing on your Chatwoot version (#13809).
  • No PII redaction in logs — scrub customer message content before logging bridge payloads.

What to Add Next

This article covers the wiring. Support bots usually add:

  • Chatwoot → Hermes session mapping in Postgres/Redis
  • Proper handoff via Chatwoot conversation assignment APIs
  • Eval traces (did the bot answer from the right source?)
  • Rate limits on the bridge
  • Hermes skills for your support macros and refund policy
  • Template message library for outbound campaigns outside the 24h window

Bottom Line

Building a WhatsApp agent with Cloud API, Chatwoot, Hermes, and OpenAI is a composition problem, not a single-product install:

  • Chatwoot owns the WhatsApp webhook and team inbox
  • Hermes + OpenAI own the agent brain, tools, and memory
  • A thin bridge connects Chatwoot’s Agent Bot protocol to Hermes’s OpenAI-compatible API

That split gives you official WhatsApp Cloud API messaging, human handoff, and a real agent harness — without forcing one tool to be all three.

Sources

Primary sources consulted on July 3, 2026:

  1. Hermes Agent — WhatsApp Business (Cloud API) — direct webhook path, env vars (WHATSAPP_APP_SECRET, etc.), 24h window. hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp-cloud
  2. Hermes Agent — Environment Variables ReferenceAPI_SERVER_KEY, gateway, and provider env vars. hermes-agent.nousresearch.com/docs/reference/environment-variables
  3. Hermes Agent — Messaging Gateway — platform capabilities, hermes-whatsapp toolset. hermes-agent.nousresearch.com/docs/user-guide/messaging/
  4. Hermes Agent — API Server — OpenAI-compatible endpoint, Responses API conversation chaining, X-Hermes-Session-Key, Sessions API. hermes-agent.nousresearch.com/docs/user-guide/features/api-server
  5. Hermes Agent — AI ProvidersOPENAI_API_KEY, openai-api provider. hermes-agent.nousresearch.com/docs/integrations/providers
  6. Hermes Agent — Configurationconfig.yaml, model provider setup. hermes-agent.nousresearch.com/docs/user-guide/configuration
  7. Chatwoot — How to set up a WhatsApp channel (manual flow) — Cloud API inbox, webhook URL format, verify token. chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow
  8. Chatwoot — How to use Agent bots — webhook events, handoff via toggle_status, inbox connection. chatwoot.com/hc/user-guide/articles/1677497472-how-to-use-agent-bots
  9. Chatwoot — How to use webhooksX-Chatwoot-Signature / X-Chatwoot-Timestamp HMAC verification. chatwoot.com/hc/user-guide/articles/1677693021-how-to-use-webhooks
  10. Chatwoot Developer Docs — Create Agent Bot — API payload and access_token. developers.chatwoot.com/api-reference/account-agentbots/create-an-agent-bot
  11. Chatwoot Developer Docs — Toggle StatusPOST .../toggle_status with {"status": "open"}. developers.chatwoot.com/api-reference/conversations/toggle-status
  12. Chatwoot Developer Docs — Create New Message — outgoing replies, WhatsApp template_params. developers.chatwoot.com/api-reference/messages/create-new-message
  13. Meta — WhatsApp embedded signup webhooks — WABA subscription context. developers.facebook.com/docs/whatsapp/embedded-signup/webhooks

Issue-tracker evidence (secondary, for operational pitfalls):

  1. Chatwoot #11725subscribed_apps and inbound message delivery. github.com/chatwoot/chatwoot/issues/11725
  2. Chatwoot #13097subscribed_apps fix for missing inbound messages. github.com/chatwoot/chatwoot/issues/13097
  3. Chatwoot #13932 — dev-mode phone number format #131030. github.com/chatwoot/chatwoot/issues/13932
  4. Chatwoot #13809 — Agent Bot webhook signing secret vs access_token distinction. github.com/chatwoot/chatwoot/issues/13809

Series:

  1. Part 1: Hermes Agent architecture and RAG lessons
  2. RAG Apps in Practice — if support bots need document grounding with eval gates
  3. Stateful vs Stateless Agents in Production — session continuity tradeoffs