Terminal WhatsApp client (ratatui) with per-chat and global AI autoreply via an OpenAI-compatible API.
cargo build --release
wh --version (or -V) prints the build version and exits: package version and short git commit, e.g. wh 0.1.0 (cde0f55) (unknown when built without git). The same version is shown at the bottom right of the main screen.
Built from Windows with cargo-zigbuild. Zig provides the C cross-compiler and linker for the bundled C code (SQLite, ring). TLS uses ring only (reqwest is built with rustls-no-provider and the ring provider is installed at startup), so the large aws-lc-sys C build is avoided. A rebuild after a source change takes seconds; a full rebuild is needed only when dependencies change.
One-time prerequisites:
rustup target add x86_64-unknown-linux-musl
winget install -e --id zig.zig # or: pip install ziglang
cargo install --locked cargo-zigbuild
Build:
cargo zigbuild --release --target x86_64-unknown-linux-musl
Output: target/x86_64-unknown-linux-musl/release/wh
Verify on Linux:
file wh # ELF 64-bit x86-64, statically linked
chmod +x wh && ./wh --version
On Linux everything lives in ~/.config/wh/ of the user running wh; on Windows in %APPDATA%\wh. File names are case-sensitive on Linux.
| File | Content |
|---|---|
config.toml |
settings (optional) |
SOUL.md |
system prompt (required for the AI) |
VISION.md |
request used to describe images (optional) |
session.db |
WhatsApp linked-device session |
history.db |
chats, messages, autoreply flags, image descriptions |
ai_requests.log |
AI request log, only with ai_log = true |
Copy session.db from Windows to reuse the linked session instead of scanning the QR again; never run both copies at the same time. To pair again, use F8 or the web panel "Nuovo QR" button.
Settings live in config.toml in the data directory. Start from config.toml.global.template: copy it there and fill openai_api_key. A missing file means defaults. Unknown keys are ignored.
| Key | Default | Description |
|---|---|---|
openai_api_key |
unset | API key of the OpenAI-compatible endpoint (required for AI) |
openai_base_url |
https://ollama.com/v1 |
endpoint base URL |
openai_model |
deepseek-v4.1-flash |
model for autoreplies |
openai_vision_model |
unset | model that describes images (see Images); unset disables image understanding |
ai_log |
false |
append every AI request and its outcome to ai_requests.log (full chat context in clear text) |
reply_delay_seconds |
30 |
seconds the autoreply waits after the first unanswered message of a chat before answering (0 = at once) |
catch_up_seconds |
7200 |
after a restart, answer the last unanswered private message up to this age (0 = off) |
ai_retries |
2 |
retries of a failed AI request, 2s, 4s, ... backoff (0 = off; HTTP 4xx except 429 never retried) |
web_port |
unset | port of the web control panel (1-65535); unset disables it |
Voice-note transcription adds four optional transcribe_* keys, documented in Voice notes.
config.toml, SOUL.md and VISION.md are checked every 2 seconds and reloaded when changed, without restarting wh (status Configurazione ricaricata). An invalid file keeps the current configuration (Configurazione non ricaricata: ...). Only web_port needs a restart.
SOUL.mdThe system prompt (the responder's identity and rules) lives in its own file, SOUL.md, next to config.toml, and is sent as written. Start from soul.md.template. Without openai_api_key and a non-blank SOUL.md the app runs as a plain WhatsApp client and the status line says AI non configurata. SOUL.md and VISION.md are personal and ignored by git.
The app appends to the prompt only factual context: whether the chat is private (Chat privata con «nome».) or a group (Chat di gruppo «nome».) and who writes the user/assistant messages. Everything about behaviour belongs in SOUL.md. Texts the model can find in the conversation:
| Text | Meaning |
|---|---|
Nome: ... |
in groups, each participant message starts with the sender name |
@[te] |
a mention of the account owner |
[Il contatto ha inviato un'immagine. Descrizione: ...] |
an image, replaced by its stored description |
[Il contatto ha inviato un'immagine che non puoi vedere] |
an image without description |
[Il contatto ha inviato un messaggio vocale che non puoi ascoltare] |
a voice note |
no_reply toolEvery AI request offers the model one tool, no_reply (no parameters): when the model calls it, nothing is sent and the status line shows AI: nessuna risposta necessaria. The app never says when to use it: describe the cases in SOUL.md, e.g. "se il messaggio non richiede risposta, chiama il tool no_reply".
system_prompt in config.toml is no longer read: move the prompt to SOUL.md.cooldown_seconds and group_cooldown_seconds are no longer read: use reply_delay_seconds.Enable it per chat with F3 or for all chats with F4 (private chats and groups; broadcasts and newsletters are never answered).
reply_delay_seconds wait; messages arriving meanwhile join the same reply. Groups behave like private chats.Risposta AI scartata: hai già risposto).catch_up_seconds > 0, the last unanswered recent message of each private chat is answered once, at once.openai_vision_model and the description is stored in history.db; replies never send images to the AI, only their descriptions, in every later context too.Descrizione immagine fallita (N): ....VISION.md, next to SOUL.md, replaces the built-in request used to describe images; when missing or blank the built-in request (objective, concise, in Italian) is used. The vision model receives only this request and the image, never the conversation: tone and reply style belong in SOUL.md, which can react to what the description contains.[messaggio vocale] <trascrizione>: it shows in the TUI and is part of the AI reply context.transcribe_key for any OpenAI-compatible /audio/transcriptions endpoint; nothing to install locally. The default is Groq whisper-large-v3-turbo (free tier: no credit card, 2000 requests/day; works well for Italian). The audio is sent to that endpoint: with the feature on, voice notes leave your server.Enabled only when transcribe_key is set, in config.toml:
# transcribe_key = "gsk_..."
# transcribe_url = "https://api.groq.com/openai/v1/audio/transcriptions"
# transcribe_model = "whisper-large-v3-turbo"
# transcribe_language = "auto"
Audio over 25 MiB is skipped.
Transcription errors show in the status line and the reply continues without the transcription.
| Key | Action |
|---|---|
Tab |
focus list/input |
Enter |
open chat / send |
↑/↓, PgUp/PgDn, Home/End (list focused) |
move selection by one row, by 10 rows, to first/last row |
F2 |
chats/contacts |
| typing (list focused) | filter names; Backspace edits, Esc clears |
Canc/Delete twice |
delete chat locally (history only, not on WhatsApp) |
F3 |
AI autoreply for the selected chat (list focus) or the open chat (input focus) |
F4 |
AI autoreply for all chats (persistent; footer shortcut turns green when on) |
F8 twice (any screen) |
delete the WhatsApp session and pair again: unlinks this device, empties session.db and shows a new QR without restarting (Esc cancels) |
F10 |
quit (the screen is cleared, also on terminals without an alternate screen) |
Only function keys are used for commands, so they work inside GNU screen, tmux and over SSH.
web_port in config.toml (e.g. web_port = 8080); open http://<host>:<port>/.0.0.0.0 without authentication: use it only on a trusted network (e.g. a VPN).F4), per-chat AI toggle grid (private chats and groups only), "Nuovo QR" button (same as F8, with confirmation), "Chiudi bot" button to quit. While WhatsApp is not connected the page shows the connection progress or the pairing QR code, to scan from the phone. The page refreshes every 5 seconds (2 while not connected); changes show up in the TUI too.GET /api/state (JSON {global_autoreply, chats:[{jid,name,autoreply}], status, screen, qr_svg}, screen = connecting/pairing/main, qr_svg = QR code as SVG while pairing, else null); POST /api/global; POST /api/chat with the jid as plain-text body; POST /api/relink; POST /api/quit. Every POST requires the header X-Wh-Request: 1 (otherwise 403), which blocks cross-site requests from other pages.Example:
curl -X POST -H "X-Wh-Request: 1" http://host:8080/api/global