Design doc for `amy dm send|list|await`, reusing the existing Quartz gift-wrap (NIP-17/NIP-59/NIP-44) pipeline. Single file under cli/plans/; extracts FilterGiftWrapsToPubkey from amethyst/ to commons/ as a prerequisite commit so the CLI can depend on it without pulling in :amethyst.
12 KiB
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 <recipient> <text> [--peer <npub>...]
amy dm list [--peer <npub>] [--since <ts>] [--limit N] [--timeout SECS]
amy dm await --peer <npub> --match <text> [--timeout SECS]
<recipient> 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
- Thin assembly only. Every protocol piece — sealing, wrapping,
NIP-44 — already lives in
quartz/. The CLI just orchestrates. - One JSON line out, snake_case keys, exit codes 0/1/2/124.
- Reuse Marmot's gift-wrap plumbing where it already exists in
Context(cursor, drain, publish-and-confirm). - No business logic in
cli/. The single piece of Android-side code we depend on (filterGiftWrapsToPubkey) is extracted intocommons/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): ✅ —
--timeouton 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.wrapsalready includes a self-addressed wrap; we publish it the same way as the others soamy dm listworks as a sanity check immediately after sending.- We do NOT add the wrap to any local cache — there is no
LocalCachein the CLI; on nextamy dm listwe re-pull from relays. - If
recipientDmRelaysreturns empty for a recipient, exit code1with{"error":"no_dm_relays","detail":"<npub>"}. 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": <hex or null>, "messages": [
{ "id": <kind14 id>, "wrap_id": <1059 id>,
"from": <pubkey>, "to": [<pubkey>...],
"content": <plaintext>, "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.
{ "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 / unparseablenpubno_dm_relays— recipient has no kind:10050 and no NIP-65 readno_inbox_relays— local user has no inbox configured AND no bootstrapruntime— anything else fromContext/quartztimeout(124) — only fromdm 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.
- Extract
filterGiftWrapsToPubkeyfromamethyst/tocommons/.../relayClient/nip17Dm/and re-point the Android caller. Run./gradlew :amethyst:compileDebugKotlin :commons:build. - Add
amy dm send— wiresNIP17Factory+ per-recipient relay resolution + publish. Manual smoke test: send to self, then to a second test identity in a separate--data-dir. - Add
amy dm list— drain + unwrap + JSON. Smoke test reads back the message published in step 2. - Add
amy dm await— polling variant, copy ofAwaitCommands.awaitMessageshape. - Docs — add three rows to
cli/README.mdcommand table; flipcli/ROADMAP.md:55from 🆕 → ✅; record the JSON shape in the README's "JSON output" section. ./gradlew spotlessApplybefore 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-expertguidance. - Persistent local cache of decrypted messages. Not in this plan.
Each
amy dm listre-decrypts; identity-equivalence is by inner kind:14 id so it remains idempotent. If this becomes a perf issue we add adms/<peer>.logstore mirroring Marmot's pattern — separate commit. - Live subscription (
--watch). Out of scope; CLI is one-shot by Rule 3.