Skip to content

WhatsApp QR channel (linked device)

Talk to an agent over WhatsApp from your own number, linked like WhatsApp Web is: a QR scan pairs a companion device held by a small bridge service you run. No Meta Business account, no cloud API credential, and - the part that matters on a personal install - no public URL: the bridge only dials out to WhatsApp, so this works on a laptop that nothing on the internet can reach. This is the answer to "why does the WhatsApp connect form only offer Meta/Twilio?": those are the official Business Platform paths; this channel is the unofficial WhatsApp-Web-style one, and it lives here so the two are never confused.

WhatsApp  <->  whatsapp-qr bridge (yours, Baileys)  <->  VegaDūta core  ->  agent
                     state: whatsapp-qr-data volume        (pulls messages, sends replies)

Know the trade-off first

Linked-device sessions ride the WhatsApp Web protocol through a third-party library (Baileys). Meta does not bless this the way it does the Business Platform, and an account can in principle be flagged or challenged. For a personal assistant on your own number that is usually an acceptable risk - it is the same posture every OpenClaw-style bot takes - but it is your call to make, and Meta Cloud API remains the right answer for anything business-facing.

  • One session = your number. Incoming messages become conversations in the same inbox, pairing and approval machinery as every other channel; replies go through the channel.whatsappqr.send approval action (policies, audit, quotas apply).
  • A first-time sender gets one pairing code; nobody is answered until you approve the session in the console. That is not optional on this channel - your own number is the published address.
  • Groups are not supported in this version: the bridge drops @g.us, broadcasts and status posts before they reach core.
  • If Meta Cloud API or Twilio is also configured, sends still prefer them (provider precedence 10/20 over this channel's 30); the QR session answers when it is the only WhatsApp path or when a conversation lives on it.

Set it up

  1. In deploy/compose/.env let core reach the bridge: append whatsapp-qr:8080 to the allow-list, keeping whatever it already holds:
APP_SECURITY_OUTBOUND_ALLOWED_PRIVATE_HOSTS=whatsapp-qr:8080,host.docker.internal:11434

The shared secret WHATSAPP_QR_BRIDGE_SECRET is generated by scripts/vegaduta (and vegaduta.ps1) like every other secret a compose file requires; set it yourself (openssl rand -hex 32) only when you run docker compose by hand. The overlay hands core the bridge address and the same secret (APP_WHATSAPPQR_BRIDGE_URL defaults to http://whatsapp-qr:8080, APP_WHATSAPPQR_BRIDGE_SECRET to WHATSAPP_QR_BRIDGE_SECRET); set those two in .env only to override them. 2. Start the bridge next to your stack:

docker compose -f deploy/compose/personal.yml -f deploy/compose/whatsapp-qr.yml \
  --env-file deploy/compose/.env --profile whatsapp-qr up -d

First time only, build the image: the same command with build whatsapp-qr instead of up. The service publishes no port. 3. In the console open Channels > WhatsApp QR, press Link a device, and scan the code with WhatsApp > Settings > Linked devices > Link a device. The code rotates on its own; the page follows it. 4. Choose the agent that answers (Route inbound to).

The linked device survives restarts in the whatsapp-qr-data volume. Unlink in the console logs the device out on WhatsApp's side too; you can also remove it from the phone under Linked devices.

Configuration

Property Env Default Meaning
app.whatsappqr.bridge-url APP_WHATSAPPQR_BRIDGE_URL empty Where the bridge lives. Empty hides the whole channel.
app.whatsappqr.bridge-secret APP_WHATSAPPQR_BRIDGE_SECRET empty Shared Bearer token, sent only over https or to an allow-listed host.
app.whatsappqr.receive.enabled APP_WHATSAPPQR_RECEIVE_ENABLED true Run the per-session poll loop. Idle until a session exists. The jobs and worker shapes switch it off, like Signal's.
app.whatsappqr.receive.poll-seconds (none) 15 Long-poll wait per request (1 to 20).
app.whatsappqr.max-message-chars (none) 60000 A reply longer than this is split into several messages.

Bridge side: WHATSAPP_BRIDGE_PORT (8080), WHATSAPP_BRIDGE_DATA_DIR (/data), WHATSAPP_BRIDGE_QUEUE_CAP (500 undelivered messages per session).

Run the receiver in ONE core process: a session's long-poll hands each message out once. The jobs and worker shapes keep it off (application-jobs.yml, application-worker.yml); set APP_WHATSAPPQR_RECEIVE_ENABLED=false on any extra API replica that has the bridge configured.

API

All under /api/channels/whatsapp-qr, all gated on the channel-management permission - GET included, because while pairing is open it returns the QR payload that completes a link.

Method and path Purpose
POST /session Start or restart pairing; answer is {status, bridgeStatus, qr, phoneNumber, configured}.
GET /session Current state; qr only while PAIRING. 204 when nothing was ever linked.
DELETE /session Unlink: logs the device out on the bridge, keeps the row at DISCONNECTED.
POST /send Send {to, body} through the approval gate; to is digits or a stored <digits>@lid.
PUT /default-agent Which agent answers new conversations.

Limits and notes

  • The QR payload is never stored and never logged; it lives in the bridge's memory and in the console's response body while pairing is open.
  • A sender who hides their number behind WhatsApp's privacy identifier appears as <digits>@lid; their session, pairing and replies work on that opaque id.
  • Messages that arrive while core is down wait in the bridge's queue (500 per session) and are delivered when the receiver reconnects.
  • A bridge restart loses in-memory sessions; the credentials on disk resume the link without a new scan.
  • Not covered by a test against a real WhatsApp: the tests use a fake bridge that speaks the documented endpoints, and the bridge's own tests use a fake socket.