nmilat is a Go SDK for building on the Nostr protocol. It handles the plumbing — event parsing, signing, verification, and 32 NIPs — so you can focus on what you're building.
Use it to:
- Build a client or bot that reads, signs, and publishes Nostr events
- Run your own relay with the embeddable engine: event storage, sessions, and profile search included
- Talk to remote relays over WebSocket without hand-rolling the wire protocol
No CLI or UI code lives here — this module is a library only. The
ncli application is built on top of it.
go get github.com/ohstr/nmilatnip01— Core event, filter, and subscription types (the foundation every other package builds on)nip04,nip44,nip49— Encryption: direct messages, payloads, private keysnip05— NIP-05 identity verificationnip09— Event deletionnip11— Relay information documentnip13— Proof of worknip16— Event treatment (regular/replaceable/ephemeral kinds; folded into NIP-01 upstream)nip17,nip59— Private direct messages, gift wrapsnip19— Bech32-encoded entities: npub, nsec, note, plus the TLV-based nprofile, nevent, and naddrnip23— Long-form contentnip26— Event delegationnip33— Parameterized replaceable events (renamed "addressable events" and folded into NIP-01 upstream)nip40— Event expirationnip42,nip98— Relay/HTTP authenticationnip43— Relay access metadata and requestsnip46— Nostr Connect (remote signing)nip47— Wallet Connect (NWC): info/request/response/notification events, encryption negotiation, pairing URInip48— Proxy tagsnip57— Lightning zapsnip65— Relay list metadatanip77— Negentropy syncnip88— Pollsnip90— Data Vending MachinesnipAA— Agent AuthnipAZ— AltZap: zaps for energy-backed coinsnipB0— Web bookmarksnipB7— Blossom medianipcash— Cash Hub: a Chaumian ecash system built directly on NIP-47nipcw— Circle Wallet: self-service NIP-47 wallets for a group sharing one host's nodenipIC— Identity Connection: binds Web Identity accounts to Nostr pubkeysnipOA— Owner Attestation
relay— Embeddable relay enginerelay/client— Relay client (WebSocket, NWC)relay/migrations— Event store schema migrationssearch— Profile search indexing/rankingconfig— Embedded YAML config for searchwire— Relay wire-protocol packet typesutils— Shared event/key/logging helpers
NIP packages with relay-side concerns (NIP-47/48/57/65/88/90/B0/B7) stay
dependency-free on their own; blank-import their relayreg subpackage to
declare relay support, e.g. import _ "github.com/ohstr/nmilat/nip57/relayreg".
See "Run a relay" below.
package main
import (
"log"
"net/http"
"github.com/ohstr/nmilat/nip11"
"github.com/ohstr/nmilat/relay"
// Blank-import the relayreg subpackage for every optional NIP this relay
// should declare support for and auto-validate incoming events against.
// Without these, relay.New still works — it just won't know about
// zaps/polls/DVMs/etc. NIP-09/16/33/40/77 are always on (core to NIP-01
// handling), and NIP-42/43/AA/26/50 turn on automatically from
// SessionConfig — none of those need a relayreg import.
_ "github.com/ohstr/nmilat/nip57/relayreg"
_ "github.com/ohstr/nmilat/nip65/relayreg"
)
func main() {
metadata := &nip11.Metadata{
Name: "my-relay",
Limitation: nip11.Limitation{MaxLimit: 1000, MaxMessageLength: 1024 * 1024},
}
rl, err := relay.New("relay.db", metadata)
if err != nil {
log.Fatal(err)
}
defer rl.Close()
log.Fatal(http.ListenAndServe(":8080", rl))
}relay.New includes NIP-11 relay-info negotiation and starts profile
verification, with search disabled. For storage tuning, a search service, or
session options (CORS allowlist, NIP-26 delegation, ...), build the store
and handler directly with relay.NewEventStore/relay.NewSessionHandler
instead.
Connect with relayclient.Connect against ws://localhost:8080 (next example).
Connect, subscribe to a filter, and read events until EOSE. Relay input is untrusted,
so always call Verify() before acting on an event:
package main
import (
"context"
"fmt"
"net/url"
"github.com/ohstr/nmilat/nip01"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
filters := nip01.NewSubscriptionFilterGroup(nip01.NewFilter().WithKinds(1).WithLimit(10))
relayURL, _ := url.Parse("wss://relay.ohstr.com")
events, err := relayclient.ReadEventsFromRelay(context.Background(), relayURL, filters)
if err != nil {
panic(err)
}
for _, ev := range events {
if err := ev.Verify(); err != nil {
continue // bad signature, bad ID, or malformed — skip it
}
fmt.Println(ev.ID, ev.Content)
}
}Create an event, sign it with your private key, and publish it to a relay:
package main
import (
"context"
"fmt"
"net/url"
"github.com/ohstr/nmilat/nip01"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
// privateKeyHex is your hex-encoded Nostr private key — see
// "Encode & decode keys" below for converting to/from npub/nsec.
ev, err := nip01.NewSignedEvent(1, "hello nostr", privateKeyHex)
if err != nil {
panic(err)
}
relayURL, _ := url.Parse("wss://relay.ohstr.com")
conn, err := relayclient.Connect(context.Background(), relayURL)
if err != nil {
panic(err)
}
defer conn.Close()
res, err := conn.Publish(context.Background(), ev)
if err != nil {
panic(err)
}
fmt.Println("accepted:", res.Accepted, res.Message)
}Build a chat message, seal and gift-wrap it so only the recipient can read it (sender identity included), and publish the wrapper like any other event:
package main
import (
"context"
"fmt"
"net/url"
"github.com/ohstr/nmilat/nip17"
"github.com/ohstr/nmilat/nip59"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
rumor := nip17.NewChatMessage("gm from nmilat")
if err := rumor.Sign(senderPrivKeyHex); err != nil {
panic(err)
}
// Wrap encrypts the rumor twice (seal, then gift wrap) so relays and
// onlookers see only an anonymous kind-1059 event addressed to recipientPubKeyHex.
giftWrap, err := nip59.Wrap(rumor, senderPrivKeyHex, recipientPubKeyHex)
if err != nil {
panic(err)
}
relayURL, _ := url.Parse("wss://relay.ohstr.com")
conn, err := relayclient.Connect(context.Background(), relayURL)
if err != nil {
panic(err)
}
defer conn.Close()
res, err := conn.Publish(context.Background(), giftWrap)
if err != nil {
panic(err)
}
fmt.Println("delivered:", res.Accepted, res.Message)
}nip57 implements the spec-compliant kind 9734/9735 zap request/receipt/LNURL
flow:
package main
import (
"context"
"fmt"
"net/url"
"github.com/ohstr/nmilat/nip57"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
zapRequest := nip57.NewZapRequest(nip57.ZapRequestParams{
Recipient: recipientPubKeyHex,
Lnurl: recipientLnurl,
AmountMsat: 21000,
Relays: []string{"wss://relay.ohstr.com"},
})
if err := zapRequest.Sign(senderPrivKeyHex); err != nil {
panic(err)
}
relayURL, _ := url.Parse("wss://relay.ohstr.com")
conn, err := relayclient.Connect(context.Background(), relayURL)
if err != nil {
panic(err)
}
defer conn.Close()
res, err := conn.Publish(context.Background(), zapRequest)
if err != nil {
panic(err)
}
fmt.Println("zap request accepted:", res.Accepted, res.Message)
}AltZap is the same flow for non-Bitcoin chains — a mandatory chain tag
and its own kinds (5520-5523). Its most common use isn't zapping a native
Nostr pubkey (nipAZ.Pubkey(hex)) — it's zapping a recipient who only has an
account on another platform and no Nostr keypair yet. nipAZ.Connection
covers that case by deriving a NIP-IC ConnectionKey internally:
package main
import (
"context"
"fmt"
"net/url"
"github.com/ohstr/nmilat/nipAZ"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
zapRequest, err := nipAZ.NewAltZapRequest(nipAZ.AltZapRequestParams{
PrivateKey: senderPrivKeyHex, // signs internally
Chain: "flokicoin", // prevents cross-chain replay
// Recipient has no Nostr pubkey yet — identified by their Discord
// account instead. nipAZ.Connection hashes platform+externalID into
// a nipIC.ConnectionKey; use nipAZ.Pubkey(hex) for a native Nostr
// recipient instead.
Recipient: nipAZ.Connection("discord", externalUserID),
Lnurl: recipientLnurl,
AmountMloki: 21000,
Relays: []string{"wss://relay.ohstr.com"},
})
if err != nil {
panic(err)
}
relayURL, _ := url.Parse("wss://relay.ohstr.com")
conn, err := relayclient.Connect(context.Background(), relayURL)
if err != nil {
panic(err)
}
defer conn.Close()
res, err := conn.Publish(context.Background(), zapRequest)
if err != nil {
panic(err)
}
fmt.Println("zap request accepted:", res.Accepted, res.Message)
}nipIC implements Identity Connection: an Identity Authority (IA) attests that a Web Identity account (Discord, Telegram, ...) belongs to a Nostr pubkey by signing a Kind 35522 event; the user then references it from their own Kind 35521.
package main
import (
"fmt"
"github.com/ohstr/nmilat/nipIC"
)
func main() {
// 1. Mint a challenge + pre-auth code for the user to prove control of
// their Nostr key, e.g. by posting the pre-auth code publicly.
challenge, preAuthCode, err := nipIC.NewChallenge(userPubkeyHex)
if err != nil {
panic(err)
}
// 2. Once the IA has verified the public post, it signs the attestation.
connectionKey := nipIC.NewConnectionKey("discord", externalUserID)
attestation, err := nipIC.NewAttestation(nipIC.AttestationParams{
PrivateKey: iaPrivKeyHex, // IA's nsec hex, signs internally
ConnectionKey: connectionKey,
UserPubkey: userPubkeyHex,
Platform: "discord",
ExpirationDays: 90,
Evidence: nipIC.Evidence{
Platform: "discord",
UserID: externalUserID,
Username: "alice",
EvidenceURL: "https://discord.com/channels/.../123456789",
Challenge: challenge,
PreAuthCode: preAuthCode,
},
})
if err != nil {
panic(err)
}
fmt.Println("attestation id:", attestation.ID)
}A verifier re-checks cross-IA re-attestation evidence with
challenge.Verify(userPubkeyHex, preAuthCode) before trusting it — see
NIP-IC's Cross-IA Challenge Binding
for the full security model.
Parse a nostr+walletconnect:// pairing URI and construct a NWCClient —
it dials the wallet's relay once and keeps the connection open for reuse
across calls. PayInvoice and the other wallet operations are plain
methods: no type parameter at the call site, and a wallet-side decline comes
back as a *relayclient.WalletError you can errors.As for the code and
message:
package main
import (
"context"
"fmt"
"github.com/ohstr/nmilat/nip47"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
// pairingURI is the nostr+walletconnect:// string the user's wallet gave
// you; it carries the wallet's pubkey, relay(s), and your app's secret key.
pairing, err := nip47.ParsePairingURI(pairingURI)
if err != nil {
panic(err)
}
wallet, err := relayclient.NewNWCClient(context.Background(), pairing, nip47.EncryptionNIP44V2)
if err != nil {
panic(err)
}
defer wallet.Close()
result, err := wallet.PayInvoice(context.Background(), nip47.PayInvoiceParams{
Invoice: "lnfcxxxx....", // lnbcxxx for bitcoin
})
if err != nil {
panic(err)
}
fmt.Println("paid! preimage:", result.Preimage)
}NIP-CASH mints lokicash1.../satscash1... bech32 tokens that carry
real, spendable value the moment they're minted — hand one to someone the
way you'd hand over a bill. NIP-CW self-serves a personal wallet from a
host's own node, for people who don't want to run one themselves. The two
compose naturally: a Circle Wallet member redeeming a cash token straight
into their own wallet, on the same host that minted the cash, is the one
case where cash_redeem's same-node fee exemption applies deterministically
— always the full amount, zero fee, since nothing leaves the node. Both
packages follow the protocol/client split every other NIP here does
(nipcash builds/parses, nipcash/client dials out) — imported under an
alias here since both are used together in the same file:
package main
import (
"context"
"fmt"
"time"
"github.com/ohstr/nmilat/nip47"
"github.com/ohstr/nmilat/nipcash"
cashclient "github.com/ohstr/nmilat/nipcash/client"
"github.com/ohstr/nmilat/nipcw"
cwclient "github.com/ohstr/nmilat/nipcw/client"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
ctx := context.Background()
// Mint a slice for a Nostr-identified recipient, over the Cash Hub's
// own connection.
hub, err := cashclient.Connect(ctx, cashHubPairingURI)
if err != nil {
panic(err)
}
defer hub.Close()
minted, err := hub.MintCash(ctx, nipcash.MintCashParams{
Recipients: []nipcash.Allocation{nipcash.Send(nipcash.Pubkey(memberPubkeyHex), 21_000_000)},
Expiry: 24 * time.Hour,
})
if err != nil {
panic(err)
}
// The member self-serves their own Circle Wallet from the host's Hub.
circleHub, err := cwclient.Connect(ctx, circleHubPairingURI)
if err != nil {
panic(err)
}
defer circleHub.Close()
wallet, err := circleHub.CreateCircleWallet(ctx, nipcw.CreateCircleWalletParams{
Credential: nipcw.BySigning(memberPrivKeyHex),
MaxAmountMillis: 100_000_000,
})
if err != nil {
panic(err)
}
// Invoice from their own Circle Wallet — same host as the Cash Hub, so
// this redemption always resolves same-node: full amount, zero fee.
memberPairing, err := nip47.ParsePairingURI(wallet.PairingURI)
if err != nil {
panic(err)
}
member, err := relayclient.NewNWCClient(ctx, memberPairing, nip47.EncryptionNIP44V2)
if err != nil {
panic(err)
}
defer member.Close()
invoice, err := member.MakeInvoice(ctx, nip47.MakeInvoiceParams{Amount: 21_000_000})
if err != nil {
panic(err)
}
cashWallet, err := cashclient.Connect(ctx, minted.CashToken)
if err != nil {
panic(err)
}
defer cashWallet.Close()
result, err := cashWallet.CashRedeem(ctx, nipcash.CashRedeemParams{
Invoice: invoice.Invoice,
Credential: nipcash.BySigning(memberPrivKeyHex),
})
if err != nil {
panic(err)
}
fmt.Println("redeemed! preimage:", result.Preimage, "fees paid:", result.FeesPaid)
}Build a BUD-11 Authorization token scoped to the upload verb, then hand it
to nipB7/client to stream the blob to a server and get back its Blob
Descriptor:
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/ohstr/nmilat/nipB7"
blossom "github.com/ohstr/nmilat/nipB7/client"
)
func main() {
auth := nipB7.NewAuthorization(nipB7.AuthorizationParams{
Verb: nipB7.VerbUpload,
Content: "Upload blob",
Expiration: time.Now().Add(5 * time.Minute),
})
if err := auth.Sign(privateKeyHex); err != nil {
panic(err)
}
c := &blossom.Client{}
descriptor, err := c.Upload(context.Background(), "https://blossom.example", blossom.UploadRequest{
Body: strings.NewReader("hello nostr"),
Size: 11,
ContentType: "text/plain",
Auth: auth,
})
if err != nil {
panic(err)
}
fmt.Println("stored at:", descriptor.URL)
}c.Get/c.GetFromServers download the same way (streamed, with
server-list fallback), and nipB7.VerifyAuthorization is the server-side,
BUD-11 analogue of NIP-98's VerifyAuthHeader.
Convert between raw hex keys/IDs and Nostr's bech32 encoding (npub/nsec/note):
import "github.com/ohstr/nmilat/nip19"
npub, err := nip19.EncodePublicKey(pubkeyHex)
if err != nil {
panic(err)
}
fmt.Println(npub) // npub1...
decoded, err := nip19.DecodePublicKey(npub)
if err != nil {
panic(err)
}
fmt.Println(decoded) // pubkeyHexDecodePublicKey/DecodePrivateKey/DecodeNote are typed wrappers; the
generic nip19.Decode is also available for callers that need to handle an
identifier of unknown/mixed type.
The TLV-based "shareable identifiers with extra metadata" — nprofile,
nevent, and naddr — carry a public key/event ID plus optional relay
hints (and, for nevent/naddr, an optional author and kind):
nprofile, err := nip19.EncodeProfile(pubkeyHex, []string{"wss://relay.example.com"})
profile, err := nip19.DecodeProfile(nprofile) // *nip19.ProfilePointer{PublicKey, Relays}
nevent, err := nip19.EncodeEvent(nip19.EventPointer{
ID: eventIDHex,
Relays: []string{"wss://relay.example.com"},
Author: pubkeyHex, // optional
Kind: 1, // optional
})
event, err := nip19.DecodeEvent(nevent) // *nip19.EventPointer
naddr, err := nip19.EncodeAddr(nip19.EntityPointer{
Identifier: "my-article",
PublicKey: pubkeyHex,
Kind: 30023,
Relays: []string{"wss://relay.example.com"},
})
addr, err := nip19.DecodeAddr(naddr) // *nip19.EntityPointerUses just for build automation:
just build # compile-check (library, no binary)
just test # go test ./...
just vet # go vet ./...
just tidy # go mod tidy
just check # build + vet + testUnlicense — public domain.