Extends Blossom support toward a full client on both the CLI and the mobile app. Quartz (protocol): - BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11 `server` domain scoping on every factory (stops replayable upload/delete tokens) - BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and BUD-07 payment header constants, and a lowercase bare-domain helper - BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08) - BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning) - BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders Commons (shared JVM client, now in jvmAndroid so Android shares it too): - BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05), preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling - BlossomAuth: media/list/delete passthroughs with server scoping CLI (first-class): - amy blossom now routes all HTTP through the shared client and adds `media` and `report` verbs; auth tokens are scoped to --server Android (first-class): - uploads mirror to the user's other Blossom servers (BUD-04) best-effort - new "Manage stored files" screen: per-server presence matrix (BUD-02 list + BUD-01 HEAD), delete, mirror-to-missing, and report actions Tests: quartz URL/auth/descriptor/payment parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Amy — Amethyst CLI
amy is the command-line face of Amethyst.
It speaks the same Nostr protocol as the Android and Desktop apps and shares
their codebase. From a terminal you can post notes, send NIP-17 DMs,
manage MLS group chats, switch identities, and pipe machine-readable JSON
into the rest of your toolbox.
amy is built for three audiences at once: humans at a terminal,
agents/LLMs driving an account through a deterministic JSON interface,
and interop test harnesses pinning Amethyst against the rest of the
Nostr-client ecosystem.
Looking for the architecture and the public-API contract? See DEVELOPMENT.md. For what's coming, see ROADMAP.md.
Install
amy builds from this repository — no package manager yet.
# build the runnable distribution
./gradlew :cli:installDist
# the launch script
./cli/build/install/amy/bin/amy --help
# put it on your PATH if you want
ln -s "$PWD/cli/build/install/amy/bin/amy" ~/.local/bin/amy
Requires JDK 21. All state lives under ~/.amy/ — delete to reset.
Quick start
# 1. Create an account named alice — keypair, default relays, kind:0 metadata,
# everything Amethyst stamps on first run.
amy --account alice create --name "Alice"
# 2. With one account you can drop the flag from now on (auto-pick).
amy whoami
# 3. Post a short note.
amy notes post "hello from amy"
# 4. Send a NIP-17 DM.
amy dm send bob@example.com "hey"
# 5. Read your inbox.
amy dm list
That's the full loop. Add --json to any command if you want a single-line
JSON object instead of human-readable text — same data, machine shape.
Examples
1. Post a note
$ amy notes post "good morning nostr"
event_id: a3c1f9c2…(64 hex)
kind: 1
accepted_by:
- wss://relay.damus.io/
- wss://nos.lol/
rejected_by: (none)
amy notes feed reads recent kind:1 notes from your follows; --limit N
caps the count, --author npub1… narrows to one user.
2. Send a direct message
$ amy dm send npub1uu8m… "lunch friday?"
event_id: 18bd0a7e…
kind: 14
recipients:
- pubkey: e70fb804…
relay_source: kind_10050
relays:
- wss://nostr.wine/
recipients[*].relay_source tells you how amy resolved the recipient's
inbox — kind_10050 is the strict NIP-17 inbox; nip65_read /
bootstrap only fire when you pass --allow-fallback.
3. Read a DM thread
$ amy dm list --peer npub1uu8m… --limit 5
messages:
- event_id: a82f04e1…
author: 71cf3ab2…
type: text
created_at: 2026-04-25 13:42:11Z (8m ago)
content: sounds good
- event_id: 18bd0a7e…
author: e70fb804…
type: text
created_at: 2026-04-25 13:30:02Z (20m ago)
content: lunch friday?
amy dm await --peer NPUB --match TEXT --timeout 60 blocks until a matching
DM arrives — useful in scripts.
4. View a profile
$ amy profile show npub1th9z…
pubkey: 5dca27ae…
found: yes
source: cache
event_id: a041df5a…
created_at: 2026-04-25 13:36:23Z (1h ago)
metadata:
name: Alice
picture: https://example.test/a.png
about: demo identity
nip05: alice@example.test
queried_relays: (none)
source: cache means the local store served the lookup; pass --refresh to
force a relay round-trip. Profiles for name@domain.tld (NIP-05) are
resolved transparently.
5. Create a group, invite someone, send a message
# Mint a group and invite Bob.
GID=$(amy --json marmot group create --name "Lunch Plans" | jq -r .group_id)
amy marmot group add "$GID" npub1...bob
# Send an MLS-encrypted message.
amy marmot message send "$GID" "hello group"
On the other side:
# Bob waits for the invite to land, then sees the message.
amy --account bob marmot await group --name "Lunch Plans" --timeout 60
amy --account bob marmot message list "$GID"
6. Switch between accounts
$ amy whoami
error: bad_args: multiple accounts in /home/me/.amy (alice, bob); pick one with --account <name> or `amy use <name>`
$ amy use bob
current: bob
root: /home/me/.amy
$ amy whoami
name: bob
npub: npub1uu8m…
data_dir: /home/me/.amy/bob
amy use --clear removes the pin; amy --account alice <cmd> overrides
it for one command.
7. Add a relay
$ amy relay add wss://nostr.wine
url: wss://nostr.wine
added_to:
- nip65
- inbox
- key_package
already_present: (none)
$ amy relay search add wss://relay.nostr.band # search-only bucket
$ amy relay blocked set wss://bad.relay # replace the blocked list
$ amy relay outbox add wss://nostr.wine # NIP-65 write relay
$ amy relay publish-lists # broadcast every updated relay list
Commands
Primitives (stateless — no account or network)
Army-knife verbs that operate purely on their arguments. They never touch
~/.amy/, so they run with zero state — handy for scripting and piping
(amy decode … | jq, … | amy verify).
| Command | What it does |
|---|---|
amy decode ENTITY |
Decode a NIP-19/21 entity (npub/nsec/note/nevent/nprofile/naddr/nrelay/nembed) to JSON. Accepts an optional nostr: prefix. |
amy encode npub HEX / nsec HEX / note ID |
Encode a single 32-byte hex value into the matching NIP-19 entity. |
amy encode nevent ID [--author HEX] [--kind N] [--relay URL[,URL…]] |
Encode an event pointer with optional author/kind/relay hints. |
amy encode nprofile HEX [--relay URL[,URL…]] |
Encode a profile pointer with optional relay hints. |
amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] |
Encode an addressable-event (a tag) pointer. |
amy verify [EVENT-JSON] |
Check an event's id hash and signature. Reads stdin when the argument is omitted or -. Reports id_ok + signature_ok separately. |
amy pow check EVENT-JSON|- |
NIP-13 difficulty of a signed event: actual_bits, committed_target, has_commitment, and effective_pow (capped at the commitment so lucky low-target spam doesn't over-count), plus valid (id + signature). |
amy pow mine --target N [--pubkey HEX] [--timeout SECS] [--threads N] TEMPLATE-JSON|- |
Mine an unsigned template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Mines on all cores by default (--threads to override). Exit 124 on timeout. |
amy pow bench |
Benchmark this machine's hash rate — hashes_per_second is the all-cores rate pow mine uses by default, hashes_per_second_single_core the one-thread rate — and print expected mining time at 16/20/24/28 bits. |
amy key generate |
Mint a fresh keypair (nsec + npub + hex). Does not persist — use init/login for that. |
amy key public NSEC|HEX |
Derive the public key from a secret key. |
amy key encrypt NSEC|HEX --password X |
NIP-49 encrypt a secret key to an ncryptsec1…. |
amy key decrypt NCRYPTSEC --password X |
NIP-49 decrypt back to nsec/hex/npub. |
amy key validate NPUB|HEX |
Parse-check a public key. Prints {valid, pubkey, npub} or {valid:false} — never errors, so scripts branch on the field. |
amy filter [filter flags] |
Assemble and print a NIP-01 filter JSON from the same flags fetch/subscribe use — no query is sent. |
amy nip N / amy nip list |
Look up a NIP — the nostr-protocol/nips repo first, then a Nostr wiki/long-form fallback. list fetches the index. |
amy kind N / amy kind NAME |
Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's KindNames registry. |
amy namecoin resolve IDENT [--server HOST:PORT[:tcp][,…]] [--timeout SECS] |
Resolve a Namecoin identifier (.bit, d/, id/, alice@example.bit) to a Nostr pubkey + relays via the Namecoin blockchain. Stateless: talks directly to one or more ElectrumX servers over TLS (:tcp for plaintext), no account needed. Reuses the same NIP-05-Namecoin parser, server set, and pinned trust store as the Android and Desktop apps. |
amy namecoin servers |
Print the default ElectrumX server list (host, port, TLS flag). |
amy relay info URL |
Fetch and print a relay's NIP-11 information document. |
Remote signing (NIP-46 bunker)
Two amy processes can talk: one hosts a bunker with its local key; the other logs in through it and signs remotely (events come out authored by the host's key).
| Command | What it does |
|---|---|
amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS] |
Run a NIP-46 remote signer for the active local-key account. Prints a bunker://… URI, then services sign / nip04 / nip44 / get_public_key / ping requests until interrupted (or --timeout). |
amy login bunker://PUBKEY?relay=…&secret=… |
Log in through a bunker (signer advertises). Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. Percent-encoded relay params are decoded. |
amy bunker connect nostrconnect://… |
Client-initiated (NostrConnect) flow, signer side: ack a client's offer (echo its secret) and service its requests. |
amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS] |
Client-initiated flow, client side: print a nostrconnect:// offer, wait for a signer to connect, then persist a bunker account that acts as the signer's key. |
Interop-tested against the real nak binary:
- bunker:// both directions —
amy login bunker://⇄nak bunker, andnak event --sec bunker://⇄amy bunker. - nostrconnect:// client —
amy login --nostrconnect⇄nak bunker connect(amy signs, event authored by nak's key).
Supports connect (secret-checked), get_public_key, get_relays, sign_event, nip04_encrypt/decrypt, nip44_encrypt/decrypt, ping. When a bunker answers with an auth_url challenge, amy prints the authorization URL to stderr and keeps waiting for the real response (open the URL in a browser to authorize).
Example (two terminals, shared $HOME):
# terminal 1 — host alice's key as a bunker
amy --account alice bunker --relay wss://relay.example --secret s3cret
# → bunker://<alice-pubkey>?relay=wss://relay.example/&secret=s3cret
# terminal 2 — bob signs through it
amy --account bob login 'bunker://<alice-pubkey>?relay=wss://relay.example/&secret=s3cret'
amy --account bob event --kind 1 --content "signed remotely" # authored by alice
Raw events
| Command | What it does |
|---|---|
amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS] |
Build + sign an arbitrary event with the active account. Prints the signed event. --tags is a JSON array-of-arrays, e.g. '[["t","nostr"],["e","<id>"]]'. |
amy event … --publish / --relay URL[,URL…] |
As above, then broadcast (to the outbox, or to the given relays). |
amy publish [EVENT-JSON] [--relay URL[,URL…]] |
Broadcast a pre-made signed event (verified first). Reads stdin when the argument is omitted or -. |
Queries
Filter flags are shared by fetch and subscribe: --kind K[,K], --author U[,U] (npub/nprofile/hex), --id ID[,ID] (note/nevent/naddr/hex), --tag e=ID,p=PK,t=hashtag, --since TS, --until TS, --limit N, --search TEXT, --relay URL[,URL…]. Relays default to your outbox, then the bootstrap set.
| Command | What it does |
|---|---|
amy fetch [filter flags] [--timeout SECS] |
One-shot query — collect until every relay sends EOSE (or --timeout, default 8s), dedupe, sort newest-first, print and exit. --limit defaults to 100. |
amy fetch CODE [--timeout SECS] |
Code mode — pass a single nevent/naddr/nprofile/npub/note or name@domain. Resolves relays the outbox way: the hints embedded in the code plus the author's NIP-65 write relays (draining their kind:10002 on a cache miss), exactly how the app opens a shared link. |
amy subscribe [filter flags] [--timeout SECS] |
Live stream — print each matching event as it arrives (NDJSON under --json). Runs until --timeout SECS or until interrupted. |
amy count [filter flags] [--timeout SECS] |
NIP-45 COUNT — per-relay match counts, no event download. |
amy outbox USER [--refresh] [--timeout SECS] |
Show USER's NIP-65 read/write relays (outbox model). Cache-first; --refresh forces a relay drain. |
amy sync --relay URL [filter flags] [--down] [--up] |
NIP-77 Negentropy reconcile between the local store and a relay. --down (default) pulls events we lack; --up pushes events the relay lacks; both for bidirectional. |
Encryption
| Command | What it does |
|---|---|
amy encrypt --to USER [TEXT] [--nip04] |
NIP-44 (default) or NIP-04 encrypt with the active account's key. Reads stdin when TEXT is omitted or -. USER accepts npub/nprofile/hex/NIP-05. |
amy decrypt --from USER [CIPHERTEXT] [--nip04] |
Inverse of encrypt. |
amy gift wrap --to USER [EVENT-JSON] [--relay …] |
NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; --relay also broadcasts it. |
amy gift unwrap [GIFTWRAP-JSON] |
Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. |
Git (NIP-34)
nak's clone/push/pull (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events.
| Command | What it does |
|---|---|
amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--d ID] |
Publish a kind:30617 repository announcement. |
amy git list [USER] |
List a user's repo announcements (defaults to self). |
amy git show NADDR|kind:pubkey:id |
Print one repo announcement (cache-first). |
amy git issue NADDR|coords --subject S [BODY] [--hashtag T[,T]] |
Publish a kind:1621 issue against a repo. BODY from arg or stdin. |
Podcasts (NIP-F4)
| Command | What it does |
|---|---|
amy podcast metadata --title T --image URL --description D [--website URL[,URL]] |
Publish kind:10154 show metadata (replaceable). |
amy podcast publish --title T --description D --audio URL[,URL] [--audio-type MIME] [--image URL] [--content MD] |
Publish a kind:54 episode. |
amy podcast list [USER] [--limit N] |
List a user's show metadata + episodes. |
Blossom blobs (NIP-B7)
| Command | What it does |
|---|---|
amy blossom upload --server URL FILE [--mime-type M] |
Upload a file (BUD-01, authed). Prints the blob URL + sha256. |
amy blossom download URL [--out FILE] |
Download a blob (public). Accepts a full URL, or a HASH plus --server URL. |
amy blossom list --server URL [USER] |
List a user's blobs (BUD-04). USER defaults to the active account. |
amy blossom delete HASH --server URL |
Delete a blob you own (BUD-02). |
amy blossom check --server URL HASH[,HASH] |
HEAD-check the server has each blob; exit 1 if any is missing. |
amy blossom mirror --server URL SOURCE-URL |
Ask the server to mirror a blob from SOURCE-URL (BUD-04). |
Cashu wallet (NIP-60 / NIP-61)
A NIP-60 ecash wallet + NIP-61 nutzaps, driven by the same shared
commons CashuWalletOps / CashuWalletReader the Android wallet runs — so
amy's on-relay events match the app's. NUT-13 counters persist in
~/.amy/<account>/cashu.json. mint ping/info are stateless (no account).
| Command | What it does |
|---|---|
amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2] |
Publish a kind:17375 wallet + kind:10019 nutzap info. Advertises your outbox relays for nutzaps unless --relay overrides. |
amy cashu wallet show |
P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. |
amy cashu wallet export-key |
Decrypt and print the wallet's P2PK private key. |
amy cashu wallet destroy |
Withdraw the nutzap advertisement and NIP-09 delete the wallet (leaves token events — the ecash still lives at the mint). |
amy cashu balance [--mint URL] |
Spendable balance from the local store (optionally one mint). |
amy cashu mint ping URL / info URL |
Stateless /v1/info probe (name/pubkey/version) / full DTO. |
amy cashu receive ln SATS [--mint URL] |
Request a mint quote; prints the bolt11 + kind:7374 quote. |
amy cashu receive complete QUOTE_ID / resume QUOTE_ID |
Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). |
amy cashu receive token TOKEN |
Redeem a cashuB… token into the wallet. |
amy cashu receive nutzap-sweep [--mint URL] |
Redeem inbound NIP-61 nutzaps locked to your wallet key. |
amy cashu send ln INVOICE [--mint URL] |
Melt proofs to pay a bolt11 (scrubs stale proofs first). |
amy cashu send token SATS [--mint URL] [--memo S] |
Export a cashuB… token of SATS. |
amy cashu send nutzap USER SATS [--zapped EVENT_ID] [--message S] |
Send a P2PK-locked nutzap to USER (resolves their kind:10019). |
amy cashu maintenance scrub [--mint URL] |
NUT-07 + NIP-09 prune of spent proofs. |
amy cashu maintenance restore MINT_URL |
NUT-09 restore unspent proofs from the wallet seed. |
amy cashu maintenance migrate-keysets [--mint URL] |
Consolidate proofs onto each mint's active keyset. |
amy cashu mint-rec show [--author NPUB] / add URL [--dtag X] [--review T] / remove EVENT_ID |
NIP-87 mint recommendations (kind:38000). |
Relay management — admin (NIP-86)
Signs a NIP-98 request with the active account and POSTs it to the relay's
HTTP endpoint. Reuses quartz's Nip86Client and the shared Nip86Retriever
(the same path Amethyst's relay-management screen runs).
| Command | What it does |
|---|---|
amy admin RELAY supported-methods |
List the NIP-86 methods the relay implements. |
amy admin RELAY ban-pubkey HEX [--reason R] / unban-pubkey HEX / list-banned-pubkeys |
Pubkey ban list. |
amy admin RELAY allow-pubkey HEX [--reason R] / unallow-pubkey HEX / list-allowed-pubkeys |
Pubkey allow list. |
amy admin RELAY ban-event ID [--reason R] / allow-event ID / list-banned-events / list-needing-moderation |
Event moderation. |
amy admin RELAY allow-kind N / disallow-kind N / list-allowed-kinds |
Kind allow list. |
amy admin RELAY block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips |
IP block list. |
amy admin RELAY change-name S / change-description S / change-icon URL |
Relay metadata. |
Run a relay — serve
| Command | What it does |
|---|---|
amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS] |
Run a Nostr relay by embedding geode (the standalone Ktor relay on quartz's relay-server code). In-memory by default; --db FILE for SQLite. The active account is always an admin, so amy admin ws://host:port … works against it. Blocks until interrupted. |
Identity
| Command | What it does |
|---|---|
amy --account NAME init [--nsec NSEC] |
Create or import a bare keypair. No relay traffic. |
amy --account NAME create [--name X] |
Full Amethyst-style bootstrap: keypair, default relays, kind:0, kind:3, the works. |
amy login KEY [--password X] |
Import an existing identity (nsec/ncryptsec/mnemonic/npub/nprofile/hex/NIP-05). |
amy whoami |
Print the active account's name + npub. |
amy use NAME / --clear / no-arg |
Pin / clear / inspect the active account. |
amy status |
Read-only overview of everything under ~/.amy/: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. |
amy logoff [--yes] [--keep-events] |
Log off an account: delete its key + backend secret, the whole ~/.amy/<account>/ directory (run-state, aliases, cashu counters, Marmot state), the current pin if it points here, and the account's events (authored + #p-addressed) in the shared store. --keep-events leaves the shared cache alone. Destructive and irreversible — requires --yes; without it, prints a dry run and exits 2. |
Social
| Command | What it does |
|---|---|
amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]] |
Publish a kind:1 short text note; --pow mines a NIP-13 proof of work into it first, using all cores (blocks while mining, exit 124 on timeout with nothing published; --json adds pow, pow_target, pow_millis). |
amy notes feed [--author USER | --following] [--limit N] |
Read recent kind:1 notes (yours, one user's, or your follow set). |
amy profile show [USER] |
Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
amy profile edit --name … --about … --picture URL … |
Patch and re-publish your kind:0. |
amy follow USER / amy unfollow USER |
Add/remove USER from your kind:3 contact list (fetches the freshest list first). |
amy graperank [OBSERVER] [--offline] [--min-rank N] |
Crawl + score: compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, then persist the result. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. Every score run persists its result locally: the cards (rank cutoff --min-rank, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer service key — each carries rank, followers (trusted-follower count, cutoff --followers-threshold, default 0.02) and hops (follow distance from the observer); changed cards re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). --offline skips the crawl. |
amy graperank crawl [OBSERVER] [--max-hops N] [--no-preconnect] |
Pipeline stage 1 — network only: crawl the follow/mute/report graph (kind 3/10000/1984/10002) into the local store, no scoring. Idempotent and cumulative: run it a few times to load everything, then score. |
amy graperank followers [OBSERVER] [--relay URL[,URL…]] |
The reverse crawl: find every user who follows the observer by asking as many relays as possible for kind:3 lists that #p-tag them (paged past each relay's cap). The outbox model can't find followers — you don't know one exists until you've seen their list — so this casts a wide net over the whole relay universe (reachability-cache live set + every kind:10002/30166 relay in the store + index/aggregator relays), skipping proven-dead relays. Persists each follower's list, enriching the graph a later score builds. Idempotent and cumulative. |
amy graperank score [OBSERVER] |
Pipeline stage 2 — local only: score from the store and persist the cards (identical to bare --offline; same scoring flags). No network, so re-run with different --rigor/--attenuation/--min-rank without re-crawling. |
amy graperank publish [OBSERVER] [--relay URL[,URL…]] |
Pipeline stage 3 — transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. |
amy graperank rank USER [--provider PUBKEY] [--refresh] |
The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; --refresh (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. |
amy graperank refresh [--down] [--up] |
Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next score runs on current data without a full re-crawl. (update is the pre-rename alias.) |
amy graperank status |
Read-only local inventory, no network, no signing: WoT record counts in the store (the "do I need to crawl again?" answer), reachability-cache size + age, operator/service-key state, and the persisted card + retraction counts per observer. |
amy graperank operator [status | relay <url>… | keys] |
Manage the machine's operator keys (independent of any account, under ~/.amy/operator/). relay sets where publish sends cards + retractions; status shows the master pubkey and relays; keys lists the observer → service-pubkey map (providers is the pre-rename alias). |
amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL] |
Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the 30382:rank provider). |
amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL] |
The inverse of register: remove matching entries (public + private) from your kind:10040 and re-publish it. --service/--relay narrow the match; without them every entry for that provider key is dropped. |
amy graperank providers [USER] |
List a user's declared NIP-85 trusted providers (public + your own private entries). |
amy fof get USER |
Follows-of-follows social proof: how many accounts you follow also follow USER. Single-hop, cheap — not the computed web of trust (that's graperank). Read from the local store; run fof sync first to freshen it. |
amy fof list [--threshold N] [--limit N] |
Rank accounts by that social-proof score — who's most-followed inside your network (discovery). Defaults: --threshold 1, --limit 50. |
amy fof sync [--timeout SECS] |
Pull your follows' latest kind:3 from the index relays so the next get/list is current. (amy wot … remains as a deprecation alias for all three.) |
GrapeRank scores are persisted locally, then published (NIP-85)
Ranks are signed as kind:30382 cards, but not under your account key. A
machine holds one operator master seed (~/.amy/operator/, stored via the
same --secret-backend as accounts, independent of any account). From it a
distinct, deterministic service key is derived per observer:
serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerHex)
Because kind:30382 is addressable (pubkey + d-tag), the stable per-observer key
means re-signing replaces a target's card instead of orphaning it — and
losing everything but the master seed still re-derives every key.
Every score run persists its cards. After scoring, Amy reconciles the result
into the local store: new or changed cards (rank ≥ --min-rank, default 2) are
signed; unchanged cards are skipped (no new event id); and any card whose target
dropped out of the graph or fell below the cutoff is retracted with a kind:5
(the store applies it; the tombstone is kept). Each card carries three public
tags: rank (round(score*100)), followers — the number of the target's
followers whose own score clears --followers-threshold (default 0.02, matching
Brainstorm's trusted-follower cutoff) — and hops, the shortest follow-graph
distance from the observer (1 = a direct follow). A change to any of the three
re-signs the card. The local store is the source of truth — graperank rank USER
reads it offline, and graperank publish mirrors it out:
amy graperank operator relay wss://relay.example.com # where all cards live
amy graperank <observer> # crawl + score + persist cards locally
amy graperank publish <observer> # make the operator relay match the local set
publish never re-scores or re-signs: it runs one NIP-77 up-only reconcile per
relay over the service key's kind:30382 + kind:5, so the relay converges to the
local card set (deletions included, lost cards restored); a relay that can't
negentropy-reconcile gets the full set published event-by-event instead. When
the observer is your own account (we hold the key), publish also writes their
kind:10040 pointing 30382:rank → serviceKey @ operator relay to their outbox,
so clients can find the cards. For a third-party observer, graperank operator keys prints the observer → service-pubkey mapping to wire their
kind:10040 out-of-band.
Direct messages (NIP-17)
| Command | What it does |
|---|---|
amy dm send RECIPIENT TEXT [--allow-fallback] |
Gift-wrap a kind:14 to RECIPIENT. Strict kind:10050 routing by default. |
amy dm send-file RECIPIENT --file PATH --server URL |
Encrypt a local file, upload to a Blossom server, publish a kind:15 referencing it. |
amy dm send-file RECIPIENT URL --key HEX --nonce HEX |
Reference-mode: file already uploaded; just publish the kind:15. |
amy dm list [--peer NPUB] [--since TS] [--limit N] |
Drain and decrypt gift wraps. |
amy dm await --peer NPUB --match TEXT [--timeout SECS] |
Block until a matching DM arrives. |
Groups (Marmot / MLS)
| Command | What it does |
|---|---|
amy marmot key-package publish |
Publish a fresh KeyPackage so others can invite you. |
amy marmot key-package check NPUB |
Look up someone else's KeyPackage on relays. |
amy marmot group create [--name X] |
New empty group with you as sole admin. |
amy marmot group list |
All groups you're a member of. |
amy marmot group show GID |
Members, admins, epoch, metadata. |
amy marmot group add GID NPUB [NPUB…] |
Fetch KeyPackages and invite. |
amy marmot group rename GID NAME |
Commit a metadata change. |
amy marmot group promote / demote / remove GID NPUB |
Admin verbs. |
amy marmot group leave GID |
Self-remove. |
amy marmot message send GID TEXT |
Publish a kind:9 inner event into the group. |
amy marmot message list GID [--limit N] |
Decrypted inner events, oldest first. |
amy marmot message react GID EVENT_ID EMOJI |
Publish a kind:7 reaction. |
amy marmot message delete GID EVENT_ID … |
Publish a kind:5 deletion. |
Relay groups (NIP-29)
Relay-based groups (à la Armada / relay29). A group lives on one host relay,
addressed by (relay, group id); every read and write is pinned there. Distinct
from Marmot/MLS above — these are the NIP-29 groups Amethyst's "Relay Groups"
screen speaks.
| Command | What it does |
|---|---|
amy relaygroup list |
Your joined groups, from your kind:10009 list (public + private). |
amy relaygroup browse RELAY |
Every group a relay hosts (its 39000-39003 directory). |
amy relaygroup info RELAY GID |
A group's metadata + admin/member roster. |
amy relaygroup create RELAY --name X [--about A] [--private] [--closed] |
Create a group (publishes 9007 + 9002); prints the new group_id. |
amy relaygroup join RELAY GID [--code CODE] |
Request to join (9021) and add it to your kind:10009 list. |
amy relaygroup leave RELAY GID |
Leave (9022) and drop it from your kind:10009 list. |
amy relaygroup message RELAY GID TEXT |
Post a kind:9 chat message into the group. |
amy relaygroup edit RELAY GID [--name X] [--about A] [--private|--public] [--closed|--open] |
Edit metadata (9002, admin only). Reads current visibility and changes only the axis you pass, so re-asserting one flag never resets the other. |
amy relaygroup invite RELAY GID --code CODE |
Mint an invite code (9009, moderator). |
amy relaygroup put-user RELAY GID PUBKEY [--role admin|moderator] |
Add or promote a user (9000, moderator). |
amy relaygroup remove-user RELAY GID PUBKEY |
Kick a user (9001, moderator). |
CLINK Offers
| Command | What it does |
|---|---|
amy offer info NOFFER |
Decode a noffer1… pointer (pubkey, relays, price type/amount). Local, no network. |
amy offer request NOFFER [--amount SATS] [--timeout MS] [--payer-data K=V,…] |
kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. --amount is required for spontaneous offers; fixed offers default to the pointer's price. --payer-data attaches payer fields (e.g. email=a@b.c) for offers that require them — Lightning.Pub answers "Invalid Offer" (code 1) when they are missing. |
CLINK Debits
| Command | What it does |
|---|---|
amy debit info NDEBIT |
Decode an ndebit1… pointer (pubkey, relays, pointer id, session flag). Local, no network. |
amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS] |
kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. |
amy debit budget NDEBIT --amount SATS [--frequency day|week|month] [--timeout MS] |
Authorize a spending budget; omit --frequency for a one-time budget. |
Wait-for-condition (await)
Every await verb blocks until the condition holds, then prints the
matching event/state. All accept --timeout SECS (default 30); on
timeout the exit code is 124 so scripts can tell "didn't happen"
from "command crashed".
| Command | Blocks until… |
|---|---|
amy marmot await key-package NPUB |
NPUB has a KeyPackage discoverable on their advertised relays. |
amy marmot await group --name X |
You've been added to a group with that name. |
amy marmot await member GID NPUB |
NPUB is in GID's member set. |
amy marmot await admin GID NPUB |
NPUB is an admin of GID. |
amy marmot await message GID --match TEXT |
A message containing TEXT lands in GID. |
amy marmot await rename GID --name X |
GID's name matches X. |
amy marmot await epoch GID --min N |
GID's MLS epoch reaches N. |
amy dm await --peer NPUB --match TEXT |
A matching DM from NPUB arrives. |
Relays
amy mirrors Amethyst's relay-settings screen with one noun per relay-list
kind, followed by add/remove/set/clear (a bare noun lists it) — the same
noun-first shape as marmot group … / cashu mint …:
| Noun | Kind | Notes |
|---|---|---|
outbox / inbox / nip65 |
10002 | NIP-65. outbox = write relays, inbox = read relays; nip65 shows the combined read/write view. |
dm |
10050 | NIP-17 DM inbox. |
key-package |
10051 | MIP-00 MLS KeyPackage relays. |
search |
10007 | NIP-50 search relays. |
private |
10013 | NIP-37 private outbox (encrypted). |
blocked trusted proxy indexer broadcast feeds |
10006 / 10089 / 10087 / 10086 / 10088 / 10012 | NIP-51 lists, stored NIP-44-encrypted exactly like the app. |
Local relays (a device-only preference, no Nostr event) and named relay sets
(kind 30002) are out of scope. Edits are local-first: they build, sign, and
store the new list event but do not broadcast — run relay publish-lists.
NIP-65 markers. outbox/inbox edit the one kind:10002 event and merge per
the spec: outbox add R on a read-only R promotes it to both; outbox remove R on a both-R demotes it to read (keeps it in the inbox); dropping
the last facet removes R entirely.
| Command | What it does |
|---|---|
amy relay outbox add URL / inbox add URL |
Add URL as a write / read relay (merging to both if the other marker is already set). |
amy relay outbox remove URL / inbox remove URL |
Drop the write / read marker (demoting both to the other, or removing R). |
amy relay outbox set URL… / inbox set URL… |
Make exactly these the write / read relays. |
amy relay nip65 [list] |
Show the combined read/write/all view. nip65 remove URL drops R entirely; nip65 clear wipes kind:10002. |
amy relay <noun> add|remove URL |
For the flat buckets (dm, search, blocked, …): append / drop URL. |
amy relay <noun> set URL… / clear |
Replace a bucket's whole list, or empty it. set needs ≥1 URL; use clear to empty. |
amy relay <noun> |
List that bucket. |
amy relay add URL / remove URL |
Fan-out to the transport lists (nip65 both + dm + key-package). |
amy relay list |
Print every configured relay bucket. |
amy relay publish-lists |
Broadcast every configured relay list to the union of your relays. |
amy relay probe [--timeout SECS] [--concurrency N] |
The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured rtt-open into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (graperank crawl/refresh) read it to skip dead relays and pre-connect live ones. (amy graperank probe remains as an alias.) |
Local store maintenance
| Command | What it does |
|---|---|
amy store stat |
Event count, kind histogram, disk usage, oldest/newest timestamps. |
amy store sweep-expired |
Delete events past their NIP-40 expiration. |
amy store scrub |
Rebuild the index after external edits or a crash. |
amy store compact |
Drop dangling index entries (canonical event already gone). |
Output: text by default, JSON on demand
By default amy writes a YAML-ish, colored, human-readable result to
stdout. Pass --json and stdout becomes a single-line JSON object —
same data, stable snake_case keys, ready for jq:
$ amy --json whoami
{"name":"alice","npub":"npub1th9z…","hex":"5dca27ae…","data_dir":"/home/me/.amy/alice"}
$ amy --json marmot group create --name "Lunch" | jq -r .group_id
ab12cd34…
Errors mirror the same rule. Default:
$ amy marmot group show abc123
error: not_member: abc123 # exit 1
Under --json the error goes to stderr as {"error":"not_member","detail":"abc123"}.
Color auto-disables when stdout is a pipe; force it with CLICOLOR_FORCE=1,
turn it off entirely with NO_COLOR=1.
Exit codes — the real signal for scripts:
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | runtime error (network, permission, NIP rejection, …) |
| 2 | bad arguments |
| 124 | await timed out |
Multi-account workflows
amy is built to host more than one identity per machine. The layout
matches that:
~/.amy/
├── current # marker: which account `amy use NAME` pinned
├── shared/
│ └── events-store/ # one Nostr event store, shared by every account
├── alice/
│ ├── identity.json # keypair (or reference to keychain entry)
│ ├── state.json # sync cursors
│ ├── aliases.json # local name → npub map
│ └── marmot/ # MLS state per group
└── bob/
└── …
Account selection when you don't pass --account:
- If
~/.amy/currentis set, use it. - Else if exactly one account exists, use it (silent auto-pick).
- Else — for a read-only verb, run anonymously; for a signing verb, error and list the candidates so you can disambiguate.
No account? Reads still work. Verbs that only query relays or the shared
event store — fetch, subscribe, count, publish (broadcasts a
pre-signed event), outbox, search, sync, store …, the read halves of
profile/notes/git/podcast/podcast20, nsite/napplet fetch/serve/
list, blossom download/check, offer/debit info, and every stateless
primitive — run against an empty ~/.amy/ with a throwaway key. They read
fine; they just can't authenticate. Only verbs that sign or encrypt with
your key (post, edit, follow, dm, marmot, zap, relay-list edits, blossom
upload/list/delete, cashu, …) require an account — and say so.
amy use NAME writes ~/.amy/current; amy use --clear removes it.
For one-off override, prepend --account NAME to any command.
init and create write a self-entry into aliases.json so you can
refer to your own account by name in future commands. The alias resolver
in recipient slots (amy dm send alice "hi") is on the roadmap.
For the deeper layout (events-store internals, relay-routing rules, the public-contract guarantees) see DEVELOPMENT.md.
For agents and scripts
Three contracts keep amy machine-safe:
- One JSON object per success on stdout under
--json. Stable snake_case keys; keys never disappear silently. - Errors as JSON on stderr under
--json:{"error":"...","detail":"..."}. - Exit codes mean specific things (table above) —
124forawaittimeout in particular lets you distinguish "condition never happened" from "the command itself crashed".
Recipes
# Capture a fresh group's id.
GID=$(amy --json marmot group create --name "ops" | jq -r .group_id)
# Add several members at once and report which KeyPackages were missing.
amy --json marmot group add "$GID" npub1aaa npub1bbb npub1ccc \
| jq -r '.added[] | select(.status != "ok") | "missing: \(.pubkey)"'
# Wait up to 5 minutes for a particular message and capture its event id.
EVT=$(amy --json marmot await message "$GID" --match "deploy starting" --timeout 300 \
| jq -r .event_id)
# Run a command per follow.
amy --json notes feed --following --limit 50 \
| jq -r '.notes[].author' \
| sort -u \
| while read -r author; do
amy --json profile show "$author" | jq -r '.metadata.name // "?"'
done
Test isolation
amy reads $HOME directly to find ~/.amy/, so harnesses isolate the
exact same way git, gpg, npm, and ssh do — by overriding $HOME
for the subprocess:
HOME=$(mktemp -d) amy --account alice init
HOME=$(mktemp -d) amy --account alice marmot group create --name "scratch"
Inside the amy process there's no test mode — it just sees a fresh
~/.amy/ and behaves like a brand-new install.
Troubleshooting
no account configured/multiple accounts in ~/.amy (alice, bob)— only signing verbs raise these; reads run anonymously instead (see "No account? Reads still work" above). Create one withamy --account NAME init(bare keypair) oramy --account NAME create(full Amethyst-style bootstrap), or pin/select one withamy use NAME/--account NAME.current pins 'X' but ~/.amy/X doesn't exist— the active-account marker is stale. Rewrite withamy use OTHERoramy use --clear.no_dm_relays— recipient hasn't published a kind:10050 inbox. Pass--allow-fallbackto fall back to their kind:10002 read marker → bootstrap pool. Or wait for them to publish one.not_member— the group GID is unknown to this account. Runamy marmot group listto see what you're in, orawait group --name Xto wait for an invite.- A network verb hangs — every network verb has a relay timeout.
Inspect what amy is connecting to with
amy relay list. Wrap any command intimeout(1)if you're scripting and want a hard ceiling. - Nothing seems to publish — stderr carries
[cli] …traces with per-relayOK/REJECT. Capture with2> /tmp/amy.logand grep.
Where to go next
- DEVELOPMENT.md — design principles, architecture, the public contract, the local event store, relay routing, full on-disk layout, how to extend amy without breaking it.
- ROADMAP.md — north-star goal and the parity matrix tracking what's left to extract from the Android app.
plans/— design docs for cross-cutting work (CLI distribution, file-backed event store, NIP-17 DMs, …).- Nostr NIPs — the protocol amy speaks.