diff --git a/readme.md b/readme.md
index 9e6efd7..8d21eb7 100644
--- a/readme.md
+++ b/readme.md
@@ -388,7 +388,7 @@ ndnx wd
```
`dnx` always goes through the SDK. `ndnx` starts the cached AOT binary
-directly — no SDK needed at all. Pin a version (`wd@1.0.0`)
+directly — no SDK after the first download. Pin a version (`wd@1.0.0`)
to skip latest-version lookup.
To install a `wd` command on PATH instead:
diff --git a/src/Inbox/readme.md b/src/Inbox/readme.md
index ff1adce..eb88631 100644
--- a/src/Inbox/readme.md
+++ b/src/Inbox/readme.md
@@ -6,6 +6,192 @@ Apps that only want WhatsApp should PackageReference `WhatsBox` instead — thes
targets are **not** transitive.
+
+**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`](https://www.nuget.org/packages/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`](docs/INBOX.md).
+
+```xml
+
+```
+
+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
+
+`InboxClient` turns unary JSON-RPC methods into `Task` and exposes a
+single-consumer pull stream of typed `InboxEvent`s. 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:
+
+```csharp
+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:
+
+```csharp
+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 and contents
+
+`Events` is a single-consumer `IAsyncEnumerable`. 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`.
+
+## Implementing a box
+
+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`](docs/INBOX.md).
+
+
## Adapter packing