Skip to content

WhatsApp

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 0700whoever holds that folder is the account.

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.

SenderWhat runsWhat they get
The owner (whoever the roster names)the full super-agent turn — tools, memory, projectsa normal reply
A contact on the roster with a rolea sealed turn: no tools, no memory, no other channel, only that one conversation as historya short plain-text reply
Anyone elsenothing runssilence — 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.

  1. Open Settings → WhatsApp in the web panel and press Pair. A QR appears.

    🖥️ SCREENSHOT · web Settings → WhatsApp — the pairing QR apx web, Settings → WhatsApp, press Pair
    Settings → WhatsApp — the pairing QR
  2. On the phone: WhatsApp → Settings → Linked devices → Link a device, and scan it.

  3. 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.

{
"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.
  • bio and rules reach 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.

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.

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 asThe turn sees
Voice note[audio] <transcript> — transcribed locally
Photopixels a vision model can actually look at
Sticker[sticker: <meaning>]
GIFits first frame
Videorefused — say so plainly rather than guessing from the caption
Document[document: <name> — not opened]
Reactionlogged 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.

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.

FieldWhat it does
textthe body, plain text
stickerdescribe one in words; matched against the learned library
optionanswer a menu — the number, the exact title or the id
as_textwith option: type the label instead of a real button press
react_toput 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 credentials
PATCH /settings { enabled, auto_reply, reply_to_groups }
POST /send { jid, text }
GET /contacts PATCH · DELETE /contacts/:jid
POST /contacts/:jid/owner mark that contact as the owner
GET /roles PUT · DELETE /roles/:name
GET /suggestions POST /suggestions/:id/confirm · /dismiss
GET /stickers PATCH · DELETE /stickers/:key
GET /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 menu

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.

Terminal window
apx whatsapp chats # what is wrong — changes nothing
apx whatsapp repair # fix it
apx whatsapp repair --dry-run # ...or see it first
apx whatsapp repair --force # retry messages a phone that was offline never sent back
apx whatsapp status

What repair does:

  • links a person’s other address (their @lid and 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.

SymptomCause
Connected, but nobody is answered — including youowner_jid is empty, so everyone resolves as a stranger
logged_outthe 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 sessionexpected when nothing was ever paired. The daemon does not open a socket or produce a QR unasked
A contact’s replies open a second threadthey wrote from a …@lid address APX has not folded in yet — apx whatsapp repair links it
A contact wrote and got nothing backa 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