Thoughts To Remember
Translation pending
SocialTypeScript · JavaScriptGitHub @greenidoOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
A local, single-user operator console for wacli.sh - Monitor incoming WhatsApp messages, search message history, and send (even LATER) or reply to messages with safety guardrails from a clean browser UI.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
WhatsApp, but make it a control room.
A local-first, keyboard-driven, safe-by-default browser console for wacli.
Read, search, schedule and (carefully) send WhatsApp messages from a big screen and a real keyboard.
Your phone has a six-inch screen and a keyboard made of glass. Your desk has a big monitor and a keyboard you're actually fast on. Mission Control moves WhatsApp to the desk.Read the full README ↗WhatsApp, but make it a control room.
A local-first, keyboard-driven, safe-by-default browser console for wacli. Read, search, schedule and (carefully) send WhatsApp messages from a big screen and a real keyboard.
Quick start · Tour · Shortcuts · How it works · Security · Config · API · FAQ
Your phone has a six-inch screen and a keyboard made of glass. Your desk has a big monitor and a keyboard you're actually fast on. Mission Control moves WhatsApp to the desk.
wacli already does the hard part: it pairs with your account as a linked device and mirrors your history into a local SQLite database with full-text search. Mission Control is the cockpit on top. It supervises wacli's sync daemon, streams live events into your browser, and wraps every outgoing action in enough guardrails to make an airline safety officer tear up a little.
┌────────────────────────────────────────────────────────────────────────────────────┐
│ LIVE OPERATOR MODE (MUTATIONS ENABLED) [ ENGAGE SAFE MODE ] │
├──────────────────────┬────────────────────────────────────┬────────────────────────┤
│ CHATS + │ Ada Lovelace INFO EXPORT │ SYSTEM STATUS z ? , │
│ [ search chats / ] │ ARCHIVE Mar 14, 2021 · 4,211 MSG │ MODE LIVE WRITE │
│ All Unread Pinned .. │ │ DAEMON running │
│ #family #work │ Is the engine ready? │ WS RELAY connected │
│ │ 09:41 │ HEARTBEAT 2s ago │
│ > Ada Lovelace (2) │ │ STORE LOCK daemon │
│ typing... │ Almost. Coffee first. │ │
│ Charles Babbage │ 09:42 read │ ACTIVITY | LATER (1) │
│ You: see you at 9 │ │ sent -> Ada 09:42 │
│ Engine Club (12) │ │ 09:00 -> Charles │
│ Ada: it compiles! │ [+] Message Ada... LATER SEND │ │
└──────────────────────┴────────────────────────────────────┴────────────────────────┘
127.0.0.1 and talks to the wacli on your machine. No cloud relay, no accounts, no sign-up.typing... show up the moment wacli sees them. There's no refresh button, because you don't need one.J/K through chats, R to reply, Cmd+K to search everything. The mouse is optional.Unofficial, and proud of it. Mission Control is not affiliated with WhatsApp or Meta. wacli is an unofficial client built on whatsmeow, and this is an unofficial UI for it. Use it the way you'd use WhatsApp Web: for your own account, at human speed.
You'll need:
Node.js 22.13 or newer (23.4 or newer if you're on the 23 line). That's where node:sqlite came out from behind its experimental flag, and Mission Control keeps its own state in it. No native modules to compile, no tears.
wacli, installed and paired with your WhatsApp account:
brew install openclaw/tap/wacli # other ways to install: https://wacli.sh
wacli auth # scan the QR code from WhatsApp > Linked devices
The newest wacli (0.22 or later) works best: it lets Mission Control hand sends and read receipts to the running sync daemon, stop a read receipt at the last message you actually saw, and ask your phone for media WhatsApp has thrown away. Anything from 0.18.2 hands sends over too. Older versions still work; Mission Control just pauses the daemon for each send, and skips whatever the binary can't do.
Then launch:
npx wacli-mission-control --open
That's it. The console opens at http://127.0.0.1:3002, starts the wacli sync daemon for you, and begins in safe read-only mode: read, search and poke around all you like. Nothing leaves until you press UNLOCK LIVE SENDS.
Prefer a permanent install?
npm install -g wacli-mission-control
wacli-mission-control # or `wacli-ui`: same app, fewer keystrokes
| Flag | What it does |
|---|---|
-p, --port <n> | Listen on another port (default 3002, or $PORT) |
-o, --open | Open the console in your browser once it's up |
--no-sync | Don't start wacli sync --follow (same as WACLI_DISABLE_SYNC=1) |
-v, --version | Print the version |
-h, --help | Print the help |
There's no --host flag, and that's on purpose. See Security & privacy.
Left: your chats.
You: when it's yours; media is described instead of left blank).typing... indicators.Center: the conversation.
Right: mission status.
Drag either splitter to resize a rail, and double-click it to snap back. The widths are remembered.
Cmd+Shift+L asks first. Unlocking is the one guardrail a stray key chord could drop, so the shortcut opens a confirmation instead of flipping the switch.WACLI_READONLY=1, so even a request that somehow slipped past the API's lock would run into wacli's.Cmd+Enter (or the LATER button) schedules the current draft, text or file. The queue lives in Mission Control's database, so it survives restarts.sending on disk before wacli is asked. If Mission Control dies mid-send, the next start marks that message failed instead of sending it again, because it may already have gone out. The resend dialog leaves the call to you. One birthday wish is sweet; two is a bug.~/.wacli-mission-control/scheduled-files/ (mode 0700), not in a temp directory your OS likes to tidy. It's deleted once it's sent, cancelled or discarded. If it vanishes anyway, the message fails and says why. The caption never goes out alone.Put the console to bed. The alarm clock keeps working.
The moon button in the status strip stops the wacli sync daemon and all polling. The screen stays as it was, under a banner that says when sleep began, how many messages are scheduled, and when the next one is due.
Scheduled messages still go out on time. Each send connects to WhatsApp on its own and leaves the daemon asleep afterwards.
Only you can wake it. No timer, reconnect, server restart or scheduled send will. Press WAKE, or do something that needs fresh data or sends:
Every wake is logged with its reason. Scrolling, bookmarks, tags, Help, Settings, safe mode, queueing a Send Later, and the LATER queue's own buttons all leave it asleep.
Every tab, across restarts. Sleep is stored in the database, so every open tab dozes together and a restarted server comes back asleep without starting the daemon.
Frozen, not fetched. While asleep, the API answers wacli reads with 409 ASLEEP instead of running wacli, so a forgotten tab or a script can't quietly undo sleep. An attachment that wasn't downloaded before sleep stays a placeholder.
It doesn't keep your computer awake. If the machine sleeps, Node sleeps too, and messages that come due go out when it wakes. For sends that must be on time, keep the machine awake (on macOS, caffeinate -i). Nap for hours or days, not weeks: WhatsApp eventually unlinks a device that stays offline.
Cmd+K searches every chat at once with SQLite FTS5 and shows the snippet, sender, chat and time. Click a result to jump to that message.ARCHIVE Mar 14, 2021 · 4,211 MSG), so a thread that stops has an explanation, not just an ending.wacli history backfill to ask your phone for more once the local archive runs out. It writes to the store, so safe mode refuses it.,) shows the wacli binary and version, daemon state, PID and heartbeat, pairing and linked JID, FTS5 availability and store counts, plus Restart Daemon and the notifications switch.?) explains each pane, safe mode, and where history stops, and lists every shortcut. The shortcut table is rendered from the same catalogue the key handler uses (shortcuts.ts), so the docs can't drift away from the bindings.Press ? in the app to see this table in context. You can drive the whole console without touching the mouse, and your wrist will thank you.
These use a modifier, so they work even mid-sentence in the composer. Cmd on macOS, Ctrl elsewhere.
| Shortcut | Action |
|---|---|
| Cmd + K | Open or close global message search |
| Cmd + ↓ / Cmd + ↑ | Next / previous chat in the rail |
| Cmd + U | Attach a file to this message |
| Enter | Send (opens the confirmation step) |
| Shift + Enter | New line in the composer |
| Cmd + Enter | Send later: schedule this message |
| Cmd + Shift + L | Switch between SAFE (read-only) and LIVE sending (asks first) |
| Esc | Close a dialog, clear the reply pill, or step out of the composer |
Clicking a chat puts the caret in the composer. Press Esc to step out and these single keys come alive; C drops you back in.
| Shortcut | Action |
|---|---|
| ? | Open the help |
| J / ↓ | Next chat |
| K / ↑ | Previous chat |
| 1 … 5 | Rail filter: All · Unread · Pinned · Muted · Archived |
| / | Jump to the chat filter box |
| C | Back into the composer |
| N | Start a new chat |
| , | Settings & diagnostics |
| R | Reply to the newest incoming message |
| I | Chat info: contact, alias and tags |
| E | Export this conversation |
| O | Load older messages |
| G | Jump to the newest message |
flowchart TB
UI["Browser UI<br/>React 19 · Vite 7"]
API["Mission Control API<br/>Express 5 on 127.0.0.1:3002<br/>+ its own SQLite database"]
CLI["wacli … --json<br/>one-shot commands"]
D["wacli sync --follow<br/>supervised daemon"]
WA(("WhatsApp"))
UI <-->|"REST + WebSocket"| API
API -->|"runs"| CLI
API -->|"spawns, restarts, watches"| D
D -->|"HMAC-signed webhooks<br/>+ NDJSON lifecycle events"| API
CLI -.->|"sends and read receipts<br/>handed over .send.sock"| D
D <-->|"linked device"| WA
wacli sync --follow --events and supervises it. The daemon posts message, receipt and chat_presence events to an internal webhook, signed with HMAC-SHA256 using a secret generated fresh on every start./ws. Arriving messages are folded straight into the cached chat rail and thread, so the common case costs no request at all. A slow poll stays on as the safety net for anything the socket misses.wacli … --json commands with WACLI_READONLY=1.wacli send …, which hands the job to the running daemon over the store's .send.sock, so the daemon stays connected throughout. If the daemon isn't connected yet (or your wacli is too old to hand off), Mission Control pauses the daemon for the send and brings it back afterwards. A contact alias or a history backfill always pauses it. A Restart, Sleep or shutdown waits for any send the daemon is carrying to finish first.~/.wacli-mission-control/mission-control.db, built on Node's own node:sqlite and created with mode 0600.Stack
apps/api: Node.js 22.13+, Express 5, ws, multer, node:sqlite, TypeScript.apps/web: React 19, TypeScript, Vite 7, Tailwind CSS 3, TanStack Query 5, Zustand 5, Lucide icons.Mission Control holds a live WhatsApp session and has no login screen. So its whole security model fits in one sentence: the request came from this machine, from a page Mission Control served itself. Everything below exists to keep that sentence true.
127.0.0.1, and the bind address isn't configurable. That's why there's no --host flag. A request whose Host header names some other machine gets 403 FORBIDDEN_HOST, which is what stops DNS-rebinding tricks.403 FORBIDDEN_ORIGIN, even though it's on localhost too. Under npm run dev, the Vite dev server (5174) and preview (4174) are trusted as well. Clients that send no Origin header, such as curl, aren't web pages and are let through.http://wacli-ui:3002? Add 127.0.0.1 wacli-ui to /etc/hosts. Mission Control reads the hosts file at startup and trusts any name it maps to 127.0.0.1. A name that isn't in the file is never trusted, because DNS could answer for it.media/. The media route serves only files under the store's media/ directory, so session.db (your linked device's keys) and wacli.db (the archive) one level up are off limits. A file's type comes from an allowlist of extensions, never from a download's name. SVG is never rendered inline, and anything not on the list is served as opaque bytes.<redacted>.sqlite3 shell can open it only after it stops. To run two consoles, give each its own WACLI_DB_FILE.Mission Control reads a .env file from the directory you start it in (apps/api/.env under npm run dev). Variables already set in your environment win over the file.
| Variable | Default | What it does |
|---|---|---|
PORT | 3002 | Port for the API and the UI it serves |
WACLI_BIN | wacli | Path or command name of the wacli binary |
WACLI_STORE_DIR | ~/.wacli (~/.local/state/wacli on Linux) | wacli's store directory |
WACLI_ACCOUNT | wacli's default | Which account in the store to act as (passed as --account) |
WACLI_DISABLE_SYNC | 0 | 1 runs the API without spawning wacli sync --follow (same as --no-sync) |
WACLI_POST_SEND_WAIT | 500ms | Passed to wacli's --post-send-wait on every send |
WACLI_SEND_SPACING | 1ms | Passed to wacli sync --send-spacing (wacli 0.15.1+). The daemon then drops a queued send or read receipt the console has stopped waiting for, instead of running it late. A range such as 2s-5s also paces sends; empty leaves the flag off |
WACLI_UPDATE_CHECK | 1 | Twice a day, asks GitHub for the latest wacli release and, if yours is older, says so in the log and in a dismissible notice with the upgrade command. It never upgrades anything. 0 skips the request |
WACLI_DB_FILE | ~/.wacli-mission-control/mission-control.db | Where safe mode, the scheduled queue, the activity log, bookmarks, tags and sleep state live |
WACLI_LOG_WEBHOOK_PAYLOADS | 0 |
The bind address is deliberately missing from this table. See Security & privacy.
The UI is just a client of a small local API, so you can script against it too. A browser may call it only from a page Mission Control served; curl and friends (no Origin header) are welcome.
| Method | Endpoint | What it does |
|---|---|---|
GET | /api/health | wacli install and pairing status, daemon state, PID, heartbeat and store lock. ?fresh=1 skips the 10-second wacli doctor cache |
POST | /api/daemon/restart | Restart the sync daemon |
GET | /api/mode | Whether safe read-only mode is on ({ readOnly }) |
POST | /api/mode | Turn safe mode on or off ({ readOnly: boolean }) |
GET | /api/settings | Current mode and session metadata |
GET | /api/sleep | Whether Mission Control is asleep, and since when ({ sleeping, since }) |
POST | /api/sleep | Sleep or wake ({ sleeping: boolean, reason? }); the reason is only logged |
GET | /api/chats | Chats with unread counts and previews (?query=&limit=&unread=&pinned=&muted=&archived=) |
POST | /api/chats/mark-read | Send a read receipt for a chat ({ chat, through? }), stopping at message through on wacli 0.22+; refused in safe mode |
GET | /api/messages | A chat's messages (?chat=<jid>&limit=100, plus before, after, asc); raise limit to page further back |
JSON responses share the shape { success, data, error }. The six send and schedule routes refuse to run without confirm: true. In safe mode, sends, reactions, scheduling, mark-read, alias and backfill all answer 403. A command that can't get wacli's store lock answers 503 with "code": "STORE_LOCKED".
While asleep, a route that reads from wacli answers 409 with "code": "ASLEEP" instead of running it: health, chats, messages, export, coverage, contacts, groups and search. GET /api/media/content serves only what's already on disk. A route that writes through wacli (a send, reaction, mark-read, alias, backfill, media download or daemon restart) wakes the app first.
Connect to ws://127.0.0.1:3002/ws to receive live event frames:
{
"type": "message.new",
"data": {
"msgId": "3EB0...",
"chatJid": "[email protected]",
"senderJid": "[email protected]",
"fromMe": false,
"text": "Hello world!",
"ts": "2026-08-31T17:00:00.000Z"
},
"ts": "2026-08-31T17:00:00.050Z"
}
Event types: message.new, message.receipt, chat.presence, chat.update, scheduled.update, sync.progress, connection.status and sleep.changed.
Events always go to the terminal. Start with LOG=1 to also write apps/api/logs/run-<timestamp>.log. Every line is one event with the same shape: a constant message, then the details as key=value fields.
[2026-09-04T20:20:25.976Z] [INFO] [http] GET /chats status=200 durationMs=155 query="limit=100&archived=false"
[2026-09-04T20:20:23.203Z] [WARN] [process] Sync daemon exited reason="exited with code 1" code=1 signal=null
Which is what makes the file worth grepping. For example, every request that took more than a second:
grep 'durationMs=[0-9]\{4,\}' apps/api/logs/run-*.log
http (one line per API call), api, process (the sync daemon), ws, webhook, send, media and automation.WARN and above go to stderr, the rest to stdout. ERROR means a real failure. A routine outcome, like media that expired on WhatsApp's servers, is DEBUG, not an incident.durationMs. A wacli command over 1s or a request over 1.5s is promoted to WARN on its own, so "it feels slow" becomes a line you can find.LOG_LEVEL=DEBUG adds per-command timings, the wacli invocation behind each failure, and wacli's own stderr. Start here when a read returns something you didn't expect.... (repeated 24x more)) instead of copied down the page. Only WARN and ERROR collapse; routine lines keep every occurrence, because their fields differ.--webhook-secret <redacted>, and message text is redacted as described in Security & privacy.git clone https://github.com/greenido/wacli-ui.git
cd wacli-ui
npm install
npm run dev
npm run dev starts both halves with hot reload: the UI at http://127.0.0.1:5174 (Vite, which proxies /api and /ws to the API) and the API at http://127.0.0.1:3002.
| Command | What it does |
|---|---|
npm run dev | API (tsx watch) and web (Vite) together |
npm run build | Compile the API and build the web app |
npm start | Run the built API, which also serves the built UI on :3002 |
npm run build:start | Build, then start |
npm run preview | Preview the web build with Vite on :4174 |
npm test | Every test in both workspaces (Vitest) |
npm run test -w @wacli/api | Just the API's tests (or -w @wacli/web for the UI's) |
npm run typecheck | TypeScript checks for both workspaces |
npm run lint / npm run lint:fix | ESLint, and its tidy-up mode |
npm run verify | Lint, typecheck and test: the whole preflight |
GitHub Actions runs lint, typecheck, tests and a build on every push and pull request to main. At last count that's 884 tests. Run npm run verify before you push: CI will run it anyway, and CI never gets tired.
apps/api/ Express API: routes, the wacli supervisor, scheduler and WebSocket bridge
apps/web/ The React console
bin/cli.js What `npx wacli-mission-control` runs
docs/ The PRD and design notes
Releases are driven by tags. Push a v* tag and GitHub Actions lints, typechecks, tests and builds, stamps the tag's version into package.json, publishes to npm with provenance, and creates a GitHub Release with the tarball attached.
git tag vX.Y.Z
git push origin vX.Y.Z
No need to bump package.json by hand: the tag is the version. To rehearse first, run the Release & Publish workflow manually with Dry run checked. It builds and packs everything, then stops short of npm.
Is this an official WhatsApp app? No. It's not affiliated with WhatsApp or Meta. It's an unofficial UI for wacli, which is an unofficial client. Two unofficials don't make an official, but they do make a nice console.
Can I open it from my phone, or run it on a server?
No, and that's on purpose. It holds your WhatsApp session and has no login screen, so it listens on 127.0.0.1 and there's no flag to change that.
A scheduled message failed instead of sending. Why? Most likely safe mode was on when it came due. Mission Control would rather fail loudly than send behind your back. Unlock live sends and press RESEND in the LATER queue.
Why won't a second copy start?
Two consoles on one database would each send every scheduled message, and your friends would get every birthday wish twice. Give the second one its own WACLI_DB_FILE.
I bookmarked a message. Why isn't it starred on my phone? Because wacli can read stars but can't set them. The bookmark lives on this machine, and the UI says so.
My laptop slept, and my 9:00 message went out at 9:47.
When the laptop sleeps, Node sleeps with it. On macOS, caffeinate -i keeps it awake for sends that have to be on time.
Does it phone home? No. Mission Control talks to your local wacli, and wacli talks to WhatsApp. The only other request that leaves the machine is your browser fetching the UI's fonts from Google Fonts. Typography is its one vice.
Will it spam my contacts? Only if you unlock live sends, type a message, press Enter, and then confirm the read-back. That's four decisions, which is about three more than most spam gets.
Why "Mission Control"? "WhatsApp Web with a daemon supervisor, a heartbeat monitor, a store-lock gauge and a scheduled-dispatch queue" didn't fit in the tab title.
Built on wacli and whatsmeow, which do the actual talking to WhatsApp.
--message <redacted> or --caption <redacted> wherever a command is logged or quoted in an error, including a failed scheduled message's stored reason and the activity log. Incoming message bodies stay out of the logs unless you set WACLI_LOG_WEBHOOK_PAYLOADS=1. Nothing is written to disk without LOG=1, and run logs older than 3 days are deleted at startup.0600, and entries older than 90 days are deleted at startup.~/.wacli-mission-control/scheduled-files/ (mode 0700) and are deleted once they're done.1 logs full inbound webhook payloads. Off by default, so message bodies and contact details stay out of log files |
LOG | 0 | 1 also writes apps/api/logs/run-<timestamp>.log. Events always go to the terminal |
LOG_LEVEL | INFO | DEBUG, INFO, WARN or ERROR. DEBUG adds per-command timings, subprocess failures and wacli's own stderr |
NO_COLOR | unset | Any value turns off ANSI colour in terminal logs |
STATIC_WEB_DIR | the bundled apps/web/dist | Serve the UI from a different build directory |
VITE_API_URL | the page's own origin | Build time: the API base URL the web app calls |
VITE_WS_URL | ws://<page host>/ws | Build time: the WebSocket URL the web app connects to |
WACLI_SETTINGS_FILE, WACLI_SCHEDULED_FILE, WACLI_BOOKMARKS_FILE, WACLI_TAGS_FILE | ~/.wacli-mission-control/*.json | Migration only. Read once to import a pre-SQLite install, then ignored |
POST |
/api/messages/bookmark |
Add or remove a local bookmark ({ chat, id, bookmarked }) |
GET | /api/messages/export | Export a conversation (?chat=<jid>&limit=1000); answers truncated when the cap was hit |
GET | /api/search | Full-text search (?q=<query>&limit=50, plus chat, before, after, type) |
GET | /api/history/coverage | How far back the local archive reaches (?chat=<jid>, or every chat when omitted) |
POST | /api/history/backfill | Ask your phone for older messages ({ chat, count? }); refused in safe mode |
GET | /api/contacts/show | One contact's metadata and tags (?jid=<jid>) |
POST | /api/contacts/alias | Set or clear wacli's alias ({ jid, alias }); refused in safe mode |
GET | /api/groups | Local group metadata (?query=) |
GET | /api/tags | Every local tag in use, and which chats carry it |
POST | /api/tags | Add or remove a local tag ({ jid, tag, add }); allowed in safe mode |
POST | /api/tags/rename | Rename a tag on every chat ({ from, to }); merges when to already exists |
POST | /api/tags/delete | Remove a tag from every chat ({ tag }) |
POST | /api/send/text | Send a text or a reply ({ to, message, replyTo?, confirm: true }) |
POST | /api/send/file | Send a file (multipart/form-data: file, to, caption?, replyTo?, confirm) |
POST | /api/send/react | React with an emoji ({ to, id, reaction, sender?, confirm: true }) |
POST | /api/send/schedule | Schedule a text ({ to, message, scheduledAt, replyTo?, confirm: true }) |
POST | /api/send/schedule-file | Schedule a file (multipart/form-data, as for /send/file plus scheduledAt) |
GET | /api/send/scheduled | The LATER queue: every pending message, then resolved ones paged (?chat=&limit=&before=) |
DELETE | /api/send/scheduled/:id | Cancel a pending message; 409 when there's nothing left to cancel |
POST | /api/send/scheduled/:id/resend | Resend a failed message now, or at scheduledAt ({ confirm: true, scheduledAt? }) |
POST | /api/send/scheduled/:id/discard | Drop a failed message for good |
GET | /api/activity | The outgoing send log, newest first (?limit=&before=) |
GET | /api/media/content | Stream an attachment (?chat=<jid>&id=<msgId>), downloading it through wacli if it isn't on disk yet. Serves only files under the store's media/ directory |
POST | /api/media/download | Download a message's attachment into the store ({ chat, id }). Answers 410 with "code": "MEDIA_EXPIRED" when WhatsApp no longer has it; { fromPhone: true } then asks the phone (wacli 0.22+, refused in safe mode) |
POST | /internal/wacli/webhook | Where the daemon posts its HMAC-signed events |