To ensure the long-term sustainability of this project, users of this package who generate revenue must pay an Open Source Maintenance Fee. While the source code is freely available under the terms of the License, this package and other aspects of the project require adherence to the Maintenance Fee.
To pay the Maintenance Fee, become a Sponsor at the proper OSMF tier. A single fee covers all of Devlooped packages.
whatsbox is a local WhatsApp companion: one process owns a linked-device session and exposes it as a JSON-RPC 2.0 pub/sub bus over stdio. Clients subscribe to chats (and two system topics), send a small set of actions, and receive live events. It is not an archive, a search engine, or a WhatsApp CLI.
The WhatsApp connection is powered by whatsmeow.
Use the managed client from .NET, or speak the native protocol from any language that can spawn a process and exchange newline-delimited JSON.
The WhatsBox package is a typed
.NET host for the native whatsbox sidecar. PackageReference it, then
publish for your RID — the matching native binary is restored and copied next
to the app.
<PackageReference Include="WhatsBox" Version="*" />Target framework: net10.0. The managed surface is AOT-compatible (source-generated JSON).
WhatsBox is a pointer package: it ships WhatsBox.dll plus a
runtime.json that maps each runtime identifier to a RID-only package.
| Package | Contents |
|---|---|
WhatsBox |
Managed API (WhatsBox.dll) and runtime.json |
WhatsBox.win-x64 / .win-arm64 / .linux-x64 / .linux-arm64 / .osx-x64 / .osx-arm64 |
Native whatsbox / whatsbox.exe under runtimes/{rid}/native/ |
You only reference WhatsBox. Restore and dotnet publish -r <rid> pull the
matching WhatsBox.{rid} package automatically. The sidecar lands next to the
app (AppContext.BaseDirectory); WhatsBoxClient starts it from there — never
from the current working directory.
dotnet add package WhatsBox
dotnet publish -c Release -r win-x64Do not add WhatsBox.win-x64 (or any other RID package) by hand. Do not treat
this as a .NET tool (PackAsTool); it is a PackageReference library plus a
native asset.
The companion REPL is a separate tool package (wd) with the
same pointer + RID split:
dotnet tool install -g wd
wdSupported RIDs: win-x64, win-arm64, linux-x64, linux-arm64, osx-x64,
osx-arm64.
WhatsBoxClient owns the child process, turns unary JSON-RPC methods into
Task<T>, and exposes a single-consumer pull stream of typed events.
Disposing the client stops the sidecar.
Start consuming Events before (or concurrently with) a connecting
InitializeAsync. Pairing QR codes arrive as events while that call is still
waiting for a scan.
using WhatsBox;
var store = Path.GetFullPath("whatsbox-store");
var files = Path.GetFullPath("whatsbox-files");
Directory.CreateDirectory(store);
Directory.CreateDirectory(files);
await using var box = new WhatsBoxClient();
var pump = Task.Run(async () =>
{
await foreach (var ev in box.Events)
{
switch (ev)
{
case SessionQr qr:
// Render qr.Code as a QR image and scan it in WhatsApp → Linked devices.
Console.WriteLine(qr.Code);
break;
case SessionOnline online:
Console.WriteLine($"online as {online.Me}");
break;
case SessionPairError err:
Console.Error.WriteLine(err.Message);
break;
case DirectoryReady:
var page = await box.ListDirectoryAsync(new DirectoryListOptions { Kind = "user" });
foreach (var row in page.Items)
Console.WriteLine($"{row.Name ?? row.Topic} {row.Pn}");
break;
case DirectoryUpsert upsert:
Console.WriteLine($"directory: {upsert.Name ?? upsert.Jid}");
break;
case ChatText text:
Console.WriteLine($"{text.ByName ?? text.By}: {text.Text}");
if (text.Id is { } id)
await box.ReadAsync(text.Topic, [id], text.By);
break;
case ChatMedia media:
Console.WriteLine($"{media.Kind} {media.Path ?? media.Error}");
break;
}
}
});
var session = await box.InitializeAsync(new InitializeOptions
{
Store = store,
Files = files,
Subscribe = ["$directory"],
Connect = true,
});
if (session.Status == "online")
{
var listed = await box.ListDirectoryAsync(new DirectoryListOptions { Query = "+15551234567" });
var chat = listed.Items[0].Topic;
await box.SubscribeAsync([chat]);
await box.SendAsync(chat, text: "hello from whatsbox");
}
await pump;InitializeAsync(store) is the short form: no files, no extra subscriptions,
no connect. The linked-device name defaults to whatsbox on {machine}. Pass
InitializeOptions when you want blobs, initial topics, a custom
DeviceName, or Connect = true (implicit session.connect, and implicit
QR pairing when the store is new).
| Method | RPC | Result |
|---|---|---|
InitializeAsync |
initialize |
SessionSnapshot |
ConnectAsync |
session.connect |
SessionSnapshot |
PairAsync |
session.pair |
SessionSnapshot |
DisconnectAsync |
session.disconnect |
SessionSnapshot |
LogoutAsync |
session.logout |
SessionSnapshot (new) |
StatusAsync |
session.status |
SessionSnapshot |
SubscribeAsync / UnsubscribeAsync |
subscribe / unsubscribe |
TopicsResult (canonical JIDs) |
ListDirectoryAsync |
directory.list |
DirectoryListResult |
GetDirectoryAsync |
directory.get |
DirectoryRow |
SendAsync |
messages.send |
SendResult (Id, canonical Topic) |
ReadAsync |
messages.read |
ReadResult |
SessionSnapshot.Status is new (never paired), offline (keys on disk,
socket down), or online. Me is the paired LID and is omitted when new.
SubscribeAsync / UnsubscribeAsync take canonical JIDs (LID, group, or
PN JID). Resolve names and phone numbers with ListDirectoryAsync first.
SendAsync, ReadAsync, and GetDirectoryAsync still accept a LID, a
phone-number JID, or a phone number (+15551234567 or digits). Results and
event topics are always canonical (LID or group JID) once a LID is known.
Send text, a file under files, a reply, and/or a reaction:
await box.SendAsync(chat, text: "hello");
await box.SendAsync(chat, path: "out/photo.jpg");
await box.SendAsync(chat, text: "agreed",
reply: new MessageReply(id, by));
await box.SendAsync(chat,
react: new MessageReact(id, by, "👍"));by is required on reply and react — copy it from the inbound event. Use
"me" when targeting your own message. ReadAsync needs by only in
groups (and every id in that call must share that author). Mark-read is
never automatic.
RPC failures throw WhatsRpcException with the JSON-RPC Code and a stable
Token (not_initialized, files_required, not_found, …).
Events is a single-consumer IAsyncEnumerable<WhatsEvent>. Enumerate it
once. It completes when the child stdout ends or the client is disposed.
| Type | Topic | Kind |
|---|---|---|
SessionQr |
$session |
qr — string to render as QR |
SessionPaired |
$session |
paired |
SessionPairError |
$session |
pair_error |
SessionOnline / SessionOffline |
$session |
online / offline |
SessionLoggedOut |
$session |
logged_out |
SessionRemap |
$session |
remap — subscription moved PN → LID |
SessionOverflow |
$session |
overflow — per-topic queue dropped oldest |
DirectoryUpsert / DirectoryRemove / DirectoryReady |
$directory |
catalog changes |
ChatText |
chat JID | text |
ChatImage ChatVideo ChatAudio ChatDocument ChatSticker |
chat JID | media; Path is relative to files |
ChatLocation |
chat JID | location |
ChatReaction |
chat JID | reaction |
ChatAck |
chat JID | ack — delivered / read / played |
ChatMeta |
chat JID | meta — join/leave/rename/… |
ChatUnknown |
chat JID | anything else (polls, view-once, …); no blob |
Chat events share Id, By ("me" or a LID), Handle (@username when
known), TopicName, and ByName. There is no phone number on chat events —
look up By (or a 1:1 Topic) with GetDirectoryAsync when you need Pn.
new WhatsBoxClient() starts whatsbox / whatsbox.exe from
AppContext.BaseDirectory. Use WhatsBoxClient.Start(baseDirectory) to
point at another folder, or construct from an already-started WhatsBoxHost
/ a raw NDJSON TextReader+TextWriter pair if you spawn the process
yourself.
stderr is logs only (Debug.WriteLine when the client owns the host). It is
never protocol.
The whatsbox binary is a local companion process. Spawn it, speak
JSON-RPC 2.0 on stdio, and keep the process alive for the life of the
session. The WhatsApp Web socket behind it is
whatsmeow; clients never talk to
whatsmeow directly.
whatsbox [--store ABSOLUTE_PATH] [--version] [--help]
- stdin / stdout: JSON-RPC only. One compact JSON object per line (NDJSON).
- stderr: logs. Never treat stderr as protocol or as “the command failed.”
--version/--helpprint and exit (no RPC).- There is no default store. Pass
--storeand/orinitialize.store. - One process, one store, one WhatsApp session. A second process on the same
store fails with
store_locked. - stdin EOF disconnects WhatsApp and exits.
Pairing keys stay on disk across restarts until session.logout. Live
messages missed while the process is down are gone (at-most-once). WhatsApp
may deliver a short offline catch-up on the next connect; that is applied
only to current subscriptions.
session.logout unlinks the device, wipes session identity and the local
directory, and returns status new.
Request
{"jsonrpc":"2.0","id":"1","method":"initialize","params":{}}Result / error
{"jsonrpc":"2.0","id":"1","result":{}}
{"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"store_required"}}Notification (server → client only). All live traffic uses method
event; params.topic is always present; params.kind discriminates the
payload.
{"jsonrpc":"2.0","method":"event","params":{"topic":"$session","kind":"qr","code":"2@..."}}Do not speak events before a successful initialize. Protocol version is
"0.1" (initialize.params.version). An unsupported version is rejected
with the versions the process does support.
| Entity | Canonical topic |
|---|---|
| 1:1 user | LID JID, e.g. 123456789012345@lid (primary key) |
| Group | 120363…@g.us |
| System | $session, $directory ($ is reserved) |
Phone-number JIDs (15551234567@s.whatsapp.net) are a mutable label
(pn), like a display name. They are not topics once a LID is known.
subscribe / unsubscribe accept canonical JIDs only (LID, group, PN JID,
or $directory). Names, handles, and phone numbers are resolved by the
client via directory.list. messages.send.to, messages.read.to, and
directory.get id still accept LID, PN JID, or a phone number
(+15551234567 or digits). The process normalizes those. The wire result /
topic is always canonical once a LID is known. Unknown phone on
send/read/get → error (no ghost topic). Groups cannot be addressed by phone.
When a LID↔PN mapping appears later, the process upserts $directory, moves
any PN subscription to the LID, and emits $session {kind:"remap", from, to}.
by is the author of the original message, not the logged-in user
sending the RPC:
| Value | Meaning |
|---|---|
| a JID | That user (normalized to LID when known) |
"me" |
The paired account’s LID |
Reply and react always require by (copy it from the inbound event; use
"me" for your own message). messages.read requires by in groups and
omits it in 1:1.
initialize must be first. A second initialize is an error.
{
"version": "0.1",
"store": "/data/whatsbox",
"files": "/data/wa-files",
"subscribe": ["$directory", "123…@lid"],
"verbosity": "info",
"connect": true,
"deviceName": "whatsbox on DESKTOP-ADA"
}| Field | Required | Description |
|---|---|---|
version |
yes | "0.1" |
store |
if --store omitted |
Absolute store path |
files |
no | Absolute blob directory. Missing → text-only |
subscribe |
no | Initial topics, applied before any event is eligible. $session is implicit |
verbosity |
no | stderr: error | warn | info (default) | debug |
connect |
no | If true, implicit session.connect after subscriptions. Default false |
deviceName |
no | Linked-device name after pairing. Omitted or blank → whatsbox on {hostname} |
Store resolution: --store or initialize.store is enough; both set to
different paths → store_mismatch; neither → store_required. The
directory is created if missing.
connect: true on a never-paired store starts QR pairing and the call lasts
until the scan succeeds (or fails). Watch $session qr events and render
the latest code.
| Method | In | Out |
|---|---|---|
initialize |
see above | status snapshot (connect:true ⇒ session.connect) |
session.connect |
— | status (new ⇒ implicit pair) |
session.pair |
— | status (no-op if already linked) |
session.disconnect |
— | status |
session.logout |
— | status new |
session.status |
— | {me?, status, topics} |
subscribe |
{topics} |
{topics} canonical |
unsubscribe |
{topics} |
{topics} remaining |
directory.list |
{query?, kind?, limit?, cursor?} |
{items, cursor?} |
directory.get |
{id, icon?} |
one directory row (participants on groups; icon when fetched) |
messages.send |
{to, text?, path?, reply?, react?} |
{id, topic} |
messages.read |
{to, ids, by?} |
{topic} |
Status: new (no me) · offline (keys, socket down) · online.
topics always includes $session. Subscriptions persist across reconnects.
session.pair: already paired → no-op. QR codes rotate as $session
{kind:"qr", code}. Success emits {kind:"paired"} and ends connected.
There is no pair-code / phone path. Passkey / WebAuthn → {kind:"pair_error"}
and the RPC fails.
subscribe / unsubscribe: unknown entries fail the whole call (no
partial apply). $session cannot be unsubscribed. Late subscribe has no
replay. Result is canonical topics only — call directory.get for names.
directory.list: optional query (name, pn, handle, JID), kind
(user | group), limit (max 100), cursor. No sort-by-last-message
(bodies are not stored). cursor omitted/empty means last page.
directory.get: icon omitted defaults to whether files was set.
icon: true without files → files_required. list / $directory
upsert never carry icon.
messages.send: at least one of text, path, react. path is
relative under files. reply is {id, by}. react is {id, by, emoji}
(emoji "" removes the reaction). Result is {id, topic} after
normalization. If the client is subscribed to that topic, a normal inbound
event with by: "me" is also emitted.
messages.read: WhatsApp read receipts (blue ticks). Never automatic.
by required for groups.
Every notification:
{"jsonrpc":"2.0","method":"event","params":{"topic":"…","kind":"…"}}$session (always subscribed)
kind |
Payload | When |
|---|---|---|
qr |
code |
Pairing; render the latest |
paired |
me |
Pair success |
pair_error |
message |
Pair failed / passkey required |
online |
me |
Socket up (including after reconnect) |
offline |
reason? |
Socket down; will reconnect unless disconnect / logout / EOF |
logged_out |
reason? |
Session revoked remotely; local status becomes new |
remap |
from, to |
Subscription moved PN → LID |
overflow |
queue, dropped |
Per-topic in-memory queue overflow (oldest dropped) |
$directory
kind |
Payload |
|---|---|
upsert |
Directory row. Event envelope already uses topic/kind, so the row id is jid and the entity type is entityKind (user | group). No participants — use directory.get |
remove |
jid (canonical) |
ready |
{generated} after the first populate wave. Later upserts may still arrive |
Directory populate starts after online and does not block session.connect.
The catalog is metadata only: names, handles, phone labels, mute/pin/archive.
No transcripts, no last-message previews.
Chat topics (bare JID)
kind |
Meaning |
|---|---|
text |
Body in text |
image video audio document sticker |
Media; path if files is set and download succeeded, else error |
location |
lat, lng (optional name / address). No file |
reaction |
emoji, target (id of the reacted-to message), by |
ack |
ids, ack = delivered | read | played |
meta |
Room notice: action (join / leave / promote / demote / rename / topic / icon / …) |
unknown |
Anything else (polls, view-once, buttons, …). No blob. May include a short label |
Common fields on message-like events: id, by ("me" or a LID), handle
(@username when known), topicName (chat display name), byName (author
display name). No pn on chat events.
Unsubscribed chats are acknowledged at the protocol layer and dropped. No event, no download.
Delivery is at-most-once, arrival order per topic. Process restart: no replay;
re-initialize and subscribe / connect. HistorySync message bodies are
never persisted and never emitted.
Set initialize.files to an absolute directory the process may read and write.
| Direction | Path | Who writes |
|---|---|---|
| Inbound media | {files}/in/{safeTopic}/{id}[.ext] |
process, then event |
Inbound icon from directory.get |
{files}/in/_dir/{safeTopic}[.ext] |
process, then the RPC result |
| Outbound | any file under {files} |
client; RPC carries a relative path |
- No
files→ no downloads, no icons, send-with-patherrors. - Unsubscribed inbound media is never downloaded.
- View-once and other
unknownkinds are never written. - The process never deletes. TTL/GC is the client’s.
- Paths that escape
files→path_escape.
Application errors use codes in -32000…-32099. error.message is a
stable token:
| Token | Meaning |
|---|---|
not_initialized |
RPC before initialize |
already_initialized |
Second initialize |
store_required |
No --store and no initialize.store |
store_mismatch |
Both set and not the same path |
store_locked |
Another process holds the store |
unsupported_version |
version is not "0.1" |
not_paired |
Action needs keys |
pair_error |
QR pairing failed / passkey required |
not_found |
directory.get / unknown topic |
invalid_topic |
$ reserved, bad JID, unsubscribe $session |
files_required |
Blob op without files |
path_escape |
path outside files |
invalid_params |
Missing by, empty topics, … |
disconnected |
Needs online (send, read, live resolve) |
Does: pair via QR, connect / auto-reconnect / disconnect / logout,
directory populate + list/get + live $directory, subscribe by JID
(LID-first), live messages / receipts / in-chat meta, send text or file,
reply, react, explicit mark-read.
Does not: message history, search, backfill, export; stored bodies or last-message previews; typing or “available” presence; edit or revoke; pair-code or passkey pairing; channels, status, calls, blocklist or group admin RPCs; MCP / sockets; multi-account in one process; topic wildcards; a default store path.
