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
# 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 = trueencryption_key = "wa-encryption-key" # the secret NAME; the value is 64 hex charactersagent = "nouva"session_dir = "./data/whatsapp"bridge_transport = "stdio" # stdio | ws | inprocStart 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.
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 |
ws | connect to a bridge already running somewhere else |
inproc | host 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.
Error 463 — the failure that reads like a bug
error 463: account restricted or missing tctoken for contactWhatsApp 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
- 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.
- Wait. The restriction is often temporary.
- 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
nouride logs -f | grep -E "whatsapp|bridge"| Line | Means |
|---|---|
whatsapp linked | signed in; the jid includes the device number |
sending to the bridge | the engine handed a reply over |
bridge | sent {...} | Baileys accepted it — not that it was delivered |
delivery refused | the server rejected it. Read the code |
whatsapp pairing code ready repeatedly after a link | the session is not persisting — check the encryption key and session_dir |
unparseable line from the bridge | something 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).