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.
Inbox Client Protocol (ICP) is a JSON-RPC 2.0 pub/sub bus over stdio for local companion processes. One binary owns one messaging-product session. 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 product CLI.
The Inbox package is the managed
client (InboxClient) for any implementation of that protocol. Spawn a
box, exchange newline-delimited JSON, and keep the process alive for the life
of the session. Product differences are opaque topic strings, $session auth
payloads, and product / identity / capabilities on initialize. There
are no per-product method names.
Wire version "0.1": methods, events (contents[] on chat topics), files,
errors, and capabilities β docs/INBOX.md.
<PackageReference Include="Inbox" Version="*" />Target framework: net10.0. The managed surface is AOT-compatible
(source-generated JSON). Adapters that ship a native sidecar PackageReference
Inbox so pointer +
dotnet pack -r packaging is shared.
InboxClient turns unary JSON-RPC methods into Task<T> and exposes a
single-consumer pull stream of typed InboxEvents. Construct it over an
already-started NDJSON TextReader+TextWriter pair (the boxβs stdout and
stdin). Disposing the client completes Events.
Start consuming Events before (or concurrently with) a connecting
InitializeAsync. Auth events (QR, OAuth, device-code, β¦) arrive on
$session while that call is still waiting.
WhatsBox-shaped illustration β the same types work for any box:
using Inbox;
await using var box = new InboxClient(stdout, stdin);
var pump = Task.Run(async () =>
{
await foreach (var ev in box.Events)
{
switch (ev)
{
case SessionQr qr:
// Product auth: WhatsBox emits qr; other boxes may emit oauth, β¦
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}");
break;
case DirectoryUpsert upsert:
Console.WriteLine($"directory: {upsert.Name ?? upsert.Jid}");
break;
case ChatMessage msg:
Console.WriteLine($"{msg.ByName ?? msg.By}: {msg.Text}");
if (msg.Id is not null)
await box.ReadAsync(msg);
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 = "alice" });
var chat = listed.Items[0].Topic;
await box.SubscribeAsync([chat]);
await box.SendAsync(chat, text: "hello from Inbox");
}
await pump;InitializeAsync(store) is the short form: no files, no extra subscriptions,
no connect. Pass InitializeOptions for blobs, initial topics, or
Connect = true (implicit session.connect).
| Method | RPC | Result |
|---|---|---|
InitializeAsync |
initialize |
SessionSnapshot |
ConnectAsync |
session.connect |
SessionSnapshot |
PairAsync |
session.pair |
SessionSnapshot (PairAsync(me) when capabilities.me is claimed) |
DisconnectAsync |
session.disconnect |
SessionSnapshot |
LogoutAsync |
session.logout |
SessionSnapshot (new) |
StatusAsync |
session.status |
SessionSnapshot |
SubscribeAsync / UnsubscribeAsync |
subscribe / unsubscribe |
TopicsResult (canonical topics) |
ListDirectoryAsync |
directory.list |
DirectoryListResult |
GetDirectoryAsync |
directory.get |
DirectoryRow |
FindDirectoryAsync |
directory.find |
DirectoryListResult |
JoinDirectoryAsync / LeaveDirectoryAsync |
directory.join / directory.leave |
TopicResult |
CreateDirectoryAsync |
directory.create |
TopicResult |
SendAsync / ReactAsync |
messages.send |
SendResult (Id, canonical Topic) |
ReadAsync |
messages.read |
ReadResult |
SessionSnapshot.Status is new (never authenticated), offline (keys on
disk, socket down), or online. Me is the authenticated identity and is
omitted when new.
SubscribeAsync / UnsubscribeAsync take canonical topics only. Resolve
names with ListDirectoryAsync first. Results and event topics are always
canonical once the box knows them.
Send text (sugar), a file under files, a reply, and/or a reaction:
await box.SendAsync(chat, text: "hello");
await box.SendAsync(chat, [new ImagePart { Path = "out/photo.jpg" }]);
await box.SendAsync(chat, text: "agreed",
reply: new MessageReply(id, by));
await box.ReactAsync(chat, target: id, by, "π");by is required on reply, react, and ReadAsync β copy it from the inbound
event. Use "me" when targeting your own message. Mark-read is never
automatic.
RPC failures throw InboxRpcException with the JSON-RPC Code and a stable
Token (not_initialized, files_required, not_found, β¦).
stderr is logs only. It is never protocol.
Events is a single-consumer IAsyncEnumerable<InboxEvent>. Enumerate it
once. It completes when the child stdout ends or the client is disposed.
| Type | Topic | Kind |
|---|---|---|
SessionQr |
$session |
qr β string to render (WhatsBox illustration) |
SessionPaired |
$session |
paired |
SessionPairError |
$session |
pair_error |
SessionOnline / SessionOffline |
$session |
online / offline |
SessionLoggedOut |
$session |
logged_out |
SessionRemap |
$session |
remap β subscription moved to a new canonical topic |
SessionOverflow |
$session |
overflow β per-topic queue dropped oldest |
DirectoryUpsert / DirectoryRemove / DirectoryReady |
$directory |
catalog changes |
ChatMessage |
chat topic | message β Contents (text, media, location, unknown); Text concatenates text parts |
ChatReaction |
chat topic | reaction β one reaction part |
ChatAck |
chat topic | ack β delivered / read / played |
ChatMeta |
chat topic | meta β join/leave/rename/β¦ |
Chat events share Id, By ("me" or an opaque user id), Handle
(@username when known), TopicName, ByName, and Contents. Look up By
(or a 1:1 Topic) with GetDirectoryAsync for extra directory fields.
Content parts: text, image, video, audio, document, sticker,
location, unknown, plus reaction / ack / meta on those kinds.
Blob parts carry a relative Path under initialize.files.
An implementation speaks this envelope on stdio (JSON-RPC 2.0, one object per
line, no batch arrays). Advertise product, identity, and capabilities on
initialize / session.status. Consumers construct InboxClient over that
process β they never type-parse topic / by strings.
Suggested binaries (not normative): whatsbox, discordbox, slackbox,
teamsbox, telegrambox, matrixbox. Full method table, event shapes, and
error tokens: docs/INBOX.md.
The native whatsbox adapter implements Inbox Client Protocol (ICP) for WhatsApp β
one process owns a linked-device session and exposes it on the bus. The
WhatsBox NuGet is the managed
host on top of that native adapter (it is not the protocol itself). Protocol,
events, and InboxClient: the Inbox
package.
The WhatsApp connection is powered by whatsmeow;
clients never talk to whatsmeow directly. WhatsApp-specific mapping (LID
topics, QR pairing, ContextInfo quotes, HistorySync headers,
attachments: "single") is docs/WHATSBOX.md.
WhatsBoxClient is an InboxClient that starts the native whatsbox /
whatsbox.exe sidecar. PackageReference it, then publish for your RID β
the matching native binary is restored and copied next to the app.
<PackageReference Include="WhatsBox" Version="*" />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. Inbox's RID packing targets are not
transitive.
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 sample REPL is a separate tool package (wd, for WhatsBox Demo) with the
same pointer + RID split:
ndnx wdWe recommend using
ndnxfor fastest native-only execution. It's like dnx but native, with no .NET runtime/SDK dependency.
Supported RIDs: win-x64, win-arm64, linux-x64, linux-arm64, osx-x64,
osx-arm64.
new WhatsBoxClient() starts the sidecar from AppContext.BaseDirectory.
Use WhatsBoxClient.Start(baseDirectory) to point at another folder, or
construct from an already-started WhatsBoxHost if you spawn the process
yourself.
Start consuming Events before (or concurrently with) a connecting
InitializeAsync. Pairing QR codes arrive as SessionQr 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 ChatMessage msg:
Console.WriteLine($"{msg.ByName ?? msg.By}: {msg.Text}");
if (msg.Id is not null)
await box.ReadAsync(msg);
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. 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).
There is no default store. One process, one store, one WhatsApp session.
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.
JID or Jabber ID is the canonical identifier for a WhatsApp chat. LID is a logical identifier (like a username) that is stable across devices.
Chat events have no phone number β look up By (or a 1:1 Topic) with
GetDirectoryAsync when you need Pn. In 1:1 the sidecar ignores by on
reply / react / read; in groups every id in that call must share that author.
whatsbox [--store ABSOLUTE_PATH] [--version] [--help]
stdin / stdout is NDJSON JSON-RPC; stderr is logs. LID / QR / store layout:
docs/WHATSBOX.md.
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 contents[],
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.
wd is a pointer tool: it restores the matching RID package
(wd.win-x64, wd.linux-x64, β¦) and starts the Native AOT REPL plus
the whatsbox sidecar.
Run it with dnx
(SDK 10+) or the faster native-only ndnx:
dnx wd
ndnx wddnx always goes through the SDK. ndnx starts the cached AOT binary
directly β no SDK needed at all. Pin a version (wd@1.0.0)
to skip latest-version lookup.
To install a wd command on PATH instead:
dotnet tool install -g wd
wdThe command is wd. RID matrix matches WhatsBox: win-x64, win-arm64,
linux-x64, linux-arm64, osx-x64, osx-arm64.
The working directory is the session root. First run creates .store,
prints a pairing QR, and waits for WhatsApp β Linked devices. Later runs
reuse that store.
