Skip to content

WhatsApp

WhatsApp works through the multi-device protocol, in a separate process. It has no bot token — you link a device by scanning a QR code from the phone that holds the number, once.

Setting it up

Terminal window
# The session is a login, so it is encrypted at rest. Generate the key first.
openssl rand -hex 32 | nouride secret set wa-encryption-key --value-stdin
[gateways.whatsapp.main]
enabled = true
encryption_key = "wa-encryption-key" # the secret NAME; the value is 64 hex characters
agent = "nouva"
session_dir = "./data/whatsapp"
bridge_transport = "stdio" # stdio | ws | inproc

Start the daemon and open the dashboard — the QR appears under that agent’s Connections. Scan it from the phone: WhatsApp → Linked Devices → Link a Device.

The WhatsApp QR in the dashboard

Why it is a separate process

┌──────────────────────────┐ ┌─────────────────────────────┐
│ Nouride daemon │ newline-JSON │ wa-bridge process │
│ (proprietary) │◄──── over stdio ──►│ (GPL-3.0) │
│ agents · tools · queue │ │ Baileys · QR · auth state │
└──────────────────────────┘ └─────────────────────────────┘

Baileys depends on libsignal, which is GPL-3.0. Process isolation is where that boundary sits, so the default binary is not GPL and the bridge ships as its own executable beside it.

It is a compiled executable, not a script. It used to be a JavaScript bundle the daemon started with bun run, which meant any server that wanted WhatsApp needed Bun installed after all — and it drifted exactly the way that invites: the daemon was upgraded to a newer Bun while the bridge kept being started by the older one on the host’s PATH.

Three transports

stdio (default)the daemon spawns the bridge beside itself
wsconnect to a bridge already running somewhere else
inprochost it inside the daemon

inproc reclaims about 36 MB — a separate process cannot share the Bun runtime — and it needs a binary built with --bundle-wa-bridge. That binary is GPL-3.0. A bundle build has no stdio fallback, because the point of bundling is that there is no second process.

Builds and what each costs →

Error 463 — the failure that reads like a bug

error 463: account restricted or missing tctoken for contact

WhatsApp is refusing to deliver, because the number is restricted from starting new chats. Established chats already carry a privacy token and keep working, which is why this looks selective.

Typing indicators still appear, because presence is not restricted — only delivery to a new chat is. That combination is not a bug.

A number earns this by looking like spam: brand new, linked and unlinked repeatedly across an afternoon of testing, then sending automated replies to somebody who has not saved it.

What may lift it

  1. Save the bot’s number as a contact on the other phone, then message it. Being a contact usually causes the privacy token to be issued.
  2. Wait. The restriction is often temporary.
  3. Use a number a human has actually used for a while, rather than a fresh one.

What must not happen: retrying. Each attempt counts as another reach-out and worsens the restriction. The daemon knows this — a terminal refusal walls off that chat so the next message does not repeat it, and the wall clears on a relink, which is the one moment a restriction may have lifted.

It is also no longer silent. The send used to resolve, the ack used to come back, every log on both sides said success, and the person watched an empty chat. Now the refusal is a protocol event, logged at error in words an operator can act on, and alerted through a channel that is by definition not the one that failed — so set [alerts].

Groups

Mentions tag a number, not a name. The adapter compares the tagged ids against both of the connection’s own addresses — phone JID and LID — normalised across device suffixes.

Quoted context comes along. The text of the replied-to message is carried with a > prefix, because “@bot ini aman?” means nothing without the message it points at.

The sender in a group is the participant. In a DM the chat is the sender; collapsing the two would give every group member one identity.

One person, two ids

The same human shows up under two addresses — …@s.whatsapp.net and …@lid. Give both a [[people]] entry, or the agent knows them under one and not the other.

[[people]]
id = "whatsapp:main:628123456789@s.whatsapp.net"
name = "Rina"
[[people]]
id = "whatsapp:main:91805060202719@lid"
name = "Rina"

Read receipts

Sent after the engine accepts a message, never on arrival. In pair mode an unknown sender’s message is dropped, and a read marker on it would tell them they had been seen by something that is in fact ignoring them.

When it goes quiet

Terminal window
nouride logs -f | grep -E "whatsapp|bridge"
LineMeans
whatsapp linkedsigned in; the jid includes the device number
sending to the bridgethe engine handed a reply over
bridge | sent {...}Baileys accepted it — not that it was delivered
delivery refusedthe server rejected it. Read the code
whatsapp pairing code ready repeatedly after a linkthe session is not persisting — check the encryption key and session_dir
unparseable line from the bridgesomething is writing to stdout that should not be. stdout belongs to the protocol

NOURIDE_WA_LOG_LEVEL=debug turns Baileys all the way up, on stderr.

There is no build without WhatsApp

There was — --no-wa-bridge — and it is gone. It saved 36 MB of tarball by dropping the bridge executable, and what it produced was a daemon that silently could not connect to a platform its own config.toml might name. WhatsApp is always included now; the choice is only whether it runs as its own process (the default) or inside the daemon (--bundle-wa-bridge).