wacli specification (plan)
Read when: changing the CLI contract, store model, sync model, or v1 behavior boundaries.
This document defines the v1 plan for wacli: a WhatsApp CLI that syncs messages locally, supports fast search, sending, and contact/group management. Implementation will use whatsmeow under the hood.
#Goals
- Explicit authentication step:
wacli authshows a QR code and completes login. - Auth starts syncing immediately: after successful QR pairing,
wacli authbegins initial sync (history + metadata). - Non-interactive sync:
wacli syncnever displays a QR code; it fails with a clear error if not authenticated. - Fast offline message search: local SQLite + FTS5 index.
- Human-first output: readable tables by default,
--jsonopt-in for scripting. - Single-instance safety: store locking to avoid multi-instance session conflicts (device/session replacement issues).
- Group management: list groups, inspect, rename, manage participants, invites.
#Non-goals (v1)
- Guaranteed full-history export (WhatsApp/WhatsApp Web history is best-effort).
- End-to-end “contact creation” in WhatsApp (we can manage local aliases/notes; WhatsApp contacts are sourced from the account/device).
- Full message-type parity (polls, reactions, ephemeral nuances, etc.) in v1.
#Terminology
- JID: WhatsApp Jabber ID, e.g.
1234567890@s.whatsapp.net(user),123456789@g.us(group), or123456789012345@newsletter(channel). - Store directory: directory containing all local state, default
~/.local/state/waclion Linux and~/.waclielsewhere.
#Storage layout
Default store: ~/.local/state/wacli on Linux and ~/.wacli elsewhere (override with --store DIR). Existing Linux ~/.wacli stores are reused when the XDG state store does not exist.
Proposed files:
<store>/session.db—whatsmeowSQL store (device identity, keys, app-state).<store>/wacli.db— our SQLite DB (messages/chats, FTS, local metadata).<store>/media/...— downloaded media (optional, on-demand or background).<store>/LOCK— store lock to prevent concurrent access.<store>/HEARTBEAT— last observed sync follow activity timestamp (RFC 3339), written bysync --followat most once per minute. Permissions0600. Letsdoctorand external watchdogs inspect local follow activity; it is not a process-liveness or keepalive-health marker.
Rationale for two SQLite files: reduce coupling and keep the whatsmeow-owned schema separate from wacli’s local schema. It’s still “one store directory” for the user.
#Concurrency + locking
Every command that accesses the WhatsApp session must acquire an exclusive lock in the store dir.
Behavior:
- If lock is held: fail fast with a clear message (include PID and start time if available).
- This prevents running multiple
wacliinstances against the same WhatsApp device identity, which can cause disconnects or “device replaced” style failures.
#Authentication model
#Commands
wacli auth(interactive)- If not authenticated: connect, show QR code, wait for success.
- After success: start initial sync (bootstrap) immediately.
- Exits after initial sync “goes idle” (configurable), unless
--followis set.
wacli sync(non-interactive)- Requires an existing authenticated session in
session.db. - Never displays QR; if not authenticated, prints “run
wacli auth”. --onceperforms a bounded sync and exits.- Default (or
--follow) stays connected and continues capturing messages.
#UX principle
Only wacli auth is expected to show a QR code. wacli sync should be safe to run in scripts/daemons without surprising interactivity.
#Sync model (best-effort)
wacli captures messages via whatsmeow event handlers:
events.HistorySync: initial/batch history sync delivered by WhatsApp Web.events.Message: new incoming/outgoing messages while connected.- Connection lifecycle events (
Connected,Disconnected) for logging/reconnect.
#Bootstrap sync (after auth)
Immediately after QR pairing success, wacli auth runs a bootstrap sync:
- Processes history sync events and stores message metadata.
- Updates chats, names, and contact-derived names as available.
- Optionally starts media download worker (off by default, behind a flag).
- Exits once “idle for N seconds” (no new history events) unless
--follow.
#Continuous sync
wacli sync --follow keeps running:
- persists new messages as they arrive
- performs safe reconnect with backoff on disconnect
- continues best-effort history catch-up when WhatsApp emits it
- optional
--stale-thresholddetects keepalive failures and force-reconnects when the last successful keepalive is older than the given duration; accepted values are1sup to but not including2m20s, reserving one maximum keepalive probe interval plus response deadline before whatsmeow auto-reconnects after 3 minutes of failed keepalives
#Database schema (wacli.db)
#Tables (proposed)
chatsjid(PK),name,kind(dm|group|broadcast|newsletter|unknown),last_message_ts,archived,pinned,muted_until,unread,unread_count, …contactsjid(PK),push_name,full_name,business_name,phone, …groupsjid(PK),name,owner_jid,created_ts,is_parent,linked_parent_jid, …is_parentmarks WhatsApp Communities;linked_parent_jidpoints from a subgroup to its parent Community when WhatsApp exposes that metadata.messagesrowid(PK),chat_jid,msg_id,sender_jid,ts,from_me,text,display_text,revoked,deleted_for_me,media_type,media_caption,filename,mime_type,direct_path, hashes/keys, …- unique constraint: (
chat_jid,msg_id) status_messagesrowid(PK),msg_id(unique),ts,from_me,sender_jid,sender_name,text,media_type,media_caption,filename,mime_type,direct_path, hashes/keys,background_color,font, …- status broadcasts use WhatsApp's
status@broadcasttarget and are kept out of normal chatmessages. contact_aliases(local management)jid(PK/FK),alias,notes,tags(or join table)
#Message search (FTS5)
Use SQLite FTS5 for fast full-text search.
Approach:
- Maintain canonical data in
messages. - Maintain an FTS5 virtual table
messages_fts(external content) indexing: - message body text
- media caption
- document filename
- (optionally) denormalized sender/chat names for convenience
- Revoked and delete-for-me tombstones are excluded from list/search/starred/export results and FTS rows, but remain addressable by direct
messages show.
Query behavior:
- Default:
MATCHqueries (FTS syntax) with ranking viabm25. - Filters implemented in SQL:
--chat,--from,--after,--before,--has-media,--type. - Human output includes snippets/highlights;
--jsonreturns structured matches + offsets/snippet string.
Fallback:
- If FTS5 is unavailable, fall back to
LIKEwith an explicit warning (slower).
#CLI command surface (v1)
Global flags:
--store DIR(default: XDG state dir on Linux,~/.waclielsewhere)--account NAME(named account fromconfig.yaml; mutually exclusive with--store)--json(default: human text)--full(disable table truncation; non-TTY output keeps full IDs)--timeout DURATION(non-sync commands; e.g.5m)--lock-wait DURATION(wait for the store lock before failing write commands)--read-only(reject commands that intentionally write WhatsApp or the local store; alsoWACLI_READONLY=1)--version(prints version and exits)
#Doctor
wacli doctor [--connect]
#Auth
wacli auth [--follow] [--idle-exit 30s]wacli auth statuswacli auth logout
#Accounts
wacli accounts listwacli accounts add NAME [--no-auth]wacli accounts use NAMEwacli accounts show NAMEwacli accounts remove NAME
Named accounts resolve to isolated store directories. Account config lives in <base>/config.yaml; relative account store paths resolve from that config directory. --store remains the direct manual-store escape hatch and cannot be combined with --account.
#Sync
wacli sync [--once] [--follow] [--stale-threshold DURATION] [--download-media] [--webhook URL] [--webhook-secret SECRET]
Notes:
syncerrors if not authenticated (never prints QR).--download-mediaruns a bounded/concurrent media downloader for messages that contain downloadable media metadata.--webhookposts live message JSON after successful local storage on a bounded background worker.--webhook-secretadds an HMAC-SHA256X-Wacli-Signatureheader and requires--webhook.- Webhook failures and full-queue drops emit warnings but do not fail sync.
#History backfill (best-effort)
WhatsApp Web history is best-effort. If you want to try fetching older messages for a specific chat, wacli can send an on-demand history request to your primary device:
wacli history backfill --chat JID [--count 50] [--requests N]wacli history coverageinspects local chat/message coverage without connecting.wacli history fill --dry-runplans matching chats with local anchors; it does not write or connect.- Backfill caps:
--count <= 500,--requests <= 100. - During backfill, automatic initial history-sync blob downloads are disabled; only on-demand history-sync notifications are downloaded and stored.
#Messages
wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--before TS] [--after TS] [--forwarded] [--starred]wacli messages search <query> [--chat JID] [--from JID] [--limit N] [--before TS] [--after TS] [--type text|image|video|audio|document] [--forwarded] [--starred]wacli messages starred [--chat JID] [--limit N] [--before TS] [--after TS] [--asc]wacli messages export [--chat JID] [--limit N] [--before TS] [--after TS] [--output PATH]wacli messages show --chat JID --id MSG_IDwacli messages context --chat JID --id MSG_ID [--before N] [--after N]wacli messages edit --chat JID --id MSG_ID --message TEXT [--post-send-wait 2s]wacli messages delete --chat JID --id MSG_ID [--for-me] [--delete-media] [--post-send-wait 2s]
#Send
wacli send text --to RECIPIENT --message TEXT [--message-escapes] [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]wacli send file --to RECIPIENT --file PATH [--caption TEXT] [--mime TYPE] [--pick N] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID]wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]wacli send voice --to RECIPIENT --file PATH [--mime TYPE] [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]wacli send status [--message TEXT] [--file PATH] [--mime TYPE] [--background-color '#RRGGBB'] [--font N]
RECIPIENT accepts a JID, phone number, channel JID (...@newsletter), or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass --pick N. Sending to channels requires channel posting permission. File sends to channels use WhatsApp's unencrypted newsletter media upload and pass the returned media handle through whatsmeow.SendRequestExtra. Text sends automatically include a link preview for the first http:// or https:// URL unless --no-preview is passed. Voice notes require OGG/Opus audio and use optional ffprobe/ffmpeg metadata when available. Stickers require 512x512 WebP input and are stored locally as sticker media after sending. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and carry animation metadata in the outgoing proto. Status broadcasts are sent to status@broadcast; text statuses can carry background color and font metadata, and media statuses reuse the file upload path with optional captions.
Send-file uploads and media downloads are capped at 100 MiB to avoid reading or writing unexpectedly large payloads in one command.
#Contacts (read + local management)
wacli contacts search <query>wacli contacts show --jid JIDwacli contacts refreshwacli contacts alias set --jid JID --alias "Name"wacli contacts alias rm --jid JIDwacli contacts tags add|rm --jid JID --tag TAG
#Chats
wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]wacli chats show --jid JIDwacli chats archive|unarchive --chat CHAT [--pick N]wacli chats pin|unpin --chat CHAT [--pick N]wacli chats mute --chat CHAT [--duration DURATION] [--pick N]wacli chats unmute --chat CHAT [--pick N]wacli chats mark-read|mark-unread --chat CHAT [--pick N]
#Groups
wacli groups list [--query TEXT]wacli groups refreshwacli groups info --jid GROUP_JIDwacli groups rename --jid GROUP_JID --name "New Name"wacli groups participants add|remove --jid GROUP_JID --user PHONE_OR_JID [--user ...]wacli groups participants promote|demote --jid GROUP_JID --user PHONE_OR_JID [--user ...]wacli groups invite link get|revoke --jid GROUP_JIDwacli groups join --code INVITE_CODEwacli groups leave --jid GROUP_JID
#Channels
wacli channels listwacli channels info --jid CHANNEL_JIDwacli channels join --invite LINK_OR_CODEwacli channels leave --jid CHANNEL_JID
#Output formats
Default: human-readable text (tables / aligned columns; TTY-aware wrapping).
Optional:
--jsonprints{"success":true,"data":...,"error":null}-style responses.
Recommendation:
- Write logs/progress (sync counters, reconnect notices) to stderr.
- Write primary command output to stdout.
#Reliability considerations
- Session conflicts: running multiple instances can cause disconnects or “device replaced” behavior; locking is mandatory.
- Reconnect: on disconnect, retry with exponential backoff and respect context cancellation.
- Idempotency: message inserts are upserts keyed by (
chat_jid,msg_id) so replays/history sync don’t duplicate data.
#Security considerations
- Store contains encryption keys/session data; protect permissions:
- store dir
0700 - DB files
0600 - Avoid printing sensitive identifiers in logs unless needed for debugging (
--verbose).
#Implementation milestones
#v0.1 (MVP)
auth(QR + bootstrap sync)sync(non-interactive, follow mode)messages list/searchwith FTS5send text- store locking, default state dir
#v0.2
- contacts: show + local alias/notes/tags
- chats list/show with better naming resolution
- groups list/info/rename/participants
#v0.3
- media download command + optional background downloader
messages show/contextpolish
#Prior art / credit
This spec borrows ideas and lessons learned from:
https://github.com/vicentereig/whatsapp-cli