APX runs a WhatsApp plugin backed by a WhatsApp Web session: one account, paired by QR, owned by
the daemon. Credentials live at ~/.apx/whatsapp/auth/default/ at mode 0700 — whoever holds
that folder is the account.
The three outcomes
Section titled “The three outcomes”Every inbound message resolves to exactly one of these, decided by code, before any model sees the message. Whether an unknown person gets an answer is never a judgement the model makes about a message that is trying to persuade it.
| Sender | What runs | What they get |
|---|---|---|
| The owner (whoever the roster names) | the full super-agent turn — tools, memory, projects | a normal reply |
| A contact on the roster with a role | a sealed turn: no tools, no memory, no other channel, only that one conversation as history | a short plain-text reply |
| Anyone else | nothing runs | silence — logged, and the owner is told |
Silence for a stranger is the designed outcome, not a failure. Two things happen for every
non-owner message regardless of the reply: it is logged to the whatsapp channel with the
sender’s JID as actor, and the owner is notified by the daemon rather than by the model — a
sealed turn has no tools, so it could not escalate even if it wanted to.
Pair an account
Section titled “Pair an account”-
Open Settings → WhatsApp in the web panel and press Pair. A QR appears.
🖥️ SCREENSHOT · web Settings → WhatsApp — the pairing QRapx web, Settings → WhatsApp, press PairSettings → WhatsApp — the pairing QR -
On the phone: WhatsApp → Settings → Linked devices → Link a device, and scan it.
-
Set
owner_jid— the number you write from. APX does not guess it.
The line you paired and the person who owns it are different things. A common deployment pairs
a dedicated number that the assistant answers on, while the human writes to it from their own,
different phone. Setting “owner = whoever paired” would make the line the owner of itself and leave
the actual human a stranger on it, so owner_jid is asked for and never deduced.
The roster is the allowlist
Section titled “The roster is the allowlist”{ "whatsapp": { "enabled": true, "auto_reply": true, "reply_to_groups": false, "owner_jid": "1234567890@s.whatsapp.net", "contacts": [{ "jid": "1234567891@s.whatsapp.net", "name": "Sam", "nickname": "Sammy", "relationship": "my accountant", "bio": "who they are, in the owner's words", "rules": "what the owner wants done with this person", "role": "client", "auto_reply": true }], "roles": { "client": { "auto_reply": true } } }}- Someone who writes is recorded automatically as guest. That is not permission — a guest is still never answered. Recording exists so the owner has something to click allow on.
role: "owner"cannot be granted from the roster.- An undefined role fails closed (silence), so a typo never widens access.
bioandrulesreach only the turn that answers that person. What is written about one contact is never in the prompt that answers another.- Groups are off by default: everything said in a group is read by people nobody vouched for.
One human can hold two addresses — a phone JID and an opaque …@lid. Both fold into one thread.
Menus, buttons and lists
Section titled “Menus, buttons and lists”A business account rarely writes sentences. It sends a menu, and APX unpacks it into text before any turn sees it:
Hello! What can I help you with?[Opciones: 1. Cars | 2. Home | 3. Life]A tap somebody made on one reads as [eligió: Cars]. All four generations still live in the wild
are decoded — quick-reply buttons, lists, hydrated templates and nativeFlow (including a
single_select list hidden inside one button, and carousels) — plus polls, and all of them wrapped
in viewOnceMessage or ephemeralMessage, which is how business accounts usually send them.
To answer one, pass option to the send_whatsapp tool — the number as it was shown ("2"), the
exact title ("Cars") or the option’s id. APX finds the last menu in that chat, off the message
ledger, and sends the real selection so the bot sees its button pressed rather than a sentence.
- Quick-reply buttons, lists and templates get a real tap.
- nativeFlow menus and polls are answered by typing the option’s label — there is no encoder
for
interactiveResponseMessage, and inventing a protocol node would put a malformed message on somebody else’s server. In practice this is what a person does when a button will not open. - If a tap goes unanswered, retry with
as_text: true. - An ambiguous fragment matches nothing rather than tapping a button on a guess:
"insurance"against Car insurance and Home insurance is refused, and the refusal carries both options.
The thread records the label, not the button id — a transcript full of opaque ids is not the conversation that happened.
Where it lives, and how fast it answers
Section titled “Where it lives, and how fast it answers”WhatsApp has its own entry in the rail, under Code: the roster, the sticker lexicon, the requests waiting on you and the conversations on this line, on one page. Settings → WhatsApp is the same panel — it earns the rail entry because it is a place you visit while somebody is writing, not a page you set up once.
Answers wait a couple of seconds before they leave (whatsapp.reply_delay_ms, 2500 ms by default,
0 to answer at once). Not politeness theatre: people write in bursts — “hola”, “che”, then the actual
question — and an answer that lands in under a second answers the first third of a thought and
produces three replies to one message. If another message arrives from the same chat while the timer
runs, the earlier turn stands down and the later one answers everything. The typing indicator is on
throughout, so the wait reads as thinking.
A file somebody sends is kept. A PDF or a text-ish file is also read: its words go into the turn, so the agent answers what the quote says rather than that a quote arrived. Everything else is saved with its own name and offered in the panel.
Two things are never downloaded, and the thread says which: programs and scripts
(.exe .msi .sh .apk .dmg .ps1 .jar …, matched on the last extension so quote.pdf.exe is an exe)
and anything over 25 MB. Nothing here ever executes an attachment — the refusal exists because
the file would otherwise land in your own media folder under a name a stranger chose. Archives
(zip, rar, 7z) are kept and never opened.
A file that arrived before this existed can be fetched afterwards: apx whatsapp repair asks the
phone for the message again and downloads it then.
Sending one works too: the agent’s send_whatsapp takes a file (an absolute path on this machine),
a document keeps its name and an image arrives as a picture, and whatever text goes with it becomes
the caption.
Inbound media is resolved before the turn runs, so the log is complete even for people who are never answered.
| Arrives as | The turn sees |
|---|---|
| Voice note | [audio] <transcript> — transcribed locally |
| Photo | pixels a vision model can actually look at |
| Sticker | [sticker: <meaning>] |
| GIF | its first frame |
| Video | refused — say so plainly rather than guessing from the caption |
| Document | [document: <name> — not opened] |
| Reaction | logged as [reaccionó ❤️], and never answered |
Stickers are learned once: the first sighting is described by a vision model and stored by
content hash in ~/.apx/whatsapp/stickers.json; every later sighting reuses those exact words, so
the history stays coherent. The owner can overwrite the wording and the model never overwrites it
back.
Sending
Section titled “Sending”Use the send_whatsapp tool: { to, text }. to takes a phone number in any format or a full
JID. Plain text only — WhatsApp renders no markdown, so asterisks and backticks arrive as literal
characters.
| Field | What it does |
|---|---|
text | the body, plain text |
sticker | describe one in words; matched against the learned library |
option | answer a menu — the number, the exact title or the id |
as_text | with option: type the label instead of a real button press |
react_to | put an emoji on a message instead of sending one |
It is delivered the instant the call returns. There is no draft, no undo and no recall.
Messages the owner types on their own phone are recorded too (WhatsApp mirrors them to the
companion device as fromMe). They are logged and never answered — replying would be answering
yourself — and carry meta.authored_by: "owner" so a thread reads as the whole conversation.
All routes are under /api/whatsapp.
GET /status session state + roster summary (never the QR)POST /pair start pairing → { qr, qr_data_url }POST /logout drop the credentialsPATCH /settings { enabled, auto_reply, reply_to_groups }POST /send { jid, text }GET /contacts PATCH · DELETE /contacts/:jidPOST /contacts/:jid/owner mark that contact as the ownerGET /roles PUT · DELETE /roles/:nameGET /suggestions POST /suggestions/:id/confirm · /dismissGET /stickers PATCH · DELETE /stickers/:keyGET /repair what is wrong with the chats (asks, changes nothing)POST /repair { dry_run, force }POST /choose { jid, option } tap an option on that chat's last menuRepairing chats that came out wrong
Section titled “Repairing chats that came out wrong”Some failures do not fix themselves on the next message. A business lands on the roster nameless
(a business account sends no push name), so its thread is headed by a raw address. A conversation APX
opened cannot be continued, because the roster was fed only by people writing in. A menu is written
into the ledger as [empty message], by a decoder that did not know the shape it arrived in. And a
contact’s message starts a turn that a restart kills mid-thought — WhatsApp does not deliver that
message twice, so nothing will ever retry it.
apx whatsapp chats # what is wrong — changes nothingapx whatsapp repair # fix itapx whatsapp repair --dry-run # ...or see it firstapx whatsapp repair --force # retry messages a phone that was offline never sent backapx whatsapp statusWhat repair does:
- links a person’s other address (their
@lidand their number are one row, not two), - names a nameless row from what the ledger already heard them called,
- makes a guest APX wrote to first answerable, flagged
pending_review— answerable, not vetted, - asks the phone for any message that arrived unreadable. The phone still has it; the resend is recognised as a repair, so it is never re-answered or re-reported to you. A recovered menu comes back with its options, and the panel draws them as buttons.
What it will not do: invent a name, promote anyone you have already decided about, merge two rows that may each hold notes you wrote, or answer a message on its own. A chat nobody answered is reported, never answered automatically — writing to somebody hours later is your call.
When something looks wrong
Section titled “When something looks wrong”| Symptom | Cause |
|---|---|
| Connected, but nobody is answered — including you | owner_jid is empty, so everyone resolves as a stranger |
logged_out | the credentials are dead. The plugin will not retry on its own — retrying dead credentials in a loop is how an account gets flagged. Pair again |
| Idle after a restart, no session | expected when nothing was ever paired. The daemon does not open a socket or produce a QR unasked |
| A contact’s replies open a second thread | they wrote from a …@lid address APX has not folded in yet — apx whatsapp repair links it |
| A contact wrote and got nothing back | a restart landed while the turn was running. The message was delivered, so nothing retries it: apx whatsapp chats lists every chat in that state, with the words |
A thread titled by a raw address, or a message reading [empty message] | apx whatsapp repair |