Skip to content

0.2.0

Choose a tag to compare

@github-actions github-actions released this 21 Oct 20:26
· 146 commits to main since this release
de47a36

Changed — may break callers

  • --qr-file is gone: without a terminal, session start and setup print the QR code as text for an agent to
    paste into the chat, instead of a PNG the person opens in an image viewer. The text is plain half blocks, no
    colours, about 73 columns wide. Each code is followed by a note telling an agent to paste it, and with --json it is
    also one stderr line, {"event":"qr","qr":{"text","expires_at","scan"}}. A QR link no longer needs a terminal; a
    pairing-code link (phone) still does, and without one says that it asks questions. A script that passed
    --qr-file now fails with an unknown option. In a terminal the code is drawn as before, as in tg and max.

  • A one-to-one chat typed by its phone number is addressed by its LID, the id WhatsApp files that chat under,
    when WhatsApp has already given it. WhatsApp answers a send by the number with the LID, so a later
    messages delete, messages star, reactions add or polls vote by the number could miss the message: the
    deleted text stayed searchable, the star was lost, and the vote made a second chat. Every command that names a chat now looks the
    number up in the LIDs this device knows; nothing is asked of WhatsApp and nothing is recorded. A number with no
    known LID, a group, a LID or a channel is used as typed. An --offline command reads the store by the id as typed,
    and finds a chat that moved from that number to its LID once the store recorded the move (see Added).
    Engine wire change: a canonicalChat request. Engine protocol 38. Two things the owner may notice:

    • A recipient list entry added by the phone number stops matching once that number has a LID, and a send to it
      is refused with exit 7. The refusal names the id to allow: run recipients add with it, or with the number
      again, which now saves the LID. An entry added by the number moves to the LID by itself at the next serve or
      watch start, when this device already knows the number's LID, whether or not the store holds a chat under it
      (cli-core 0.356.0). The number is not asked of WhatsApp and is not recorded. Engine wire change: recipients on
      the storedChats request. Engine protocol 40.
    • A chat the store already holds under the phone number, such as one wa 0.1.0 saved, moves to its LID on the spot
      (cli-core 0.356.0): the first command that names it by the number, a send, delete, star or reaction
      included, moves the stored chat, its messages and its recipient-list entry to the LID, then acts on the LID
      chat. An --offline command moves nothing; it finds such a chat by the number once the store recorded the move.
  • wa --help and a bare wa show a short first screen instead of every command and option: what wa is for,
    the usage line, the other WireCat tools it works with, and where to start. wa --help-all prints the old help and wa help options the
    options of every command. Each command's own --help now ends with what it returns, the commands that usually
    come next, and an example. A bare wa commands prints a text list by section, also when piped; a script that read
    its JSON must pass --json (cli-core 0.344.0).

  • chats mark-read takes a mark-unread off, and needs WhatsApp's settings keys for it. It sent a read receipt
    only, which leaves a chat marked unread on the phone, and in your own chat it sent nothing at all. It now first
    marks the chat read on your devices, up to --until-id or the newest message the store holds, then sends the
    receipt as before. The same goes for --mark-read on messages list, inbox and review, one more write per
    chat. Right after linking, before WhatsApp has sent the settings keys, these now fail with
    settings.keys-missing (safe to retry) and send no receipt, where they used to send it; keep serve running a few
    minutes and run them again. Engine protocol 34.

  • The MCP server's wa_read and wa_write take each command's own arguments, in snake_case. search messages
    takes query (not text) and no ast; polls create takes question; wa_tools_search shows each command's
    fields. The agent is offered every command the profile's permissions allow, including some it was not offered
    before; marking read, --as-owner and --allow-any-file stay off MCP. An agent's runs are named mcp <command>
    in run history and the send journal. conversations rebuild replaces the agent-only conversations_refresh.

  • A global npm install no longer writes the agent skill into ~/.claude/skills and ~/.agents/skills on its
    own.
    npm hides what an install script prints, so the files appeared without a word. wa personal setup still
    offers the skill, and wa skill install installs it; set WA_INSTALL_AGENT (all, claude, codex, cursor or
    gemini) to have the install do it as before. The Windows installer is unchanged: it says what it installs.

  • session start phone and setup --method phone refuse a number without + and the country code (exit 2,
    input.invalid). WhatsApp reads the first digits of the number as the country code, so a number typed the way it
    is dialled at home asked for a pairing code on someone else's account. Nothing reaches WhatsApp until the number
    starts with +.

  • messages delete without --for-everyone deletes the message for you only, where it used to be refused (exit
    2). wa asks WhatsApp to take the messages from this account's devices, to leave them with the
    others in the chat, and to keep the photos and files the phone saved from them. Each message must be in the local
    store, since WhatsApp names it by its sender and the time it was sent; one the store lacks is exit 6 and nothing is
    deleted. A device without WhatsApp's settings keys yet answers settings.keys-missing, as chats mute does. An
    agent's messages delete over MCP, which was refused too, now does the same, for the owner only; deleting for
    everyone stays the owner's --for-everyone. A deletion asks first by default, and over MCP that level lets the
    agent's call through, so at the default an agent can now delete the owner's copy. To keep agents from it, set
    permissions.messages.delete to readonly or deny: the MCP server then does not offer the command, and an
    agent's call is refused with permission_error (access.denied), as the owner's own messages delete at those
    levels is, with exit 5. A delete
    of several messages that fails partway, with or without --for-everyone, now names the ones already deleted and
    is not marked safe to retry: delete only the rest. A message you delete for yourself on the phone leaves the
    local store the same way while serve or watch runs. Checked live on the phone for your own message in your
    own chat; a message in a group or in a one-to-one chat with someone else, and a delete made on the phone, are not
    checked yet.

  • messages download asks your phone for a file WhatsApp no longer keeps, as WhatsApp's apps do, instead of
    answering not_found (exit 6) at once. It waits up to 20 seconds for the phone to upload it again and fetches the
    new copy. A phone that does not answer ends the download with timeout (exit 9); exit 6 now means the phone no
    longer has the file either. A script that read exit 6 as "expired" must also expect exit 9, and give a slow phone a
    longer --timeout. A new copy that then fails to download is provider_error (exit 11, media.retry-failed),
    whatever the first failure's code was; when the phone answered an error but still gave a path, it is the phone's
    answer instead (see Fixed). The store keeps the new copy's address, so a later download or forward
    uses it and does not ask the phone again (see Fixed).
    Known limitation: a file WhatsApp no longer keeps does not download yet. On the phones tried, the phone answers
    that it uploaded the file again, but it encrypts the new copy with a new key that it does not send to linked
    devices, so wa cannot read it. The download then ends with media.retry-failed ("the phone uploaded this file
    again, but the new copy did not download") and saves nothing, rather than a broken file. A file WhatsApp still
    keeps downloads as before. For an old file, open it on the phone, or ask the sender to send it again.

  • messages forward sends a stored place or contact card, where it used to refuse it. The store now keeps a
    place's coordinates, name and address, and a contact card itself, next to a file's keys, where no answer shows
    them. A place or card stored before this, a live location and several cards in one message are still refused.

  • A person's chat moves to their LID when WhatsApp gives them one. WhatsApp names a one-to-one chat by the
    person's phone number until it gives them a LID, its private id for a person. serve and watch now move the
    stored chat and its messages to the LID, so messages list and search show one chat, and no chat is left under
    the phone-number id (…@s.whatsapp.net). A script that saved that id must take the new one from chats list. The
    move is repeated at each start for 30 days, so one that came while only a one-shot command ran still lands. A
    send typed to the old id stores a chat under it again, which a later repeat of the move merges. The merged chat
    keeps the stricter privacy of its two forms: hidden from search if either was, personal if they disagree, and a
    scope pinned on either stays pinned (cli-core 0.353.0). A chat stored under a phone number by wa 0.1.0 moves too:
    serve and watch hand the stored chat ids to the engine as they start, and one whose LID WhatsApp already gave
    moves at once (cli-core 0.350.0, storedChats). If that fails, wa says so once on stderr (stored_chats_failed
    in the run record) and goes on. Engine wire change: a storedChats request. Engine protocol 37.

  • serve asks WhatsApp for the LID of a chat still in phone form, so that chat moves too. It asks a minute after
    history stops coming or serve or watch starts, for chats the account already has only: five people per
    request, one request every 10 seconds, at most 20 people per run, and each person at most once a week. The owner's
    own lookup keeps its own pace. If WhatsApp asks to slow down, the lookups stop for a week and the next write is
    refused with account.rate-limited, which holds the profile's writes for 15 minutes, also when serve restarts
    meanwhile. Not yet run against WhatsApp.

  • A command cut off by a dropped or ended connection no longer answers network_error (exit 10), which invited
    a retry and a double send. A send, edit, reaction, group change or other write whose request left before the
    network dropped, and could not be sent again, now answers outcome_unknown: exit 14, retry.safe: false, also
    when WhatsApp handed the login to another connection meanwhile, since the write may have gone through. Check the
    chat before you send again. Otherwise, a command relayed through serve while another process took the login over
    now answers device.replaced, and after a ban or a logout account.banned or device.logged-out. A script that
    retried on network_error must not retry these.

  • chats rules set refuses a newAccount rule with validation_error (exit 2) and saves nothing: WhatsApp does
    not say when an account was made, so the rule was saved and could never match. A script that sets one must drop it.

  • A send that waits on another wa process exits 9, not 1 (cli-core 0.338.0): when another process on the
    profile is sending or clearing the pace for longer than the 5 s wait, a send or flood clear answers timeout,
    since nothing was sent and a retry is safe. A change to the recipient list held elsewhere for 5 s is
    refused with exit 9 and the list stays as it was; two changes at once no longer undo each other.

  • wa upgrade --offline exits 2 instead of asking npm anyway.

  • WA_READ_ONLY refuses more, always with exit 5 and "this process is read-only (WA_READ_ONLY)" (cli-core
    0.328.0 to 0.337.0). An agent that did any of these under the lock must do it in a process without
    it. Newly refused:

    • session start, session end and setup, before they act ("it cannot log in or out"); session start and
      setup on a profile linked before used to go ahead;
    • the message store: store reset, restore, clear, migrate, repair, decrypt and copies delete
      (--dry-run still previews);
    • what the owner saved: searches create|delete|clear, contacts alias set|rm, contacts notes add|edit|remove and tags remove;
    • other local writes: tags add, tags auto, mentions confirm|reject, contacts link|unlink, attachments text set, chats tracking add|remove, conversations links add|clear, conversations consents revoke and tasks reject; tasks close only suggests the close;
    • and their MCP tools.

    messages delete under the lock exits 5, not 7 ("asks before it acts"). wa upgrade --check now runs under the
    lock; wa upgrade without it is still refused.

  • conversations embed and conversations build count as writes to an outside service, and search conversations as a read from one (cli-core 0.334.0, 0.336.0): a failed run is not marked safe to
    retry, and under WA_READ_ONLY each refuses before it sends chat text or the query to a remote provider; a local
    model still works. They always run in the calling process, never through serve. Their help now says "Changes
    something in WhatsApp", which here means the outside provider.

  • A ban or rate limit holds every write, for as long as it lasts (cli-core 0.334.0): while WhatsApp has the
    account banned or rate-limited, a reaction, chats mark-read and a group title change are refused with exit 5
    too, not only sends and edits. Reads still work; inbox and review with --mark-read still show what they read
    and warn that nothing was marked read. A temporary ban WhatsApp dates holds writes until its end, the error says
    when, and standing.until is that time. A ban with no end has standing.state banned, not frozen, and
    until: null in doctor --json, flood clear --json and server status --json, where it held for a year
    before; wa <profile> flood clear or a doctor --online that connects lifts it.

  • serve banned by WhatsApp is not restarted by its unit (exit 5 joins exit 4 in the unit's no-restart list):
    a restart 30 s later connected again during the ban. wa <profile> server status says it stopped and will not
    restart; start it again with wa <profile> server start once the ban ends.

  • An older client's session end relayed into serve is refused with permission_error, exit
    5, sent: false, naming server stop; it was provider_unavailable, exit 12, device.in-use. serve's
    connection and the keys stay as they were.

  • Ctrl+C or --timeout during the history wait after linking ends the wait, not the command: session start
    keeps what arrived, counts it and exits 0. It exited 130 or 9, though the device was linked. setup goes on to
    its account, chat and agent steps, which get a deadline of their own (cli-core 0.334.0); a
    Ctrl+C after the wait ends setup (130). With --timeout, the link and the wait share that one deadline, and the
    steps after it get that long again.

  • chats list and chats show answer from the local store and never connect (cli-core 0.328.0), so they run
    here while serve runs. recipients add runs through serve instead of being refused with exit 12.

  • A link without a terminal waits 5 minutes by default, not 30 seconds (cli-core 0.328.0), so an agent's
    session start is not cut off before the code is scanned. --timeout still decides when given.

  • A command path given as one argument fails (cli-core 0.328.0): wa 'chats list' printed the root help and
    exited 0; it now fails with validation_error.

  • wa mcp offers only what wa has (cli-core 0.328.0, 0.335.0): chats link create, which failed, is gone,
    and a command that refuses --offline no longer has an offline input; an agent that passes one gets
    validation_error. metadata get and metadata refresh, which failed too, now work (see Added).

  • chats events answers from the local store (cli-core 0.336.0): it no longer refuses with exit 2, never
    connects, and works with --offline. What it lists, and how stats chats show counts members from it, is under
    Added.

  • A chat or person named by name is found in the local store first (cli-core 0.337.0): part of the name is
    enough, and a name several stored chats match is refused with "… matches N chats", as chats show refuses it; a
    caller that matched the old "N chats share that name" must match the new text. Only a name the store does not hold
    is asked of WhatsApp's chat list, so a one-shot command now finds a stored group by its name. watch titles a
    chat from the store and follows its renames; a chat the store has never held has no title in watch.

  • A live edit or deletion of a channel post is no longer passed on: watch and serve used to hand it on under
    an id the store does not file the post by. A channel's posts are now read with messages sync <channel>.

Security

  • The engine log (WA_ENGINE_LOG) no longer keeps media download links. When a download failed on the network,
    whatsmeow wrote the whole link into the log: the file's path on WhatsApp's servers, its hash and the signed
    oh/oe values, which together fetch the file for anyone who has them; a failed delete of history-sync media wrote
    the media auth token the same way. Every link now becomes [url], a server path [path], and those values …,
    before the line is written.
  • A message sent to a phone number is saved in the chat WhatsApp answered, with that chat's privacy kept
    (cli-core 0.356.0). wa answers a send to a person who has a LID with their LID chat, but the message was saved
    under the number that was typed, in a second chat. Now messages send, polls create and messages forward save
    it in the answered chat. A chat made for it takes the typed chat's search visibility and scope; a chat that exists
    already keeps the stricter of the two, so a chat hidden from search does not come back into search under its other
    id. wa <profile> sends list names the answered chat beside the typed one when they differ.
  • A failed background messages sync no longer writes a chat title or a name into its job file (cli-core
    0.355.0). The file keeps the error's code and points at wa store jobs show <id>, which prints the path of the
    job's log. The full message stays in that log, which only you can read (mode 0600).
  • A chat hidden from search no longer turns up in a search that does not name it (cli-core 0.353.0). The
    messages by and about a person in contacts context, the pictures lane of search messages, and the meaning half
    of search conversations and conversations related left it in, and the search answers listed it as not yet
    embedded. A sender-only lookup no longer counts its messages. wa has no command that hides a chat from search,
    so only a store edited by hand was affected.
  • WA_READ_ONLY refuses a write to WhatsApp before anything connects. messages send, edit, delete,
    reactions add and the other commands that change something on WhatsApp used to connect and find the chat first;
    now they exit 5 at once, and a broken login no longer turns the refusal into a network failure. A permission level
    of readonly or deny on the command does the same (cli-core 0.343.0).

Fixed

  • An album wa sends shows on the phone as one album. It showed as two messages, a photo and a video. Messages
    wa sends now carry a message secret and a reporting token (all but reactions and poll votes), as WhatsApp Web,
    Baileys and mautrix-whatsapp send them, and each file of an album points at the album by the chat id WhatsApp
    delivered it to (the LID when the chat was typed by its phone number). Checked on a phone.
  • Photos and videos wa sends show on the phone. They never did, as an album or a forward either, while wa's own
    linked devices received them: the message lacked the file's URL. Photos, videos, voice notes, documents and
    stickers now carry it, as WhatsApp's own apps and mautrix-whatsapp send them. Checked on a phone. A message now
    also carries the time its file's key was made, as Baileys sends it, and a document with a caption goes in the
    wrapper WhatsApp's apps use. A file stored before this fix, or one the phone uploaded again, gets a URL built from
    its path when it is forwarded. The URL is kept with the file's keys in the store and is never printed. Engine wire
    change: url and mediaKeyTimestamp in a file's ref. Engine protocol 41.
  • A chat id works in a query without quotes. chat:100000000000002@lid and chat:120363000000000001@g.us
    failed with query.unsupported-operator, because the @ was read as an operator; only the quoted form worked.
    Both forms work now (cli-core 0.375.0, cli-base 0.47.3).
  • session end unlinks the device on WhatsApp's side, which it never did before. Since the first release the
    engine sent the logout without connecting, so every session end stopped with "the connection to WhatsApp broke
    before it confirmed the change; it may have gone through — check first", sent nothing and kept the keys. If you
    deleted a profile's keys by hand after that, the device is still linked: remove it on the phone, in WhatsApp →
    Linked devices. session end now connects first, then logs out. When WhatsApp refuses the device on connect (logged
    out, or removed on the phone), the keys here are deleted, the command exits 0 and says "WhatsApp had already dropped
    this device". A network failure before the logout is sent keeps the keys; a connection cut after it was sent still
    answers "may have gone through". No engine wire change.
  • A group the owner left is found by its name or any id when a command reads its kept history (cli-core
    0.368.0): store export (also --output-dir with named chats), store status, messages context --thread,
    messages evidence, a search's --chat and chat:, metadata get, chats members, chats events,
    chats documents, stats, tags, tasks, conversations, embeddings, pictures and attachments, as chats show and
    messages list already did. Before, these refused a left group by every name and id. chats tracking add and
    store gaps plan still refuse it.
  • More of serve's warnings carry a code in its log, so a script can match the code instead of the sentence: a
    flood wait (flood_wait) or a long pace wait (pace_wait), both with counts.seconds, a held account
    (account_frozen, account_limited, account_banned), a wait that could not be remembered
    (wait_not_remembered), a send missing from the send journal (send_not_journaled), a recipient list that could
    not be re-keyed or read, and the reply audience and template warnings. The log record holds no sentence, chat id or
    name; the sentence at a terminal is unchanged (cli-core 0.364.0).
  • The first serve after an upgrade hands its chat re-keys to the store together, up to 500 at a time, instead
    of one at a time. On an account with thousands of chats stored by phone number, that start announces a re-key for
    each, and every one was saved as a batch of its own. A re-key that arrives alone waits at most 0.1 s. The store
    saves them in short passes with a pause after each, so another wa command run during that start no longer waits
    seconds for it; re-keys left when serve stops are one warning, and the next serve applies them (cli-core 0.359.0).
  • A group you left, were removed from, or that was deleted is marked left in the store, so
    wa <profile> store clear --left finds it; until now it found nothing. The mark comes from WhatsApp's group
    notice while serve or watch runs, and from a wa <profile> chats leave that succeeds; a chats join that gets
    in takes it off (cli-core 0.356.0), and so does WhatsApp's notice that someone added you back (cli-core 0.357.0);
    a group you had archived stays archived. A group left while nothing was connected is not marked. A group marked
    left drops out of chats list; chats show shows it and says you left it, and its messages stay in the store and
    in search until you clear it.
    Engine wire change: membershipState on a pushed chat, "left" (protocol 39) and "member" (protocol 40).
  • setup's link step is headed "Link this device" and no longer asks you to scan a QR code before one is
    drawn. The line under the code says where to scan; a narrow terminal shows only the hint to widen it.
  • A terminal too narrow for the QR code no longer asks you to scan it (cli-core 0.350.0). session start and
    setup said where to scan in WhatsApp before the code; when the code did not fit, only the hint to widen the
    terminal followed. Now the scan line comes after the drawn code, and a narrow terminal shows only the hint.
  • A one-shot command now saves the history WhatsApp pushes to it. WhatsApp sends a history push to a linked
    device once. A command such as messages send with no serve running received it and dropped it. It is now saved
    to the store like the messages queued for it, and the command waits up to 3 seconds at exit for a push still
    downloading (cli-core 0.348.0, listenHistory). session end ends this quietly; when the phone logs the device out
    mid-command, wa says on stderr that history batches arriving were not saved.
  • A group taken out of its WhatsApp community no longer names it in the store. wa saves WhatsApp's notice that
    a group left its community, or that the community was deleted, and clears the group's parent_chat_id; its title,
    unread count, member count and last activity stay. Each followed channel's push at connect, and any chat whose
    source does not say, no longer erases a stored unread count and last activity either (cli-core 0.348.0). Engine
    wire change: unread and lastMessageAt may be absent, and name and participants too, on that notice. Engine
    protocol 36.
  • A group added to a WhatsApp community names it in the store at once. wa saves WhatsApp's notice that a group
    joined a community and sets the group's parent_chat_id, keeping its title, counts and last activity. Before, the
    parent showed only after a live read of that one group.
  • session start no longer gives up right after the phone confirms the link. WhatsApp restarts the connection
    once pairing is done, and wa took the start of that restart for a failure: it said "linked, but could not reconnect
    to WhatsApp in time" and exited 10, the device linked but the history never received, while the phone's linking
    screen hung waiting for it. wa now waits for the new connection, up to a minute, and then receives the history as
    before. Both the QR code and the pairing code went this way. If that new connection fails, wa connects once more
    itself a few seconds later, within the same minute, instead of waiting the minute out. Every other command's wait
    for its connection uses the same loop now, so it no longer spins while the old connection is being closed. When the
    phone has pushed nothing after 20 seconds, session start says once that it is still waiting, so the pause does not
    look like a hang.
  • A pushed community no longer loses its mark. The store replaced a chat's provider_metadata whole on every
    push, so a later push that did not say a group was a community took community: true off it. A key a push leaves
    out now keeps its stored value, and so does a group's parent_chat_id (cli-core 0.346.0). Engine protocol
    35.
  • inbox and review check --json=<fields> against the message summary's fields, and refuse a name it does
    not have instead of printing empty items. --brief and --full no longer mention mail (cli-core 0.345.0).
  • session start and setup link a new profile. They failed before showing a code with "cannot open the lock
    beside the device store": nothing made the profile's own folder before the engine opened its lock there. The engine
    now makes it, mode 700, before it takes the lock.
  • One pushed event that wa cannot handle no longer stops the ones after it. serve and watch handle what
    WhatsApp pushes one event at a time, in order. A history push or a live message whose handling failed used to stop
    the queue, so no later history reached the local store until wa restarted. Now that event is skipped, a warning
    on stderr names its kind and error code (never its content), the next event goes on, and when wa stops listening it
    says how many were skipped if there was more than one. A failure that means the engine or the connection is gone
    still ends the listening, as before.
  • A message that arrives while no serve runs is no longer lost. WhatsApp keeps messages for a linked device
    while it is offline and hands each one over once, to the next connection. When that connection was a one-shot
    command (messages send, doctor --online, an MCP session), wa received the messages and saved none of them. Now
    every connection saves what arrives, and the command waits for those saves before it exits (cli-core 0.343.0). A
    history push that arrives during such a command is saved too (above).
  • A file your phone uploaded again stays downloadable, once its new copy downloaded. On the phones tried so far
    it does not (see the known limitation under messages download). wa keeps the new copy's address in the store,
    so a later messages download of it no longer asks the phone again, and messages forward sends a file that opens
    instead of the expired one. The same holds for attachments extract --download, messages transcribe --local and the
    picture index (images embed).
  • A history push after linking again keeps that address. The phone pushes each file with the address it had
    before, and the store used to save it over the new one, so the next download asked the phone again. A message the
    store does not hold yet still takes the pushed address.
  • A download your phone could not give back says why, in its reason, not only in its words. A file
    WhatsApp no longer keeps fails, per file in batch.failures, with media.not-on-phone (the phone has it no more),
    media.phone-cannot-decrypt (the phone could not read the request), media.phone-failed or
    media.phone-answer-unreadable (the phone answered an error), media.phone-timeout (no answer in 20 s),
    media.retry-failed (the new copy did not download) or engine.stopped; branch on those, never on the message.
    A download the phone's new upload served writes one line, media.reuploaded, to the engine's log when
    WA_ENGINE_LOG is set: the download's answer has no field for it.
  • A new copy that did not download says why. The failure's message ends with the cause in fixed words: the HTTP
    status WhatsApp answered, a checksum or key that did not match, a file too short, a network error or the time
    running out, and whether the phone gave the expired path back. With WA_ENGINE_LOG set, a media.refetch-failed
    line adds the first download's cause and the phone's result. A phone that answered an error but still gave a path is tried as before; when that path
    does not download either, the reason is the phone's answer (media.phone-failed, media.not-on-phone or
    media.phone-cannot-decrypt), not media.retry-failed, which now means the phone said it uploaded the file.
  • A new copy that no longer matches the message's checksums is checked against the message's key before it is
    refused.
    If the phone encrypted the old file again under the same key, wa accepts the copy when its MAC
    matches that key, which proves it was encrypted by someone holding the key, and stores the new checksums with the
    new address; a copy whose MAC does not match is still refused with media.retry-failed ("the file did not match
    its key"). On the phones tried so far, every new copy was encrypted under a different key and was refused this way
    (see the known limitation under messages download).
  • Node 22 no longer prints ExperimentalWarning: SQLite is an experimental feature on every command that opens
    the store. Other warnings still print.
  • wa mcp --help and the root help suggest claude mcp add wa -- wa <profile> mcp, where they suggested a
    command without a profile that always failed.
  • The commands page labels conversations embed, conversations build, search conversations and the model
    downloads as sending text to, or reading from, an outside service
    , not as changes in WhatsApp.
  • A chat muted for good on the phone is stored as muted, so inbox leaves it out. The history the phone sends
    marks such a mute with an end no clock can read (WhatsApp writes -1 into an unsigned field), and wa took that for
    a mute that had already ended. Any end past the year 9999 now reads as muted with no end, as whatsmeow reads it in
    app state. How the phone writes it in history is not yet checked live.
  • A lock another process holds no longer stalls serve (cli-core 0.340.0): the send journal, the request pace
    and recipients add used to wait for it by blocking, so every relayed command and the connection's keep-alives
    stopped for up to 5 seconds. They now wait without blocking; the timeout (exit 9) is unchanged.
  • setup without a terminal no longer waits 5 minutes on a dead connection after the history push (cli-core
    0.340.0): the steps after it get the usual 30 seconds. At a terminal they stay unbounded.
  • The MCP messages list tool no longer offers topic: (cli-core 0.340.0): WhatsApp has no forum topics, and a
    topic: term is refused as an unknown field, as on the command line.
  • chats admins add has a summary that fits in 80 characters (cli-core 0.340.0): "make a member an admin, with
    every right"; --help still names the seven rights.
  • A late answer from the phone no longer ends the next messages sync page early: each request for older messages
    goes under its own id, and an answer that names an earlier request is saved like any history the phone sends instead
    of being taken for the current page. An empty late answer used to read as the chat's start. This holds if the phone
    names the request by that id, which is not yet checked live; otherwise answers are matched by their chat, as before.
  • A page the phone sends in pieces is filled: when the phone sends fewer messages than asked, wa asks again from
    the oldest one it sent, one second apart, at most three asks and 25 seconds per page; what arrived before a
    timeout is kept.
  • A rate limit or a ban that ends watch or serve's feed holds the next command's writes as a one-shot
    command meeting it would.
  • images embed runs inside serve when serve holds the connection (cli-core 0.337.0): it
    downloads pictures through WhatsApp, so it is a read from WhatsApp, and its MCP tool has no offline input.
  • Model downloads no longer go through a running serve (cli-core 0.336.0): models text download, models audio download and images models download fetch from a model host, so they run in the calling process.
  • wa chats list --json | head ends quietly (cli-core 0.328.0): through a running serve the client kept
    writing into the closed pipe and printed a stack trace; it now exits 0.
  • The phone-number and agent prompts of session start and setup go to the run's own stderr, so a test or an
    embedding program that captures the run sees them.
  • polls vote and polls voters no longer mention anonymous polls in their help and their agent tools
    (cli-core 0.328.0): WhatsApp has none.
  • --json=item.<field> keeps that field (cli-base 0.44.0): --json=item.profile printed item: {}.
  • A query with a / that opens a regular expression and never closes it says to quote the value (cli-base
    0.44.0): text/plain now hints "text/plain". The error code and reason are unchanged.

Added

  • messages list <old id> --offline finds the chat that id moved to, and so does every other command that
    names a chat by the phone-number id it had before WhatsApp gave the person a LID. The store now keeps each chat's
    history: its id, title, membership, scope, search visibility and parent, one row per change. A read by an old id
    follows it within the account, also for a chat the account left when the command reads its kept history (cli-core
    0.368.0). Only a move the store
    recorded is followed: one serve or watch makes from now on, or one they repeat. The engine repeats the moves of
    the last 30 days at each start, so those are recorded at the first serve or watch start after the upgrade. An
    older move, and a number that was never a stored chat, still read as typed. The store moves to version 25 the
    first time this version opens it. That cannot be undone, so take a copy first (wa <profile> store backup <file>).
    wa 0.1.0 and older tg and max builds still open the upgraded store and write to it.
  • wa <profile> store clear --left lists the chats it would delete, by id, title and message count, when it
    refuses without --allow-dangerous, and after deleting lists what it deleted; --dry-run lists them all and
    deletes nothing (cli-core 0.353.0).
  • wa <profile> chats events <group> lists who joined, left, was added, removed, promoted or demoted, and each new
    title
    , from the local store, without connecting. wa keeps the group service messages the phone pushes with the
    history and, under wa serve and wa watch, each change as it happens; a change seen both ways is kept once.
    wa watch --events prints them as chat_event lines, and stats chats show counts the joins and leaves in
    members, where it used to leave them out. Changes from before the link that the phone did not push are not
    there. Engine protocol 33.
  • wa <profile> messages send <chat> --sticker-file <file.webp> sends a .webp as a WhatsApp sticker, alone,
    with no text; agents send sticker_file through wa_write (command: "messages send"). It is a message like any
    other: the profile's permissions, the send journal and --operation-id apply, and so does the file guard
    (--allow-any-file for a file in wa's own folders). A file that is not a WebP, or is larger than 500 KiB, is
    refused before connecting. A sticker to a channel is refused for now.
  • wa <profile> chats update <group> --remove-photo takes a group's photo away; agents send remove_photo
    through wa_write (command: "chats update"). The members see it. It goes through the profile's permissions
    under chats.update, as --photo does, and is refused beside --photo before connecting. The photo goes first: if a
    title, description or setting given with it then fails, the error says the photo was removed.
  • chats update --photo and account update --photo say they take a .jpg or .jpeg file, and refuse a file
    named otherwise, as they already refused one whose first bytes are not a JPEG's, before connecting. The engine still
    checks the photo is a square of 190 to 720 pixels a side.
  • wa <profile> conversations rebuild builds, then embeds on this machine, the chats that changed and the group
    chats never built, or only --chat, bounded by --max-chats and --max-chunks. It sends nothing anywhere.
  • setup and session start warn about bans before they show the QR code or the pairing code. The warning
    says that wa is an unofficial client, that WhatsApp may ban an account for using one or for bulk or unsolicited
    messages, what wa does to keep the risk low, and what not to do. It goes to stderr once per link, in every
    output mode and with --quiet too, since it belongs to the code the person is about to scan; stdout and the JSON
    answer are unchanged. In machine mode, a link that fails after this point now has the warning on stderr before the
    error JSON, so read the error from the last line. A device that is linked already, and every later command, prints
    nothing new. The README, the installation and linking pages and the agent skill carry the same warning. The MCP
    page has its own: by default an agent may send up to 30 messages an hour without asking, and how to lower that.
  • wa <profile> chats mute and chats unmute: stop a chat's notifications for good or --until a time (2h,
    7d, 2026-09-25T09:00), and hear it again; agents run both through wa_write. It is the account's own
    setting, which WhatsApp syncs to the other devices; nobody in the chat is told (not yet checked live on the phone).
    It is a write and goes through the profile's permissions, under chats.mute and chats.unmute; a setting for
    chats.mute alone still covers both. A device linked minutes ago may not have WhatsApp's settings keys yet: the
    command then answers exit 12 with reason settings.keys-missing and says to keep serve running a few minutes. A
    channel is muted on the phone.
  • wa <profile> chats archive and unarchive, chats pin and unpin, and chats mark-unread: move a chat
    into WhatsApp's archive and back, keep it at the top of the chat list, or mark it unread, on the account's
    devices; nobody in the chat is told. Agents run them too, through wa_write (command: "chats archive" and the
    rest). chats pin pins the chat, not a message (messages pin does that). Archiving a chat also
    unpins it. Archive, unarchive and mark-unread name the chat's newest stored message, as chats delete does, and
    refuse a chat the store holds nothing of (exit 6); pin needs no message. WhatsApp's apps keep three pinned chats;
    wa does not count them. A mark-unread sends no receipt; chats mark-read takes the mark off (see Changed).
    Channels are refused. Each answers settings.keys-missing as chats mute does, and has its own
    permission key (chats.archive, chats.unarchive, chats.pin, chats.unpin, chats.mark-unread), allowed by
    default. Not yet checked live on the phone.
  • wa <profile> chats delete and chats clear: delete a chat, or every message in it, from this account's
    devices; the others in it keep theirs. WhatsApp deletes up to a message it is told of, so wa names the chat's
    newest message in the local store, the owner's own or not, and refuses a chat the store holds nothing of (exit 6)
    rather than guess. A message that arrived after the store last heard should stay. chats clear deletes starred
    messages too; neither asks WhatsApp to delete the photos and files the phone saved from the chat. Both ask first
    at the default permission level (chats.delete, chats.clear; --allow-dangerous skips the question), are not
    offered over MCP, and answer settings.keys-missing as chats mute does. The local store keeps the chat's
    messages. Not yet checked live on the phone.
  • wa <profile> messages star and messages unstar: star one message so it is easy to find again, or take the
    star off. It is the account's own mark, which WhatsApp syncs to the other devices; nobody in the chat is told. The
    message must be in the local store, since WhatsApp names it by its sender; one the store lacks is exit 6 and
    nothing is sent. A channel's post is starred on the phone. It goes through the profile's permissions
    (messages.star, messages.unstar), an agent can do it over MCP, and it answers settings.keys-missing as chats mute does. The local store keeps a star set with wa, so messages list <chat> starred:true and search messages is:starred find it. While serve or watch runs, a star set or taken off on the phone reaches the
    store too, and watch --events --jsonl prints it as { "event": "star", "chatId", "chatTitle", "messageId", "starred" }; the messages the phone pushes after linking keep their stars. The store moves to version 24
    for this the first time this version opens it; wa 0.1.0 still opens it. Not yet checked live on the phone.
  • wa <profile> messages pin and messages unpin: a message in the local store is pinned for the whole chat; a
    message the store lacks is not_found. WhatsApp tells the chat's members about every pin, so each one notifies and
    counts toward the hourly limit; messages pin takes no --notify, and an agent's wa_write call no notify.
    --expire-time 24h, 7d or 30d (MCP expire_time) says how long it lasts; without it the pin lasts 7 days, as
    the help says; any other length is validation_error, over MCP before it connects.
  • wa <profile> account privacy show and account privacy set: who sees last seen (--last-seen), the
    profile photo (--profile-photo) and the about text (--about-text), whether online and last seen are hidden
    (--hide-online), whether read receipts go (--read-receipts; off also hides others' receipts from the owner, as
    on the phone), who may add the account to groups (--chat-invites) and whose calls ring (--calls). show asks
    WhatsApp each time, and reports a setting kept on the phone as "my contacts except…" as contacts_except, which
    set refuses; for groups that value is left out. set changes only the settings named and answers them all; it
    is a write under permissions.account.privacy.set. WhatsApp has no setting for who finds the account by its
    number or who sees the number, so --find-by-phone and --phone-number are not offered. Refused before anything
    is sent: --chat-invites nobody (WhatsApp is reported to refuse it), --calls nobody, and --hide-online with
    --last-seen, which both set last seen. --calls contacts sends the value meant as the phone's "silence unknown
    callers" (not yet checked on a real account). --hide-online off shows last seen to contacts. set sends one
    request per setting: if WhatsApp fails one, those sent before it stay changed, and the error says so; a change cut
    by a dropped connection answers outcome_unknown (exit 14), since it may have gone through.
  • wa <profile> account update sets the owner's WhatsApp name, about line and profile photo: --first-name sets
    the name people see, with --last-name joined to it after a space (WhatsApp keeps one name, so a last name alone is
    refused), and --description the "about" line. The name is a setting WhatsApp syncs to the other devices; others
    probably see it only once the phone next comes online, since a device tells WhatsApp its name when it says it is
    online and wa never does (not checked live). --photo sets the profile photo (not checked live): wa takes
    a square JPEG of 190 to 720 pixels a side, as for a group's photo, checked before anything changes. The answer is
    the account with the new name. A device without WhatsApp's settings keys yet answers settings.keys-missing, as
    chats mute does; the name goes first, then the about, then the photo, and a step WhatsApp refuses after another
    changed says which did. The permission key is account.update.
  • wa <profile> chats update <group> --photo <file> sets the group's photo. WhatsApp takes only a square JPEG
    of 190 to 720 pixels a side, and wa does not convert one: anything else is refused with validation_error
    before WhatsApp is asked, a PNG or WebP by its bytes whatever its name. The photo is set before the title,
    description or settings given with it, so a refused photo leaves the group unchanged, and a --title over 25
    characters is refused before the photo is set. If the rest of the change then fails, the error begins "the photo
    was set", keeping its code. It runs under the same permission and hourly limit as the rest of chats update.
    --remove-photo takes it away (above).
  • A group says which WhatsApp community it is in. A group linked to a community answers with parent_chat_id,
    the community's id; the community itself is a group with provider_metadata.community: true. They come with the
    group as WhatsApp gives it: the answers of chats update, chats create and chats join. wa also reads them
    from the history the phone pushes, so the local store keeps them and chats list and chats show show them, and
    follows WhatsApp's notices that a group joined or left a community (see Fixed). Listing a community's groups, and
    linking or unlinking one, are not built.
  • A photo and a video in one send go as an album (messages send --photo … --file clip.mp4): one message that
    announces the album, then each file tied to it. The text is the photo's caption and the answer is the photo's
    message, under the send id. A repeat with the same --operation-id after an unknown outcome sends the same message
    ids, which WhatsApp takes as the same messages, as it does for a single send. A document beside a photo is still
    refused. Checked on a phone: it shows as one album.
  • A sent photo carries its preview: the chat shows a small blurred picture before the download. Not yet for a
    video or a WEBP photo.
  • wa <profile> metadata get and metadata refresh read a group's title and description into the store for
    tags auto; the group is not changed, and agents get both. A channel in the list answers with an error of its
    own.
  • tasks agenda lists what others owe you (cli-core 0.328.0): assigned in JSON and an "assigned — others owe
    you" section hold the open tasks someone else does, oldest first, each with its assignees.
  • wa commands --json has two new keys (cli-base 0.44.0): runnable: true on a group that runs on its own, and
    offline: false on a command that cannot answer offline. A reader that rejects unknown keys must accept them.
  • wa follows and reads WhatsApp channels, the one-way chats whose ids end in @newsletter: chats join https://whatsapp.com/channel/<code> follows one and chats leave <channel> unfollows it, both through the
    profile's permissions, and the channel's admins see that you followed. The channels you follow reach chats list
    as kind: "channel" when serve or watch connects. messages sync <channel> reads its posts into the local
    store from WhatsApp's servers, not from your phone: newest first, 50 at a time. Each post keeps its message id, and
    WhatsApp's number for it is in provider_metadata.serverId. Every post's sender is the channel, so a post you
    made shows outgoing: false. See channels.
  • wa posts to a WhatsApp channel you run: messages send <channel> <text> asks WhatsApp for your role in the
    channel first and refuses anyone but its owner and admins with permission_error (exit 5) before anything is sent.
    The channel's followers see the post at once. Not a reply; a poll, a vote, a forward, an edit or a deletion in a
    channel is refused before anything is sent. A refused post still passes the profile's send permissions first, so
    under ask it asks before it is refused. Not yet checked live.
  • wa posts a photo, a video, a voice note or a file to a WhatsApp channel you run: messages send <channel> --photo/--file/--voice <path>, the text as the caption. The role is asked first, as for a text post, so a channel
    you only follow is refused (exit 5) before anything is uploaded. A channel's files are not encrypted: wa uploads
    them as WhatsApp's apps do for channels, and the post carries no file key. One file per post; an album to a channel
    is refused (exit 2). A forward of a channel's file into a chat is refused too (exit 2,
    media.channel-file-forward), since it would arrive as a file that does not open. Not yet checked live: that needs
    a channel you own or administer.