# NIP-17 DMs in `amy` **Status:** plan · **Date:** 2026-04-23 · **Roadmap row:** `cli/ROADMAP.md:55` ("NIP-17 DMs send / list / read") · **Order:** step 5 in `cli/ROADMAP.md:91`. Adds three verbs: ``` amy dm send [--peer ...] amy dm list [--peer ] [--since ] [--limit N] [--timeout SECS] amy dm await --peer --match [--timeout SECS] ``` `` accepts everything `Context.requireUserHex` does: `npub`, `nprofile`, 64-hex, `name@domain.tld`. `--peer` may repeat on `send` to start a multi-recipient chat (NIP-17 supports group DMs); on `list`/`await` it filters by counter-party. --- ## Guiding principles 1. **Thin assembly only.** Every protocol piece — sealing, wrapping, NIP-44 — already lives in `quartz/`. The CLI just orchestrates. 2. **One JSON line out, snake_case keys, exit codes 0/1/2/124.** 3. **Reuse Marmot's gift-wrap plumbing** where it already exists in `Context` (cursor, drain, publish-and-confirm). 4. **No business logic in `cli/`.** The single piece of Android-side code we depend on (`filterGiftWrapsToPubkey`) is extracted into `commons/` first, in its own commit. --- ## API surface we reuse (no changes needed) | Concern | Class | File | |---|---|---| | Build `kind:14` chat message template | `ChatMessageEvent.build(msg, to, …)` | `quartz/.../nip17Dm/messages/ChatMessageEvent.kt:51` | | Sign + seal + wrap (one wrap per recipient incl. self) | `NIP17Factory().createMessageNIP17(template, signer)` | `quartz/.../nip17Dm/NIP17Factory.kt:37` | | `GiftWrapEvent.create / unwrapOrNull / recipientPubKey` | `GiftWrapEvent` (kind 1059) | `quartz/.../nip59Giftwrap/wraps/GiftWrapEvent.kt` | | Inner seal layer (kind 13) | `SealedRumorEvent` | `quartz/.../nip59Giftwrap/seals/SealedRumorEvent.kt` | | NIP-44 v2 encrypt/decrypt (called from wrap/seal) | `Nip44` singleton | `quartz/.../nip44Encryption/Nip44.kt` | | Resolve recipient relay lists (10050 / 10051 / 10002) | `RecipientRelayFetcher.fetchRelayLists` | `quartz/.../marmot/RecipientRelayFetcher.kt:80` | | Publish + per-relay ACK | `Context.publish(event, relays)` | `cli/.../Context.kt:154` | | Drain a subscription until EOSE/timeout | `Context.drain(filters, timeoutMs)` | `cli/.../Context.kt:168` | | Resolve identifiers | `Context.requireUserHex(input)` | `cli/.../Context.kt:117` | | In-memory signer (NIP-44 ops included) | `NostrSignerInternal` (`Context.signer`) | `cli/.../Context.kt:68` | Everything above is already on the JVM classpath of the `cli` module. --- ## Extraction matrix | Piece | Status | Current | Target | Notes | |---|---|---|---|---| | `NIP17Factory`, `GiftWrapEvent`, `SealedRumorEvent`, `Nip44`, `ChatMessageEvent` | ✅ Reuse | `quartz/` | — | Pure protocol; CLI calls directly. | | `RecipientRelayFetcher.fetchRelayLists` | ✅ Reuse | `quartz/.../marmot/` | — | Already used by `AwaitCommands.awaitKeyPackage`; same call shape. | | `filterGiftWrapsToPubkey` | 📦 Extract | `amethyst/.../service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt:31` | `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt` | Pure function, no Android deps. Update Android caller (`AccountGiftWrapsEoseManager.kt:55`) in the same commit. | | Per-recipient relay routing (kind:10050 → 10002 read → bootstrap) | 🆕 New (in CLI) | inline inside `Account.computeRelayListToBroadcast` (`amethyst/.../model/Account.kt:908`) | `cli/.../commands/DmCommands.kt` (private helper) | Tiny — three-line fallback. Promoting it to commons can wait until a second consumer appears. | | Send command | 🆕 New | — | `cli/.../commands/DmCommands.kt :: send()` | | | List command | 🆕 New | — | `cli/.../commands/DmCommands.kt :: list()` | | | Await command | 🆕 New | — | `cli/.../commands/DmCommands.kt :: await()` | | | Cursor on disk | ✅ Reuse | `RunState.giftWrapSince` (already used by Marmot `Context.syncIncoming`) | — | Don't fork. DM drain advances the same cursor only when used by `dm list` without `--peer/--since`. | | Routing in `Main.kt` / `Commands.kt` | 🆕 New | — | `Main.kt` dispatch + `Commands.dm(...)` | One new branch each. | | Account-bootstrap of own DM inbox (kind:10050) | ⚠️ Not in this plan | `amethyst/.../model/AccountSettings.kt:668` | future `amy relay publish-lists --dm` | Out of scope; we read existing inbox from disk and warn if missing. | **Rule check (from `amy-expert`):** - *Rule 1 (thin)*: ✅ — only one new file in `cli/commands/`, < 200 lines. - *Rule 2 (JSON)*: ✅ — see Output Contract below. - *Rule 3 (non-interactive)*: ✅ — `--timeout` on the wait verb, no prompts. - *Rule 4 (data-dir is the world)*: ✅ — relays + signer + cursor all reload from `--data-dir`. - *Rule 5 (extract first)*: applies once, to `filterGiftWrapsToPubkey` (separate commit). --- ## Send flow (`amy dm send`) ``` parse argv → Context.open → ctx.prepare() ↓ recipients = (positional + --peer) → requireUserHex each ↓ template = ChatMessageEvent.build(text, recipients.map { PTag(it) }) ↓ result = NIP17Factory().createMessageNIP17(template, ctx.signer) ─ signs the inner kind:14 ─ produces one GiftWrapEvent (1059) per recipient INCLUDING self ↓ for each wrap in result.wraps: relayList = recipientDmRelays(wrap.recipientPubKey()) ─ 1. RecipientRelayFetcher.fetchRelayLists(client, recipient, ctx.bootstrapRelays()) ─ 2. .dmInboxOrFallback() (kind:10050 → kind:10002 read marker) ─ 3. fallback to ctx.bootstrapRelays() if both empty ack = ctx.publish(wrap, relayList) record { recipient, wrap_id, published_to: ack.true-keys } ↓ emit one JSON line: { "event_id": result.msg.id, # inner kind:14 id (stable identity of the message) "kind": 14, "recipients": [ {"pubkey":…, "wrap_id":…, "published_to":[…], "relays_tried":[…]} … ] } ``` Notes: - `result.wraps` already includes a self-addressed wrap; we publish it the same way as the others so `amy dm list` works as a sanity check immediately after sending. - We do NOT add the wrap to any local cache — there is no `LocalCache` in the CLI; on next `amy dm list` we re-pull from relays. - If `recipientDmRelays` returns empty for a recipient, exit code `1` with `{"error":"no_dm_relays","detail":""}`. Do not silently drop. --- ## List flow (`amy dm list`) ``` parse argv → Context.open → ctx.prepare() ↓ inbox = ctx.relays.normalized("inbox") .ifEmpty { ctx.outboxRelays() } .ifEmpty { ctx.bootstrapRelays() } ↓ since = --since OR (cursor advance mode: state.giftWrapSince OR null) filters = inbox.flatMap { filterGiftWrapsToPubkey(it, ctx.signer.pubKey, since) } .groupBy { it.relay }.mapValues { it.value.map(RelayBasedFilter::filter) } ↓ raw = ctx.drain(filters, timeoutMs = --timeout * 1000 ?: 8_000) ↓ messages = raw.mapNotNull { (relay, event) -> if (event !is GiftWrapEvent) return@mapNotNull null val sealed = event.unwrapOrNull(ctx.signer) as? SealedRumorEvent ?: return@mapNotNull null val inner = sealed.unsealOrNull(ctx.signer) as? ChatMessageEvent ?: return@mapNotNull null DecryptedMsg(inner, relay, event.id) }.dedupBy { it.inner.id } .filter { peerHex == null || peerHex in it.inner.groupMembers() } .sortedBy { it.inner.createdAt } .takeLast(limit) ↓ emit: { "peer": , "messages": [ { "id": , "wrap_id": <1059 id>, "from": , "to": [...], "content": , "created_at": <unix>, "relay": <where wrap was found> }, … ] } ``` Cursor handling: only when `--since` and `--peer` are both absent do we advance `state.giftWrapSince` to `now()` after a successful drain. Otherwise the call is a stateless query (parity with how Marmot reads behave). `GIFT_WRAP_LOOKBACK_SECS` (already defined in `Context.kt`) is applied automatically by `filterGiftWrapsToPubkey` (it does `since - twoDays()` itself). --- ## Await flow (`amy dm await`) Mirror `AwaitCommands.awaitMessage` line-for-line (`cli/.../commands/AwaitCommands.kt`). Difference: matches against the unwrapped `ChatMessageEvent.content` instead of a Marmot inner event. Polls every 2 s, decrypts what arrived, returns the first match or exits `124`. ```json { "id": "<kind14 id>", "from": "<pubkey>", "content": "<text>", "created_at": <unix>, "wrap_id": "<1059 id>", "relay": "<url>" } ``` --- ## Output contract (stable JSON keys) | Verb | Success keys | |---|---| | `dm send` | `event_id` (kind 14), `kind`, `recipients[]{pubkey, wrap_id, published_to[], relays_tried[]}` | | `dm list` | `peer` (hex or null), `messages[]{id, wrap_id, from, to[], content, created_at, relay}` | | `dm await` | `id, from, content, created_at, wrap_id, relay` | Errors (stderr, single-line): - `bad_args` — missing positional / unparseable `npub` - `no_dm_relays` — recipient has no kind:10050 and no NIP-65 read - `no_inbox_relays` — local user has no inbox configured AND no bootstrap - `runtime` — anything else from `Context`/quartz - `timeout` (124) — only from `dm await` Adding a new key later is non-breaking; renaming/removing requires a note in the commit message. --- ## File layout after the change ``` cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/ ├── Main.kt # + "dm" branch → Commands.dm(…) ├── commands/ │ ├── Commands.kt # + suspend fun dm(dataDir, tail) │ └── DmCommands.kt # NEW: object DmCommands { dispatch, send, list, await } └── (nothing else) commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/ └── FilterGiftWrapsToPubkey.kt # NEW (extracted from amethyst/) amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/ ├── AccountGiftWrapsEoseManager.kt # import-only update to point at commons └── FilterGiftWrapsToPubkey.kt # DELETED in the same commit ``` `README.md` table + `ROADMAP.md` row update in the same PR. --- ## Implementation sequence Each bullet = one commit. 1. **Extract** `filterGiftWrapsToPubkey` from `amethyst/` to `commons/.../relayClient/nip17Dm/` and re-point the Android caller. Run `./gradlew :amethyst:compileDebugKotlin :commons:build`. 2. **Add `amy dm send`** — wires `NIP17Factory` + per-recipient relay resolution + publish. Manual smoke test: send to self, then to a second test identity in a separate `--data-dir`. 3. **Add `amy dm list`** — drain + unwrap + JSON. Smoke test reads back the message published in step 2. 4. **Add `amy dm await`** — polling variant, copy of `AwaitCommands.awaitMessage` shape. 5. **Docs** — add three rows to `cli/README.md` command table; flip `cli/ROADMAP.md:55` from 🆕 → ✅; record the JSON shape in the README's "JSON output" section. 6. **`./gradlew spotlessApply`** before each commit. --- ## Open questions / non-goals - **Publishing our own kind:10050.** Out of scope; assume the user has one already (created via Amethyst, or via a future `amy relay publish-lists --dm`). If missing we still send (using bootstrap as fallback) but do not auto-publish a list. - **Reactions / replies / encrypted file attachments (kind 32 with NIP-17 attachment metadata).** Out of scope — separate plan, if ever. - **NIP-04 legacy DMs (kind 4).** Not supported. NIP-17-only is a deliberate choice consistent with `nostr-expert` guidance. - **Persistent local cache of decrypted messages.** Not in this plan. Each `amy dm list` re-decrypts; identity-equivalence is by inner kind:14 id so it remains idempotent. If this becomes a perf issue we add a `dms/<peer>.log` store mirroring Marmot's pattern — separate commit. - **Live subscription (`--watch`).** Out of scope; CLI is one-shot by Rule 3.