Linking WhatsApp Without a Camera
To bring WhatsApp into one calm list, a daemon with no screen and no camera has to link an account anyway. The fix was an eight-character code you type once โ and on the way to it we found WhatsApp had been quietly broken end-to-end by a library that moved a single export.
The vision NAOMS is building toward in its chat work is easy to say and hard to earn: one calm list that holds every conversation you have, across every messenger you use, in your hands โ no server in the middle reading along, no feed algorithm deciding which of your people you get to see today. WhatsApp is one of those messengers. So somewhere in that grand picture there is a small, stubbornly physical question that has to be answered before any of it is real: how does NAOMS link your WhatsApp account in the first place?
For a phone, the answer is a ritual everyone knows. You open WhatsApp on your laptop, it shows a QR code, you point your phone's camera at it, and the two devices shake hands. That is WhatsApp's multi-device linking, and it is built around a camera pointed at a screen.
NAOMS does not have a camera. More to the point, the part of NAOMS that links your WhatsApp is a headless daemon โ a background process with no screen to display a QR on and no camera to scan one with. The whole orchestration model of the project runs without a human staring at a terminal. So the camera ritual, the one piece of the WhatsApp flow that assumes a person and a lens, is exactly the piece that does not fit. This week (item 1497, milestone 14) we wired the way around it.
The eight-character handshake
WhatsApp has a second, less famous way to link a device, built for exactly this situation: a pairing code. Instead of the new device showing a QR for your phone to scan, the new device shows you an eight-character code, and you type it into your phone โ Settings โ Linked Devices โ Link a Device โ Link with phone number. The direction of the handshake is reversed. No camera, no lens, no QR PNG that something has to render and something else has to point at. Just eight characters that travel through the one input device a headless daemon can always count on: a human reading a screen and typing.
The mechanism underneath is precise. When the bridge connects and WhatsApp is ready to begin linking, it normally hands us a QR string. Now, if a phone number has been configured and this device is not yet registered, the bridge instead asks WhatsApp's library for a pairing code for that number and emits it:
if (pairingPhone && !sock.authState?.creds?.registered) {
if (!pairingRequested) {
pairingRequested = true;
const code = await sock.requestPairingCode(pairingPhone);
emitProgress(`pairing-code:${code}`); // a distinct event, not qr:
}
} else {
emitProgress(`qr:${update.qr}`); // unchanged fallback
}Two details in that small block carry the whole design. The first is the guard
!pairingRequested โ a connection can fire its "ready to link" event more than
once, and a pairing code must be requested at most once per session, or you
would hand the user a new code every few seconds and none of them would be the
one to type. The second is that the code is emitted as a distinct event type โ
pairing-code: rather than qr:. That distinction is what lets everything
downstream know it is holding a number to type, not a picture to render. And
the else branch is the quiet promise of the whole change: when no phone number
is configured, the original QR flow runs exactly as it did before. Nobody's
existing setup changes.
From a config field to the prompt on your screen
A bridge emitting a code into the void is not a feature. The week's companion change threaded that one optional setting โ a WhatsApp phone number โ all the way through the daemon so the code actually reaches you, and so the daemon knows to ask for one in the first place.
flowchart TD
A["You: WhatsApp settings
phone number (optional)
'+1 415 555 2671'"] --> B[auth-orchestrator
loads your saved config]
B --> C{phone number
configured?}
C -- "no" --> Q[QR flow โ unchanged
render a code to scan]
C -- "yes" --> D[subprocess-manager
threads number into
the bridge's init config]
D --> E["bridge subprocess
strips to digits only,
requestPairingCode(number)"]
E --> F["emit 'pairing-code:CODE'
(distinct from 'qr:')"]
F --> G["your screen:
'WhatsApp pairing code: ABCD-1234 โ
enter it under Linked Devices'"]
G --> H["you type it once
on your phone"]
H --> I["creds persist on disk
โ every later sync is headless"]
Four files, one thread. The phone number is a new optional field on the
WhatsApp adapter's config โ labelled in plain language, "WhatsApp number in
international format โฆ link with an eight-character code instead of scanning a QR.
Leave blank to use the QR code flow." When you begin linking, the orchestrator
reads that saved config; if a number is there it passes it down, and if it is not
there the QR path runs untouched. The subprocess manager carries the number into
the Node subprocess that actually talks to WhatsApp. The bridge strips it to
digits only โ the linking library wants 14155552671, not +1 415-555-2671 โ
and requests the code. And when the pairing-code: event comes back up, the
progress handler turns it into the sentence you actually read:
WhatsApp pairing code: ABCD-1234 โ on your phone, open WhatsApp โ Settings โ Linked Devices โ Link a Device โ Link with phone number, and enter this code.
You do it once. After that the credentials persist on disk, and every subsequent sync runs headless โ the "captured once, then quiet" credential model the rest of the daemon is built around. The camera ritual is replaced by a single human keystroke session that never has to happen again.
The breakage we found on the way in
Here is the part we did not go looking for, and the part most worth telling, because it is the honesty axiom doing its job on infrastructure nobody sees.
To add pairing-code login we had to open the WhatsApp bridge โ the small Node subprocess that drives the linking library (Baileys). The moment we ran it against the currently installed version of that library, we found WhatsApp was not working at all. Not degraded. Broken end to end, on every fresh install, silently.
The cause is the kind of thing that does not show up in any test that mocks the
library away. Across the library's 6.x releases, the shape of what it exports
had drifted. The bridge loaded it with a reasonable-looking line โ
return mod.default ?? mod โ written when mod.default was a namespace holding
all the named pieces. But in the installed build (6.7.23) mod.default had become
the main socket function itself, not the namespace. So that line returned a
function, the code that destructured useMultiFileAuthState and friends off it
got undefined, and every session threw "export shape unexpected." A new user
installing NAOMS and trying to link WhatsApp would simply have hit a wall, with an
error pointing nowhere useful.
The fix is two lines of judgement: prefer whichever object actually exposes the named exports, and only then fall back to the old guess.
if (mod && typeof mod.useMultiFileAuthState === "function") return mod;
if (mod?.default && typeof mod.default.useMultiFileAuthState === "function")
return mod.default;
return mod.default ?? mod; // older bundlingsBut the line we are proudest of is not the fix โ it is the alarm we wired next to it. The bridge's self-check used to say, cheerfully, "baileys ok." Now it asserts the exact shape the session depends on โ that both the socket function and the auth-state function resolve โ and if a future library bump ever moves them again, it exits loudly at smoke-check instead of failing silently at someone's first real link:
if (!haveSock || !haveAuth) {
lines.push(`baileys EXPORT SHAPE BAD โ makeWASocket=${typeof baileys.makeWASocket} โฆ`);
process.exit(1); // fail at the smoke test, never at a user's first sync
}That is the same move we keep making everywhere in NAOMS, and it is the one we most want to be true about the project: when a thing can quietly become wrong, turn the quiet into a loud, early, unmissable failure. A silent breakage that waits to bite a real user is the worst kind. A self-check that screams the moment the floor shifts is the same honesty we hold the workshop to โ pushed down into a forty-line subprocess almost nobody will ever read.
Honestly scoped
Let us be exact about what is true today, because the commits are.
What is wired and verified at every layer we can verify without a phone in
hand: the optional phone-number config field; the orchestrator sourcing it and
branching; the number threaded through the subprocess into the bridge; the
pairing-code request; the distinct event surfaced as a human-readable prompt; the
QR fallback left untouched. The type-checks are clean across all four daemon
files, the bridge's self-check passes, and the linking library's
requestPairingCode signature is confirmed in the installed version.
What is not yet verified: a live pairing. Proving the last inch โ a real WhatsApp number, the daemon driving the bridge end to end, the code typed on a real phone, the link actually taking โ needs exactly that real phone and that one human moment, and we have not run it yet. The path is built and checked at every seam; the final living handshake is the honest open edge. We are not going to call it done until a real account links through it.
And the larger frame is honest too. This is one messenger's front door, not the calm multi-source list itself. The list we are really building โ a thousand conversations across every channel, surfaced by your choice and not an algorithm's โ is still being assembled thread by thread. But you cannot pool a channel you cannot link, and you cannot link WhatsApp on a screenless daemon by asking it to point a camera it does not have. This week the daemon learned to hold up eight characters and wait for you instead. That is a small door. It is also the only door that fits the building.
Written by AI agents from real project logs; owned and edited by Mujo.