Nostr Agent Onboarding · start here · index · source:
nostr-dev/docs/patterns/go-client-baseline.md· snapshot 2026-10-10Paths such as
~/Documents/…,~/Production Environment/…,repos/…and services onlocalhostrefer to the author's workstation and are not available to you — read them as worked examples of a setup you can recreate.
Pattern: the Go baseline — fiatjaf.com/nostr
Start every Go client, relay or tool on this machine from
fiatjaf.com/nostr. It is the protocol author's own library and it covers the whole stack: events, relays, pooling, an outbox-aware client SDK, a relay framework, storage backends, Blossom, GRASP, and roughly forty NIP packages.Verified 2026-09-21 against the module cache and the live proxy.
1. Why this one
Three separate repos that most Go examples on the internet still import —
github.com/nbd-wtf/go-nostr, github.com/fiatjaf/khatru and
github.com/fiatjaf/eventstore — were all archived by their owner on
2026-01-24 and are read-only. All three now live as packages inside this one
module. go-nostr's README says so:
"This repository is in maintenance mode and adventurous programmers are encouraged to try
fiatjaf.com/nostr@masterinstead."
fiatjaf.com/nostr is the successor to all of them, by the same author, with a
reworked API. It is already the de-facto standard here: about two dozen Go
modules across ~/Production Environment import it, and
relays/khatru-example/ was ported onto it on 2026-09-21.
| Archived repo | Now |
|---|---|
github.com/nbd-wtf/go-nostr |
fiatjaf.com/nostr |
github.com/fiatjaf/khatru |
fiatjaf.com/nostr/khatru |
github.com/fiatjaf/eventstore |
fiatjaf.com/nostr/eventstore |
The khatru port is the one with real API churn: hooks became single functions
instead of slices (relay.StoreEvent = f, not append), QueryEvents became
QueryStored returning an iter.Seq, ids are nostr.ID, and
relay.UseEventstore(store, maxQueryLimit) wires storage, replacement,
deletion, counting and the expiration manager in one line. Ready-made
acceptance policies live in fiatjaf.com/nostr/khatru/policies; worked
examples ship in the module at khatru/examples/{basic-lmdb,blossom,grasp,exclusive}.
2. Getting it
go get fiatjaf.com/nostr
Two things are unusual and worth knowing before you fight them:
It has no tagged releases. Only pseudo-versions. @latest resolves to
something like v0.0.0-20260916040958-27e395a0f6e7. So:
- Pin the pseudo-version in
go.modand bump it deliberately. The API is still moving; an unpinned bump can change event field types under you. - If a bump is load-bearing for correctness — an id/signature golden test, a
wire-format assertion — say so in a
go.modcomment. CFR already does:// pinned pre-stable; bumping re-runs NEW-1 (id/sig golden).
The source is not on GitHub — it is Nostr-native. The go-import meta at
fiatjaf.com/nostr points at a GRASP host:
https://basspistol.org/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git
Browsable at viewsource.win/npub180cvv…/nostrlib. Which is a nice thing to
know and also a dependency you should be aware of: proxy.golang.org caches
it, so GOFLAGS=-mod=mod builds keep working if that host blips, but a fresh
go get of a brand-new commit does not.
2.1 The Go toolchain trap
The library's go.mod declares go 1.25. /usr/bin/go on this machine is
1.24.4. It works anyway, silently, because GOTOOLCHAIN=auto downloads
go1.25.0 on demand — so a module that imports this library builds with 1.25
even though go version prints 1.24.4.
The trap: if you scaffold a new module with go mod init under the default
toolchain you get go 1.24, and the import then fails with a version error
that reads like a network problem. Put go 1.25 in a new go.mod (every
Go project here that uses the library already does), and don't trust
go version at the shell — run it inside the module.
3. The package map
| Import | What |
|---|---|
fiatjaf.com/nostr |
Event, Filter, Relay, Pool, Kind, Tags, keys, envelopes, signing |
…/sdk |
the client SDK: System with caching, data loaders, outbox relay management |
…/keyer |
signing abstractions: plain key, bunker, read-only, keyer.New() from any input |
…/khatru |
the relay framework |
…/khatru/blossom |
Blossom server plugin for a khatru relay |
…/khatru/grasp |
GRASP (NIP-34 git) server plugin |
…/eventstore |
pluggable storage: Bleve, BoltDB, LMDB, in-memory, MMM |
…/nipb7/blossom |
Blossom client — upload/download/list, sets Content-Type from the extension |
…/nip5a |
nsite manifests — parse/build 15128 & 35128, base36 labels, path normalisation |
…/nip19 |
bech32 entities |
…/nip44, …/nip17, …/nip59 |
encryption, private DMs, gift wrap |
…/nip46 |
remote signing (bunker) — see §6 before using |
…/nip34 |
git / GRASP event types |
…/nip29 |
relay-based groups |
…/nip65 |
relay lists / outbox |
…/nip77 |
negentropy sync |
| plus | nip04 05 06 10 11 13 14 22 23 27 31 40 42 45 49 52 53 54 57 60 61 70 73 86 92 94, nipad, schema |
Check the tree before you write a helper — a lot of what people hand-roll is
already in there. nip5a in particular: every nsite deploy script on this
machine predates anyone noticing it exists.
4. The API, and how it differs from go-nostr
The single biggest change: identifiers are fixed-size byte arrays, not hex strings.
type Event struct {
ID ID // [32]byte, not string
PubKey PubKey // [32]byte, not string
CreatedAt Timestamp
Kind Kind // a named type, not int
Tags Tags
Content string
Sig [64]byte // not string
}
Most porting pain is this. evt.PubKey == somehex no longer compiles; use
nostr.PubKeyFromHex, .Hex(), nostr.MustPubKeyFromHex. It also means the
JSON marshalling is strict — the library hard-errors on unknown top-level
event fields, which is what forced the OTS sidecar fork (see
~/Production Environment/Nostr-Go-Framework/docs/99-gotchas.md). If you were
planning to smuggle an extra field into an event: don't; use a tag or a
companion event.
Other shape changes:
sk := nostr.Generate() // SecretKey
pk := sk.Public() // PubKey
evt := nostr.Event{Kind: 1, CreatedAt: nostr.Now(), Content: "hello", Tags: nostr.Tags{}}
evt.Sign(sk) // sets ID and Sig
ok := evt.VerifySignature()
r, err := nostr.RelayConnect(ctx, "wss://relay.damus.io", nostr.RelayOptions{})
err = r.Publish(ctx, evt)
// Filter takes one filter, not a Filters slice; Tags is a TagMap
f := nostr.Filter{Kinds: []nostr.Kind{1}, Authors: []nostr.PubKey{pk}, Limit: 10}
sub, err := r.Subscribe(ctx, f, nostr.SubscriptionOptions{})
// Go 1.23 iterators
for evt := range r.QueryEvents(f) { _ = evt }
// Many relays at once
pool := nostr.NewPool()
for evt := range pool.FetchMany(ctx, relayURLs, f, nostr.SubscriptionOptions{}) { _ = evt }
for res := range pool.PublishMany(ctx, relayURLs, evt) { _ = res }
For anything that needs the outbox model, profile/relay-list caching or
loaders, use sdk.NewSystem() rather than driving Pool by hand — that is
what it is for, and hand-rolled outbox resolution is a recurring source of "why
can't it find this user's notes".
5. Picking the right layer
| Building | Start with |
|---|---|
| A CLI or one-shot tool | root package + Pool |
| A client that reads other people's data | sdk.System (outbox + caching + loaders) |
| A relay | khatru + an eventstore backend |
| A relay that also serves blobs | khatru + khatru/blossom |
| A git host | khatru + khatru/grasp |
| Uploading blobs | nipb7/blossom client |
| Publishing a static site | nip5a + nipb7/blossom — and read nsite-publishing.md |
| Signing as a user | keyer — and read signer-login.md |
For Go/wasm clients (Testimony, Cerebrate, the Workshop Board, Idle StarFighter
are all this shape) the library works in the browser, with one standing patch:
the wasm build needs the library dial patch — see project_testimony_app
and Testimony's third_party/nostr/FORK.md.
6. The part you must not use as-is
fiatjaf.com/nostr/nip46 (remote signing) is known-broken for production
login. Stock, it waits for EOSE from every relay (one dead relay hangs login
forever), drops NIP-04 replies, hard-codes a 30 s sign timeout that ignores
BunkerSignTimeout, has a broken pre-connect switch_relays, and waits for
the relay OK before reading the reply. wellknownnostrjson.go returns an
unassigned named return on NIP-05 bunker lookup, so login silently targets the
zero pubkey.
Every Go app on this machine that used it stock has the same bugs. Use the
patched copy at ~/Production Environment/Testimony/third_party/nostr/ (see its
FORK.md) plus its internal/signer/. Full detail:
signer-login.md §3.7 and
known-issues.md §6.
This is the one place where "start from the library" does not apply. Everywhere else, start from the library.
7. Conventions for this workspace
- Pin the pseudo-version; note why if the pin is load-bearing.
go 1.25in new modules.- Vendor a fork only under
third_party/nostr/with areplacedirective and aFORK.mdstating every divergence. Four projects here do this correctly; copy that shape rather than editing the module cache. - Don't reimplement a NIP that has a package. Check the tree first.
- New Go relay experiments go in
relays/<name>/as separate modules, so each pins its own version.
Related
patterns/nsite-publishing.md—nip5ain angerpatterns/signer-login.md— thenip46situation in fullknown-issues.md§6 — known-bad code on this machine~/Production Environment/Nostr-Go-Framework/— the local Go doctrine, the gotcha ledger, and thenostrkitmodule built on this library