Skip to Content
WhatsApp Agent Docs

Architecture

Monorepo layout, the webhook to reply pipeline, and the tenant-scoped data model.

Monorepo layout

A pnpm workspace orchestrated by Turborepo.

PathPackageRole
apps/engine@wa/engineCloudflare Worker built with Hono. Receives OpenWA webhooks, runs QStash job handlers (inbound, answer, send, ingest), and exposes a bearer-protected /admin API (sessions, groups, contacts).
apps/admin@wa/adminNext.js app with shadcn/ui and Better Auth, deployed to Cloudflare through OpenNext. Workspace sign-in, WhatsApp linking, assistant settings, knowledge uploads, inbox, groups, usage.
apps/docs@wa/docsThis site (Fumadocs, static export).
packages/db@wa/dbDrizzle ORM schema and migrations on Neon Postgres, withTenant helper, UUIDv7 newId(). Row level security is forced on tenant tables.
packages/core@wa/coreThe answerer (Claude call, citation validation), chunking, full-text retrieval, pricing, AES-GCM secret envelope, and the OpenWA client, webhook normalizer, signature check, and error classifier.
packages/email@wa/emailResend client and React Email templates (handoff alert, action email).

External services: Neon (Postgres), Cloudflare R2 (binding BLOB), Cloudflare KV (binding ROUTES), Upstash QStash (queue) and Redis (rate limit), Anthropic (answers), Sarvam and ElevenLabs (voice, optional), Resend (email), and the self-hosted OpenWA gateway that holds the WhatsApp sessions (see OpenWA gateway).

Request flow

Diagram: Message flow from OpenWA webhook to delivered reply.
  1. Webhook (POST /webhooks/openwa). The handler reads the raw body and verifies X-OpenWA-Signature (HMAC-SHA256 with OPENWA_WEBHOOK_SECRET) before parsing. Invalid signature returns 401. A signed but malformed payload is stored under raw/openwa/invalid/ and acknowledged so OpenWA stops retrying. No model or database work happens in this request.
  2. Raw payload to R2. Valid payloads are written to raw/openwa/<date>/<uuidv7>.json, which keeps an inspectable record of what the gateway sent. Inline media (up to 1 MB) is moved to its own R2 object first.
  3. One QStash job per event. The payload is normalized into events (message, own, status, session) and published to /jobs/inbound in batches, with retries: 5. Each job carries a deduplication ID: msg:<sessionId>:<messageId> for messages, own:... for messages sent from the linked account, st:<sessionId>:<messageId>:<status> for acks, and ev:<idempotencyKey> for session and group events. Message jobs also carry a flow-control key (conv:<sessionId>:<sender>, or the group id for groups) with parallelism: 1, so one conversation is processed strictly in order. If enqueueing fails the webhook returns 5xx and OpenWA redelivers; dedup IDs make that safe.
  4. Inbound job. Verified by QStash signature. It looks up the tenant by session ID in channel_route (cached in KV), then does everything else inside withTenant. It upserts the contact and conversation, and inserts the message (unique on provider message ID, so redelivery is a no-op). It archives inbound media to ws/<workspaceId>/media/<messageId> in R2, marks the message read and shows a typing indicator (private chats only), skips conversations in human mode and blocked contacts, and ignores silent types. Messages the account sent that the engine did not send put the conversation into human mode.
  5. Answer job. About 3 seconds later /jobs/answer answers the newest unanswered message together with any earlier ones (see Voice notes, groups and calling). Retrieval is Postgres full-text search over the current ready version of active sources (see Knowledge and answers).
  6. Claude answer. One structured-output call returns action, reply, citations, quickReplies, and language. Before it is accepted, code checks that every cited chunk ID was actually retrieved. An answer with no valid citation is downgraded to a handoff. Quick replies are appended to the text as a short "• option" list (omitted in groups) because linked accounts cannot send buttons.
  7. Outbox. The reply is inserted into outbox with idempotency key reply:<messageId> and the set of source versions it cited. A retry of the answer job finds the existing row and only re-enqueues the send.
  8. Send job (/jobs/send). Enqueued with dedup ID send:<outboxId> and flow key send:<conversationId>. It claims the row under SELECT ... FOR UPDATE, runs freshness checks, marks it sending, shows typing (or recording) for a human-like delay, calls the gateway, then records sent and inserts the outbound message. Details in Operations.
  9. Acks. message.ack and message.failed events update message.delivery_status monotonically (sent, delivered, read), so out-of-order webhooks never move a message backwards.

Retryable conditions (Anthropic 429, 5xx, 529, 408, connection errors; gateway 409, 429 and 503; the per-tenant answer rate limit) make the worker return 503 {"error":"retry"} so QStash redelivers with backoff. Everything else returns 500 and is logged with the request's X-Trace-Id (a UUIDv7 set on every response).

Other engine routes

RoutePurpose
GET /healthLiveness.
POST /webhooks/openwaSigned OpenWA webhook receiver.
POST /jobs/inbound, /jobs/answer, /jobs/sendQStash job handlers.
POST /jobs/ingestChunk an uploaded source version. Enqueued by the admin app.
POST /admin/*Bearer-token API for the admin app: session/connect, session/qr, session/pairing-code, session/status, session/logout, contact block and unblock, and group operations.

Data model

All primary keys are UUIDv7 (newId() from the uuidv7 package), which sort by creation time. Tenant tables carry workspace_id referencing the Better Auth organization.

TableNotes
assistant, assistant_versionVersioned AssistantConfig (persona, instructions, languages, AI disclosure, handoff and fallback messages, model, effort). The published version is what the engine reads.
channel_connectionOpenWA session ID and name, last session status, linked phone number and push name, status (pending, active, paused, disconnected). Unique on session ID. No credentials are stored per tenant.
channel_routeCross-tenant routing index (session ID to workspace). No secrets or content, and no RLS, because it is read before the tenant is known.
contact, contact_identityCustomer identity keyed on the WhatsApp id (<digits>@c.us or <id>@lid) with phone as optional.
conversationOne private conversation per (channel connection, contact), or one per group. mode is assistant or human.
messageInbound and outbound messages. Unique on (workspace, provider message ID). delivery_status: sent, delivered, read, failed.
source, source_version, chunkDocuments, their immutable versions (processing, ready, failed), and searchable chunks with a generated tsvector and GIN index.
outboxSend intents. Unique on (workspace, idempotency key). See Operations.
channel_eventSession and group webhook events (status changes, restrictions).
usage_event, audit_eventMetering (tokens, cost in micro-USD, chunks) and an actor-attributed audit trail.

Tenant isolation in one paragraph

Every tenant table has a tenant_isolation policy comparing workspace_id to the transaction-local setting app.workspace_id, and FORCE ROW LEVEL SECURITY so even the owner role obeys it. withTenant(db, workspaceId, fn) opens a transaction and runs set_config('app.workspace_id', ..., true) first. See Security.

Why Workers need a per-request pool

createDb builds a Neon WebSocket Pool (needed for interactive transactions with SET LOCAL). On Workers a pool must not outlive the request, so the engine middleware ends it with ctx.waitUntil(pool.end()) after each handler.

On this page