> **Nostr Agent Onboarding** · [start here](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/start.md) · [index](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/llms.txt) · source: `nostr-dev/docs/patterns/go-client-baseline.md` · snapshot 2026-10-10
>
> Paths such as `~/Documents/…`, `~/Production Environment/…`, `repos/…` and services on `localhost` refer 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`](https://pkg.go.dev/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@master` instead."

`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

```bash
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.mod`** and 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.mod` comment. 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.**

```go
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:

```go
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`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/nsite-publishing.md) |
| Signing as a user | `keyer` — and read [`signer-login.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/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`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/signer-login.md) §3.7 and
[`known-issues.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/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.25` in new modules.
- Vendor a fork only under `third_party/nostr/` with a `replace` directive and a
  `FORK.md` stating 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`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/nsite-publishing.md) — `nip5a` in anger
- [`patterns/signer-login.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/patterns/signer-login.md) — the `nip46` situation in full
- [`known-issues.md`](https://npub1d70emggs6jzun5lhvqnfqsd9reqmaarn2qjf6q3r02gryyl4v8sqjn44xe.nsite.lol/docs/design/known-issues.md) §6 — known-bad code on this machine
- `~/Production Environment/Nostr-Go-Framework/` — the local Go doctrine, the
  gotcha ledger, and the `nostrkit` module built on this library
